Merge e908c5b6d7362039080ca5d9a6065a959b4bce31 into fca915500912709c587cf0e32e3242fda73ffbcd
4 files · +151 -8
M gen/ermite/storage/ci.pb.go +2 -1
| @@ -978,7 +978,8 @@ type CIJob struct { | |||
| 978 | 978 | // keeps its own attempt. | |
| 979 | 979 | RunAttempt int32 `protobuf:"varint,38,opt,name=run_attempt,json=runAttempt,proto3" json:"run_attempt,omitempty"` | |
| 980 | 980 | // The contexts JSON given to the runner, computed when the job is planned | |
| 981 | - | // (needs outputs are final then). | |
| 981 | + | // (needs outputs are final then). No longer written: stored under jobctx/ | |
| 982 | + | // instead. Retained as a read fallback for legacy records. | |
| 982 | 983 | ContextsJson string `protobuf:"bytes,39,opt,name=contexts_json,json=contextsJson,proto3" json:"contexts_json,omitempty"` | |
| 983 | 984 | // The job's continue-on-error, evaluated when the job is planned: a failure | |
| 984 | 985 | // of the job does not fail the run, does not cancel the siblings of its | |
| @@ -978,7 +978,8 @@ type CIJob struct { | |||||
| 978 | // keeps its own attempt. | 978 | // keeps its own attempt. | ||
| 979 | RunAttempt int32 `protobuf:"varint,38,opt,name=run_attempt,json=runAttempt,proto3" json:"run_attempt,omitempty"` | 979 | RunAttempt int32 `protobuf:"varint,38,opt,name=run_attempt,json=runAttempt,proto3" json:"run_attempt,omitempty"` | ||
| 980 | // The contexts JSON given to the runner, computed when the job is planned | 980 | // The contexts JSON given to the runner, computed when the job is planned | ||
| 981 | - | // (needs outputs are final then). | 981 | + | // (needs outputs are final then). No longer written: stored under jobctx/ |
| 982 | + | // instead. Retained as a read fallback for legacy records. | |||
| 982 | ContextsJson string `protobuf:"bytes,39,opt,name=contexts_json,json=contextsJson,proto3" json:"contexts_json,omitempty"` | 983 | ContextsJson string `protobuf:"bytes,39,opt,name=contexts_json,json=contextsJson,proto3" json:"contexts_json,omitempty"` | ||
| 983 | // The job's continue-on-error, evaluated when the job is planned: a failure | 984 | // The job's continue-on-error, evaluated when the job is planned: a failure | ||
| 984 | // of the job does not fail the run, does not cancel the siblings of its | 985 | // of the job does not fail the run, does not cancel the siblings of its | ||
M internal/adapters/kvstore/ci.go +36 -6
| @@ -26,6 +26,7 @@ import ( | |||
| 26 | 26 | // run/<repo>/<id:020d> CIRun | |
| 27 | 27 | // runsrc/<id:020d> CIRunSource | |
| 28 | 28 | // job/<run:020d>/<attempt:010d>/<id> CIJob | |
| 29 | + | // jobctx/<run:020d>/<attempt:010d>/<id> contexts JSON | |
| 29 | 30 | // check/<repo>/<sha>/<check id> CICheck | |
| 30 | 31 | // runseq Sequence: last run id | |
| 31 | 32 | // runnum/<repo>/<workflow path> Sequence: last run number | |
| @@ -46,6 +47,7 @@ const ( | |||
| 46 | 47 | runPrefix = "run/" | |
| 47 | 48 | runSrcPrefix = "runsrc/" | |
| 48 | 49 | jobPrefix = "job/" | |
| 50 | + | jobCtxPrefix = "jobctx/" | |
| 49 | 51 | checkPrefix = "check/" | |
| 50 | 52 | runSeqKey = "runseq" | |
| 51 | 53 | runNumPrefix = "runnum/" | |
| @@ -85,6 +87,10 @@ func jobRef(runID int64, attempt int32, id string) string { | |||
| 85 | 87 | return jobAttemptPrefix(runID, attempt) + id | |
| 86 | 88 | } | |
| 87 | 89 | func jobKey(runID int64, attempt int32, id string) []byte { return []byte(jobRef(runID, attempt, id)) } | |
| 90 | + | func jobContextsKey(runID int64, attempt int32, id string) []byte { | |
| 91 | + | return []byte(jobCtxPrefix + strings.TrimPrefix(jobRef(runID, attempt, id), jobPrefix)) | |
| 92 | + | } | |
| 93 | + | ||
| 88 | 94 | func checksPrefix(repo domain.RepoName, sha string) string { | |
| 89 | 95 | return checkPrefix + repo.String() + "/" + sha + "/" | |
| 90 | 96 | } | |
| @@ -335,7 +341,7 @@ func encodeJob(j domain.CIJob) ([]byte, error) { | |||
| 335 | 341 | Labels: j.Labels, Needs: j.Needs, RunnerId: j.RunnerID, RunnerName: j.RunnerName, | |
| 336 | 342 | Isolation: encodeIsolation(j.Isolation), Steps: steps, | |
| 337 | 343 | CreatedAt: encodeTime(j.CreatedAt), StartedAt: encodeTime(j.StartedAt), CompletedAt: encodeTime(j.CompletedAt), | |
| 338 | - | 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 | |
| 344 | + | Index: int32(j.Index), Combo: int32(j.Combo), MatrixJson: j.MatrixJSON, ComboTotal: int32(j.ComboTotal), //nolint:gosec // bounded by the 256 job limit | |
| 339 | 345 | TimeoutMinutes: j.TimeoutMinutes, FailFast: j.FailFast, ContinueOnError: j.ContinueOnError, MaxParallel: int32(j.MaxParallel), //nolint:gosec // validated at planning | |
| 340 | 346 | Outputs: j.Outputs, ReadyAt: encodeTime(j.ReadyAt), AssignedAt: encodeTime(j.AssignedAt), | |
| 341 | 347 | AckedAt: encodeTime(j.AckedAt), AssignCancels: int32(j.AssignCancels), //nolint:gosec // at most 3 | |
| @@ -385,6 +391,19 @@ func decodeJob(item kv.Item) (domain.CIJob, error) { | |||
| 385 | 391 | return j, nil | |
| 386 | 392 | } | |
| 387 | 393 | ||
| 394 | + | // decodeJobWithContexts reads both parts from the same snapshot. The inline | |
| 395 | + | // field is retained only as a fallback for records written before the split. | |
| 396 | + | func decodeJobWithContexts(ctx context.Context, snap *kv.Snapshot, item kv.Item) (domain.CIJob, error) { | |
| 397 | + | j, err := decodeJob(item) | |
| 398 | + | if err != nil { | |
| 399 | + | return domain.CIJob{}, err | |
| 400 | + | } | |
| 401 | + | if contexts, ok := snap.Get(ctx, jobContextsKey(j.RunID, j.RunAttempt, j.ID)); ok { | |
| 402 | + | j.ContextsJSON = string(contexts.Value) | |
| 403 | + | } | |
| 404 | + | return j, nil | |
| 405 | + | } | |
| 406 | + | ||
| 388 | 407 | func encodeCheck(c domain.CICheck) []byte { | |
| 389 | 408 | return mustMarshal(&storagepb.CICheck{ | |
| 390 | 409 | Id: c.ID, Repo: c.Repo.String(), Sha: c.Sha, Name: c.Name, Status: encodeStatus(c.Status), | |
| @@ -515,6 +534,12 @@ func (c *CI) stageRecords(ctx context.Context, b *kv.Batch, w ports.CIWrite) (gu | |||
| 515 | 534 | old = &o | |
| 516 | 535 | } | |
| 517 | 536 | } | |
| 537 | + | // Compare the separate value, not the legacy inline field: an old | |
| 538 | + | // record must materialize contexts before encodeJob drops that field. | |
| 539 | + | contextsKey := jobContextsKey(j.RunID, j.RunAttempt, j.ID) | |
| 540 | + | if contexts, ok := c.db.Get(ctx, contextsKey); !ok || string(contexts.Value) != j.ContextsJSON { | |
| 541 | + | b.Put(contextsKey, []byte(j.ContextsJSON)) | |
| 542 | + | } | |
| 518 | 543 | stage(b, key, val, j.Version) | |
| 519 | 544 | c.jobIndexes(b, j, old) | |
| 520 | 545 | if j.Version == 0 { | |
| @@ -746,19 +771,21 @@ func (c *CI) ListOpenRuns(ctx context.Context) ([]domain.CIRun, error) { | |||
| 746 | 771 | ||
| 747 | 772 | // GetJob reads one job record. | |
| 748 | 773 | func (c *CI) GetJob(ctx context.Context, runID int64, runAttempt int32, jobID string) (domain.CIJob, error) { | |
| 749 | - | item, ok := c.db.Get(ctx, jobKey(runID, runAttempt, jobID)) | |
| 774 | + | snap := c.db.Snapshot(ctx) | |
| 775 | + | item, ok := snap.Get(ctx, jobKey(runID, runAttempt, jobID)) | |
| 750 | 776 | if !ok { | |
| 751 | 777 | return domain.CIJob{}, domain.ErrNotFound | |
| 752 | 778 | } | |
| 753 | - | return decodeJob(item) | |
| 779 | + | return decodeJobWithContexts(ctx, snap, item) | |
| 754 | 780 | } | |
| 755 | 781 | ||
| 756 | 782 | // ListJobs returns the jobs of a run at an attempt, ordered by index then | |
| 757 | 783 | // combination. | |
| 758 | 784 | func (c *CI) ListJobs(ctx context.Context, runID int64, runAttempt int32) ([]domain.CIJob, error) { | |
| 759 | 785 | var out []domain.CIJob | |
| 760 | - | for key, item := range c.db.Snapshot(ctx).Prefix(ctx, []byte(jobAttemptPrefix(runID, runAttempt))) { | |
| 761 | - | j, err := decodeJob(item) | |
| 786 | + | snap := c.db.Snapshot(ctx) | |
| 787 | + | for key, item := range snap.Prefix(ctx, []byte(jobAttemptPrefix(runID, runAttempt))) { | |
| 788 | + | j, err := decodeJobWithContexts(ctx, snap, item) | |
| 762 | 789 | if err != nil { | |
| 763 | 790 | return nil, fmt.Errorf("decode %s: %w", key, err) | |
| 764 | 791 | } | |
| @@ -778,7 +805,7 @@ func (c *CI) jobByRef(snap *kv.Snapshot, ctx context.Context, ref string) (domai | |||
| 778 | 805 | if !ok { | |
| 779 | 806 | return domain.CIJob{}, domain.ErrNotFound | |
| 780 | 807 | } | |
| 781 | - | return decodeJob(item) | |
| 808 | + | return decodeJobWithContexts(ctx, snap, item) | |
| 782 | 809 | } | |
| 783 | 810 | ||
| 784 | 811 | // FindJobByToken resolves the token index, then reads the job. | |
| @@ -867,6 +894,9 @@ func (c *CI) runKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoNam | |||
| 867 | 894 | keys := [][]byte{runKey(repo, runID), runSrcKey(runID), openKey(runID)} | |
| 868 | 895 | keys = append(keys, repoTokenKeys(ctx, snap, runID)...) | |
| 869 | 896 | keys = append(keys, annotationKeys(ctx, snap, runID)...) | |
| 897 | + | for key := range snap.Prefix(ctx, []byte(jobCtxPrefix+fmt.Sprintf(idFormat, runID)+"/")) { | |
| 898 | + | keys = append(keys, slices.Clone(key)) | |
| 899 | + | } | |
| 870 | 900 | for key, item := range snap.Prefix(ctx, []byte(jobsPrefix(runID))) { | |
| 871 | 901 | keys = append(keys, slices.Clone(key)) | |
| 872 | 902 | if j, err := decodeJob(item); err == nil { | |
| @@ -26,6 +26,7 @@ import ( | |||||
| 26 | // run/<repo>/<id:020d> CIRun | 26 | // run/<repo>/<id:020d> CIRun | ||
| 27 | // runsrc/<id:020d> CIRunSource | 27 | // runsrc/<id:020d> CIRunSource | ||
| 28 | // job/<run:020d>/<attempt:010d>/<id> CIJob | 28 | // job/<run:020d>/<attempt:010d>/<id> CIJob | ||
| 29 | + | // jobctx/<run:020d>/<attempt:010d>/<id> contexts JSON | |||
| 29 | // check/<repo>/<sha>/<check id> CICheck | 30 | // check/<repo>/<sha>/<check id> CICheck | ||
| 30 | // runseq Sequence: last run id | 31 | // runseq Sequence: last run id | ||
| 31 | // runnum/<repo>/<workflow path> Sequence: last run number | 32 | // runnum/<repo>/<workflow path> Sequence: last run number | ||
| @@ -46,6 +47,7 @@ const ( | |||||
| 46 | runPrefix = "run/" | 47 | runPrefix = "run/" | ||
| 47 | runSrcPrefix = "runsrc/" | 48 | runSrcPrefix = "runsrc/" | ||
| 48 | jobPrefix = "job/" | 49 | jobPrefix = "job/" | ||
| 50 | + | jobCtxPrefix = "jobctx/" | |||
| 49 | checkPrefix = "check/" | 51 | checkPrefix = "check/" | ||
| 50 | runSeqKey = "runseq" | 52 | runSeqKey = "runseq" | ||
| 51 | runNumPrefix = "runnum/" | 53 | runNumPrefix = "runnum/" | ||
| @@ -85,6 +87,10 @@ func jobRef(runID int64, attempt int32, id string) string { | |||||
| 85 | return jobAttemptPrefix(runID, attempt) + id | 87 | return jobAttemptPrefix(runID, attempt) + id | ||
| 86 | } | 88 | } | ||
| 87 | func jobKey(runID int64, attempt int32, id string) []byte { return []byte(jobRef(runID, attempt, id)) } | 89 | func jobKey(runID int64, attempt int32, id string) []byte { return []byte(jobRef(runID, attempt, id)) } | ||
| 90 | + | func jobContextsKey(runID int64, attempt int32, id string) []byte { | |||
| 91 | + | return []byte(jobCtxPrefix + strings.TrimPrefix(jobRef(runID, attempt, id), jobPrefix)) | |||
| 92 | + | } | |||
| 93 | + | ||||
| 88 | func checksPrefix(repo domain.RepoName, sha string) string { | 94 | func checksPrefix(repo domain.RepoName, sha string) string { | ||
| 89 | return checkPrefix + repo.String() + "/" + sha + "/" | 95 | return checkPrefix + repo.String() + "/" + sha + "/" | ||
| 90 | } | 96 | } | ||
| @@ -335,7 +341,7 @@ func encodeJob(j domain.CIJob) ([]byte, error) { | |||||
| 335 | Labels: j.Labels, Needs: j.Needs, RunnerId: j.RunnerID, RunnerName: j.RunnerName, | 341 | Labels: j.Labels, Needs: j.Needs, RunnerId: j.RunnerID, RunnerName: j.RunnerName, | ||
| 336 | Isolation: encodeIsolation(j.Isolation), Steps: steps, | 342 | Isolation: encodeIsolation(j.Isolation), Steps: steps, | ||
| 337 | CreatedAt: encodeTime(j.CreatedAt), StartedAt: encodeTime(j.StartedAt), CompletedAt: encodeTime(j.CompletedAt), | 343 | CreatedAt: encodeTime(j.CreatedAt), StartedAt: encodeTime(j.StartedAt), CompletedAt: encodeTime(j.CompletedAt), | ||
| 338 | - | 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 | 344 | + | Index: int32(j.Index), Combo: int32(j.Combo), MatrixJson: j.MatrixJSON, ComboTotal: int32(j.ComboTotal), //nolint:gosec // bounded by the 256 job limit |
| 339 | TimeoutMinutes: j.TimeoutMinutes, FailFast: j.FailFast, ContinueOnError: j.ContinueOnError, MaxParallel: int32(j.MaxParallel), //nolint:gosec // validated at planning | 345 | TimeoutMinutes: j.TimeoutMinutes, FailFast: j.FailFast, ContinueOnError: j.ContinueOnError, MaxParallel: int32(j.MaxParallel), //nolint:gosec // validated at planning | ||
| 340 | Outputs: j.Outputs, ReadyAt: encodeTime(j.ReadyAt), AssignedAt: encodeTime(j.AssignedAt), | 346 | Outputs: j.Outputs, ReadyAt: encodeTime(j.ReadyAt), AssignedAt: encodeTime(j.AssignedAt), | ||
| 341 | AckedAt: encodeTime(j.AckedAt), AssignCancels: int32(j.AssignCancels), //nolint:gosec // at most 3 | 347 | AckedAt: encodeTime(j.AckedAt), AssignCancels: int32(j.AssignCancels), //nolint:gosec // at most 3 | ||
| @@ -385,6 +391,19 @@ func decodeJob(item kv.Item) (domain.CIJob, error) { | |||||
| 385 | return j, nil | 391 | return j, nil | ||
| 386 | } | 392 | } | ||
| 387 | 393 | ||||
| 394 | + | // decodeJobWithContexts reads both parts from the same snapshot. The inline | |||
| 395 | + | // field is retained only as a fallback for records written before the split. | |||
| 396 | + | func decodeJobWithContexts(ctx context.Context, snap *kv.Snapshot, item kv.Item) (domain.CIJob, error) { | |||
| 397 | + | j, err := decodeJob(item) | |||
| 398 | + | if err != nil { | |||
| 399 | + | return domain.CIJob{}, err | |||
| 400 | + | } | |||
| 401 | + | if contexts, ok := snap.Get(ctx, jobContextsKey(j.RunID, j.RunAttempt, j.ID)); ok { | |||
| 402 | + | j.ContextsJSON = string(contexts.Value) | |||
| 403 | + | } | |||
| 404 | + | return j, nil | |||
| 405 | + | } | |||
| 406 | + | ||||
| 388 | func encodeCheck(c domain.CICheck) []byte { | 407 | func encodeCheck(c domain.CICheck) []byte { | ||
| 389 | return mustMarshal(&storagepb.CICheck{ | 408 | return mustMarshal(&storagepb.CICheck{ | ||
| 390 | Id: c.ID, Repo: c.Repo.String(), Sha: c.Sha, Name: c.Name, Status: encodeStatus(c.Status), | 409 | Id: c.ID, Repo: c.Repo.String(), Sha: c.Sha, Name: c.Name, Status: encodeStatus(c.Status), | ||
| @@ -515,6 +534,12 @@ func (c *CI) stageRecords(ctx context.Context, b *kv.Batch, w ports.CIWrite) (gu | |||||
| 515 | old = &o | 534 | old = &o | ||
| 516 | } | 535 | } | ||
| 517 | } | 536 | } | ||
| 537 | + | // Compare the separate value, not the legacy inline field: an old | |||
| 538 | + | // record must materialize contexts before encodeJob drops that field. | |||
| 539 | + | contextsKey := jobContextsKey(j.RunID, j.RunAttempt, j.ID) | |||
| 540 | + | if contexts, ok := c.db.Get(ctx, contextsKey); !ok || string(contexts.Value) != j.ContextsJSON { | |||
| 541 | + | b.Put(contextsKey, []byte(j.ContextsJSON)) | |||
| 542 | + | } | |||
| 518 | stage(b, key, val, j.Version) | 543 | stage(b, key, val, j.Version) | ||
| 519 | c.jobIndexes(b, j, old) | 544 | c.jobIndexes(b, j, old) | ||
| 520 | if j.Version == 0 { | 545 | if j.Version == 0 { | ||
| @@ -746,19 +771,21 @@ func (c *CI) ListOpenRuns(ctx context.Context) ([]domain.CIRun, error) { | |||||
| 746 | 771 | ||||
| 747 | // GetJob reads one job record. | 772 | // GetJob reads one job record. | ||
| 748 | func (c *CI) GetJob(ctx context.Context, runID int64, runAttempt int32, jobID string) (domain.CIJob, error) { | 773 | func (c *CI) GetJob(ctx context.Context, runID int64, runAttempt int32, jobID string) (domain.CIJob, error) { | ||
| 749 | - | item, ok := c.db.Get(ctx, jobKey(runID, runAttempt, jobID)) | 774 | + | snap := c.db.Snapshot(ctx) |
| 775 | + | item, ok := snap.Get(ctx, jobKey(runID, runAttempt, jobID)) | |||
| 750 | if !ok { | 776 | if !ok { | ||
| 751 | return domain.CIJob{}, domain.ErrNotFound | 777 | return domain.CIJob{}, domain.ErrNotFound | ||
| 752 | } | 778 | } | ||
| 753 | - | return decodeJob(item) | 779 | + | return decodeJobWithContexts(ctx, snap, item) |
| 754 | } | 780 | } | ||
| 755 | 781 | ||||
| 756 | // ListJobs returns the jobs of a run at an attempt, ordered by index then | 782 | // ListJobs returns the jobs of a run at an attempt, ordered by index then | ||
| 757 | // combination. | 783 | // combination. | ||
| 758 | func (c *CI) ListJobs(ctx context.Context, runID int64, runAttempt int32) ([]domain.CIJob, error) { | 784 | func (c *CI) ListJobs(ctx context.Context, runID int64, runAttempt int32) ([]domain.CIJob, error) { | ||
| 759 | var out []domain.CIJob | 785 | var out []domain.CIJob | ||
| 760 | - | for key, item := range c.db.Snapshot(ctx).Prefix(ctx, []byte(jobAttemptPrefix(runID, runAttempt))) { | 786 | + | snap := c.db.Snapshot(ctx) |
| 761 | - | j, err := decodeJob(item) | 787 | + | for key, item := range snap.Prefix(ctx, []byte(jobAttemptPrefix(runID, runAttempt))) { |
| 788 | + | j, err := decodeJobWithContexts(ctx, snap, item) | |||
| 762 | if err != nil { | 789 | if err != nil { | ||
| 763 | return nil, fmt.Errorf("decode %s: %w", key, err) | 790 | return nil, fmt.Errorf("decode %s: %w", key, err) | ||
| 764 | } | 791 | } | ||
| @@ -778,7 +805,7 @@ func (c *CI) jobByRef(snap *kv.Snapshot, ctx context.Context, ref string) (domai | |||||
| 778 | if !ok { | 805 | if !ok { | ||
| 779 | return domain.CIJob{}, domain.ErrNotFound | 806 | return domain.CIJob{}, domain.ErrNotFound | ||
| 780 | } | 807 | } | ||
| 781 | - | return decodeJob(item) | 808 | + | return decodeJobWithContexts(ctx, snap, item) |
| 782 | } | 809 | } | ||
| 783 | 810 | ||||
| 784 | // FindJobByToken resolves the token index, then reads the job. | 811 | // FindJobByToken resolves the token index, then reads the job. | ||
| @@ -867,6 +894,9 @@ func (c *CI) runKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoNam | |||||
| 867 | keys := [][]byte{runKey(repo, runID), runSrcKey(runID), openKey(runID)} | 894 | keys := [][]byte{runKey(repo, runID), runSrcKey(runID), openKey(runID)} | ||
| 868 | keys = append(keys, repoTokenKeys(ctx, snap, runID)...) | 895 | keys = append(keys, repoTokenKeys(ctx, snap, runID)...) | ||
| 869 | keys = append(keys, annotationKeys(ctx, snap, runID)...) | 896 | keys = append(keys, annotationKeys(ctx, snap, runID)...) | ||
| 897 | + | for key := range snap.Prefix(ctx, []byte(jobCtxPrefix+fmt.Sprintf(idFormat, runID)+"/")) { | |||
| 898 | + | keys = append(keys, slices.Clone(key)) | |||
| 899 | + | } | |||
| 870 | for key, item := range snap.Prefix(ctx, []byte(jobsPrefix(runID))) { | 900 | for key, item := range snap.Prefix(ctx, []byte(jobsPrefix(runID))) { | ||
| 871 | keys = append(keys, slices.Clone(key)) | 901 | keys = append(keys, slices.Clone(key)) | ||
| 872 | if j, err := decodeJob(item); err == nil { | 902 | if j, err := decodeJob(item); err == nil { | ||
A internal/adapters/kvstore/ci_contexts_test.go +111 -0
| @@ -0,0 +1,111 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package kvstore | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "strings" | |
| 8 | + | "testing" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | kv "bnema.dev/ermite-kv" | |
| 12 | + | storagepb "bnema.dev/ermite/gen/ermite/storage" | |
| 13 | + | "bnema.dev/ermite/internal/domain" | |
| 14 | + | "bnema.dev/ermite/internal/ports" | |
| 15 | + | "github.com/stretchr/testify/require" | |
| 16 | + | "google.golang.org/protobuf/proto" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | func TestCIJobContextsStorage(t *testing.T) { | |
| 20 | + | ctx := context.Background() | |
| 21 | + | s, err := Open(ctx, t.TempDir()) | |
| 22 | + | require.NoError(t, err) | |
| 23 | + | t.Cleanup(func() { require.NoError(t, s.Close(ctx)) }) | |
| 24 | + | repo, err := domain.ParseRepoName("proj") | |
| 25 | + | require.NoError(t, err) | |
| 26 | + | require.NoError(t, s.Create(ctx, domain.Repo{Name: repo, Visibility: domain.VisibilityPrivate})) | |
| 27 | + | ci := s.CI() | |
| 28 | + | payload := `{"event":"` + strings.Repeat("payload", 3500) + `"}` | |
| 29 | + | j := domain.CIJob{ID: "build", Repo: repo, RunID: 1, RunAttempt: 1, Attempt: 1, Phase: domain.CIPhaseReady, ContextsJSON: payload} | |
| 30 | + | save := func() { | |
| 31 | + | v, err := ci.Apply(ctx, ports.CIWrite{Jobs: []domain.CIJob{j}}) | |
| 32 | + | require.NoError(t, err) | |
| 33 | + | j.Version = v | |
| 34 | + | } | |
| 35 | + | save() | |
| 36 | + | contextsKey := jobContextsKey(1, 1, j.ID) | |
| 37 | + | initial, ok := ci.db.Get(ctx, contextsKey) | |
| 38 | + | require.True(t, ok) | |
| 39 | + | for i := range 5 { | |
| 40 | + | j.Steps = []domain.CIStep{{Number: int32(i + 1), Name: "step", State: domain.CIStepInProgress}} | |
| 41 | + | save() | |
| 42 | + | stored, ok := ci.db.Get(ctx, jobKey(1, 1, j.ID)) | |
| 43 | + | require.True(t, ok) | |
| 44 | + | require.NotContains(t, string(stored.Value), payload) | |
| 45 | + | var pb storagepb.CIJob | |
| 46 | + | require.NoError(t, proto.Unmarshal(stored.Value, &pb)) | |
| 47 | + | require.Empty(t, pb.GetContextsJson()) | |
| 48 | + | contexts, ok := ci.db.Get(ctx, contextsKey) | |
| 49 | + | require.True(t, ok) | |
| 50 | + | require.Equal(t, initial.Version, contexts.Version, "unchanged contexts must not be rewritten") | |
| 51 | + | got, err := ci.GetJob(ctx, 1, 1, j.ID) | |
| 52 | + | require.NoError(t, err) | |
| 53 | + | require.Equal(t, payload, got.ContextsJSON) | |
| 54 | + | jobs, err := ci.ListJobs(ctx, 1, 1) | |
| 55 | + | require.NoError(t, err) | |
| 56 | + | require.Equal(t, payload, jobs[0].ContextsJSON) | |
| 57 | + | ready, err := ci.ListReadyJobs(ctx, 0) | |
| 58 | + | require.NoError(t, err) | |
| 59 | + | require.Equal(t, payload, ready[0].ContextsJSON) | |
| 60 | + | } | |
| 61 | + | // A kept job in a re-run gets an independent contexts key. | |
| 62 | + | j.RunAttempt = 2 | |
| 63 | + | j.Version = 0 | |
| 64 | + | save() | |
| 65 | + | copied, ok := ci.db.Get(ctx, jobContextsKey(1, 2, j.ID)) | |
| 66 | + | require.True(t, ok) | |
| 67 | + | require.Equal(t, payload, string(copied.Value)) | |
| 68 | + | j.ContextsJSON = "" | |
| 69 | + | save() | |
| 70 | + | got, err := ci.GetJob(ctx, 1, 2, j.ID) | |
| 71 | + | require.NoError(t, err) | |
| 72 | + | require.Empty(t, got.ContextsJSON) | |
| 73 | + | // Sweep must remove contexts from every attempt as well as job records. | |
| 74 | + | _, err = ci.Apply(ctx, ports.CIWrite{Runs: []domain.CIRun{{Repo: repo, ID: 1, Status: domain.CICompleted, CompletedAt: time.Now().Add(-time.Hour)}}}) | |
| 75 | + | require.NoError(t, err) | |
| 76 | + | ids, err := ci.Sweep(ctx, time.Now()) | |
| 77 | + | require.NoError(t, err) | |
| 78 | + | require.Equal(t, []int64{1}, ids) | |
| 79 | + | for range ci.db.Snapshot(ctx).Prefix(ctx, []byte(jobCtxPrefix)) { | |
| 80 | + | t.Fatal("orphan contexts key") | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func TestCIJobLegacyContexts(t *testing.T) { | |
| 85 | + | ctx := context.Background() | |
| 86 | + | s, err := Open(ctx, t.TempDir()) | |
| 87 | + | require.NoError(t, err) | |
| 88 | + | t.Cleanup(func() { require.NoError(t, s.Close(ctx)) }) | |
| 89 | + | ci := s.CI() | |
| 90 | + | var b kv.Batch | |
| 91 | + | b.Put(jobKey(1, 1, "build"), mustMarshal(&storagepb.CIJob{Id: "build", Repo: "proj", RunId: 1, RunAttempt: 1, ContextsJson: `{"legacy":true}`})) | |
| 92 | + | _, err = ci.db.Apply(ctx, &b) | |
| 93 | + | require.NoError(t, err) | |
| 94 | + | j, err := ci.GetJob(ctx, 1, 1, "build") | |
| 95 | + | require.NoError(t, err) | |
| 96 | + | require.Equal(t, `{"legacy":true}`, j.ContextsJSON) | |
| 97 | + | jobs, err := ci.ListJobs(ctx, 1, 1) | |
| 98 | + | require.NoError(t, err) | |
| 99 | + | require.Equal(t, j.ContextsJSON, jobs[0].ContextsJSON) | |
| 100 | + | // Updating the legacy job moves its contexts out of the protobuf. | |
| 101 | + | _, err = ci.Apply(ctx, ports.CIWrite{Jobs: []domain.CIJob{j}}) | |
| 102 | + | require.NoError(t, err) | |
| 103 | + | got, err := ci.GetJob(ctx, 1, 1, "build") | |
| 104 | + | require.NoError(t, err) | |
| 105 | + | require.Equal(t, j.ContextsJSON, got.ContextsJSON) | |
| 106 | + | item, ok := ci.db.Get(ctx, jobKey(1, 1, "build")) | |
| 107 | + | require.True(t, ok) | |
| 108 | + | pbJob, err := decodeJob(item) | |
| 109 | + | require.NoError(t, err) | |
| 110 | + | require.Empty(t, pbJob.ContextsJSON) | |
| 111 | + | } | |
| @@ -0,0 +1,111 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package kvstore | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "strings" | |||
| 8 | + | "testing" | |||
| 9 | + | "time" | |||
| 10 | + | ||||
| 11 | + | kv "bnema.dev/ermite-kv" | |||
| 12 | + | storagepb "bnema.dev/ermite/gen/ermite/storage" | |||
| 13 | + | "bnema.dev/ermite/internal/domain" | |||
| 14 | + | "bnema.dev/ermite/internal/ports" | |||
| 15 | + | "github.com/stretchr/testify/require" | |||
| 16 | + | "google.golang.org/protobuf/proto" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | func TestCIJobContextsStorage(t *testing.T) { | |||
| 20 | + | ctx := context.Background() | |||
| 21 | + | s, err := Open(ctx, t.TempDir()) | |||
| 22 | + | require.NoError(t, err) | |||
| 23 | + | t.Cleanup(func() { require.NoError(t, s.Close(ctx)) }) | |||
| 24 | + | repo, err := domain.ParseRepoName("proj") | |||
| 25 | + | require.NoError(t, err) | |||
| 26 | + | require.NoError(t, s.Create(ctx, domain.Repo{Name: repo, Visibility: domain.VisibilityPrivate})) | |||
| 27 | + | ci := s.CI() | |||
| 28 | + | payload := `{"event":"` + strings.Repeat("payload", 3500) + `"}` | |||
| 29 | + | j := domain.CIJob{ID: "build", Repo: repo, RunID: 1, RunAttempt: 1, Attempt: 1, Phase: domain.CIPhaseReady, ContextsJSON: payload} | |||
| 30 | + | save := func() { | |||
| 31 | + | v, err := ci.Apply(ctx, ports.CIWrite{Jobs: []domain.CIJob{j}}) | |||
| 32 | + | require.NoError(t, err) | |||
| 33 | + | j.Version = v | |||
| 34 | + | } | |||
| 35 | + | save() | |||
| 36 | + | contextsKey := jobContextsKey(1, 1, j.ID) | |||
| 37 | + | initial, ok := ci.db.Get(ctx, contextsKey) | |||
| 38 | + | require.True(t, ok) | |||
| 39 | + | for i := range 5 { | |||
| 40 | + | j.Steps = []domain.CIStep{{Number: int32(i + 1), Name: "step", State: domain.CIStepInProgress}} | |||
| 41 | + | save() | |||
| 42 | + | stored, ok := ci.db.Get(ctx, jobKey(1, 1, j.ID)) | |||
| 43 | + | require.True(t, ok) | |||
| 44 | + | require.NotContains(t, string(stored.Value), payload) | |||
| 45 | + | var pb storagepb.CIJob | |||
| 46 | + | require.NoError(t, proto.Unmarshal(stored.Value, &pb)) | |||
| 47 | + | require.Empty(t, pb.GetContextsJson()) | |||
| 48 | + | contexts, ok := ci.db.Get(ctx, contextsKey) | |||
| 49 | + | require.True(t, ok) | |||
| 50 | + | require.Equal(t, initial.Version, contexts.Version, "unchanged contexts must not be rewritten") | |||
| 51 | + | got, err := ci.GetJob(ctx, 1, 1, j.ID) | |||
| 52 | + | require.NoError(t, err) | |||
| 53 | + | require.Equal(t, payload, got.ContextsJSON) | |||
| 54 | + | jobs, err := ci.ListJobs(ctx, 1, 1) | |||
| 55 | + | require.NoError(t, err) | |||
| 56 | + | require.Equal(t, payload, jobs[0].ContextsJSON) | |||
| 57 | + | ready, err := ci.ListReadyJobs(ctx, 0) | |||
| 58 | + | require.NoError(t, err) | |||
| 59 | + | require.Equal(t, payload, ready[0].ContextsJSON) | |||
| 60 | + | } | |||
| 61 | + | // A kept job in a re-run gets an independent contexts key. | |||
| 62 | + | j.RunAttempt = 2 | |||
| 63 | + | j.Version = 0 | |||
| 64 | + | save() | |||
| 65 | + | copied, ok := ci.db.Get(ctx, jobContextsKey(1, 2, j.ID)) | |||
| 66 | + | require.True(t, ok) | |||
| 67 | + | require.Equal(t, payload, string(copied.Value)) | |||
| 68 | + | j.ContextsJSON = "" | |||
| 69 | + | save() | |||
| 70 | + | got, err := ci.GetJob(ctx, 1, 2, j.ID) | |||
| 71 | + | require.NoError(t, err) | |||
| 72 | + | require.Empty(t, got.ContextsJSON) | |||
| 73 | + | // Sweep must remove contexts from every attempt as well as job records. | |||
| 74 | + | _, err = ci.Apply(ctx, ports.CIWrite{Runs: []domain.CIRun{{Repo: repo, ID: 1, Status: domain.CICompleted, CompletedAt: time.Now().Add(-time.Hour)}}}) | |||
| 75 | + | require.NoError(t, err) | |||
| 76 | + | ids, err := ci.Sweep(ctx, time.Now()) | |||
| 77 | + | require.NoError(t, err) | |||
| 78 | + | require.Equal(t, []int64{1}, ids) | |||
| 79 | + | for range ci.db.Snapshot(ctx).Prefix(ctx, []byte(jobCtxPrefix)) { | |||
| 80 | + | t.Fatal("orphan contexts key") | |||
| 81 | + | } | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | func TestCIJobLegacyContexts(t *testing.T) { | |||
| 85 | + | ctx := context.Background() | |||
| 86 | + | s, err := Open(ctx, t.TempDir()) | |||
| 87 | + | require.NoError(t, err) | |||
| 88 | + | t.Cleanup(func() { require.NoError(t, s.Close(ctx)) }) | |||
| 89 | + | ci := s.CI() | |||
| 90 | + | var b kv.Batch | |||
| 91 | + | b.Put(jobKey(1, 1, "build"), mustMarshal(&storagepb.CIJob{Id: "build", Repo: "proj", RunId: 1, RunAttempt: 1, ContextsJson: `{"legacy":true}`})) | |||
| 92 | + | _, err = ci.db.Apply(ctx, &b) | |||
| 93 | + | require.NoError(t, err) | |||
| 94 | + | j, err := ci.GetJob(ctx, 1, 1, "build") | |||
| 95 | + | require.NoError(t, err) | |||
| 96 | + | require.Equal(t, `{"legacy":true}`, j.ContextsJSON) | |||
| 97 | + | jobs, err := ci.ListJobs(ctx, 1, 1) | |||
| 98 | + | require.NoError(t, err) | |||
| 99 | + | require.Equal(t, j.ContextsJSON, jobs[0].ContextsJSON) | |||
| 100 | + | // Updating the legacy job moves its contexts out of the protobuf. | |||
| 101 | + | _, err = ci.Apply(ctx, ports.CIWrite{Jobs: []domain.CIJob{j}}) | |||
| 102 | + | require.NoError(t, err) | |||
| 103 | + | got, err := ci.GetJob(ctx, 1, 1, "build") | |||
| 104 | + | require.NoError(t, err) | |||
| 105 | + | require.Equal(t, j.ContextsJSON, got.ContextsJSON) | |||
| 106 | + | item, ok := ci.db.Get(ctx, jobKey(1, 1, "build")) | |||
| 107 | + | require.True(t, ok) | |||
| 108 | + | pbJob, err := decodeJob(item) | |||
| 109 | + | require.NoError(t, err) | |||
| 110 | + | require.Empty(t, pbJob.ContextsJSON) | |||
| 111 | + | } | |||
M proto/ermite/storage/ci.proto +2 -1
| @@ -203,7 +203,8 @@ message CIJob { | |||
| 203 | 203 | // keeps its own attempt. | |
| 204 | 204 | int32 run_attempt = 38; | |
| 205 | 205 | // The contexts JSON given to the runner, computed when the job is planned | |
| 206 | - | // (needs outputs are final then). | |
| 206 | + | // (needs outputs are final then). No longer written: stored under jobctx/ | |
| 207 | + | // instead. Retained as a read fallback for legacy records. | |
| 207 | 208 | string contexts_json = 39; | |
| 208 | 209 | // The job's continue-on-error, evaluated when the job is planned: a failure | |
| 209 | 210 | // of the job does not fail the run, does not cancel the siblings of its | |
| @@ -203,7 +203,8 @@ message CIJob { | |||||
| 203 | // keeps its own attempt. | 203 | // keeps its own attempt. | ||
| 204 | int32 run_attempt = 38; | 204 | int32 run_attempt = 38; | ||
| 205 | // The contexts JSON given to the runner, computed when the job is planned | 205 | // The contexts JSON given to the runner, computed when the job is planned | ||
| 206 | - | // (needs outputs are final then). | 206 | + | // (needs outputs are final then). No longer written: stored under jobctx/ |
| 207 | + | // instead. Retained as a read fallback for legacy records. | |||
| 207 | string contexts_json = 39; | 208 | string contexts_json = 39; | ||
| 208 | // The job's continue-on-error, evaluated when the job is planned: a failure | 209 | // The job's continue-on-error, evaluated when the job is planned: a failure | ||
| 209 | // of the job does not fail the run, does not cancel the siblings of its | 210 | // of the job does not fail the run, does not cancel the siblings of its | ||