| new file mode 100644 |
| index 000000000..639479aba |
| --- /dev/null |
| +++ b/internal/moves/readiness_test.go |
| @@ -0,0 +1,571 @@ |
| +package moves |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "math" |
| + "os" |
| + "reflect" |
| + "strings" |
| + "sync" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func passingTarget(at time.Time) *TargetReport { |
| + r := &TargetReport{V: 1, At: at, Checks: map[string]Check{}, AttemptsAdmitted: 1} |
| + for _, id := range AllChecks { |
| + r.Checks[id] = Check{OK: true} |
| + } |
| + return r |
| +} |
| + |
| +func TestReadyNeedsFreshReports(t *testing.T) { |
| + now := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC) |
| + pass := passingTarget |
| + src := &SourceReport{V: 1, At: now, S1: Check{OK: true}, S2: Check{OK: true}} |
| + if ok, why := Ready(1<<20, 16<<20, pass(now.Add(-time.Minute)), src, now); !ok { |
| + t.Fatal(why) |
| + } |
| + // Review Focus 5: a passing report older than 10 minutes does not promote. |
| + if ok, why := Ready(1<<20, 16<<20, pass(now.Add(-11*time.Minute)), src, now); ok || !strings.Contains(why, "stale") { |
| + t.Fatalf("stale report promoted: %v %s", ok, why) |
| + } |
| + // The same bound on the source's report. |
| + oldSrc := &SourceReport{V: 1, At: now.Add(-11 * time.Minute), S1: Check{OK: true}, S2: Check{OK: true}} |
| + if ok, why := Ready(0, 16<<20, pass(now), oldSrc, now); ok || !strings.Contains(why, "stale") { |
| + t.Fatalf("stale source report promoted: %v %s", ok, why) |
| + } |
| + // Over the bar, a failed check, a failed S1, a missing report, zero attempts. |
| + if ok, _ := Ready(17<<20, 16<<20, pass(now), src, now); ok { |
| + t.Fatal("over the bar") |
| + } |
| + bad := pass(now) |
| + bad.Checks[CheckBudget] = Check{Reason: "needs 131 s, window 120 s"} |
| + if ok, why := Ready(0, 16<<20, bad, src, now); ok || !strings.Contains(why, "T11") { |
| + t.Fatalf("failed check: %v %s", ok, why) |
| + } |
| + if ok, _ := Ready(0, 16<<20, pass(now), &SourceReport{V: 1, At: now, S1: Check{Reason: "503"}, S2: Check{OK: true}}, now); ok { |
| + t.Fatal("failed S1") |
| + } |
| + if ok, why := Ready(0, 16<<20, pass(now), &SourceReport{V: 1, At: now, S1: Check{OK: true}, S2: Check{Reason: "strict spawn refused"}}, now); ok || !strings.Contains(why, "S2") { |
| + t.Fatalf("failed S2: %v %s", ok, why) |
| + } |
| + if ok, _ := Ready(0, 16<<20, nil, src, now); ok { |
| + t.Fatal("no report") |
| + } |
| + if ok, _ := Ready(0, 16<<20, pass(now), nil, now); ok { |
| + t.Fatal("no source report") |
| + } |
| + none := pass(now) |
| + none.AttemptsAdmitted = 0 |
| + if ok, _ := Ready(0, 16<<20, none, src, now); ok { |
| + t.Fatal("zero attempts admitted") |
| + } |
| +} |
| + |
| +// Review Focus 5, second half: a stale report after ready demotes nothing. |
| +// Ready only ever gates promotion. The one transition it feeds is |
| +// MovePromoteReady, which is legal from preparing alone, so a move that is |
| +// already ready stays ready whatever the next reading of Ready says, and |
| +// reading Ready changes neither report. |
| +func TestStaleReportAfterReadyDemotesNothing(t *testing.T) { |
| + now := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC) |
| + target := passingTarget(now) |
| + src := &SourceReport{V: 1, At: now, S1: Check{OK: true}, S2: Check{OK: true}} |
| + rec := &provision.Record{Move: &provision.MoveState{State: provision.MovePreparing}} |
| + |
| + if ok, why := Ready(0, 16<<20, target, src, now); !ok { |
| + t.Fatal(why) |
| + } |
| + if err := provision.MovePromoteReady(now)(rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + if rec.Move.State != provision.MoveReady { |
| + t.Fatalf("not promoted: %s", rec.Move.State) |
| + } |
| + |
| + // Both reports go stale. The reading is false, and nothing moves. |
| + targetBefore, srcBefore := *target, *src |
| + later := now.Add(ReportFreshness + time.Minute) |
| + if ok, why := Ready(0, 16<<20, target, src, later); ok || !strings.Contains(why, "stale") { |
| + t.Fatalf("stale reading: %v %s", ok, why) |
| + } |
| + if !reflect.DeepEqual(*target, targetBefore) || !reflect.DeepEqual(*src, srcBefore) { |
| + t.Fatal("Ready changed a report") |
| + } |
| + // The host acts on a true reading only, so the stale reading does not |
| + // reach the journal; and if it did, there is no way back to preparing. |
| + if err := provision.MovePromoteReady(later)(rec); err == nil { |
| + t.Fatal("a second promotion was accepted") |
| + } |
| + if rec.Move.State != provision.MoveReady { |
| + t.Fatalf("a stale report demoted the move to %s", rec.Move.State) |
| + } |
| +} |
| + |
| +func TestReadyRejectsMismatchedInputs(t *testing.T) { |
| + now := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC) |
| + mk := func(targetFiles, sourceFiles, targetEnv, sourceEnv string) (*TargetReport, *SourceReport) { |
| + tr := passingTarget(now) |
| + tr.Inputs = provision.MoveInputs{FilesPlain: targetFiles, EnvReport: targetEnv} |
| + sr := &SourceReport{V: 1, At: now, S1: Check{OK: true}, S2: Check{OK: true}, FilesPlain: sourceFiles, Env: EnvReport{V: 1, Digest: sourceEnv}} |
| + return tr, sr |
| + } |
| + tr, sr := mk("sha256:aaa", "sha256:aaa", "env-1", "env-1") |
| + if ok, why := Ready(0, 16<<20, tr, sr, now); !ok { |
| + t.Fatalf("equal inputs: %s", why) |
| + } |
| + tr, sr = mk("sha256:aaa", "sha256:bbb", "env-1", "env-1") |
| + ok, why := Ready(0, 16<<20, tr, sr, now) |
| + if ok || !strings.Contains(why, "sha256:aaa") || !strings.Contains(why, "sha256:bbb") { |
| + t.Fatalf("files mismatch: %v %q", ok, why) |
| + } |
| + tr, sr = mk("sha256:aaa", "sha256:aaa", "env-1", "env-2") |
| + ok, why = Ready(0, 16<<20, tr, sr, now) |
| + if ok || !strings.Contains(why, "env-1") || !strings.Contains(why, "env-2") { |
| + t.Fatalf("env mismatch: %v %q", ok, why) |
| + } |
| +} |
| + |
| +func TestReadyRefusesAnEstimateMadeUnderASmallerMirrorBar(t *testing.T) { |
| + now := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC) |
| + src := &SourceReport{V: 1, At: now, S1: Check{OK: true}, S2: Check{OK: true}} |
| + tr := passingTarget(now) |
| + |
| + // Priced at the default 16 MiB bar (MirrorBar zero), the operator's |
| + // bar raised to 32 MiB: the estimate is too small for it. |
| + if ok, why := Ready(0, 32<<20, tr, src, now); ok || !strings.Contains(why, "T11") { |
| + t.Fatalf("under-priced estimate promoted: %v %q", ok, why) |
| + } |
| + // Priced at 32 MiB, bar 32 MiB: fine. Priced at 32, bar lowered: fine. |
| + tr.Rates.MirrorBar = 32 |
| + if ok, why := Ready(0, 32<<20, tr, src, now); !ok { |
| + t.Fatal(why) |
| + } |
| + if ok, why := Ready(0, 8<<20, tr, src, now); !ok { |
| + t.Fatal(why) |
| + } |
| + // Priced at 8, bar 16: too small. |
| + tr.Rates.MirrorBar = 8 |
| + if ok, _ := Ready(0, 16<<20, tr, src, now); ok { |
| + t.Fatal("a smaller priced bar promoted") |
| + } |
| +} |
| + |
| +func TestPassingNamesTheFirstFailureInIDOrder(t *testing.T) { |
| + r := passingTarget(time.Now()) |
| + if ok, why := r.Passing(); !ok || why != "" { |
| + t.Fatalf("%v %q", ok, why) |
| + } |
| + r.Checks[CheckClaim] = Check{Reason: "touched"} |
| + r.Checks[CheckArchive] = Check{Reason: "no archive"} |
| + if ok, why := r.Passing(); ok || !strings.HasPrefix(why, "T5: ") || !strings.Contains(why, "no archive") { |
| + t.Fatalf("%v %q", ok, why) |
| + } |
| + delete(r.Checks, CheckArchive) |
| + if ok, why := r.Passing(); ok || !strings.HasPrefix(why, "T5") || strings.Contains(why, "T9") { |
| + t.Fatalf("missing check: %v %q", ok, why) |
| + } |
| + // A failed check with no reason still says something. |
| + r.Checks[CheckArchive] = Check{} |
| + if ok, why := r.Passing(); ok || strings.TrimSpace(strings.TrimPrefix(why, "T5:")) == "" { |
| + t.Fatalf("empty reason: %v %q", ok, why) |
| + } |
| + // A report with no checks at all is not passing. |
| + if ok, _ := (TargetReport{}).Passing(); ok { |
| + t.Fatal("an empty report passed") |
| + } |
| +} |
| + |
| +func TestAllChecksListsT1ToT12(t *testing.T) { |
| + want := []string{"T1", "T2", "T3", "T4", "T5", "T6", "T7", "T8", "T9", "T10", "T11", "T12"} |
| + if !reflect.DeepEqual(AllChecks, want) { |
| + t.Fatalf("%v", AllChecks) |
| + } |
| + got := []string{CheckProtocol, CheckSecrets, CheckEnv, CheckStorage, CheckArchive, CheckDisk, CheckRelease, CheckOneOwner, CheckClaim, CheckLane, CheckBudget, CheckQuiet} |
| + if !reflect.DeepEqual(got, want) { |
| + t.Fatalf("%v", got) |
| + } |
| +} |
| + |
| +func TestReportsRoundTripWithFixedWireNames(t *testing.T) { |
| + at := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC) |
| + tr := TargetReport{V: 1, Box: "uk-box", Region: "uk", MoveID: "mv_x", At: at, |
| + Checks: map[string]Check{CheckProtocol: {OK: true}}, |
| + Inputs: provision.MoveInputs{FilesPlain: "p", EnvReport: "e"}, |
| + AttemptsAdmitted: 2, CutBudgetS: 120, Rates: Rates{Cross: 10, S3: 250, Disk: 500, MirrorBar: 16}, |
| + Notes: []string{"the carried .env overrides X"}} |
| + b, err := json.Marshal(tr) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var m map[string]json.RawMessage |
| + if err := json.Unmarshal(b, &m); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, k := range []string{"v", "box", "region", "move_id", "at", "checks", "inputs", "attempts_admitted", "cut_budget_s", "rates", "notes"} { |
| + if _, ok := m[k]; !ok { |
| + t.Errorf("target report has no %q: %s", k, b) |
| + } |
| + } |
| + var back TargetReport |
| + if err := json.Unmarshal(b, &back); err != nil || !reflect.DeepEqual(back, tr) { |
| + t.Fatalf("%v %+v", err, back) |
| + } |
| + |
| + sr := SourceReport{V: 1, Box: "src", MoveID: "mv_x", At: at, S1: Check{OK: true}, S2: Check{OK: true}, |
| + FilesPlain: "p", Release: "v1.2.3", CrossRate: 9.5, CrossRateAt: at, |
| + Archive: &ArchiveRef{ManifestKey: "a/m", ManifestVersion: "v1", SealedObjectKey: "a/s", SealedVersion: "v2", Plain: "p"}} |
| + b, err = json.Marshal(sr) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + m = nil |
| + if err := json.Unmarshal(b, &m); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, k := range []string{"v", "box", "move_id", "at", "env", "s1", "s2", "files_plain", "release", "archive", "cross_rate", "cross_rate_at"} { |
| + if _, ok := m[k]; !ok { |
| + t.Errorf("source report has no %q: %s", k, b) |
| + } |
| + } |
| + var ref map[string]string |
| + if err := json.Unmarshal(m["archive"], &ref); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, k := range []string{"manifest_key", "manifest_version", "sealed_object_key", "sealed_version", "plain"} { |
| + if _, ok := ref[k]; !ok { |
| + t.Errorf("archive ref has no %q", k) |
| + } |
| + } |
| + var sback SourceReport |
| + if err := json.Unmarshal(b, &sback); err != nil || !reflect.DeepEqual(sback, sr) { |
| + t.Fatalf("%v %+v", err, sback) |
| + } |
| + // No archive yet: the field is absent, not null. |
| + b, _ = json.Marshal(SourceReport{V: 1}) |
| + if strings.Contains(string(b), `"archive"`) { |
| + t.Fatalf("an unpublished archive is written: %s", b) |
| + } |
| +} |
| + |
| +// ---- measured rates and T11 ---- |
| + |
| +var t11Now = time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC) |
| + |
| +func goodMeasurements() Measurements { |
| + at := t11Now.Add(-time.Minute) |
| + return Measurements{ |
| + Cross: MeasuredRate{MiBPerS: 10, At: at, Bytes: 100 << 20}, |
| + S3: MeasuredRate{MiBPerS: 250, At: at, Bytes: 16 << 20}, |
| + Disk: MeasuredRate{MiBPerS: 500, At: at, Bytes: 64 << 20}, |
| + } |
| +} |
| + |
| +func TestMeasurementsNeedFreshUsableRates(t *testing.T) { |
| + r, err := goodMeasurements().Rates(t11Now, 16<<20) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if r.Cross != 10 || r.S3 != 250 || r.Disk != 500 || r.MirrorBar != 16 { |
| + t.Fatalf("%+v", r) |
| + } |
| + |
| + cases := []struct { |
| + name string |
| + mutate func(m *Measurements) |
| + which string |
| + }{ |
| + {"missing cross", func(m *Measurements) { m.Cross = MeasuredRate{} }, "the cross-region copy path"}, |
| + {"old s3", func(m *Measurements) { m.S3.At = t11Now.Add(-MeasurementFreshness - time.Second) }, "regional S3"}, |
| + {"future disk", func(m *Measurements) { m.Disk.At = t11Now.Add(time.Hour) }, "the disk"}, |
| + {"zero rate", func(m *Measurements) { m.Disk.MiBPerS = 0 }, "the disk"}, |
| + {"negative rate", func(m *Measurements) { m.S3.MiBPerS = -5 }, "regional S3"}, |
| + {"NaN", func(m *Measurements) { m.Cross.MiBPerS = math.NaN() }, "the cross-region copy path"}, |
| + {"+Inf", func(m *Measurements) { m.S3.MiBPerS = math.Inf(1) }, "regional S3"}, |
| + {"no time", func(m *Measurements) { m.Disk.At = time.Time{} }, "the disk"}, |
| + } |
| + for _, c := range cases { |
| + m := goodMeasurements() |
| + c.mutate(&m) |
| + _, err := m.Rates(t11Now, 16<<20) |
| + if err == nil || !strings.Contains(err.Error(), "no fresh rate measurement for "+c.which) { |
| + t.Errorf("%s: %v", c.name, err) |
| + } |
| + } |
| + // Exactly an hour old is still fresh. |
| + m := goodMeasurements() |
| + m.S3.At = t11Now.Add(-MeasurementFreshness) |
| + if _, err := m.Rates(t11Now, 16<<20); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func TestMeasuredRateFromBytesAndTime(t *testing.T) { |
| + m := RateOf(100<<20, 10*time.Second, t11Now) |
| + if m.MiBPerS != 10 || m.Bytes != 100<<20 || !m.At.Equal(t11Now) { |
| + t.Fatalf("%+v", m) |
| + } |
| + // No elapsed time or no bytes is no measurement, not an infinite rate. |
| + for _, m := range []MeasuredRate{RateOf(1<<20, 0, t11Now), RateOf(0, time.Second, t11Now), RateOf(1<<20, -time.Second, t11Now)} { |
| + if m.MiBPerS != 0 { |
| + t.Fatalf("%+v", m) |
| + } |
| + } |
| +} |
| + |
| +func TestSourceReportCarriesTheCrossRate(t *testing.T) { |
| + at := t11Now.Add(-time.Minute) |
| + if got := (&SourceReport{CrossRate: 9.5, CrossRateAt: at}).CrossMeasurement(); got.MiBPerS != 9.5 || !got.At.Equal(at) { |
| + t.Fatalf("%+v", got) |
| + } |
| + // A source that has measured nothing yields the zero measurement, which |
| + // Measurements.Rates refuses. |
| + m := goodMeasurements() |
| + m.Cross = (&SourceReport{}).CrossMeasurement() |
| + if _, err := m.Rates(t11Now, 16<<20); err == nil || !strings.Contains(err.Error(), "cross-region") { |
| + t.Fatal(err) |
| + } |
| + if got := (*SourceReport)(nil).CrossMeasurement(); got != (MeasuredRate{}) { |
| + t.Fatalf("%+v", got) |
| + } |
| +} |
| + |
| +func TestT11BudgetCheck(t *testing.T) { |
| + // 100 MiB database at the spec's example rates fits the default window. |
| + res := BudgetCheck(100, goodMeasurements(), 16<<20, CutWindowDefault, t11Now) |
| + if !res.Check.OK || res.AttemptsAdmitted < 1 { |
| + t.Fatalf("%+v", res) |
| + } |
| + if res.Rates.Cross != 10 || res.Rates.MirrorBar != 16 { |
| + t.Fatalf("%+v", res.Rates) |
| + } |
| + if res.Seconds <= 0 || res.Seconds > CutWindowDefault { |
| + t.Fatalf("%v", res.Seconds) |
| + } |
| + |
| + // A database too big for the window admits no attempt: T11 fails and |
| + // the reason says what was needed. |
| + res = BudgetCheck(100000, goodMeasurements(), 16<<20, CutWindowDefault, t11Now) |
| + if res.Check.OK || res.AttemptsAdmitted != 0 || !strings.Contains(res.Check.Reason, "120") { |
| + t.Fatalf("%+v", res) |
| + } |
| + |
| + // A missing measurement fails closed, naming which. |
| + m := goodMeasurements() |
| + m.Disk = MeasuredRate{} |
| + res = BudgetCheck(100, m, 16<<20, CutWindowDefault, t11Now) |
| + if res.Check.OK || res.AttemptsAdmitted != 0 || !strings.Contains(res.Check.Reason, "no fresh rate measurement for the disk") { |
| + t.Fatalf("%+v", res) |
| + } |
| + |
| + // A database size that is not a number, or a window that is not, fails. |
| + for _, db := range []float64{math.NaN(), math.Inf(1), -1} { |
| + if r := BudgetCheck(db, goodMeasurements(), 16<<20, CutWindowDefault, t11Now); r.Check.OK || r.AttemptsAdmitted != 0 { |
| + t.Fatalf("db %v: %+v", db, r) |
| + } |
| + } |
| + for _, w := range []float64{math.NaN(), math.Inf(1), 0, -3} { |
| + if r := BudgetCheck(100, goodMeasurements(), 16<<20, w, t11Now); r.Check.OK || r.AttemptsAdmitted != 0 { |
| + t.Fatalf("window %v: %+v", w, r) |
| + } |
| + } |
| +} |
| + |
| +// F28(e): an override of the ready-delta bar reaches the estimate. A larger |
| +// mirror bar costs more seconds, so a window that fits the default bar can |
| +// refuse a bar of 4 GiB. |
| +func TestT11BudgetCheckPricesTheOperatorsBar(t *testing.T) { |
| + m := goodMeasurements() |
| + def := BudgetCheck(100, m, 16<<20, CutWindowDefault, t11Now) |
| + big := BudgetCheck(100, m, 4<<30, CutWindowDefault, t11Now) |
| + if !def.Check.OK { |
| + t.Fatalf("%+v", def) |
| + } |
| + if big.Rates.MirrorBar != 4096 { |
| + t.Fatalf("the bar did not reach the rates: %+v", big.Rates) |
| + } |
| + if big.Seconds <= def.Seconds { |
| + t.Fatalf("a 4 GiB bar priced at %v, the default at %v", big.Seconds, def.Seconds) |
| + } |
| + if big.Check.OK { |
| + t.Fatalf("a 4 GiB mirror at 10 MiB/s fits 120 s: %+v", big) |
| + } |
| + // A bar of zero bytes is priced at the default, never below it. |
| + zero := BudgetCheck(100, m, 0, CutWindowDefault, t11Now) |
| + if zero.Seconds != def.Seconds { |
| + t.Fatalf("%v vs %v", zero.Seconds, def.Seconds) |
| + } |
| + // A report made by BudgetCheck is accepted by Ready at the same bar, |
| + // and refused at a larger one. |
| + src := &SourceReport{V: 1, At: t11Now, S1: Check{OK: true}, S2: Check{OK: true}} |
| + tr := passingTarget(t11Now) |
| + tr.Rates, tr.AttemptsAdmitted = big.Rates, 1 |
| + if ok, why := Ready(0, 4<<30, tr, src, t11Now); !ok { |
| + t.Fatal(why) |
| + } |
| + tr.Rates = def.Rates |
| + if ok, _ := Ready(0, 4<<30, tr, src, t11Now); ok { |
| + t.Fatal("a report priced at 16 MiB promoted under a 4 GiB bar") |
| + } |
| +} |
| + |
| +func TestProbeKeyIsOutsideTheStagingPrefix(t *testing.T) { |
| + host, op := "a.go.example.com", "mv_abcdefghijklmnopqrstuvwxyz" |
| + k := ProbeKey(host, op) |
| + if k != "move-probes/a.go.example.com/mv_abcdefghijklmnopqrstuvwxyz/probe" { |
| + t.Fatal(k) |
| + } |
| + if strings.HasPrefix(k, "moves/") || strings.HasPrefix(k, StagingPrefix(host, op)) { |
| + t.Fatal("the probe object is under the staging prefix") |
| + } |
| +} |
| + |
| +// ---- PutReplace ---- |
| + |
| +// casStore is an in-memory release.Store with the CAS rules of the real |
| +// ones. beforePut runs once ahead of the first Put, so a test can lose a |
| +// race. |
| +type casStore struct { |
| + mu sync.Mutex |
| + objs map[string][]byte |
| + etags map[string]int |
| + puts int |
| + beforePut func() |
| + failGet error |
| +} |
| + |
| +func newCASStore() *casStore { |
| + return &casStore{objs: map[string][]byte{}, etags: map[string]int{}} |
| +} |
| + |
| +func (s *casStore) etag(k string) string { |
| + if _, ok := s.objs[k]; !ok { |
| + return "" |
| + } |
| + return "e" + string(rune('0'+s.etags[k])) |
| +} |
| + |
| +func (s *casStore) Get(_ context.Context, key string) ([]byte, string, error) { |
| + s.mu.Lock() |
| + defer s.mu.Unlock() |
| + if s.failGet != nil { |
| + return nil, "", s.failGet |
| + } |
| + b, ok := s.objs[key] |
| + if !ok { |
| + return nil, "", fmt.Errorf("no such key %q: %w", key, os.ErrNotExist) |
| + } |
| + return append([]byte(nil), b...), s.etag(key), nil |
| +} |
| + |
| +func (s *casStore) Put(_ context.Context, key string, body []byte, ifMatch string) (string, error) { |
| + if s.beforePut != nil { |
| + f := s.beforePut |
| + s.beforePut = nil |
| + f() |
| + } |
| + s.mu.Lock() |
| + defer s.mu.Unlock() |
| + s.puts++ |
| + if s.etag(key) != ifMatch { |
| + return "", release.ErrCASConflict |
| + } |
| + s.objs[key] = append([]byte(nil), body...) |
| + s.etags[key]++ |
| + return s.etag(key), nil |
| +} |
| + |
| +func (s *casStore) List(context.Context, string) ([]string, error) { return nil, nil } |
| +func (s *casStore) ListDirs(context.Context, string) ([]string, error) { return nil, nil } |
| +func (s *casStore) Delete(context.Context, string) error { return nil } |
| + |
| +func TestPutReplaceCreatesThenReplaces(t *testing.T) { |
| + ctx := context.Background() |
| + s := newCASStore() |
| + key := TargetReportKey(ReportPrefix("bab", "app", "h", "mv_x")) |
| + // A create-only Put refuses the second write: the reason PutReplace exists. |
| + if _, err := s.Put(ctx, "other", []byte("1"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := s.Put(ctx, "other", []byte("2"), ""); !errors.Is(err, release.ErrCASConflict) { |
| + t.Fatal(err) |
| + } |
| + for _, body := range []string{"one", "two", "three"} { |
| + if err := PutReplace(ctx, s, key, []byte(body)); err != nil { |
| + t.Fatalf("%s: %v", body, err) |
| + } |
| + if got, _, _ := s.Get(ctx, key); string(got) != body { |
| + t.Fatalf("%q", got) |
| + } |
| + } |
| +} |
| + |
| +func TestPutReplaceRetriesOnceOnAConflict(t *testing.T) { |
| + ctx := context.Background() |
| + s := newCASStore() |
| + if err := PutReplace(ctx, s, "k", []byte("old")); err != nil { |
| + t.Fatal(err) |
| + } |
| + // Someone else replaces the object between our Get and our Put. |
| + s.beforePut = func() { |
| + s.objs["k"] = []byte("theirs") |
| + s.etags["k"]++ |
| + } |
| + if err := PutReplace(ctx, s, "k", []byte("ours")); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got, _, _ := s.Get(ctx, "k"); string(got) != "ours" { |
| + t.Fatalf("%q", got) |
| + } |
| +} |
| + |
| +func TestPutReplaceGivesUpAfterTheSecondConflict(t *testing.T) { |
| + ctx := context.Background() |
| + s := &alwaysConflict{casStore: newCASStore()} |
| + err := PutReplace(ctx, s, "k", []byte("x")) |
| + if !errors.Is(err, release.ErrCASConflict) { |
| + t.Fatalf("%v", err) |
| + } |
| + if s.puts != 2 { |
| + t.Fatalf("%d puts, want exactly one retry", s.puts) |
| + } |
| +} |
| + |
| +type alwaysConflict struct{ *casStore } |
| + |
| +func (a *alwaysConflict) Put(_ context.Context, _ string, _ []byte, _ string) (string, error) { |
| + a.puts++ |
| + return "", release.ErrCASConflict |
| +} |
| + |
| +func TestPutReplaceDoesNotWriteBlindOnAReadError(t *testing.T) { |
| + s := newCASStore() |
| + s.failGet = errors.New("503 slow down") |
| + err := PutReplace(context.Background(), s, "k", []byte("x")) |
| + if err == nil || !strings.Contains(err.Error(), "503") { |
| + t.Fatalf("%v", err) |
| + } |
| + if s.puts != 0 { |
| + t.Fatal("wrote after a failed read") |
| + } |
| +} |
| + |
| +func TestPutReplaceWorksOnADirStore(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + for _, body := range []string{"one", "two"} { |
| + if err := PutReplace(ctx, s, "moves/a/b/target.json", []byte(body)); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if got, _, err := s.Get(ctx, "moves/a/b/target.json"); err != nil || string(got) != "two" { |
| + t.Fatalf("%q %v", got, err) |
| + } |
| +} |