| 1 | package moves |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "fmt" |
| 8 | "math" |
| 9 | "os" |
| 10 | "reflect" |
| 11 | "strings" |
| 12 | "sync" |
| 13 | "testing" |
| 14 | "time" |
| 15 | |
| 16 | "github.com/carlosframework/platform/internal/provision" |
| 17 | "github.com/carlosframework/platform/internal/release" |
| 18 | ) |
| 19 | |
| 20 | // passingTarget is a passing report made at `at` with real evidence: a |
| 21 | // 100 MiB database priced from rates measured a minute earlier, in a 120 s |
| 22 | // window. goodSource is the matching source report. |
| 23 | func passingTarget(at time.Time) *TargetReport { |
| 24 | r := &TargetReport{V: 1, At: at, Checks: map[string]Check{}, CutBudgetS: 120, Inputs: provision.MoveInputs{Release: "v7"}} |
| 25 | for _, id := range AllChecks { |
| 26 | r.Checks[id] = Check{OK: true} |
| 27 | } |
| 28 | m := Measurements{ |
| 29 | Cross: MeasuredRate{MiBPerS: 10, At: at.Add(-time.Minute)}, |
| 30 | S3: MeasuredRate{MiBPerS: 250, At: at.Add(-time.Minute)}, |
| 31 | Disk: MeasuredRate{MiBPerS: 500, At: at.Add(-time.Minute)}, |
| 32 | Fsync: FilesRate{PerS: 1600, At: at.Add(-time.Minute)}, |
| 33 | } |
| 34 | res := BudgetCheck(100, 0, m, 16<<20, 120, at) |
| 35 | if !res.Check.OK { |
| 36 | panic("the test fixture does not fit its own window: " + res.Check.Reason) |
| 37 | } |
| 38 | r.RecordBudget(100, 0, m, res) |
| 39 | return r |
| 40 | } |
| 41 | |
| 42 | func sourceWith(now time.Time, s1, s2 Check) *SourceReport { |
| 43 | sr := goodSource(now) |
| 44 | sr.S1, sr.S2 = s1, s2 |
| 45 | return sr |
| 46 | } |
| 47 | |
| 48 | func goodSource(now time.Time) *SourceReport { |
| 49 | return &SourceReport{V: 1, At: now, S1: Check{OK: true}, S2: Check{OK: true}, CrossRate: 10, CrossRateAt: now.Add(-time.Minute), Release: "v7"} |
| 50 | } |
| 51 | |
| 52 | func TestReadyNeedsFreshReports(t *testing.T) { |
| 53 | now := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC) |
| 54 | pass := passingTarget |
| 55 | src := goodSource(now) |
| 56 | if ok, why := Ready(1<<20, 16<<20, pass(now.Add(-time.Minute)), src, now); !ok { |
| 57 | t.Fatal(why) |
| 58 | } |
| 59 | // Review Focus 5: a passing report older than 10 minutes does not promote. |
| 60 | if ok, why := Ready(1<<20, 16<<20, pass(now.Add(-11*time.Minute)), src, now); ok || !strings.Contains(why, "stale") { |
| 61 | t.Fatalf("stale report promoted: %v %s", ok, why) |
| 62 | } |
| 63 | // The same bound on the source's report. |
| 64 | oldSrc := goodSource(now) |
| 65 | oldSrc.At = now.Add(-11 * time.Minute) |
| 66 | if ok, why := Ready(0, 16<<20, pass(now), oldSrc, now); ok || !strings.Contains(why, "stale") { |
| 67 | t.Fatalf("stale source report promoted: %v %s", ok, why) |
| 68 | } |
| 69 | // Over the bar, a failed check, a failed S1, a missing report, zero attempts. |
| 70 | if ok, _ := Ready(17<<20, 16<<20, pass(now), src, now); ok { |
| 71 | t.Fatal("over the bar") |
| 72 | } |
| 73 | bad := pass(now) |
| 74 | bad.Checks[CheckBudget] = Check{Reason: "needs 131 s, window 120 s"} |
| 75 | if ok, why := Ready(0, 16<<20, bad, src, now); ok || !strings.Contains(why, "T11") { |
| 76 | t.Fatalf("failed check: %v %s", ok, why) |
| 77 | } |
| 78 | if ok, _ := Ready(0, 16<<20, pass(now), sourceWith(now, Check{Reason: "503"}, Check{OK: true}), now); ok { |
| 79 | t.Fatal("failed S1") |
| 80 | } |
| 81 | if ok, why := Ready(0, 16<<20, pass(now), sourceWith(now, Check{OK: true}, Check{Reason: "strict spawn refused"}), now); ok || !strings.Contains(why, "S2") { |
| 82 | t.Fatalf("failed S2: %v %s", ok, why) |
| 83 | } |
| 84 | if ok, _ := Ready(0, 16<<20, nil, src, now); ok { |
| 85 | t.Fatal("no report") |
| 86 | } |
| 87 | if ok, _ := Ready(0, 16<<20, pass(now), nil, now); ok { |
| 88 | t.Fatal("no source report") |
| 89 | } |
| 90 | none := pass(now) |
| 91 | none.AttemptsAdmitted = 0 |
| 92 | if ok, _ := Ready(0, 16<<20, none, src, now); ok { |
| 93 | t.Fatal("zero attempts admitted") |
| 94 | } |
| 95 | } |
| 96 | |
| 97 | // Review Focus 5, second half: a stale report after ready demotes nothing. |
| 98 | // Ready only ever gates promotion. The one transition it feeds is |
| 99 | // MovePromoteReady, which is legal from preparing alone, so a move that is |
| 100 | // already ready stays ready whatever the next reading of Ready says, and |
| 101 | // reading Ready changes neither report. |
| 102 | func TestStaleReportAfterReadyDemotesNothing(t *testing.T) { |
| 103 | now := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC) |
| 104 | target := passingTarget(now) |
| 105 | src := goodSource(now) |
| 106 | rec := &provision.Record{Move: &provision.MoveState{State: provision.MovePreparing}} |
| 107 | |
| 108 | if ok, why := Ready(0, 16<<20, target, src, now); !ok { |
| 109 | t.Fatal(why) |
| 110 | } |
| 111 | if err := provision.MovePromoteReady(now)(rec); err != nil { |
| 112 | t.Fatal(err) |
| 113 | } |
| 114 | if rec.Move.State != provision.MoveReady { |
| 115 | t.Fatalf("not promoted: %s", rec.Move.State) |
| 116 | } |
| 117 | |
| 118 | // Both reports go stale. The reading is false, and nothing moves. |
| 119 | targetBefore, srcBefore := *target, *src |
| 120 | later := now.Add(ReportFreshness + time.Minute) |
| 121 | if ok, why := Ready(0, 16<<20, target, src, later); ok || !strings.Contains(why, "stale") { |
| 122 | t.Fatalf("stale reading: %v %s", ok, why) |
| 123 | } |
| 124 | if !reflect.DeepEqual(*target, targetBefore) || !reflect.DeepEqual(*src, srcBefore) { |
| 125 | t.Fatal("Ready changed a report") |
| 126 | } |
| 127 | // The host acts on a true reading only, so the stale reading does not |
| 128 | // reach the journal; and if it did, there is no way back to preparing. |
| 129 | if err := provision.MovePromoteReady(later)(rec); err == nil { |
| 130 | t.Fatal("a second promotion was accepted") |
| 131 | } |
| 132 | if rec.Move.State != provision.MoveReady { |
| 133 | t.Fatalf("a stale report demoted the move to %s", rec.Move.State) |
| 134 | } |
| 135 | } |
| 136 | |
| 137 | func TestReadyRejectsMismatchedInputs(t *testing.T) { |
| 138 | now := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC) |
| 139 | mk := func(targetFiles, sourceFiles, targetEnv, sourceEnv string) (*TargetReport, *SourceReport) { |
| 140 | tr := passingTarget(now) |
| 141 | tr.Inputs = provision.MoveInputs{FilesPlain: targetFiles, EnvReport: targetEnv, Release: "v7"} |
| 142 | sr := goodSource(now) |
| 143 | sr.FilesPlain, sr.Env = sourceFiles, EnvReport{V: 1, Digest: sourceEnv} |
| 144 | return tr, sr |
| 145 | } |
| 146 | tr, sr := mk("sha256:aaa", "sha256:aaa", "env-1", "env-1") |
| 147 | if ok, why := Ready(0, 16<<20, tr, sr, now); !ok { |
| 148 | t.Fatalf("equal inputs: %s", why) |
| 149 | } |
| 150 | tr, sr = mk("sha256:aaa", "sha256:bbb", "env-1", "env-1") |
| 151 | ok, why := Ready(0, 16<<20, tr, sr, now) |
| 152 | if ok || !strings.Contains(why, "sha256:aaa") || !strings.Contains(why, "sha256:bbb") { |
| 153 | t.Fatalf("files mismatch: %v %q", ok, why) |
| 154 | } |
| 155 | tr, sr = mk("sha256:aaa", "sha256:aaa", "env-1", "env-2") |
| 156 | ok, why = Ready(0, 16<<20, tr, sr, now) |
| 157 | if ok || !strings.Contains(why, "env-1") || !strings.Contains(why, "env-2") { |
| 158 | t.Fatalf("env mismatch: %v %q", ok, why) |
| 159 | } |
| 160 | } |
| 161 | |
| 162 | func TestReadyRefusesAnEstimateMadeUnderASmallerMirrorBar(t *testing.T) { |
| 163 | now := t11Now |
| 164 | src := goodSource(now) |
| 165 | // made returns a report whose budget was priced for the given bar. |
| 166 | made := func(barBytes int64) *TargetReport { |
| 167 | tr := passingTarget(now) |
| 168 | m := Measurements{ |
| 169 | Cross: MeasuredRate{MiBPerS: 10, At: now.Add(-time.Minute)}, |
| 170 | S3: MeasuredRate{MiBPerS: 250, At: tr.S3At}, |
| 171 | Disk: MeasuredRate{MiBPerS: 500, At: tr.DiskAt}, |
| 172 | Fsync: FilesRate{PerS: 1600, At: tr.FsyncAt}, |
| 173 | } |
| 174 | res := BudgetCheck(100, 0, m, barBytes, 120, now) |
| 175 | if !res.Check.OK { |
| 176 | t.Fatalf("%+v", res) |
| 177 | } |
| 178 | tr.RecordBudget(100, 0, m, res) |
| 179 | return tr |
| 180 | } |
| 181 | // Priced at the 16 MiB default, the operator's bar raised to 32 MiB: |
| 182 | // the estimate is too small for it. |
| 183 | if ok, why := Ready(0, 32<<20, made(16<<20), src, now); ok || !strings.Contains(why, "T11") || !strings.Contains(why, "priced") { |
| 184 | t.Fatalf("under-priced estimate promoted: %v %q", ok, why) |
| 185 | } |
| 186 | // Priced at 32 MiB: fine at 32, and fine at a lower bar. |
| 187 | if ok, why := Ready(0, 32<<20, made(32<<20), src, now); !ok { |
| 188 | t.Fatal(why) |
| 189 | } |
| 190 | if ok, why := Ready(0, 8<<20, made(32<<20), src, now); !ok { |
| 191 | t.Fatal(why) |
| 192 | } |
| 193 | // Priced at 8, bar 16: too small. |
| 194 | if ok, _ := Ready(0, 16<<20, made(8<<20), src, now); ok { |
| 195 | t.Fatal("a smaller priced bar promoted") |
| 196 | } |
| 197 | // A mirror bar that is not finite never promotes, nor a negative one. |
| 198 | for _, bar := range []float64{math.Inf(1), math.NaN(), -16} { |
| 199 | tr := made(16 << 20) |
| 200 | tr.Rates.MirrorBar = bar |
| 201 | if ok, _ := Ready(0, 16<<20, tr, src, now); ok { |
| 202 | t.Fatalf("mirror bar %v promoted", bar) |
| 203 | } |
| 204 | } |
| 205 | } |
| 206 | |
| 207 | // The controller's fail-closed ruling: Ready re-checks the evidence behind |
| 208 | // the budget at its own `now`, whatever the report's checks say. |
| 209 | func TestReadyValidatesTheEvidenceItself(t *testing.T) { |
| 210 | now := t11Now |
| 211 | if ok, why := Ready(0, 16<<20, passingTarget(now), goodSource(now), now); !ok { |
| 212 | t.Fatal(why) |
| 213 | } |
| 214 | bad := []float64{0, -1, math.NaN(), math.Inf(1), math.Inf(-1)} |
| 215 | type c struct { |
| 216 | name string |
| 217 | mutate func(*TargetReport, *SourceReport) |
| 218 | } |
| 219 | var cases []c |
| 220 | for _, v := range bad { |
| 221 | v := v |
| 222 | cases = append(cases, |
| 223 | c{fmt.Sprintf("s3 rate %v", v), func(tr *TargetReport, _ *SourceReport) { tr.Rates.S3 = v }}, |
| 224 | c{fmt.Sprintf("disk rate %v", v), func(tr *TargetReport, _ *SourceReport) { tr.Rates.Disk = v }}, |
| 225 | c{fmt.Sprintf("cross rate in the source %v", v), func(_ *TargetReport, sr *SourceReport) { sr.CrossRate = v }}, |
| 226 | c{fmt.Sprintf("cross rate in the target %v", v), func(tr *TargetReport, _ *SourceReport) { tr.Rates.Cross = v }}, |
| 227 | c{fmt.Sprintf("fsync rate %v", v), func(tr *TargetReport, _ *SourceReport) { tr.Rates.FsyncRate = v }}, |
| 228 | ) |
| 229 | } |
| 230 | expired := now.Add(-MeasurementFreshness - time.Second) |
| 231 | cases = append(cases, |
| 232 | c{"s3 time missing", func(tr *TargetReport, _ *SourceReport) { tr.S3At = time.Time{} }}, |
| 233 | c{"s3 expired", func(tr *TargetReport, _ *SourceReport) { tr.S3At = expired }}, |
| 234 | c{"s3 from the future", func(tr *TargetReport, _ *SourceReport) { tr.S3At = now.Add(time.Hour) }}, |
| 235 | c{"disk time missing", func(tr *TargetReport, _ *SourceReport) { tr.DiskAt = time.Time{} }}, |
| 236 | c{"disk expired", func(tr *TargetReport, _ *SourceReport) { tr.DiskAt = expired }}, |
| 237 | c{"disk from the future", func(tr *TargetReport, _ *SourceReport) { tr.DiskAt = now.Add(time.Hour) }}, |
| 238 | c{"fsync time missing", func(tr *TargetReport, _ *SourceReport) { tr.FsyncAt = time.Time{} }}, |
| 239 | c{"fsync expired", func(tr *TargetReport, _ *SourceReport) { tr.FsyncAt = expired }}, |
| 240 | c{"fsync from the future", func(tr *TargetReport, _ *SourceReport) { tr.FsyncAt = now.Add(time.Hour) }}, |
| 241 | c{"entries negative", func(tr *TargetReport, _ *SourceReport) { tr.Entries = -1 }}, |
| 242 | c{"too many entries for the window", func(tr *TargetReport, _ *SourceReport) { tr.Entries = 1 << 30 }}, |
| 243 | c{"cross time missing", func(_ *TargetReport, sr *SourceReport) { sr.CrossRateAt = time.Time{} }}, |
| 244 | c{"cross expired", func(_ *TargetReport, sr *SourceReport) { sr.CrossRateAt = expired }}, |
| 245 | c{"cross from the future", func(_ *TargetReport, sr *SourceReport) { sr.CrossRateAt = now.Add(time.Hour) }}, |
| 246 | c{"source has measured no cross rate", func(_ *TargetReport, sr *SourceReport) { sr.CrossRate, sr.CrossRateAt = 0, time.Time{} }}, |
| 247 | c{"database size NaN", func(tr *TargetReport, _ *SourceReport) { tr.DBMiB = math.NaN() }}, |
| 248 | c{"database size negative", func(tr *TargetReport, _ *SourceReport) { tr.DBMiB = -1 }}, |
| 249 | c{"no window", func(tr *TargetReport, _ *SourceReport) { tr.CutBudgetS = 0 }}, |
| 250 | c{"database too big for the window", func(tr *TargetReport, _ *SourceReport) { tr.DBMiB = 1e6 }}, |
| 251 | c{"more attempts claimed than admitted", func(tr *TargetReport, _ *SourceReport) { tr.AttemptsAdmitted = ProbeAttemptsMax + 1 }}, |
| 252 | c{"no attempts claimed", func(tr *TargetReport, _ *SourceReport) { tr.AttemptsAdmitted = 0 }}, |
| 253 | ) |
| 254 | for _, cs := range cases { |
| 255 | tr, sr := passingTarget(now), goodSource(now) |
| 256 | cs.mutate(tr, sr) |
| 257 | ok, why := Ready(0, 16<<20, tr, sr, now) |
| 258 | if ok || !strings.Contains(why, "T11") { |
| 259 | t.Errorf("%s: ready=%v %q", cs.name, ok, why) |
| 260 | } |
| 261 | } |
| 262 | |
| 263 | // Which rate is named when a measurement is missing or expired. |
| 264 | for _, w := range []struct { |
| 265 | name string |
| 266 | mutate func(*TargetReport, *SourceReport) |
| 267 | which string |
| 268 | }{ |
| 269 | {"s3", func(tr *TargetReport, _ *SourceReport) { tr.S3At = expired }, "regional S3"}, |
| 270 | {"disk", func(tr *TargetReport, _ *SourceReport) { tr.DiskAt = expired }, "the disk"}, |
| 271 | {"fsync", func(tr *TargetReport, _ *SourceReport) { tr.FsyncAt = expired }, "the disk's per-file sync"}, |
| 272 | {"cross", func(_ *TargetReport, sr *SourceReport) { sr.CrossRateAt = expired }, "the cross-region copy path"}, |
| 273 | } { |
| 274 | tr, sr := passingTarget(now), goodSource(now) |
| 275 | w.mutate(tr, sr) |
| 276 | if _, why := Ready(0, 16<<20, tr, sr, now); !strings.Contains(why, "no fresh rate measurement for "+w.which) { |
| 277 | t.Errorf("%s: %q", w.name, why) |
| 278 | } |
| 279 | } |
| 280 | |
| 281 | // A measurement that expires while its report is still fresh is |
| 282 | // refused: the target's report is 5 minutes old, its rates 58 minutes |
| 283 | // old when written, so an hour later than their time they are stale. |
| 284 | tr := passingTarget(now.Add(-5 * time.Minute)) |
| 285 | tr.S3At, tr.DiskAt = now.Add(-58*time.Minute), now.Add(-58*time.Minute) |
| 286 | if ok, why := Ready(0, 16<<20, tr, goodSource(now), now); !ok { |
| 287 | t.Fatal(why) |
| 288 | } |
| 289 | if ok, why := Ready(0, 16<<20, tr, goodSource(now.Add(5*time.Minute)), now.Add(5*time.Minute)); ok || !strings.Contains(why, "T11") { |
| 290 | t.Fatalf("a measurement that expired under a fresh report: %v %q", ok, why) |
| 291 | } |
| 292 | |
| 293 | // A stored count at or below the recomputed one is accepted. |
| 294 | tr = passingTarget(now) |
| 295 | tr.AttemptsAdmitted = 1 |
| 296 | if ok, why := Ready(0, 16<<20, tr, goodSource(now), now); !ok { |
| 297 | t.Fatal(why) |
| 298 | } |
| 299 | } |
| 300 | |
| 301 | func TestPassingNamesTheFirstFailureInIDOrder(t *testing.T) { |
| 302 | r := passingTarget(time.Now()) |
| 303 | if ok, why := r.Passing(); !ok || why != "" { |
| 304 | t.Fatalf("%v %q", ok, why) |
| 305 | } |
| 306 | r.Checks[CheckClaim] = Check{Reason: "touched"} |
| 307 | r.Checks[CheckArchive] = Check{Reason: "no archive"} |
| 308 | if ok, why := r.Passing(); ok || !strings.HasPrefix(why, "T5: ") || !strings.Contains(why, "no archive") { |
| 309 | t.Fatalf("%v %q", ok, why) |
| 310 | } |
| 311 | delete(r.Checks, CheckArchive) |
| 312 | if ok, why := r.Passing(); ok || !strings.HasPrefix(why, "T5") || strings.Contains(why, "T9") { |
| 313 | t.Fatalf("missing check: %v %q", ok, why) |
| 314 | } |
| 315 | // A failed check with no reason still says something. |
| 316 | r.Checks[CheckArchive] = Check{} |
| 317 | if ok, why := r.Passing(); ok || strings.TrimSpace(strings.TrimPrefix(why, "T5:")) == "" { |
| 318 | t.Fatalf("empty reason: %v %q", ok, why) |
| 319 | } |
| 320 | // A report with no checks at all is not passing. |
| 321 | if ok, _ := (TargetReport{}).Passing(); ok { |
| 322 | t.Fatal("an empty report passed") |
| 323 | } |
| 324 | } |
| 325 | |
| 326 | func TestAllChecksListsT1ToT13(t *testing.T) { |
| 327 | want := []string{"T1", "T2", "T3", "T4", "T5", "T6", "T7", "T8", "T9", "T10", "T11", "T12", "T13"} |
| 328 | if !reflect.DeepEqual(AllChecks, want) { |
| 329 | t.Fatalf("%v", AllChecks) |
| 330 | } |
| 331 | got := []string{CheckProtocol, CheckSecrets, CheckEnv, CheckStorage, CheckArchive, CheckDisk, CheckRelease, CheckOneOwner, CheckClaim, CheckLane, CheckBudget, CheckQuiet, CheckArmed} |
| 332 | if !reflect.DeepEqual(got, want) { |
| 333 | t.Fatalf("%v", got) |
| 334 | } |
| 335 | } |
| 336 | |
| 337 | func TestReportsRoundTripWithFixedWireNames(t *testing.T) { |
| 338 | at := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC) |
| 339 | tr := TargetReport{V: 1, Box: "uk-box", Region: "uk", MoveID: "mv_x", At: at, |
| 340 | Checks: map[string]Check{CheckProtocol: {OK: true}}, |
| 341 | Inputs: provision.MoveInputs{FilesPlain: "p", EnvReport: "e"}, |
| 342 | AttemptsAdmitted: 2, CutBudgetS: 120, Rates: Rates{Cross: 10, S3: 250, Disk: 500, MirrorBar: 16}, |
| 343 | Notes: []string{"the carried .env overrides X"}} |
| 344 | b, err := json.Marshal(tr) |
| 345 | if err != nil { |
| 346 | t.Fatal(err) |
| 347 | } |
| 348 | var m map[string]json.RawMessage |
| 349 | if err := json.Unmarshal(b, &m); err != nil { |
| 350 | t.Fatal(err) |
| 351 | } |
| 352 | for _, k := range []string{"v", "box", "region", "move_id", "at", "checks", "inputs", "attempts_admitted", "cut_budget_s", "rates", "notes"} { |
| 353 | if _, ok := m[k]; !ok { |
| 354 | t.Errorf("target report has no %q: %s", k, b) |
| 355 | } |
| 356 | } |
| 357 | var back TargetReport |
| 358 | if err := json.Unmarshal(b, &back); err != nil || !reflect.DeepEqual(back, tr) { |
| 359 | t.Fatalf("%v %+v", err, back) |
| 360 | } |
| 361 | |
| 362 | sr := SourceReport{V: 1, Box: "src", MoveID: "mv_x", At: at, S1: Check{OK: true}, S2: Check{OK: true}, |
| 363 | FilesPlain: "p", Release: "v1.2.3", CrossRate: 9.5, CrossRateAt: at, |
| 364 | Archive: &ArchiveRef{ManifestKey: "a/m", ManifestVersion: "v1", SealedObjectKey: "a/s", SealedVersion: "v2", Plain: "p"}} |
| 365 | b, err = json.Marshal(sr) |
| 366 | if err != nil { |
| 367 | t.Fatal(err) |
| 368 | } |
| 369 | m = nil |
| 370 | if err := json.Unmarshal(b, &m); err != nil { |
| 371 | t.Fatal(err) |
| 372 | } |
| 373 | for _, k := range []string{"v", "box", "move_id", "at", "env", "s1", "s2", "files_plain", "release", "archive", "cross_rate", "cross_rate_at"} { |
| 374 | if _, ok := m[k]; !ok { |
| 375 | t.Errorf("source report has no %q: %s", k, b) |
| 376 | } |
| 377 | } |
| 378 | var ref map[string]string |
| 379 | if err := json.Unmarshal(m["archive"], &ref); err != nil { |
| 380 | t.Fatal(err) |
| 381 | } |
| 382 | for _, k := range []string{"manifest_key", "manifest_version", "sealed_object_key", "sealed_version", "plain"} { |
| 383 | if _, ok := ref[k]; !ok { |
| 384 | t.Errorf("archive ref has no %q", k) |
| 385 | } |
| 386 | } |
| 387 | var sback SourceReport |
| 388 | if err := json.Unmarshal(b, &sback); err != nil || !reflect.DeepEqual(sback, sr) { |
| 389 | t.Fatalf("%v %+v", err, sback) |
| 390 | } |
| 391 | // No archive yet: the field is absent, not null. |
| 392 | b, _ = json.Marshal(SourceReport{V: 1}) |
| 393 | if strings.Contains(string(b), `"archive"`) { |
| 394 | t.Fatalf("an unpublished archive is written: %s", b) |
| 395 | } |
| 396 | } |
| 397 | |
| 398 | // ---- measured rates and T11 ---- |
| 399 | |
| 400 | var t11Now = time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC) |
| 401 | |
| 402 | func goodMeasurements() Measurements { |
| 403 | at := t11Now.Add(-time.Minute) |
| 404 | return Measurements{ |
| 405 | Cross: MeasuredRate{MiBPerS: 10, At: at, Bytes: 100 << 20}, |
| 406 | S3: MeasuredRate{MiBPerS: 250, At: at, Bytes: 16 << 20}, |
| 407 | Disk: MeasuredRate{MiBPerS: 500, At: at, Bytes: 64 << 20}, |
| 408 | Fsync: FilesRate{PerS: 1600, At: at, Files: 512}, |
| 409 | } |
| 410 | } |
| 411 | |
| 412 | func TestMeasurementsNeedFreshUsableRates(t *testing.T) { |
| 413 | r, err := goodMeasurements().Rates(t11Now, 16<<20) |
| 414 | if err != nil { |
| 415 | t.Fatal(err) |
| 416 | } |
| 417 | if r.Cross != 10 || r.S3 != 250 || r.Disk != 500 || r.MirrorBar != 16 || r.FsyncRate != 1600 { |
| 418 | t.Fatalf("%+v", r) |
| 419 | } |
| 420 | |
| 421 | cases := []struct { |
| 422 | name string |
| 423 | mutate func(m *Measurements) |
| 424 | which string |
| 425 | }{ |
| 426 | {"missing cross", func(m *Measurements) { m.Cross = MeasuredRate{} }, "the cross-region copy path"}, |
| 427 | {"old s3", func(m *Measurements) { m.S3.At = t11Now.Add(-MeasurementFreshness - time.Second) }, "regional S3"}, |
| 428 | {"future disk", func(m *Measurements) { m.Disk.At = t11Now.Add(time.Hour) }, "the disk"}, |
| 429 | {"zero rate", func(m *Measurements) { m.Disk.MiBPerS = 0 }, "the disk"}, |
| 430 | {"negative rate", func(m *Measurements) { m.S3.MiBPerS = -5 }, "regional S3"}, |
| 431 | {"NaN", func(m *Measurements) { m.Cross.MiBPerS = math.NaN() }, "the cross-region copy path"}, |
| 432 | {"+Inf", func(m *Measurements) { m.S3.MiBPerS = math.Inf(1) }, "regional S3"}, |
| 433 | {"no time", func(m *Measurements) { m.Disk.At = time.Time{} }, "the disk"}, |
| 434 | {"no fsync", func(m *Measurements) { m.Fsync = FilesRate{} }, "the disk's per-file sync"}, |
| 435 | {"old fsync", func(m *Measurements) { m.Fsync.At = t11Now.Add(-MeasurementFreshness - time.Second) }, "the disk's per-file sync"}, |
| 436 | {"NaN fsync", func(m *Measurements) { m.Fsync.PerS = math.NaN() }, "the disk's per-file sync"}, |
| 437 | } |
| 438 | for _, c := range cases { |
| 439 | m := goodMeasurements() |
| 440 | c.mutate(&m) |
| 441 | _, err := m.Rates(t11Now, 16<<20) |
| 442 | if err == nil || !strings.Contains(err.Error(), "no fresh rate measurement for "+c.which) { |
| 443 | t.Errorf("%s: %v", c.name, err) |
| 444 | } |
| 445 | } |
| 446 | // Exactly an hour old is still fresh. |
| 447 | m := goodMeasurements() |
| 448 | m.S3.At = t11Now.Add(-MeasurementFreshness) |
| 449 | if _, err := m.Rates(t11Now, 16<<20); err != nil { |
| 450 | t.Fatal(err) |
| 451 | } |
| 452 | } |
| 453 | |
| 454 | func TestMeasuredRateFromBytesAndTime(t *testing.T) { |
| 455 | m := RateOf(100<<20, 10*time.Second, t11Now) |
| 456 | if m.MiBPerS != 10 || m.Bytes != 100<<20 || !m.At.Equal(t11Now) { |
| 457 | t.Fatalf("%+v", m) |
| 458 | } |
| 459 | // No elapsed time or no bytes is no measurement, not an infinite rate. |
| 460 | for _, m := range []MeasuredRate{RateOf(1<<20, 0, t11Now), RateOf(0, time.Second, t11Now), RateOf(1<<20, -time.Second, t11Now)} { |
| 461 | if m.MiBPerS != 0 { |
| 462 | t.Fatalf("%+v", m) |
| 463 | } |
| 464 | } |
| 465 | } |
| 466 | |
| 467 | func TestSourceReportCarriesTheCrossRate(t *testing.T) { |
| 468 | at := t11Now.Add(-time.Minute) |
| 469 | if got := (&SourceReport{CrossRate: 9.5, CrossRateAt: at}).CrossMeasurement(); got.MiBPerS != 9.5 || !got.At.Equal(at) { |
| 470 | t.Fatalf("%+v", got) |
| 471 | } |
| 472 | // A source that has measured nothing yields the zero measurement, which |
| 473 | // Measurements.Rates refuses. |
| 474 | m := goodMeasurements() |
| 475 | m.Cross = (&SourceReport{}).CrossMeasurement() |
| 476 | if _, err := m.Rates(t11Now, 16<<20); err == nil || !strings.Contains(err.Error(), "cross-region") { |
| 477 | t.Fatal(err) |
| 478 | } |
| 479 | if got := (*SourceReport)(nil).CrossMeasurement(); got != (MeasuredRate{}) { |
| 480 | t.Fatalf("%+v", got) |
| 481 | } |
| 482 | } |
| 483 | |
| 484 | func TestT11BudgetCheck(t *testing.T) { |
| 485 | // 100 MiB database at the spec's example rates fits the default window. |
| 486 | res := BudgetCheck(100, 0, goodMeasurements(), 16<<20, CutWindowDefault, t11Now) |
| 487 | if !res.Check.OK || res.AttemptsAdmitted < 1 { |
| 488 | t.Fatalf("%+v", res) |
| 489 | } |
| 490 | if res.Rates.Cross != 10 || res.Rates.MirrorBar != 16 { |
| 491 | t.Fatalf("%+v", res.Rates) |
| 492 | } |
| 493 | if res.Seconds <= 0 || res.Seconds > CutWindowDefault { |
| 494 | t.Fatalf("%v", res.Seconds) |
| 495 | } |
| 496 | |
| 497 | // A database too big for the window admits no attempt: T11 fails and |
| 498 | // the reason says what was needed. |
| 499 | res = BudgetCheck(100000, 0, goodMeasurements(), 16<<20, CutWindowDefault, t11Now) |
| 500 | if res.Check.OK || res.AttemptsAdmitted != 0 || !strings.Contains(res.Check.Reason, "120") { |
| 501 | t.Fatalf("%+v", res) |
| 502 | } |
| 503 | |
| 504 | // A missing measurement fails closed, naming which. |
| 505 | m := goodMeasurements() |
| 506 | m.Disk = MeasuredRate{} |
| 507 | res = BudgetCheck(100, 0, m, 16<<20, CutWindowDefault, t11Now) |
| 508 | if res.Check.OK || res.AttemptsAdmitted != 0 || !strings.Contains(res.Check.Reason, "no fresh rate measurement for the disk") { |
| 509 | t.Fatalf("%+v", res) |
| 510 | } |
| 511 | |
| 512 | // A database size that is not a number, or a window that is not, fails. |
| 513 | for _, db := range []float64{math.NaN(), math.Inf(1), -1} { |
| 514 | if r := BudgetCheck(db, 0, goodMeasurements(), 16<<20, CutWindowDefault, t11Now); r.Check.OK || r.AttemptsAdmitted != 0 { |
| 515 | t.Fatalf("db %v: %+v", db, r) |
| 516 | } |
| 517 | } |
| 518 | for _, w := range []float64{math.NaN(), math.Inf(1), 0, -3} { |
| 519 | if r := BudgetCheck(100, 0, goodMeasurements(), 16<<20, w, t11Now); r.Check.OK || r.AttemptsAdmitted != 0 { |
| 520 | t.Fatalf("window %v: %+v", w, r) |
| 521 | } |
| 522 | } |
| 523 | } |
| 524 | |
| 525 | // F28(e): an override of the ready-delta bar reaches the estimate. A larger |
| 526 | // mirror bar costs more seconds, so a window that fits the default bar can |
| 527 | // refuse a bar of 4 GiB. |
| 528 | func TestT11BudgetCheckPricesTheOperatorsBar(t *testing.T) { |
| 529 | m := goodMeasurements() |
| 530 | def := BudgetCheck(100, 0, m, 16<<20, CutWindowDefault, t11Now) |
| 531 | big := BudgetCheck(100, 0, m, 4<<30, CutWindowDefault, t11Now) |
| 532 | if !def.Check.OK { |
| 533 | t.Fatalf("%+v", def) |
| 534 | } |
| 535 | if big.Rates.MirrorBar != 4096 { |
| 536 | t.Fatalf("the bar did not reach the rates: %+v", big.Rates) |
| 537 | } |
| 538 | if big.Seconds <= def.Seconds { |
| 539 | t.Fatalf("a 4 GiB bar priced at %v, the default at %v", big.Seconds, def.Seconds) |
| 540 | } |
| 541 | if big.Check.OK { |
| 542 | t.Fatalf("a 4 GiB mirror at 10 MiB/s fits 120 s: %+v", big) |
| 543 | } |
| 544 | // A bar of zero bytes is priced at the default, never below it. |
| 545 | zero := BudgetCheck(100, 0, m, 0, CutWindowDefault, t11Now) |
| 546 | if zero.Seconds != def.Seconds { |
| 547 | t.Fatalf("%v vs %v", zero.Seconds, def.Seconds) |
| 548 | } |
| 549 | // A rejected budget never promotes: recording the 4 GiB result in a |
| 550 | // report leaves it with zero attempts, and Ready refuses it. |
| 551 | src := goodSource(t11Now) |
| 552 | tr := passingTarget(t11Now) |
| 553 | tr.RecordBudget(100, 0, m, big) |
| 554 | if tr.AttemptsAdmitted != 0 { |
| 555 | t.Fatalf("%d", tr.AttemptsAdmitted) |
| 556 | } |
| 557 | if ok, _ := Ready(0, 4<<30, tr, src, t11Now); ok { |
| 558 | t.Fatal("a report whose budget admits no attempt promoted") |
| 559 | } |
| 560 | } |
| 561 | |
| 562 | func TestProbeKeyIsOutsideTheStagingPrefix(t *testing.T) { |
| 563 | host, op := "a.go.example.com", "mv_abcdefghijklmnopqrstuvwxyz" |
| 564 | k := ProbeKey(host, op) |
| 565 | if k != "move-probes/a.go.example.com/mv_abcdefghijklmnopqrstuvwxyz/probe" { |
| 566 | t.Fatal(k) |
| 567 | } |
| 568 | if strings.HasPrefix(k, "moves/") || strings.HasPrefix(k, StagingPrefix(host, op)) { |
| 569 | t.Fatal("the probe object is under the staging prefix") |
| 570 | } |
| 571 | } |
| 572 | |
| 573 | // ---- PutReplace ---- |
| 574 | |
| 575 | // casStore is an in-memory release.Store with the CAS rules of the real |
| 576 | // ones. beforePut runs once ahead of the first Put, so a test can lose a |
| 577 | // race. |
| 578 | type casStore struct { |
| 579 | mu sync.Mutex |
| 580 | objs map[string][]byte |
| 581 | etags map[string]int |
| 582 | puts int |
| 583 | beforePut func() |
| 584 | failGet error |
| 585 | } |
| 586 | |
| 587 | func newCASStore() *casStore { |
| 588 | return &casStore{objs: map[string][]byte{}, etags: map[string]int{}} |
| 589 | } |
| 590 | |
| 591 | func (s *casStore) etag(k string) string { |
| 592 | if _, ok := s.objs[k]; !ok { |
| 593 | return "" |
| 594 | } |
| 595 | return "e" + string(rune('0'+s.etags[k])) |
| 596 | } |
| 597 | |
| 598 | func (s *casStore) Get(_ context.Context, key string) ([]byte, string, error) { |
| 599 | s.mu.Lock() |
| 600 | defer s.mu.Unlock() |
| 601 | if s.failGet != nil { |
| 602 | return nil, "", s.failGet |
| 603 | } |
| 604 | b, ok := s.objs[key] |
| 605 | if !ok { |
| 606 | return nil, "", fmt.Errorf("no such key %q: %w", key, os.ErrNotExist) |
| 607 | } |
| 608 | return append([]byte(nil), b...), s.etag(key), nil |
| 609 | } |
| 610 | |
| 611 | func (s *casStore) Put(_ context.Context, key string, body []byte, ifMatch string) (string, error) { |
| 612 | if s.beforePut != nil { |
| 613 | f := s.beforePut |
| 614 | s.beforePut = nil |
| 615 | f() |
| 616 | } |
| 617 | s.mu.Lock() |
| 618 | defer s.mu.Unlock() |
| 619 | s.puts++ |
| 620 | if s.etag(key) != ifMatch { |
| 621 | return "", release.ErrCASConflict |
| 622 | } |
| 623 | s.objs[key] = append([]byte(nil), body...) |
| 624 | s.etags[key]++ |
| 625 | return s.etag(key), nil |
| 626 | } |
| 627 | |
| 628 | func (s *casStore) List(context.Context, string) ([]string, error) { return nil, nil } |
| 629 | func (s *casStore) ListDirs(context.Context, string) ([]string, error) { return nil, nil } |
| 630 | func (s *casStore) Delete(context.Context, string) error { return nil } |
| 631 | |
| 632 | func TestPutReplaceCreatesThenReplaces(t *testing.T) { |
| 633 | ctx := context.Background() |
| 634 | s := newCASStore() |
| 635 | key := TargetReportKey(ReportPrefix("bab", "app", "h", "mv_x")) |
| 636 | // A create-only Put refuses the second write: the reason PutReplace exists. |
| 637 | if _, err := s.Put(ctx, "other", []byte("1"), ""); err != nil { |
| 638 | t.Fatal(err) |
| 639 | } |
| 640 | if _, err := s.Put(ctx, "other", []byte("2"), ""); !errors.Is(err, release.ErrCASConflict) { |
| 641 | t.Fatal(err) |
| 642 | } |
| 643 | for _, body := range []string{"one", "two", "three"} { |
| 644 | if err := PutReplace(ctx, s, key, []byte(body)); err != nil { |
| 645 | t.Fatalf("%s: %v", body, err) |
| 646 | } |
| 647 | if got, _, _ := s.Get(ctx, key); string(got) != body { |
| 648 | t.Fatalf("%q", got) |
| 649 | } |
| 650 | } |
| 651 | } |
| 652 | |
| 653 | func TestPutReplaceRetriesOnceOnAConflict(t *testing.T) { |
| 654 | ctx := context.Background() |
| 655 | s := newCASStore() |
| 656 | if err := PutReplace(ctx, s, "k", []byte("old")); err != nil { |
| 657 | t.Fatal(err) |
| 658 | } |
| 659 | // Someone else replaces the object between our Get and our Put. |
| 660 | s.beforePut = func() { |
| 661 | s.objs["k"] = []byte("theirs") |
| 662 | s.etags["k"]++ |
| 663 | } |
| 664 | if err := PutReplace(ctx, s, "k", []byte("ours")); err != nil { |
| 665 | t.Fatal(err) |
| 666 | } |
| 667 | if got, _, _ := s.Get(ctx, "k"); string(got) != "ours" { |
| 668 | t.Fatalf("%q", got) |
| 669 | } |
| 670 | } |
| 671 | |
| 672 | func TestPutReplaceGivesUpAfterTheSecondConflict(t *testing.T) { |
| 673 | ctx := context.Background() |
| 674 | s := &alwaysConflict{casStore: newCASStore()} |
| 675 | err := PutReplace(ctx, s, "k", []byte("x")) |
| 676 | if !errors.Is(err, release.ErrCASConflict) { |
| 677 | t.Fatalf("%v", err) |
| 678 | } |
| 679 | if s.puts != 2 { |
| 680 | t.Fatalf("%d puts, want exactly one retry", s.puts) |
| 681 | } |
| 682 | } |
| 683 | |
| 684 | type alwaysConflict struct{ *casStore } |
| 685 | |
| 686 | func (a *alwaysConflict) Put(_ context.Context, _ string, _ []byte, _ string) (string, error) { |
| 687 | a.puts++ |
| 688 | return "", release.ErrCASConflict |
| 689 | } |
| 690 | |
| 691 | func TestPutReplaceDoesNotWriteBlindOnAReadError(t *testing.T) { |
| 692 | s := newCASStore() |
| 693 | s.failGet = errors.New("503 slow down") |
| 694 | err := PutReplace(context.Background(), s, "k", []byte("x")) |
| 695 | if err == nil || !strings.Contains(err.Error(), "503") { |
| 696 | t.Fatalf("%v", err) |
| 697 | } |
| 698 | if s.puts != 0 { |
| 699 | t.Fatal("wrote after a failed read") |
| 700 | } |
| 701 | } |
| 702 | |
| 703 | func TestPutReplaceWorksOnADirStore(t *testing.T) { |
| 704 | ctx := context.Background() |
| 705 | s := release.NewDirStore(t.TempDir()) |
| 706 | for _, body := range []string{"one", "two"} { |
| 707 | if err := PutReplace(ctx, s, "moves/a/b/target.json", []byte(body)); err != nil { |
| 708 | t.Fatal(err) |
| 709 | } |
| 710 | } |
| 711 | if got, _, err := s.Get(ctx, "moves/a/b/target.json"); err != nil || string(got) != "two" { |
| 712 | t.Fatalf("%q %v", got, err) |
| 713 | } |
| 714 | } |
| 715 | |
| 716 | // A report dated ahead of the reader's clock would stay fresh until the |
| 717 | // clock caught up and then some. One minute ahead is clock skew between |
| 718 | // boxes and is accepted; beyond that is refused. |
| 719 | func TestReadyBoundsReportTimestampsInTheFuture(t *testing.T) { |
| 720 | now := t11Now |
| 721 | // A target report one minute ahead is fine (its own measurements are |
| 722 | // dated relative to it, so they sit in the past of now). |
| 723 | tr := passingTarget(now.Add(ReportSkew)) |
| 724 | if ok, why := Ready(0, 16<<20, tr, goodSource(now), now); !ok { |
| 725 | t.Fatalf("a target report one minute ahead: %s", why) |
| 726 | } |
| 727 | tr = passingTarget(now.Add(ReportSkew + time.Nanosecond)) |
| 728 | if ok, why := Ready(0, 16<<20, tr, goodSource(now), now); ok || !strings.Contains(why, "ahead") || !strings.Contains(why, "target") { |
| 729 | t.Fatalf("a target report beyond the skew: %v %q", ok, why) |
| 730 | } |
| 731 | // Far ahead, as a stuck or wrong clock would write. |
| 732 | if ok, _ := Ready(0, 16<<20, passingTarget(now.Add(24*time.Hour)), goodSource(now), now); ok { |
| 733 | t.Fatal("a target report a day ahead promoted") |
| 734 | } |
| 735 | |
| 736 | sr := goodSource(now) |
| 737 | sr.At = now.Add(ReportSkew) |
| 738 | if ok, why := Ready(0, 16<<20, passingTarget(now), sr, now); !ok { |
| 739 | t.Fatalf("a source report one minute ahead: %s", why) |
| 740 | } |
| 741 | sr.At = now.Add(ReportSkew + time.Nanosecond) |
| 742 | if ok, why := Ready(0, 16<<20, passingTarget(now), sr, now); ok || !strings.Contains(why, "ahead") || !strings.Contains(why, "source") { |
| 743 | t.Fatalf("a source report beyond the skew: %v %q", ok, why) |
| 744 | } |
| 745 | sr.At = now.Add(24 * time.Hour) |
| 746 | if ok, _ := Ready(0, 16<<20, passingTarget(now), sr, now); ok { |
| 747 | t.Fatal("a source report a day ahead promoted") |
| 748 | } |
| 749 | |
| 750 | // The old edge is unchanged: exactly ReportFreshness old is fresh. |
| 751 | if ok, why := Ready(0, 16<<20, passingTarget(now.Add(-ReportFreshness)), goodSource(now), now); !ok { |
| 752 | t.Fatal(why) |
| 753 | } |
| 754 | if ok, _ := Ready(0, 16<<20, passingTarget(now.Add(-ReportFreshness-time.Nanosecond)), goodSource(now), now); ok { |
| 755 | t.Fatal("a report a nanosecond past the freshness window promoted") |
| 756 | } |
| 757 | } |
| 758 | |
| 759 | // The exact edges of a measurement's window: an hour old and a minute |
| 760 | // ahead are accepted, a nanosecond beyond either is refused. |
| 761 | func TestMeasurementWindowEdges(t *testing.T) { |
| 762 | at := func(d time.Duration) error { |
| 763 | m := goodMeasurements() |
| 764 | m.S3.At = t11Now.Add(d) |
| 765 | _, err := m.Rates(t11Now, 16<<20) |
| 766 | return err |
| 767 | } |
| 768 | for _, c := range []struct { |
| 769 | d time.Duration |
| 770 | ok bool |
| 771 | }{ |
| 772 | {-MeasurementFreshness, true}, |
| 773 | {-MeasurementFreshness - time.Nanosecond, false}, |
| 774 | {MeasurementSkew, true}, |
| 775 | {MeasurementSkew + time.Nanosecond, false}, |
| 776 | {0, true}, |
| 777 | } { |
| 778 | if err := at(c.d); (err == nil) != c.ok { |
| 779 | t.Errorf("age %v: err %v, want ok=%v", c.d, err, c.ok) |
| 780 | } |
| 781 | } |
| 782 | } |
| 783 | |
| 784 | // Ruling T7-budget: the archive's entry count is priced (and carried in the |
| 785 | // report, so Ready re-prices it); a count that is not usable fails closed. |
| 786 | func TestT11BudgetCheckPricesEntries(t *testing.T) { |
| 787 | m := goodMeasurements() |
| 788 | none := BudgetCheck(100, 0, m, 16<<20, CutWindowDefault, t11Now) |
| 789 | many := BudgetCheck(100, 50000, m, 16<<20, CutWindowDefault, t11Now) |
| 790 | if !none.Check.OK || many.Seconds <= none.Seconds { |
| 791 | t.Fatalf("entries were not priced: %v vs %v", many.Seconds, none.Seconds) |
| 792 | } |
| 793 | if r := BudgetCheck(100, -1, m, 16<<20, CutWindowDefault, t11Now); r.Check.OK || r.AttemptsAdmitted != 0 || r.Check.Reason != "the archive's file count could not be measured" { |
| 794 | t.Fatalf("%+v", r) |
| 795 | } |
| 796 | tr := passingTarget(t11Now) |
| 797 | tr.RecordBudget(100, 50000, m, many) |
| 798 | if tr.Entries != 50000 || tr.Rates.FsyncRate != 1600 || !tr.FsyncAt.Equal(m.Fsync.At) { |
| 799 | t.Fatalf("RecordBudget dropped the entry evidence: %+v", tr) |
| 800 | } |
| 801 | } |
| 802 | |
| 803 | // The release a promotion pins is one the target checked (T7): a target |
| 804 | // report with no release, or one for another release than the source now |
| 805 | // runs, never promotes (Task 14 review finding 3). |
| 806 | func TestReadyNeedsTheReleaseTheTargetChecked(t *testing.T) { |
| 807 | now := t11Now |
| 808 | if ok, why := Ready(0, 16<<20, passingTarget(now), goodSource(now), now); !ok { |
| 809 | t.Fatal(why) |
| 810 | } |
| 811 | tr := passingTarget(now) |
| 812 | tr.Inputs.Release = "" |
| 813 | if ok, why := Ready(0, 16<<20, tr, goodSource(now), now); ok || why != "the target has not checked a release yet" { |
| 814 | t.Fatalf("no release checked: %v %q", ok, why) |
| 815 | } |
| 816 | src := goodSource(now) |
| 817 | src.Release = "v8" |
| 818 | if ok, why := Ready(0, 16<<20, passingTarget(now), src, now); ok || why != "the target checked release v7 and the source now runs v8" { |
| 819 | t.Fatalf("release drift: %v %q", ok, why) |
| 820 | } |
| 821 | src.Release = "" |
| 822 | tr = passingTarget(now) |
| 823 | tr.Inputs.Release = "" |
| 824 | if ok, _ := Ready(0, 16<<20, tr, src, now); ok { |
| 825 | t.Fatal("two empty releases promoted") |
| 826 | } |
| 827 | } |
| 828 | |
| 829 | // Measurements.Rates names the stale rate as data (*NoFreshRateError), and |
| 830 | // BudgetCheck builds its reason from that, never from an error's text. |
| 831 | func TestNoFreshRateIsTyped(t *testing.T) { |
| 832 | m := goodMeasurements() |
| 833 | m.S3 = MeasuredRate{} |
| 834 | _, err := m.Rates(t11Now, 16<<20) |
| 835 | var nf *NoFreshRateError |
| 836 | if !errors.As(err, &nf) || nf.Which != "regional S3" { |
| 837 | t.Fatalf("%v", err) |
| 838 | } |
| 839 | if r := BudgetCheck(100, 0, m, 16<<20, CutWindowDefault, t11Now); r.Check.Reason != NoFreshRate("regional S3") { |
| 840 | t.Fatalf("%q", r.Check.Reason) |
| 841 | } |
| 842 | } |
| 843 | |
| 844 | // Final review finding 4: an archive reference pins its objects only by |
| 845 | // version ids that name one immutable version each: neither "" nor the |
| 846 | // suspended bucket's "null". |
| 847 | func TestArchiveRefPinnedNeedsImmutableVersions(t *testing.T) { |
| 848 | good := ArchiveRef{ManifestKey: "m", ManifestVersion: "mv1", SealedObjectKey: "s", SealedVersion: "sv1"} |
| 849 | if !good.Pinned() { |
| 850 | t.Fatal("a reference with real version ids is not pinned") |
| 851 | } |
| 852 | for _, c := range []struct{ manifest, sealed string }{{"null", "sv1"}, {"mv1", "null"}, {"", "sv1"}, {"mv1", ""}, {"null", "null"}} { |
| 853 | r := good |
| 854 | r.ManifestVersion, r.SealedVersion = c.manifest, c.sealed |
| 855 | if r.Pinned() { |
| 856 | t.Errorf("versions %q and %q read as pinned", c.manifest, c.sealed) |
| 857 | } |
| 858 | } |
| 859 | } |
| 860 | |
| 861 | // Final review finding 2: the target's report priced the cut (T11) and |
| 862 | // checked the disk (T6) for the database size it saw. When the source's |
| 863 | // report now carries a larger one (its local file, or the logical size of |
| 864 | // its replica's newest LTX), the target's evidence no longer covers it and |
| 865 | // Ready refuses until the target reports again. The review's example: a |
| 866 | // fresh report for 100 MiB, then the database grows to 2.5 GiB, which at |
| 867 | // 10/250/500 MiB/s no longer fits the 120-second window. |
| 868 | func TestReadyRefusesADatabaseGrownSinceTheTargetPriced(t *testing.T) { |
| 869 | now := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC) |
| 870 | target := passingTarget(now.Add(-time.Minute)) |
| 871 | m := Measurements{ |
| 872 | Cross: MeasuredRate{MiBPerS: 10, At: now.Add(-time.Minute)}, |
| 873 | S3: MeasuredRate{MiBPerS: 250, At: now.Add(-time.Minute)}, |
| 874 | Disk: MeasuredRate{MiBPerS: 500, At: now.Add(-time.Minute)}, |
| 875 | Fsync: FilesRate{PerS: 1600, At: now.Add(-time.Minute)}, |
| 876 | } |
| 877 | if res := BudgetCheck(2560, 0, m, 16<<20, 120, now); res.Check.OK { |
| 878 | t.Fatalf("the example does not overrun its window: %+v", res) |
| 879 | } |
| 880 | const grown = 2560 << 20 |
| 881 | for name, src := range map[string]func(*SourceReport){ |
| 882 | "local file": func(s *SourceReport) { s.DBBytes = grown }, |
| 883 | "replica": func(s *SourceReport) { s.ReplicaBytes = grown }, |
| 884 | "one byte": func(s *SourceReport) { s.ReplicaBytes = 100<<20 + 1 }, |
| 885 | } { |
| 886 | s := goodSource(now) |
| 887 | src(s) |
| 888 | ok, why := Ready(0, 16<<20, target, s, now) |
| 889 | if ok || !strings.HasPrefix(why, "T11: the database has grown to ") { |
| 890 | t.Errorf("%s: Ready = %v %q, want the grown-database refusal", name, ok, why) |
| 891 | } |
| 892 | } |
| 893 | want := "T11: the database has grown to 2560 MiB since the target priced 100 MiB. waiting for the target's next report" |
| 894 | s := goodSource(now) |
| 895 | s.DBBytes = grown |
| 896 | if _, why := Ready(0, 16<<20, target, s, now); why != want { |
| 897 | t.Errorf("reason %q, want %q", why, want) |
| 898 | } |
| 899 | // The size the target priced, or less, is covered. |
| 900 | for _, size := range []int64{0, 1, 100 << 20} { |
| 901 | s := goodSource(now) |
| 902 | s.DBBytes, s.ReplicaBytes = size, size |
| 903 | if ok, why := Ready(0, 16<<20, target, s, now); !ok { |
| 904 | t.Errorf("size %d: not ready: %s", size, why) |
| 905 | } |
| 906 | } |
| 907 | } |
| 908 | |
| 909 | // The cut guard (Paul's ruling, 2026-10-11): T13 is the target's statement |
| 910 | // that it can land the move and roll it back. Only a report that states it |
| 911 | // passing is armed: a nil report, an older box's report that has no T13, |
| 912 | // and a failing T13 all read unarmed, and an older report is not ready. |
| 913 | func TestTargetReportArmedFailsClosed(t *testing.T) { |
| 914 | now := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC) |
| 915 | var none *TargetReport |
| 916 | if none.Armed() { |
| 917 | t.Fatal("a missing report is armed") |
| 918 | } |
| 919 | r := passingTarget(now) |
| 920 | if !r.Armed() { |
| 921 | t.Fatal("a report with a passing T13 is not armed") |
| 922 | } |
| 923 | r.Checks[CheckArmed] = Check{Reason: "not armed"} |
| 924 | if r.Armed() { |
| 925 | t.Fatal("a failing T13 is armed") |
| 926 | } |
| 927 | old := passingTarget(now) |
| 928 | delete(old.Checks, CheckArmed) |
| 929 | if old.Armed() { |
| 930 | t.Fatal("an older box's report, with no T13, is armed") |
| 931 | } |
| 932 | if ok, why := Ready(0, 16<<20, old, goodSource(now), now); ok || why != "T13 was not reported" { |
| 933 | t.Fatalf("an older box's report: ready %v (%q)", ok, why) |
| 934 | } |
| 935 | // The wire form: an older box's JSON has no T13 and reads unarmed. |
| 936 | body, err := json.Marshal(old) |
| 937 | if err != nil { |
| 938 | t.Fatal(err) |
| 939 | } |
| 940 | var back TargetReport |
| 941 | if err := json.Unmarshal(body, &back); err != nil || back.Armed() { |
| 942 | t.Fatalf("round trip of an older report: armed %v, %v", back.Armed(), err) |
| 943 | } |
| 944 | } |
| 945 | |