1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package kvstore
4
5import (
6 "context"
7 "errors"
8 "fmt"
9 "slices"
10 "strconv"
11 "strings"
12 "time"
13
14 kv "bnema.dev/ermite-kv"
15 "google.golang.org/protobuf/proto"
16
17 storagepb "bnema.dev/ermite/gen/ermite/storage"
18 "bnema.dev/ermite/internal/domain"
19 "bnema.dev/ermite/internal/ports"
20)
21
22// Key layout of CI. Repository names hold no '/', so a prefix ending in "/"
23// never matches another repository; ids are zero-padded so that keys sort
24// numerically. Job records are keyed by run, attempt of the run and job id.
25//
26// run/<repo>/<id:020d> CIRun
27// runsrc/<id:020d> CIRunSource
28// job/<run:020d>/<attempt:010d>/<id> CIJob
29// check/<repo>/<sha>/<check id> CICheck
30// runseq Sequence: last run id
31// runnum/<repo>/<workflow path> Sequence: last run number
32// runner/<id>, runnername/<name>, runnertoken/<hash>
33//
34// Indexes, derived from the job and run records by every write:
35//
36// ciopen/<id:020d> run not completed (value: repo)
37// ciready/<ready_at:020d>/<job key> job waiting for a runner
38// ciactive/<job key> job assigned or running
39// jobtoken/<hash> job token (value: job key)
40const (
41 runPrefix = "run/"
42 runSrcPrefix = "runsrc/"
43 jobPrefix = "job/"
44 checkPrefix = "check/"
45 runSeqKey = "runseq"
46 runNumPrefix = "runnum/"
47 runnerPrefix = "runner/"
48 runnerName = "runnername/"
49 runnerTokPref = "runnertoken/"
50 openPrefix = "ciopen/"
51 readyPrefix = "ciready/"
52 activePrefix = "ciactive/"
53 jobTokPrefix = "jobtoken/"
54 idFormat = "%020d"
55)
56
57// CI is the ermite-kv backed ports.CIStore. It shares the database of the
58// Store it comes from.
59type CI struct {
60 db *kv.DB
61}
62
63var _ ports.CIStore = (*CI)(nil)
64
65// CI returns the CI store over the same database. It is valid until the Store
66// is closed.
67func (s *Store) CI() *CI { return &CI{db: s.db} }
68
69func runKey(repo domain.RepoName, id int64) []byte {
70 return []byte(runPrefix + repo.String() + "/" + fmt.Sprintf(idFormat, id))
71}
72func runsPrefix(repo domain.RepoName) string { return runPrefix + repo.String() + "/" }
73func runSrcKey(id int64) []byte { return []byte(runSrcPrefix + fmt.Sprintf(idFormat, id)) }
74func openKey(id int64) []byte { return []byte(openPrefix + fmt.Sprintf(idFormat, id)) }
75func jobsPrefix(runID int64) string { return jobPrefix + fmt.Sprintf(idFormat, runID) + "/" }
76func jobAttemptPrefix(runID int64, attempt int32) string {
77 return jobsPrefix(runID) + fmt.Sprintf("%010d", attempt) + "/"
78}
79func jobRef(runID int64, attempt int32, id string) string {
80 return jobAttemptPrefix(runID, attempt) + id
81}
82func jobKey(runID int64, attempt int32, id string) []byte { return []byte(jobRef(runID, attempt, id)) }
83func checksPrefix(repo domain.RepoName, sha string) string {
84 return checkPrefix + repo.String() + "/" + sha + "/"
85}
86func checkKey(repo domain.RepoName, sha, id string) []byte {
87 return []byte(checksPrefix(repo, sha) + id)
88}
89func runNumKey(repo domain.RepoName, workflow string) []byte {
90 return []byte(runNumPrefix + repo.String() + "/" + workflow)
91}
92func readyKey(at time.Time, ref string) []byte {
93 return []byte(readyPrefix + fmt.Sprintf(idFormat, at.UnixNano()) + "/" + ref)
94}
95func activeKey(ref string) []byte { return []byte(activePrefix + ref) }
96func jobTokenKey(h domain.TokenHash) []byte {
97 return []byte(jobTokPrefix + h.String())
98}
99
100// --- encoding ---
101
102func encodeStatus(s domain.CIStatus) storagepb.CIStatus {
103 switch s {
104 case domain.CIQueued:
105 return storagepb.CIStatus_CI_STATUS_QUEUED
106 case domain.CIInProgress:
107 return storagepb.CIStatus_CI_STATUS_IN_PROGRESS
108 case domain.CICompleted:
109 return storagepb.CIStatus_CI_STATUS_COMPLETED
110 }
111 return storagepb.CIStatus_CI_STATUS_UNSPECIFIED
112}
113
114func decodeStatus(s storagepb.CIStatus) domain.CIStatus {
115 switch s {
116 case storagepb.CIStatus_CI_STATUS_IN_PROGRESS:
117 return domain.CIInProgress
118 case storagepb.CIStatus_CI_STATUS_COMPLETED:
119 return domain.CICompleted
120 }
121 return domain.CIQueued
122}
123
124var conclusions = []struct {
125 d domain.CIConclusion
126 p storagepb.CIConclusion
127}{
128 {domain.CISuccess, storagepb.CIConclusion_CI_CONCLUSION_SUCCESS},
129 {domain.CIFailure, storagepb.CIConclusion_CI_CONCLUSION_FAILURE},
130 {domain.CICancelled, storagepb.CIConclusion_CI_CONCLUSION_CANCELLED},
131 {domain.CISkipped, storagepb.CIConclusion_CI_CONCLUSION_SKIPPED},
132 {domain.CITimedOut, storagepb.CIConclusion_CI_CONCLUSION_TIMED_OUT},
133 {domain.CINeutral, storagepb.CIConclusion_CI_CONCLUSION_NEUTRAL},
134 {domain.CIActionRequired, storagepb.CIConclusion_CI_CONCLUSION_ACTION_REQUIRED},
135}
136
137func encodeConclusion(c domain.CIConclusion) storagepb.CIConclusion {
138 for _, e := range conclusions {
139 if e.d == c {
140 return e.p
141 }
142 }
143 return storagepb.CIConclusion_CI_CONCLUSION_UNSPECIFIED
144}
145
146func decodeConclusion(c storagepb.CIConclusion) domain.CIConclusion {
147 for _, e := range conclusions {
148 if e.p == c {
149 return e.d
150 }
151 }
152 return domain.CINoConclusion
153}
154
155var phases = []struct {
156 d domain.CIJobPhase
157 p storagepb.CIJobPhase
158}{
159 {domain.CIPhaseWaiting, storagepb.CIJobPhase_CI_JOB_PHASE_WAITING},
160 {domain.CIPhaseReady, storagepb.CIJobPhase_CI_JOB_PHASE_READY},
161 {domain.CIPhaseAssigned, storagepb.CIJobPhase_CI_JOB_PHASE_ASSIGNED},
162 {domain.CIPhaseRunning, storagepb.CIJobPhase_CI_JOB_PHASE_RUNNING},
163 {domain.CIPhaseEnded, storagepb.CIJobPhase_CI_JOB_PHASE_ENDED},
164}
165
166func encodePhase(p domain.CIJobPhase) (storagepb.CIJobPhase, error) {
167 for _, e := range phases {
168 if e.d == p {
169 return e.p, nil
170 }
171 }
172 return 0, fmt.Errorf("invalid job phase %q", p)
173}
174
175func decodePhase(p storagepb.CIJobPhase) domain.CIJobPhase {
176 for _, e := range phases {
177 if e.p == p {
178 return e.d
179 }
180 }
181 return domain.CIPhaseWaiting
182}
183
184var isolations = []struct {
185 d domain.CIIsolation
186 p storagepb.CIIsolation
187}{
188 {domain.CIIsolationVM, storagepb.CIIsolation_CI_ISOLATION_VM},
189 {domain.CIIsolationContainerSysbox, storagepb.CIIsolation_CI_ISOLATION_CONTAINER_SYSBOX},
190 {domain.CIIsolationContainerPrivileged, storagepb.CIIsolation_CI_ISOLATION_CONTAINER_PRIVILEGED},
191}
192
193func encodeIsolation(i domain.CIIsolation) storagepb.CIIsolation {
194 for _, e := range isolations {
195 if e.d == i {
196 return e.p
197 }
198 }
199 return storagepb.CIIsolation_CI_ISOLATION_UNSPECIFIED
200}
201
202func decodeIsolation(i storagepb.CIIsolation) domain.CIIsolation {
203 for _, e := range isolations {
204 if e.p == i {
205 return e.d
206 }
207 }
208 return domain.CIIsolationNone
209}
210
211var stepStates = []struct {
212 d domain.CIStepState
213 p storagepb.CIStepState
214}{
215 {domain.CIStepPending, storagepb.CIStepState_CI_STEP_STATE_PENDING},
216 {domain.CIStepInProgress, storagepb.CIStepState_CI_STEP_STATE_IN_PROGRESS},
217 {domain.CIStepCompleted, storagepb.CIStepState_CI_STEP_STATE_COMPLETED},
218}
219
220func encodeStepState(s domain.CIStepState) storagepb.CIStepState {
221 for _, e := range stepStates {
222 if e.d == s {
223 return e.p
224 }
225 }
226 return storagepb.CIStepState_CI_STEP_STATE_UNSPECIFIED
227}
228
229func decodeStepState(s storagepb.CIStepState) domain.CIStepState {
230 for _, e := range stepStates {
231 if e.p == s {
232 return e.d
233 }
234 }
235 return domain.CIStepPending
236}
237
238var cancelReasons = []struct {
239 d domain.CICancelReason
240 p storagepb.CICancelReason
241}{
242 {domain.CICancelUser, storagepb.CICancelReason_CI_CANCEL_REASON_USER},
243 {domain.CICancelFailFast, storagepb.CICancelReason_CI_CANCEL_REASON_FAIL_FAST},
244 {domain.CICancelTimeout, storagepb.CICancelReason_CI_CANCEL_REASON_TIMEOUT},
245}
246
247func encodeCancelReason(r domain.CICancelReason) storagepb.CICancelReason {
248 for _, e := range cancelReasons {
249 if e.d == r {
250 return e.p
251 }
252 }
253 return storagepb.CICancelReason_CI_CANCEL_REASON_UNSPECIFIED
254}
255
256func decodeCancelReason(r storagepb.CICancelReason) domain.CICancelReason {
257 for _, e := range cancelReasons {
258 if e.p == r {
259 return e.d
260 }
261 }
262 return domain.CICancelNone
263}
264
265func encodeRun(r domain.CIRun) ([]byte, error) {
266 if r.Repo.IsZero() {
267 return nil, fmt.Errorf("%w: empty name", domain.ErrInvalidName)
268 }
269 return mustMarshal(&storagepb.CIRun{
270 Repo: r.Repo.String(), Id: r.ID, Number: r.Number, Attempt: r.Attempt,
271 WorkflowPath: r.WorkflowPath, WorkflowName: r.WorkflowName, Title: r.Title,
272 Event: r.Event, Ref: r.Ref, Sha: r.Sha, Actor: r.Actor,
273 Status: encodeStatus(r.Status), Conclusion: encodeConclusion(r.Conclusion), Error: r.Error,
274 CreatedAt: encodeTime(r.CreatedAt), StartedAt: encodeTime(r.StartedAt), CompletedAt: encodeTime(r.CompletedAt),
275 CancelRequested: r.CancelRequested, CancelRequestedAt: encodeTime(r.CancelRequestedAt),
276 }), nil
277}
278
279func decodeRun(item kv.Item) (domain.CIRun, error) {
280 var pb storagepb.CIRun
281 if err := proto.Unmarshal(item.Value, &pb); err != nil {
282 return domain.CIRun{}, fmt.Errorf("decode run: %w", err)
283 }
284 repo, err := domain.ParseRepoName(pb.GetRepo())
285 if err != nil {
286 return domain.CIRun{}, fmt.Errorf("decode run: %w", err)
287 }
288 return domain.CIRun{
289 Repo: repo, ID: pb.GetId(), Number: pb.GetNumber(), Attempt: pb.GetAttempt(),
290 WorkflowPath: pb.GetWorkflowPath(), WorkflowName: pb.GetWorkflowName(), Title: pb.GetTitle(),
291 Event: pb.GetEvent(), Ref: pb.GetRef(), Sha: pb.GetSha(), Actor: pb.GetActor(),
292 Status: decodeStatus(pb.GetStatus()), Conclusion: decodeConclusion(pb.GetConclusion()), Error: pb.GetError(),
293 CreatedAt: decodeTime(pb.GetCreatedAt()), StartedAt: decodeTime(pb.GetStartedAt()), CompletedAt: decodeTime(pb.GetCompletedAt()),
294 CancelRequested: pb.GetCancelRequested(), CancelRequestedAt: decodeTime(pb.GetCancelRequestedAt()),
295 Version: item.Version,
296 }, nil
297}
298
299func encodeJob(j domain.CIJob) ([]byte, error) {
300 if j.Repo.IsZero() {
301 return nil, fmt.Errorf("%w: empty name", domain.ErrInvalidName)
302 }
303 phase, err := encodePhase(j.Phase)
304 if err != nil {
305 return nil, err
306 }
307 steps := make([]*storagepb.CIStep, 0, len(j.Steps))
308 for _, s := range j.Steps {
309 steps = append(steps, &storagepb.CIStep{
310 Number: s.Number, Name: s.Name, State: encodeStepState(s.State), Conclusion: encodeConclusion(s.Conclusion),
311 StartedAt: encodeTime(s.StartedAt), CompletedAt: encodeTime(s.CompletedAt),
312 LogStart: s.LogStart, HasLines: s.HasLines,
313 })
314 }
315 pb := &storagepb.CIJob{
316 Id: j.ID, RunId: j.RunID, Attempt: j.Attempt, RunAttempt: j.RunAttempt, Repo: j.Repo.String(),
317 Key: j.Key, Name: j.Name, Phase: phase, Conclusion: encodeConclusion(j.Conclusion), Error: j.Error,
318 Labels: j.Labels, Needs: j.Needs, RunnerId: j.RunnerID, RunnerName: j.RunnerName,
319 Isolation: encodeIsolation(j.Isolation), Steps: steps,
320 CreatedAt: encodeTime(j.CreatedAt), StartedAt: encodeTime(j.StartedAt), CompletedAt: encodeTime(j.CompletedAt),
321 Index: int32(j.Index), Combo: int32(j.Combo), MatrixJson: j.MatrixJSON, ContextsJson: j.ContextsJSON, ComboTotal: int32(j.ComboTotal), //nolint:gosec // bounded by the 256 job limit
322 TimeoutMinutes: j.TimeoutMinutes, FailFast: j.FailFast, ContinueOnError: j.ContinueOnError, MaxParallel: int32(j.MaxParallel), //nolint:gosec // validated at planning
323 Outputs: j.Outputs, ReadyAt: encodeTime(j.ReadyAt), AssignedAt: encodeTime(j.AssignedAt),
324 AckedAt: encodeTime(j.AckedAt), AssignCancels: int32(j.AssignCancels), //nolint:gosec // at most 3
325 TokenValidUntil: encodeTime(j.TokenValidUntil), CancelReason: encodeCancelReason(j.CancelReason), CancelMessage: j.CancelMessage,
326 CancelRequestedAt: encodeTime(j.CancelRequestedAt), Planned: j.Planned,
327 }
328 if j.HasToken() {
329 pb.TokenHash = j.TokenHash[:]
330 }
331 return mustMarshal(pb), nil
332}
333
334//hexcheck:ignore suspicious-business-logic-in-adapter maps the stored record to the domain job field by field: no rule is decided here
335func decodeJob(item kv.Item) (domain.CIJob, error) {
336 var pb storagepb.CIJob
337 if err := proto.Unmarshal(item.Value, &pb); err != nil {
338 return domain.CIJob{}, fmt.Errorf("decode job: %w", err)
339 }
340 repo, err := domain.ParseRepoName(pb.GetRepo())
341 if err != nil {
342 return domain.CIJob{}, fmt.Errorf("decode job: %w", err)
343 }
344 j := domain.CIJob{
345 ID: pb.GetId(), RunID: pb.GetRunId(), Attempt: pb.GetAttempt(), RunAttempt: pb.GetRunAttempt(), Repo: repo,
346 Key: pb.GetKey(), Name: pb.GetName(), Phase: decodePhase(pb.GetPhase()), Conclusion: decodeConclusion(pb.GetConclusion()),
347 Error: pb.GetError(), Labels: pb.GetLabels(), Needs: pb.GetNeeds(), RunnerID: pb.GetRunnerId(), RunnerName: pb.GetRunnerName(),
348 Isolation: decodeIsolation(pb.GetIsolation()),
349 CreatedAt: decodeTime(pb.GetCreatedAt()), StartedAt: decodeTime(pb.GetStartedAt()), CompletedAt: decodeTime(pb.GetCompletedAt()),
350 Index: int(pb.GetIndex()), Combo: int(pb.GetCombo()), Planned: pb.GetPlanned(), MatrixJSON: pb.GetMatrixJson(), ContextsJSON: pb.GetContextsJson(),
351 ComboTotal: int(pb.GetComboTotal()), TimeoutMinutes: pb.GetTimeoutMinutes(), FailFast: pb.GetFailFast(), ContinueOnError: pb.GetContinueOnError(),
352 MaxParallel: int(pb.GetMaxParallel()), Outputs: pb.GetOutputs(),
353 TokenValidUntil: decodeTime(pb.GetTokenValidUntil()), ReadyAt: decodeTime(pb.GetReadyAt()),
354 AssignedAt: decodeTime(pb.GetAssignedAt()), AckedAt: decodeTime(pb.GetAckedAt()), AssignCancels: int(pb.GetAssignCancels()),
355 CancelMessage: pb.GetCancelMessage(), CancelRequestedAt: decodeTime(pb.GetCancelRequestedAt()),
356 Version: item.Version,
357 }
358 j.CancelReason = decodeCancelReason(pb.GetCancelReason())
359 copy(j.TokenHash[:], pb.GetTokenHash())
360 for _, s := range pb.GetSteps() {
361 j.Steps = append(j.Steps, domain.CIStep{
362 Number: s.GetNumber(), Name: s.GetName(), State: decodeStepState(s.GetState()), Conclusion: decodeConclusion(s.GetConclusion()),
363 StartedAt: decodeTime(s.GetStartedAt()), CompletedAt: decodeTime(s.GetCompletedAt()),
364 LogStart: s.GetLogStart(), HasLines: s.GetHasLines(),
365 })
366 }
367 return j, nil
368}
369
370func encodeCheck(c domain.CICheck) []byte {
371 return mustMarshal(&storagepb.CICheck{
372 Id: c.ID, Repo: c.Repo.String(), Sha: c.Sha, Name: c.Name, Status: encodeStatus(c.Status),
373 Conclusion: encodeConclusion(c.Conclusion), RunId: c.RunID, JobId: c.JobID, Attempt: c.Attempt,
374 WorkflowName: c.WorkflowName, CreatedAt: encodeTime(c.CreatedAt), StartedAt: encodeTime(c.StartedAt),
375 CompletedAt: encodeTime(c.CompletedAt),
376 })
377}
378
379func decodeCheck(item kv.Item) (domain.CICheck, error) {
380 var pb storagepb.CICheck
381 if err := proto.Unmarshal(item.Value, &pb); err != nil {
382 return domain.CICheck{}, fmt.Errorf("decode check: %w", err)
383 }
384 repo, err := domain.ParseRepoName(pb.GetRepo())
385 if err != nil {
386 return domain.CICheck{}, fmt.Errorf("decode check: %w", err)
387 }
388 return domain.CICheck{
389 ID: pb.GetId(), Repo: repo, Sha: pb.GetSha(), Name: pb.GetName(), Status: decodeStatus(pb.GetStatus()),
390 Conclusion: decodeConclusion(pb.GetConclusion()), RunID: pb.GetRunId(), JobID: pb.GetJobId(), Attempt: pb.GetAttempt(),
391 WorkflowName: pb.GetWorkflowName(), CreatedAt: decodeTime(pb.GetCreatedAt()), StartedAt: decodeTime(pb.GetStartedAt()),
392 CompletedAt: decodeTime(pb.GetCompletedAt()), Version: item.Version,
393 }, nil
394}
395
396func encodeRunner(r domain.CIRunner) []byte {
397 return mustMarshal(&storagepb.CIRunner{
398 Id: r.ID, Name: r.Name, Labels: r.Labels, Isolation: encodeIsolation(r.Isolation), Version: r.Version,
399 Arch: r.Arch, Os: r.OS, RegisteredAt: encodeTime(r.RegisteredAt), TokenHash: r.TokenHash[:],
400 })
401}
402
403func decodeRunner(val []byte) (domain.CIRunner, error) {
404 var pb storagepb.CIRunner
405 if err := proto.Unmarshal(val, &pb); err != nil {
406 return domain.CIRunner{}, fmt.Errorf("decode runner: %w", err)
407 }
408 r := domain.CIRunner{
409 ID: pb.GetId(), Name: pb.GetName(), Labels: pb.GetLabels(), Isolation: decodeIsolation(pb.GetIsolation()),
410 Version: pb.GetVersion(), Arch: pb.GetArch(), OS: pb.GetOs(), RegisteredAt: decodeTime(pb.GetRegisteredAt()),
411 }
412 copy(r.TokenHash[:], pb.GetTokenHash())
413 return r, nil
414}
415
416// --- writes ---
417
418// stage adds the preconditions and writes of one record to the batch.
419func stage(b *kv.Batch, key, val []byte, version uint64) {
420 if version == 0 {
421 b.RequireAbsent(key)
422 } else {
423 b.RequireVersion(key, version)
424 }
425 b.Put(key, val)
426}
427
428// runIndexes stages the open-run index of the run.
429func runIndexes(b *kv.Batch, r domain.CIRun) {
430 if r.Status == domain.CICompleted {
431 b.Delete(openKey(r.ID))
432 return
433 }
434 b.Put(openKey(r.ID), []byte(r.Repo.String()))
435}
436
437// jobIndexes stages the ready, active and token indexes of the job. old is
438// the job as stored before, if any: its token index goes when the token
439// changed.
440func (c *CI) jobIndexes(b *kv.Batch, j domain.CIJob, old *domain.CIJob) {
441 ref := jobRef(j.RunID, j.RunAttempt, j.ID)
442 if j.Phase == domain.CIPhaseReady {
443 b.Put(readyKey(j.ReadyAt, ref), nil)
444 } else {
445 b.Delete(readyKey(j.ReadyAt, ref))
446 }
447 if old != nil && old.Phase == domain.CIPhaseReady && (j.Phase != domain.CIPhaseReady || !old.ReadyAt.Equal(j.ReadyAt)) {
448 b.Delete(readyKey(old.ReadyAt, ref))
449 }
450 if j.Active() {
451 b.Put(activeKey(ref), nil)
452 } else {
453 b.Delete(activeKey(ref))
454 }
455 if old != nil && old.HasToken() && old.TokenHash != j.TokenHash {
456 b.Delete(jobTokenKey(old.TokenHash))
457 }
458 if j.HasToken() {
459 b.Put(jobTokenKey(j.TokenHash), []byte(ref))
460 }
461}
462
463// guardRepo is guardRepoKey on the database of the CI store, for the writes that
464// create keys under a repository.
465func (c *CI) guardRepo(ctx context.Context, b *kv.Batch, repo domain.RepoName) error {
466 return guardRepoKey(ctx, c.db, b, repo)
467}
468
469// stageRecords stages the writes of w. guarded collects the repositories that
470// need guarding.
471func (c *CI) stageRecords(ctx context.Context, b *kv.Batch, w ports.CIWrite) (guarded map[domain.RepoName]bool, err error) {
472 guarded = map[domain.RepoName]bool{}
473 for _, r := range w.Runs {
474 val, err := encodeRun(r)
475 if err != nil {
476 return nil, err
477 }
478 stage(b, runKey(r.Repo, r.ID), val, r.Version)
479 runIndexes(b, r)
480 if r.Version == 0 {
481 guarded[r.Repo] = true
482 }
483 }
484 for _, j := range w.Jobs {
485 val, err := encodeJob(j)
486 if err != nil {
487 return nil, err
488 }
489 key := jobKey(j.RunID, j.RunAttempt, j.ID)
490 var old *domain.CIJob
491 if j.Version != 0 {
492 item, ok := c.db.Get(ctx, key)
493 if !ok {
494 return nil, domain.ErrConflict
495 }
496 if o, err := decodeJob(item); err == nil {
497 old = &o
498 }
499 }
500 stage(b, key, val, j.Version)
501 c.jobIndexes(b, j, old)
502 if j.Version == 0 {
503 guarded[j.Repo] = true
504 }
505 }
506 // Checks are derived from the jobs, written in the same batch and under
507 // the lock of their run: they are upserted without a precondition.
508 for _, ck := range w.Checks {
509 b.Put(checkKey(ck.Repo, ck.Sha, ck.ID), encodeCheck(ck))
510 }
511 return guarded, nil
512}
513
514// Apply writes the records in one batch.
515//
516//hexcheck:ignore suspicious-business-logic-in-adapter stages records and their derived indexes under version preconditions: a storage concern, no policy
517func (c *CI) Apply(ctx context.Context, w ports.CIWrite) (uint64, error) {
518 var b kv.Batch
519 guarded, err := c.stageRecords(ctx, &b, w)
520 if err != nil {
521 return 0, err
522 }
523 for repo := range guarded {
524 if err := c.guardRepo(ctx, &b, repo); err != nil {
525 return 0, err
526 }
527 }
528 v, err := c.db.Apply(ctx, &b)
529 if errors.Is(err, kv.ErrConflict) {
530 return 0, domain.ErrConflict
531 }
532 if err != nil {
533 return 0, fmt.Errorf("write CI records: %w", err)
534 }
535 return v, nil
536}
537
538func (c *CI) readSeq(ctx context.Context, key []byte) (last int64, item kv.Item, ok bool, err error) {
539 item, ok = c.db.Get(ctx, key)
540 if !ok {
541 return 0, kv.Item{}, false, nil
542 }
543 var seq storagepb.Sequence
544 if err := proto.Unmarshal(item.Value, &seq); err != nil {
545 return 0, kv.Item{}, false, fmt.Errorf("decode sequence %s: %w", key, err)
546 }
547 return seq.GetLast(), item, true, nil
548}
549
550func stageSeq(b *kv.Batch, key []byte, item kv.Item, ok bool, last int64) {
551 if ok {
552 b.RequireVersion(key, item.Version)
553 } else {
554 b.RequireAbsent(key)
555 }
556 b.Put(key, mustMarshal(&storagepb.Sequence{Last: last}))
557}
558
559const createRunTries = 20
560
561// CreateRun takes the next run id and number and stores the draft.
562//
563//hexcheck:ignore suspicious-business-logic-in-adapter takes the next numbers under version preconditions: a storage concern, no policy
564func (c *CI) CreateRun(ctx context.Context, repo domain.RepoName, workflowPath string, build func(id, number int64) ports.CIRunDraft) (ports.CIRunDraft, error) {
565 for range createRunTries {
566 lastID, idItem, idOK, err := c.readSeq(ctx, []byte(runSeqKey))
567 if err != nil {
568 return ports.CIRunDraft{}, err
569 }
570 numKey := runNumKey(repo, workflowPath)
571 lastNum, numItem, numOK, err := c.readSeq(ctx, numKey)
572 if err != nil {
573 return ports.CIRunDraft{}, err
574 }
575 draft := build(lastID+1, lastNum+1)
576 var b kv.Batch
577 stageSeq(&b, []byte(runSeqKey), idItem, idOK, lastID+1)
578 stageSeq(&b, numKey, numItem, numOK, lastNum+1)
579 w := ports.CIWrite{Runs: []domain.CIRun{draft.Run}, Jobs: draft.Jobs, Checks: draft.Checks}
580 guarded, err := c.stageRecords(ctx, &b, w)
581 if err != nil {
582 return ports.CIRunDraft{}, err
583 }
584 src := mustMarshal(&storagepb.CIRunSource{
585 RunId: draft.Run.ID, Repo: repo.String(), WorkflowYaml: draft.Source.WorkflowYAML, GithubJson: draft.Source.GithubJSON,
586 })
587 b.RequireAbsent(runSrcKey(draft.Run.ID))
588 b.Put(runSrcKey(draft.Run.ID), src)
589 for r := range guarded {
590 if err := c.guardRepo(ctx, &b, r); err != nil {
591 return ports.CIRunDraft{}, err
592 }
593 }
594 v, err := c.db.Apply(ctx, &b)
595 if errors.Is(err, kv.ErrConflict) {
596 continue
597 }
598 if err != nil {
599 return ports.CIRunDraft{}, fmt.Errorf("create run of %q: %w", repo, err)
600 }
601 draft.Run.Version = v
602 for i := range draft.Jobs {
603 draft.Jobs[i].Version = v
604 }
605 for i := range draft.Checks {
606 draft.Checks[i].Version = v
607 }
608 return draft, nil
609 }
610 return ports.CIRunDraft{}, fmt.Errorf("create run of %q: %w", repo, domain.ErrConflict)
611}
612
613// --- reads ---
614
615// GetRun reads run/<repo>/<id>.
616func (c *CI) GetRun(ctx context.Context, repo domain.RepoName, id int64) (domain.CIRun, error) {
617 item, ok := c.db.Get(ctx, runKey(repo, id))
618 if !ok || id < 1 {
619 return domain.CIRun{}, domain.ErrNotFound
620 }
621 return decodeRun(item)
622}
623
624// GetRunSource reads runsrc/<id>.
625func (c *CI) GetRunSource(ctx context.Context, runID int64) (domain.CIRunSource, error) {
626 item, ok := c.db.Get(ctx, runSrcKey(runID))
627 if !ok || runID < 1 {
628 return domain.CIRunSource{}, domain.ErrNotFound
629 }
630 var pb storagepb.CIRunSource
631 if err := proto.Unmarshal(item.Value, &pb); err != nil {
632 return domain.CIRunSource{}, fmt.Errorf("decode run source: %w", err)
633 }
634 repo, err := domain.ParseRepoName(pb.GetRepo())
635 if err != nil {
636 return domain.CIRunSource{}, fmt.Errorf("decode run source: %w", err)
637 }
638 return domain.CIRunSource{RunID: pb.GetRunId(), Repo: repo, WorkflowYAML: pb.GetWorkflowYaml(), GithubJSON: pb.GetGithubJson()}, nil
639}
640
641// ListRuns returns the runs of the repository, newest first.
642//
643//hexcheck:ignore suspicious-business-logic-in-adapter port method name only: reads a key range and reverses it
644func (c *CI) ListRuns(ctx context.Context, repo domain.RepoName, before int64, limit int) ([]domain.CIRun, error) {
645 start := []byte(runsPrefix(repo))
646 end := []byte(runPrefix + repo.String() + "0") // '/' + 1
647 if before > 0 {
648 end = runKey(repo, before)
649 }
650 // Collect then reverse: the snapshot iterates in key order only.
651 var all []domain.CIRun
652 for key, item := range c.db.Snapshot(ctx).Range(ctx, start, end) {
653 r, err := decodeRun(item)
654 if err != nil {
655 return nil, fmt.Errorf("decode %s: %w", key, err)
656 }
657 all = append(all, r)
658 }
659 slices.Reverse(all)
660 if limit > 0 && len(all) > limit {
661 all = all[:limit]
662 }
663 return all, nil
664}
665
666// RunIDs returns the ids of the runs of the repository.
667func (c *CI) RunIDs(ctx context.Context, repo domain.RepoName) ([]int64, error) {
668 var ids []int64
669 prefix := runsPrefix(repo)
670 for key := range c.db.Snapshot(ctx).Prefix(ctx, []byte(prefix)) {
671 id, err := strconv.ParseInt(strings.TrimPrefix(string(key), prefix), 10, 64)
672 if err != nil {
673 return nil, fmt.Errorf("decode %s: %w", key, err)
674 }
675 ids = append(ids, id)
676 }
677 return ids, nil
678}
679
680// ListOpenRuns returns the runs that have not completed.
681func (c *CI) ListOpenRuns(ctx context.Context) ([]domain.CIRun, error) {
682 snap := c.db.Snapshot(ctx)
683 var out []domain.CIRun
684 for key, item := range snap.Prefix(ctx, []byte(openPrefix)) {
685 id, err := strconv.ParseInt(strings.TrimPrefix(string(key), openPrefix), 10, 64)
686 if err != nil {
687 return nil, fmt.Errorf("decode %s: %w", key, err)
688 }
689 repo, err := domain.ParseRepoName(string(item.Value))
690 if err != nil {
691 return nil, err
692 }
693 it, ok := snap.Get(ctx, runKey(repo, id))
694 if !ok {
695 continue
696 }
697 r, err := decodeRun(it)
698 if err != nil {
699 return nil, err
700 }
701 out = append(out, r)
702 }
703 return out, nil
704}
705
706// GetJob reads one job record.
707func (c *CI) GetJob(ctx context.Context, runID int64, runAttempt int32, jobID string) (domain.CIJob, error) {
708 item, ok := c.db.Get(ctx, jobKey(runID, runAttempt, jobID))
709 if !ok {
710 return domain.CIJob{}, domain.ErrNotFound
711 }
712 return decodeJob(item)
713}
714
715// ListJobs returns the jobs of a run at an attempt, ordered by index then
716// combination.
717func (c *CI) ListJobs(ctx context.Context, runID int64, runAttempt int32) ([]domain.CIJob, error) {
718 var out []domain.CIJob
719 for key, item := range c.db.Snapshot(ctx).Prefix(ctx, []byte(jobAttemptPrefix(runID, runAttempt))) {
720 j, err := decodeJob(item)
721 if err != nil {
722 return nil, fmt.Errorf("decode %s: %w", key, err)
723 }
724 out = append(out, j)
725 }
726 slices.SortFunc(out, func(a, b domain.CIJob) int {
727 if a.Index != b.Index {
728 return a.Index - b.Index
729 }
730 return a.Combo - b.Combo
731 })
732 return out, nil
733}
734
735func (c *CI) jobByRef(snap *kv.Snapshot, ctx context.Context, ref string) (domain.CIJob, error) {
736 item, ok := snap.Get(ctx, []byte(ref))
737 if !ok {
738 return domain.CIJob{}, domain.ErrNotFound
739 }
740 return decodeJob(item)
741}
742
743// FindJobByToken resolves the token index, then reads the job.
744func (c *CI) FindJobByToken(ctx context.Context, hash domain.TokenHash, now time.Time) (domain.CIJob, error) {
745 snap := c.db.Snapshot(ctx)
746 idx, ok := snap.Get(ctx, jobTokenKey(hash))
747 if !ok {
748 return domain.CIJob{}, domain.ErrNotFound
749 }
750 j, err := c.jobByRef(snap, ctx, string(idx.Value))
751 if err != nil {
752 return domain.CIJob{}, err
753 }
754 if j.TokenHash != hash || (!j.TokenValidUntil.IsZero() && !now.Before(j.TokenValidUntil)) {
755 return domain.CIJob{}, domain.ErrNotFound
756 }
757 return j, nil
758}
759
760// ListReadyJobs returns the jobs waiting for a runner, oldest first.
761func (c *CI) ListReadyJobs(ctx context.Context, limit int) ([]domain.CIJob, error) {
762 snap := c.db.Snapshot(ctx)
763 var out []domain.CIJob
764 for key := range snap.Prefix(ctx, []byte(readyPrefix)) {
765 ref := string(key)[len(readyPrefix)+len(fmt.Sprintf(idFormat, 0))+1:]
766 j, err := c.jobByRef(snap, ctx, ref)
767 if errors.Is(err, domain.ErrNotFound) {
768 continue
769 }
770 if err != nil {
771 return nil, err
772 }
773 out = append(out, j)
774 if limit > 0 && len(out) >= limit {
775 break
776 }
777 }
778 return out, nil
779}
780
781// ListActiveJobs returns the jobs given to a runner and not over.
782func (c *CI) ListActiveJobs(ctx context.Context) ([]domain.CIJob, error) {
783 snap := c.db.Snapshot(ctx)
784 var out []domain.CIJob
785 for key := range snap.Prefix(ctx, []byte(activePrefix)) {
786 j, err := c.jobByRef(snap, ctx, strings.TrimPrefix(string(key), activePrefix))
787 if errors.Is(err, domain.ErrNotFound) {
788 continue
789 }
790 if err != nil {
791 return nil, err
792 }
793 out = append(out, j)
794 }
795 return out, nil
796}
797
798// ListChecks returns the checks of a commit ordered by creation time then
799// name.
800func (c *CI) ListChecks(ctx context.Context, repo domain.RepoName, sha string, limit int) ([]domain.CICheck, bool, error) {
801 var out []domain.CICheck
802 for key, item := range c.db.Snapshot(ctx).Prefix(ctx, []byte(checksPrefix(repo, sha))) {
803 ck, err := decodeCheck(item)
804 if err != nil {
805 return nil, false, fmt.Errorf("decode %s: %w", key, err)
806 }
807 out = append(out, ck)
808 }
809 slices.SortStableFunc(out, func(a, b domain.CICheck) int {
810 if d := a.CreatedAt.Compare(b.CreatedAt); d != 0 {
811 return d
812 }
813 return strings.Compare(a.Name, b.Name)
814 })
815 more := limit > 0 && len(out) > limit
816 if more {
817 out = out[:limit]
818 }
819 return out, more, nil
820}
821
822// --- retention and deletion ---
823
824// runKeys lists every key of a run except its record: source, jobs, indexes.
825func (c *CI) runKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName, runID int64) [][]byte {
826 keys := [][]byte{runKey(repo, runID), runSrcKey(runID), openKey(runID)}
827 for key, item := range snap.Prefix(ctx, []byte(jobsPrefix(runID))) {
828 keys = append(keys, slices.Clone(key))
829 if j, err := decodeJob(item); err == nil {
830 ref := jobRef(j.RunID, j.RunAttempt, j.ID)
831 keys = append(keys, readyKey(j.ReadyAt, ref), activeKey(ref))
832 if j.HasToken() {
833 keys = append(keys, jobTokenKey(j.TokenHash))
834 }
835 }
836 }
837 return keys
838}
839
840// ciKeys lists every key of the snapshot that belongs to the repository's CI
841// runs, checks and numbers.
842func ciKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][]byte {
843 c := &CI{}
844 var keys [][]byte
845 for key, item := range snap.Prefix(ctx, []byte(runsPrefix(repo))) {
846 if r, err := decodeRun(item); err == nil {
847 keys = append(keys, c.runKeys(ctx, snap, repo, r.ID)...)
848 }
849 keys = append(keys, slices.Clone(key))
850 }
851 for _, p := range []string{checkPrefix + repo.String() + "/", runNumPrefix + repo.String() + "/"} {
852 for k := range snap.Prefix(ctx, []byte(p)) {
853 keys = append(keys, slices.Clone(k))
854 }
855 }
856 return keys
857}
858
859const sweepChunk = 500
860
861// Sweep removes the runs completed before the time.
862//
863//hexcheck:ignore suspicious-business-logic-in-adapter chunked delete of expired records: a storage concern, the retention is decided by the caller
864func (c *CI) Sweep(ctx context.Context, before time.Time) ([]int64, error) {
865 snap := c.db.Snapshot(ctx)
866 var ids []int64
867 var keys [][]byte
868 for _, rp := range []string{runPrefix} {
869 for _, item := range snap.Prefix(ctx, []byte(rp)) {
870 r, err := decodeRun(item)
871 if err != nil {
872 return nil, err
873 }
874 if r.Status != domain.CICompleted || r.CompletedAt.IsZero() || !r.CompletedAt.Before(before) {
875 continue
876 }
877 ids = append(ids, r.ID)
878 keys = append(keys, c.runKeys(ctx, snap, r.Repo, r.ID)...)
879 for ck := range snap.Prefix(ctx, []byte(checkPrefix+r.Repo.String()+"/"+r.Sha+"/")) {
880 // A check belongs to the run only while it is the latest one
881 // for its job: leave it when another run replaced it.
882 if it, ok := snap.Get(ctx, ck); ok {
883 if chk, err := decodeCheck(it); err == nil && chk.RunID == r.ID {
884 keys = append(keys, slices.Clone(ck))
885 }
886 }
887 }
888 }
889 }
890 for len(keys) > 0 {
891 n := min(sweepChunk, len(keys))
892 var b kv.Batch
893 for _, k := range keys[:n] {
894 b.Delete(k)
895 }
896 if _, err := c.db.Apply(ctx, &b); err != nil {
897 return nil, fmt.Errorf("sweep CI runs: %w", err)
898 }
899 keys = keys[n:]
900 }
901 return ids, nil
902}
903
904// --- runners ---
905
906func runnerKey(id string) []byte { return []byte(runnerPrefix + id) }
907func runnerNameKey(n string) []byte { return []byte(runnerName + strings.ToLower(n)) }
908func runnerTokenKey(h domain.TokenHash) []byte { return []byte(runnerTokPref + h.String()) }
909
910// CreateRunner stores the runner, its name index and its token index.
911//
912//hexcheck:ignore suspicious-business-logic-in-adapter replaces a runner of the same name in one batch: a storage concern, the rule is decided by the caller
913func (c *CI) CreateRunner(ctx context.Context, r domain.CIRunner, replace bool) ([]domain.CIRunner, error) {
914 for range createRunTries {
915 var b kv.Batch
916 var removed []domain.CIRunner
917 if idx, ok := c.db.Get(ctx, runnerNameKey(r.Name)); ok {
918 if !replace {
919 return nil, domain.ErrAlreadyExists
920 }
921 old, err := c.GetRunner(ctx, string(idx.Value))
922 if err != nil && !errors.Is(err, domain.ErrNotFound) {
923 return nil, err
924 }
925 b.RequireVersion(runnerNameKey(r.Name), idx.Version)
926 if err == nil {
927 removed = append(removed, old)
928 b.Delete(runnerKey(old.ID))
929 b.Delete(runnerTokenKey(old.TokenHash))
930 }
931 } else {
932 b.RequireAbsent(runnerNameKey(r.Name))
933 }
934 b.RequireAbsent(runnerKey(r.ID))
935 b.RequireAbsent(runnerTokenKey(r.TokenHash))
936 b.Put(runnerKey(r.ID), encodeRunner(r))
937 b.Put(runnerNameKey(r.Name), []byte(r.ID))
938 b.Put(runnerTokenKey(r.TokenHash), []byte(r.ID))
939 _, err := c.db.Apply(ctx, &b)
940 if errors.Is(err, kv.ErrConflict) {
941 if !replace {
942 if _, ok := c.db.Get(ctx, runnerNameKey(r.Name)); ok {
943 return nil, domain.ErrAlreadyExists
944 }
945 }
946 continue
947 }
948 if err != nil {
949 return nil, fmt.Errorf("create runner %q: %w", r.Name, err)
950 }
951 return removed, nil
952 }
953 return nil, fmt.Errorf("create runner %q: %w", r.Name, domain.ErrConflict)
954}
955
956// GetRunner reads runner/<id>.
957func (c *CI) GetRunner(ctx context.Context, id string) (domain.CIRunner, error) {
958 item, ok := c.db.Get(ctx, runnerKey(id))
959 if !ok || id == "" || strings.Contains(id, "/") {
960 return domain.CIRunner{}, domain.ErrNotFound
961 }
962 return decodeRunner(item.Value)
963}
964
965// FindRunnerByToken resolves the token index, then reads the runner.
966func (c *CI) FindRunnerByToken(ctx context.Context, hash domain.TokenHash) (domain.CIRunner, error) {
967 snap := c.db.Snapshot(ctx)
968 idx, ok := snap.Get(ctx, runnerTokenKey(hash))
969 if !ok {
970 return domain.CIRunner{}, domain.ErrNotFound
971 }
972 item, ok := snap.Get(ctx, runnerKey(string(idx.Value)))
973 if !ok {
974 return domain.CIRunner{}, domain.ErrNotFound
975 }
976 r, err := decodeRunner(item.Value)
977 if err != nil {
978 return domain.CIRunner{}, err
979 }
980 if r.TokenHash != hash {
981 return domain.CIRunner{}, fmt.Errorf("runner %q: token index does not match the stored hash", r.ID)
982 }
983 return r, nil
984}
985
986// ListRunners returns every runner ordered by name.
987func (c *CI) ListRunners(ctx context.Context) ([]domain.CIRunner, error) {
988 var out []domain.CIRunner
989 for key, item := range c.db.Snapshot(ctx).Prefix(ctx, []byte(runnerPrefix)) {
990 r, err := decodeRunner(item.Value)
991 if err != nil {
992 return nil, fmt.Errorf("decode %s: %w", key, err)
993 }
994 out = append(out, r)
995 }
996 slices.SortFunc(out, func(a, b domain.CIRunner) int {
997 return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name))
998 })
999 return out, nil
1000}
1001
1002// RemoveRunner removes the runner, its name and its token.
1003func (c *CI) RemoveRunner(ctx context.Context, id string) (domain.CIRunner, error) {
1004 for range createRunTries {
1005 item, ok := c.db.Get(ctx, runnerKey(id))
1006 if !ok || id == "" || strings.Contains(id, "/") {
1007 return domain.CIRunner{}, domain.ErrNotFound
1008 }
1009 r, err := decodeRunner(item.Value)
1010 if err != nil {
1011 return domain.CIRunner{}, err
1012 }
1013 var b kv.Batch
1014 b.RequireVersion(runnerKey(id), item.Version)
1015 b.Delete(runnerKey(id))
1016 b.Delete(runnerNameKey(r.Name))
1017 b.Delete(runnerTokenKey(r.TokenHash))
1018 _, err = c.db.Apply(ctx, &b)
1019 if errors.Is(err, kv.ErrConflict) {
1020 continue
1021 }
1022 if err != nil {
1023 return domain.CIRunner{}, fmt.Errorf("remove runner %q: %w", id, err)
1024 }
1025 return r, nil
1026 }
1027 return domain.CIRunner{}, fmt.Errorf("remove runner %q: %w", id, domain.ErrConflict)
1028}