internal/store/builds.go

166 lines · 4681 bytes

  1package store
  2
  3import (
  4	"database/sql"
  5	"errors"
  6)
  7
  8// Build is one CI job execution for one commit.
  9type Build struct {
 10	ID         int64
 11	RepoID     int64
 12	Number     int64
 13	Job        string
 14	SHA        string
 15	Ref        string
 16	Steps      string // JSON array of shell commands
 17	Status     string // pending|running|success|failure
 18	CreatedAt  string
 19	StartedAt  string
 20	FinishedAt string
 21}
 22
 23// MaxBuildLog caps a build's stored log; appends past it are dropped.
 24const MaxBuildLog = 2 << 20
 25
 26// CreateBuild allocates the per-repo build number in the same transaction
 27// as the insert, like issue and MR numbers.
 28func (s *Store) CreateBuild(repoID int64, job, sha, ref, stepsJSON string) (int64, error) {
 29	tx, err := s.DB.Begin()
 30	if err != nil {
 31		return 0, err
 32	}
 33	defer tx.Rollback()
 34	if _, err := tx.Exec("UPDATE repos SET build_counter = build_counter + 1 WHERE id = ?", repoID); err != nil {
 35		return 0, err
 36	}
 37	var n int64
 38	if err := tx.QueryRow("SELECT build_counter FROM repos WHERE id = ?", repoID).Scan(&n); err != nil {
 39		return 0, err
 40	}
 41	if _, err := tx.Exec(
 42		"INSERT INTO builds (repo_id, number, job, sha, ref, steps) VALUES (?, ?, ?, ?, ?, ?)",
 43		repoID, n, job, sha, ref, stepsJSON); err != nil {
 44		return 0, err
 45	}
 46	return n, tx.Commit()
 47}
 48
 49const buildSelect = `
 50	SELECT id, repo_id, number, job, sha, ref, steps, status, created_at, started_at, finished_at
 51	FROM builds`
 52
 53func scanBuild(row interface{ Scan(...any) error }) (Build, error) {
 54	var b Build
 55	err := row.Scan(&b.ID, &b.RepoID, &b.Number, &b.Job, &b.SHA, &b.Ref, &b.Steps,
 56		&b.Status, &b.CreatedAt, &b.StartedAt, &b.FinishedAt)
 57	return b, err
 58}
 59
 60// ClaimBuild atomically hands the oldest pending build to a runner.
 61func (s *Store) ClaimBuild() (Build, bool, error) {
 62	tx, err := s.DB.Begin()
 63	if err != nil {
 64		return Build{}, false, err
 65	}
 66	defer tx.Rollback()
 67	var id int64
 68	err = tx.QueryRow("SELECT id FROM builds WHERE status = 'pending' ORDER BY id LIMIT 1").Scan(&id)
 69	if errors.Is(err, sql.ErrNoRows) {
 70		return Build{}, false, nil
 71	}
 72	if err != nil {
 73		return Build{}, false, err
 74	}
 75	if _, err := tx.Exec(
 76		"UPDATE builds SET status = 'running', started_at = strftime('%Y-%m-%dT%H:%M:%SZ','now') WHERE id = ?", id); err != nil {
 77		return Build{}, false, err
 78	}
 79	b, err := scanBuild(tx.QueryRow(buildSelect+" WHERE id = ?", id))
 80	if err != nil {
 81		return Build{}, false, err
 82	}
 83	return b, true, tx.Commit()
 84}
 85
 86// AppendBuildLog adds a chunk to the build's log, dropping bytes past the cap.
 87func (s *Store) AppendBuildLog(id int64, chunk []byte) error {
 88	_, err := s.DB.Exec(`
 89		UPDATE builds SET log = log || ?
 90		WHERE id = ? AND length(log) < ?`, chunk, id, MaxBuildLog)
 91	return err
 92}
 93
 94// FinishBuild records the outcome of a running build.
 95func (s *Store) FinishBuild(id int64, status string) error {
 96	res, err := s.DB.Exec(`
 97		UPDATE builds SET status = ?, finished_at = strftime('%Y-%m-%dT%H:%M:%SZ','now')
 98		WHERE id = ? AND status = 'running'`, status, id)
 99	if err != nil {
100		return err
101	}
102	if n, _ := res.RowsAffected(); n == 0 {
103		return ErrNotFound
104	}
105	return nil
106}
107
108func (s *Store) BuildByID(id int64) (Build, error) {
109	b, err := scanBuild(s.DB.QueryRow(buildSelect+" WHERE id = ?", id))
110	if errors.Is(err, sql.ErrNoRows) {
111		return b, ErrNotFound
112	}
113	return b, err
114}
115
116func (s *Store) BuildByNumber(repoID, number int64) (Build, error) {
117	b, err := scanBuild(s.DB.QueryRow(buildSelect+" WHERE repo_id = ? AND number = ?", repoID, number))
118	if errors.Is(err, sql.ErrNoRows) {
119		return b, ErrNotFound
120	}
121	return b, err
122}
123
124func (s *Store) ListBuilds(repoID int64, limit int) ([]Build, error) {
125	rows, err := s.DB.Query(buildSelect+" WHERE repo_id = ? ORDER BY number DESC LIMIT ?", repoID, limit)
126	if err != nil {
127		return nil, err
128	}
129	defer rows.Close()
130	var out []Build
131	for rows.Next() {
132		b, err := scanBuild(rows)
133		if err != nil {
134			return nil, err
135		}
136		out = append(out, b)
137	}
138	return out, rows.Err()
139}
140
141// BuildLog returns the stored log bytes.
142func (s *Store) BuildLog(id int64) ([]byte, error) {
143	var log []byte
144	err := s.DB.QueryRow("SELECT log FROM builds WHERE id = ?", id).Scan(&log)
145	if errors.Is(err, sql.ErrNoRows) {
146		return nil, ErrNotFound
147	}
148	return log, err
149}
150
151// LatestBuild returns the newest build for a repo, optionally narrowed to
152// one job. It is what a status badge reports.
153func (s *Store) LatestBuild(repoID int64, job string) (Build, error) {
154	q := buildSelect + " WHERE repo_id = ?"
155	args := []any{repoID}
156	if job != "" {
157		q += " AND job = ?"
158		args = append(args, job)
159	}
160	q += " ORDER BY number DESC LIMIT 1"
161	b, err := scanBuild(s.DB.QueryRow(q, args...))
162	if errors.Is(err, sql.ErrNoRows) {
163		return b, ErrNotFound
164	}
165	return b, err
166}