refactor(ci): reserve the removed storage fields and type the cancel reason
The storage schema marks the field numbers it dropped as reserved, and the cancel reason of a job is an enum with an explicit mapping instead of an index into a slice. The comment of the event stream names the live log feed.
- commit
- 6d7ddbac1cb5ce5a4753e1988ddbaf40b49342be
- author
- brice · · 2026-10-12 19:22 UTC
- parent
- 916e849
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +174 -58
M gen/ermite/storage/ci.pb.go +110 -49 generated file: collapsed
M internal/adapters/kvstore/ci.go +28 -6
| @@ -235,7 +235,32 @@ func decodeStepState(s storagepb.CIStepState) domain.CIStepState { | |||
| 235 | 235 | return domain.CIStepPending | |
| 236 | 236 | } | |
| 237 | 237 | ||
| 238 | - | var cancelReasons = []domain.CICancelReason{domain.CICancelNone, domain.CICancelUser, domain.CICancelFailFast, domain.CICancelTimeout} | |
| 238 | + | var 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 | + | ||
| 247 | + | func 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 | + | ||
| 256 | + | func 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 | + | } | |
| 239 | 264 | ||
| 240 | 265 | func encodeRun(r domain.CIRun) ([]byte, error) { | |
| 241 | 266 | if r.Repo.IsZero() { | |
| @@ -287,7 +312,6 @@ func encodeJob(j domain.CIJob) ([]byte, error) { | |||
| 287 | 312 | LogStart: s.LogStart, HasLines: s.HasLines, | |
| 288 | 313 | }) | |
| 289 | 314 | } | |
| 290 | - | reason := int32(slices.Index(cancelReasons, j.CancelReason)) | |
| 291 | 315 | pb := &storagepb.CIJob{ | |
| 292 | 316 | Id: j.ID, RunId: j.RunID, Attempt: j.Attempt, RunAttempt: j.RunAttempt, Repo: j.Repo.String(), | |
| 293 | 317 | Key: j.Key, Name: j.Name, Phase: phase, Conclusion: encodeConclusion(j.Conclusion), Error: j.Error, | |
| @@ -298,7 +322,7 @@ func encodeJob(j domain.CIJob) ([]byte, error) { | |||
| 298 | 322 | TimeoutMinutes: j.TimeoutMinutes, FailFast: j.FailFast, MaxParallel: int32(j.MaxParallel), //nolint:gosec // validated at planning | |
| 299 | 323 | Outputs: j.Outputs, ReadyAt: encodeTime(j.ReadyAt), AssignedAt: encodeTime(j.AssignedAt), | |
| 300 | 324 | AckedAt: encodeTime(j.AckedAt), AssignCancels: int32(j.AssignCancels), //nolint:gosec // at most 3 | |
| 301 | - | TokenValidUntil: encodeTime(j.TokenValidUntil), CancelReason: max(reason, 0), CancelMessage: j.CancelMessage, | |
| 325 | + | TokenValidUntil: encodeTime(j.TokenValidUntil), CancelReason: encodeCancelReason(j.CancelReason), CancelMessage: j.CancelMessage, | |
| 302 | 326 | CancelRequestedAt: encodeTime(j.CancelRequestedAt), Planned: j.Planned, | |
| 303 | 327 | } | |
| 304 | 328 | if j.HasToken() { | |
| @@ -331,9 +355,7 @@ func decodeJob(item kv.Item) (domain.CIJob, error) { | |||
| 331 | 355 | CancelMessage: pb.GetCancelMessage(), CancelRequestedAt: decodeTime(pb.GetCancelRequestedAt()), | |
| 332 | 356 | Version: item.Version, | |
| 333 | 357 | } | |
| 334 | - | if r := int(pb.GetCancelReason()); r >= 0 && r < len(cancelReasons) { | |
| 335 | - | j.CancelReason = cancelReasons[r] | |
| 336 | - | } | |
| 358 | + | j.CancelReason = decodeCancelReason(pb.GetCancelReason()) | |
| 337 | 359 | copy(j.TokenHash[:], pb.GetTokenHash()) | |
| 338 | 360 | for _, s := range pb.GetSteps() { | |
| 339 | 361 | j.Steps = append(j.Steps, domain.CIStep{ | |
| @@ -235,7 +235,32 @@ func decodeStepState(s storagepb.CIStepState) domain.CIStepState { | |||||
| 235 | return domain.CIStepPending | 235 | return domain.CIStepPending | ||
| 236 | } | 236 | } | ||
| 237 | 237 | ||||
| 238 | - | var cancelReasons = []domain.CICancelReason{domain.CICancelNone, domain.CICancelUser, domain.CICancelFailFast, domain.CICancelTimeout} | 238 | + | var 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 | + | ||||
| 247 | + | func 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 | + | ||||
| 256 | + | func 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 | + | } | |||
| 239 | 264 | ||||
| 240 | func encodeRun(r domain.CIRun) ([]byte, error) { | 265 | func encodeRun(r domain.CIRun) ([]byte, error) { | ||
| 241 | if r.Repo.IsZero() { | 266 | if r.Repo.IsZero() { | ||
| @@ -287,7 +312,6 @@ func encodeJob(j domain.CIJob) ([]byte, error) { | |||||
| 287 | LogStart: s.LogStart, HasLines: s.HasLines, | 312 | LogStart: s.LogStart, HasLines: s.HasLines, | ||
| 288 | }) | 313 | }) | ||
| 289 | } | 314 | } | ||
| 290 | - | reason := int32(slices.Index(cancelReasons, j.CancelReason)) | |||
| 291 | pb := &storagepb.CIJob{ | 315 | pb := &storagepb.CIJob{ | ||
| 292 | Id: j.ID, RunId: j.RunID, Attempt: j.Attempt, RunAttempt: j.RunAttempt, Repo: j.Repo.String(), | 316 | Id: j.ID, RunId: j.RunID, Attempt: j.Attempt, RunAttempt: j.RunAttempt, Repo: j.Repo.String(), | ||
| 293 | Key: j.Key, Name: j.Name, Phase: phase, Conclusion: encodeConclusion(j.Conclusion), Error: j.Error, | 317 | Key: j.Key, Name: j.Name, Phase: phase, Conclusion: encodeConclusion(j.Conclusion), Error: j.Error, | ||
| @@ -298,7 +322,7 @@ func encodeJob(j domain.CIJob) ([]byte, error) { | |||||
| 298 | TimeoutMinutes: j.TimeoutMinutes, FailFast: j.FailFast, MaxParallel: int32(j.MaxParallel), //nolint:gosec // validated at planning | 322 | TimeoutMinutes: j.TimeoutMinutes, FailFast: j.FailFast, MaxParallel: int32(j.MaxParallel), //nolint:gosec // validated at planning | ||
| 299 | Outputs: j.Outputs, ReadyAt: encodeTime(j.ReadyAt), AssignedAt: encodeTime(j.AssignedAt), | 323 | Outputs: j.Outputs, ReadyAt: encodeTime(j.ReadyAt), AssignedAt: encodeTime(j.AssignedAt), | ||
| 300 | AckedAt: encodeTime(j.AckedAt), AssignCancels: int32(j.AssignCancels), //nolint:gosec // at most 3 | 324 | AckedAt: encodeTime(j.AckedAt), AssignCancels: int32(j.AssignCancels), //nolint:gosec // at most 3 | ||
| 301 | - | TokenValidUntil: encodeTime(j.TokenValidUntil), CancelReason: max(reason, 0), CancelMessage: j.CancelMessage, | 325 | + | TokenValidUntil: encodeTime(j.TokenValidUntil), CancelReason: encodeCancelReason(j.CancelReason), CancelMessage: j.CancelMessage, |
| 302 | CancelRequestedAt: encodeTime(j.CancelRequestedAt), Planned: j.Planned, | 326 | CancelRequestedAt: encodeTime(j.CancelRequestedAt), Planned: j.Planned, | ||
| 303 | } | 327 | } | ||
| 304 | if j.HasToken() { | 328 | if j.HasToken() { | ||
| @@ -331,9 +355,7 @@ func decodeJob(item kv.Item) (domain.CIJob, error) { | |||||
| 331 | CancelMessage: pb.GetCancelMessage(), CancelRequestedAt: decodeTime(pb.GetCancelRequestedAt()), | 355 | CancelMessage: pb.GetCancelMessage(), CancelRequestedAt: decodeTime(pb.GetCancelRequestedAt()), | ||
| 332 | Version: item.Version, | 356 | Version: item.Version, | ||
| 333 | } | 357 | } | ||
| 334 | - | if r := int(pb.GetCancelReason()); r >= 0 && r < len(cancelReasons) { | 358 | + | j.CancelReason = decodeCancelReason(pb.GetCancelReason()) |
| 335 | - | j.CancelReason = cancelReasons[r] | |||
| 336 | - | } | |||
| 337 | copy(j.TokenHash[:], pb.GetTokenHash()) | 359 | copy(j.TokenHash[:], pb.GetTokenHash()) | ||
| 338 | for _, s := range pb.GetSteps() { | 360 | for _, s := range pb.GetSteps() { | ||
| 339 | j.Steps = append(j.Steps, domain.CIStep{ | 361 | j.Steps = append(j.Steps, domain.CIStep{ | ||
M internal/adapters/kvstore/ci_test.go +17 -0
| @@ -356,3 +356,20 @@ func TestCIStateSurvivesARestart(t *testing.T) { | |||
| 356 | 356 | active, _ := ci2.ListActiveJobs(ctx) | |
| 357 | 357 | assert.Len(t, active, 1) | |
| 358 | 358 | } | |
| 359 | + | ||
| 360 | + | func TestCICancelReasonsRoundTrip(t *testing.T) { | |
| 361 | + | ctx := context.Background() | |
| 362 | + | _, ci, n := ciRepo(t) | |
| 363 | + | d, err := ci.CreateRun(ctx, n, "w.yml", draftOf(n, "w.yml", 1)) | |
| 364 | + | require.NoError(t, err) | |
| 365 | + | for _, reason := range []domain.CICancelReason{domain.CICancelUser, domain.CICancelFailFast, domain.CICancelTimeout, domain.CICancelNone} { | |
| 366 | + | j, err := ci.GetJob(ctx, d.Run.ID, 1, d.Jobs[0].ID) | |
| 367 | + | require.NoError(t, err) | |
| 368 | + | j.CancelReason, j.CancelMessage, j.CancelRequestedAt = reason, "why", created | |
| 369 | + | _, err = ci.Apply(ctx, ports.CIWrite{Jobs: []domain.CIJob{j}}) | |
| 370 | + | require.NoError(t, err) | |
| 371 | + | got, err := ci.GetJob(ctx, d.Run.ID, 1, d.Jobs[0].ID) | |
| 372 | + | require.NoError(t, err) | |
| 373 | + | assert.Equal(t, reason, got.CancelReason) | |
| 374 | + | } | |
| 375 | + | } | |
| @@ -356,3 +356,20 @@ func TestCIStateSurvivesARestart(t *testing.T) { | |||||
| 356 | active, _ := ci2.ListActiveJobs(ctx) | 356 | active, _ := ci2.ListActiveJobs(ctx) | ||
| 357 | assert.Len(t, active, 1) | 357 | assert.Len(t, active, 1) | ||
| 358 | } | 358 | } | ||
| 359 | + | ||||
| 360 | + | func TestCICancelReasonsRoundTrip(t *testing.T) { | |||
| 361 | + | ctx := context.Background() | |||
| 362 | + | _, ci, n := ciRepo(t) | |||
| 363 | + | d, err := ci.CreateRun(ctx, n, "w.yml", draftOf(n, "w.yml", 1)) | |||
| 364 | + | require.NoError(t, err) | |||
| 365 | + | for _, reason := range []domain.CICancelReason{domain.CICancelUser, domain.CICancelFailFast, domain.CICancelTimeout, domain.CICancelNone} { | |||
| 366 | + | j, err := ci.GetJob(ctx, d.Run.ID, 1, d.Jobs[0].ID) | |||
| 367 | + | require.NoError(t, err) | |||
| 368 | + | j.CancelReason, j.CancelMessage, j.CancelRequestedAt = reason, "why", created | |||
| 369 | + | _, err = ci.Apply(ctx, ports.CIWrite{Jobs: []domain.CIJob{j}}) | |||
| 370 | + | require.NoError(t, err) | |||
| 371 | + | got, err := ci.GetJob(ctx, d.Run.ID, 1, d.Jobs[0].ID) | |||
| 372 | + | require.NoError(t, err) | |||
| 373 | + | assert.Equal(t, reason, got.CancelReason) | |||
| 374 | + | } | |||
| 375 | + | } | |||
M internal/adapters/web/events.go +2 -2
| @@ -56,8 +56,8 @@ import ( | |||
| 56 | 56 | // ci-run data: <tr id="run-<id>">…</tr> (the list of runs) or | |
| 57 | 57 | // <div id="run-body">…</div> (one run) | |
| 58 | 58 | // ci-job data: <h2 id="job-head">…</h2> and <ol id="job-steps">…</ol>; the log | |
| 59 | - | // itself is never sent in the stream, the page reads the lines after | |
| 60 | - | // the last it holds from /raw?from= | |
| 59 | + | // itself is never sent in this stream: the page follows it on its own | |
| 60 | + | // feed, /<repo>/actions/runs/<id>/job/<job>/stream?from=<line> | |
| 61 | 61 | // gap data: gap; events were lost, or what the page shows is gone or | |
| 62 | 62 | // changed: the page fetches itself again (ermiteLive in ermite.js) | |
| 63 | 63 | // | |
| @@ -56,8 +56,8 @@ import ( | |||||
| 56 | // ci-run data: <tr id="run-<id>">…</tr> (the list of runs) or | 56 | // ci-run data: <tr id="run-<id>">…</tr> (the list of runs) or | ||
| 57 | // <div id="run-body">…</div> (one run) | 57 | // <div id="run-body">…</div> (one run) | ||
| 58 | // ci-job data: <h2 id="job-head">…</h2> and <ol id="job-steps">…</ol>; the log | 58 | // ci-job data: <h2 id="job-head">…</h2> and <ol id="job-steps">…</ol>; the log | ||
| 59 | - | // itself is never sent in the stream, the page reads the lines after | 59 | + | // itself is never sent in this stream: the page follows it on its own |
| 60 | - | // the last it holds from /raw?from= | 60 | + | // feed, /<repo>/actions/runs/<id>/job/<job>/stream?from=<line> |
| 61 | // gap data: gap; events were lost, or what the page shows is gone or | 61 | // gap data: gap; events were lost, or what the page shows is gone or | ||
| 62 | // changed: the page fetches itself again (ermiteLive in ermite.js) | 62 | // changed: the page fetches itself again (ermiteLive in ermite.js) | ||
| 63 | // | 63 | // | ||
M proto/ermite/storage/ci.proto +17 -1
| @@ -63,8 +63,19 @@ enum CIIsolation { | |||
| 63 | 63 | CI_ISOLATION_CONTAINER_PRIVILEGED = 3; | |
| 64 | 64 | } | |
| 65 | 65 | ||
| 66 | + | // CICancelReason says why a job is asked to stop. | |
| 67 | + | enum CICancelReason { | |
| 68 | + | CI_CANCEL_REASON_UNSPECIFIED = 0; | |
| 69 | + | CI_CANCEL_REASON_USER = 1; | |
| 70 | + | CI_CANCEL_REASON_FAIL_FAST = 2; | |
| 71 | + | CI_CANCEL_REASON_TIMEOUT = 3; | |
| 72 | + | } | |
| 73 | + | ||
| 66 | 74 | // CIRun is stored under "run/<repo>/<id:020d>". | |
| 67 | 75 | message CIRun { | |
| 76 | + | // 18 and 19 were the workflow file and the github context, which moved to | |
| 77 | + | // CIRunSource. | |
| 78 | + | reserved 18, 19; | |
| 68 | 79 | string repo = 1; | |
| 69 | 80 | int64 id = 2; | |
| 70 | 81 | int64 number = 3; | |
| @@ -101,6 +112,9 @@ message CIRunSource { | |||
| 101 | 112 | ||
| 102 | 113 | // CIStep is a step of a job. | |
| 103 | 114 | message CIStep { | |
| 115 | + | // 8 and 10 were the end of the step, which is derived from the next step's | |
| 116 | + | // start and the line count. | |
| 117 | + | reserved 8, 10; | |
| 104 | 118 | int32 number = 1; | |
| 105 | 119 | string name = 2; | |
| 106 | 120 | CIStepState state = 3; | |
| @@ -116,6 +130,8 @@ message CIStep { | |||
| 116 | 130 | ||
| 117 | 131 | // CIJob is stored under "job/<run_id:020d>/<run_attempt:010d>/<job id>". | |
| 118 | 132 | message CIJob { | |
| 133 | + | // 36 was the line count of the log, which the log file holds. | |
| 134 | + | reserved 36; | |
| 119 | 135 | string id = 1; | |
| 120 | 136 | int64 run_id = 2; | |
| 121 | 137 | // Attempt in which the job executed (the one of its log). | |
| @@ -152,7 +168,7 @@ message CIJob { | |||
| 152 | 168 | google.protobuf.Timestamp acked_at = 30; | |
| 153 | 169 | int32 assign_cancels = 31; | |
| 154 | 170 | google.protobuf.Timestamp token_valid_until = 32; | |
| 155 | - | int32 cancel_reason = 33; | |
| 171 | + | CICancelReason cancel_reason = 33; | |
| 156 | 172 | string cancel_message = 34; | |
| 157 | 173 | google.protobuf.Timestamp cancel_requested_at = 35; | |
| 158 | 174 | // Set when the job is planned: runs-on, name and contexts are evaluated. | |
| @@ -63,8 +63,19 @@ enum CIIsolation { | |||||
| 63 | CI_ISOLATION_CONTAINER_PRIVILEGED = 3; | 63 | CI_ISOLATION_CONTAINER_PRIVILEGED = 3; | ||
| 64 | } | 64 | } | ||
| 65 | 65 | ||||
| 66 | + | // CICancelReason says why a job is asked to stop. | |||
| 67 | + | enum CICancelReason { | |||
| 68 | + | CI_CANCEL_REASON_UNSPECIFIED = 0; | |||
| 69 | + | CI_CANCEL_REASON_USER = 1; | |||
| 70 | + | CI_CANCEL_REASON_FAIL_FAST = 2; | |||
| 71 | + | CI_CANCEL_REASON_TIMEOUT = 3; | |||
| 72 | + | } | |||
| 73 | + | ||||
| 66 | // CIRun is stored under "run/<repo>/<id:020d>". | 74 | // CIRun is stored under "run/<repo>/<id:020d>". | ||
| 67 | message CIRun { | 75 | message CIRun { | ||
| 76 | + | // 18 and 19 were the workflow file and the github context, which moved to | |||
| 77 | + | // CIRunSource. | |||
| 78 | + | reserved 18, 19; | |||
| 68 | string repo = 1; | 79 | string repo = 1; | ||
| 69 | int64 id = 2; | 80 | int64 id = 2; | ||
| 70 | int64 number = 3; | 81 | int64 number = 3; | ||
| @@ -101,6 +112,9 @@ message CIRunSource { | |||||
| 101 | 112 | ||||
| 102 | // CIStep is a step of a job. | 113 | // CIStep is a step of a job. | ||
| 103 | message CIStep { | 114 | message CIStep { | ||
| 115 | + | // 8 and 10 were the end of the step, which is derived from the next step's | |||
| 116 | + | // start and the line count. | |||
| 117 | + | reserved 8, 10; | |||
| 104 | int32 number = 1; | 118 | int32 number = 1; | ||
| 105 | string name = 2; | 119 | string name = 2; | ||
| 106 | CIStepState state = 3; | 120 | CIStepState state = 3; | ||
| @@ -116,6 +130,8 @@ message CIStep { | |||||
| 116 | 130 | ||||
| 117 | // CIJob is stored under "job/<run_id:020d>/<run_attempt:010d>/<job id>". | 131 | // CIJob is stored under "job/<run_id:020d>/<run_attempt:010d>/<job id>". | ||
| 118 | message CIJob { | 132 | message CIJob { | ||
| 133 | + | // 36 was the line count of the log, which the log file holds. | |||
| 134 | + | reserved 36; | |||
| 119 | string id = 1; | 135 | string id = 1; | ||
| 120 | int64 run_id = 2; | 136 | int64 run_id = 2; | ||
| 121 | // Attempt in which the job executed (the one of its log). | 137 | // Attempt in which the job executed (the one of its log). | ||
| @@ -152,7 +168,7 @@ message CIJob { | |||||
| 152 | google.protobuf.Timestamp acked_at = 30; | 168 | google.protobuf.Timestamp acked_at = 30; | ||
| 153 | int32 assign_cancels = 31; | 169 | int32 assign_cancels = 31; | ||
| 154 | google.protobuf.Timestamp token_valid_until = 32; | 170 | google.protobuf.Timestamp token_valid_until = 32; | ||
| 155 | - | int32 cancel_reason = 33; | 171 | + | CICancelReason cancel_reason = 33; |
| 156 | string cancel_message = 34; | 172 | string cancel_message = 34; | ||
| 157 | google.protobuf.Timestamp cancel_requested_at = 35; | 173 | google.protobuf.Timestamp cancel_requested_at = 35; | ||
| 158 | // Set when the job is planned: runs-on, name and contexts are evaluated. | 174 | // Set when the job is planned: runs-on, name and contexts are evaluated. | ||