| 1 | package moves |
| 2 | |
| 3 | import ( |
| 4 | "math" |
| 5 | "testing" |
| 6 | "time" |
| 7 | ) |
| 8 | |
| 9 | // Ported from internal/sim/hostlife/budget_test.go at 9c417aaa (the model of |
| 10 | // moves spec §7.3 T11, rev 7.7 with rev 7.11's per-attempt pricing). The |
| 11 | // model's fitsRev73 helper is dropped: it priced a rule the spec retired. |
| 12 | |
| 13 | func TestReadinessBudget(t *testing.T) { |
| 14 | // a tiny instance: every fixed cost at the floor of its measured rate |
| 15 | tiny := Estimate(1, 0, ExampleRates) |
| 16 | // rev 7.3 required three attempts always: 150 s of probing alone, which |
| 17 | // the default window never held |
| 18 | if old := tiny.Fixed() + ProbeAttemptsMax*(tiny.Probe+tiny.Settle); old <= CutWindowDefault { |
| 19 | t.Fatalf("rev 7.3's formula admits a tiny instance in the default window (%.1f s): R3-7 is stale", old) |
| 20 | } |
| 21 | if n := tiny.AttemptsAdmitted(CutWindowDefault); n != 2 { |
| 22 | t.Fatalf("rev 7.4's formula admits %d attempts for a tiny instance in %.0f s, want 2", n, CutWindowDefault) |
| 23 | } |
| 24 | // the boundary: fixed costs of 70 s leave exactly one attempt; 71 s none |
| 25 | edge := CutEstimate{Stop: 70 - OpenBudget, Open: OpenBudget, Probe: ProbeSeconds, Settle: SettleSeconds} |
| 26 | if n := edge.AttemptsAdmitted(CutWindowDefault); n != 1 { |
| 27 | t.Fatalf("fixed costs of 70 s admit %d attempts, want 1", n) |
| 28 | } |
| 29 | edge.Stop++ |
| 30 | if n := edge.AttemptsAdmitted(CutWindowDefault); n != 0 { |
| 31 | t.Fatalf("fixed costs of 71 s admit %d attempts, want 0 (refused)", n) |
| 32 | } |
| 33 | // a generous window never permits more than three |
| 34 | if n := tiny.AttemptsAdmitted(600); n != ProbeAttemptsMax { |
| 35 | t.Fatalf("a 600 s window admits %d attempts, want %d", n, ProbeAttemptsMax) |
| 36 | } |
| 37 | // R5-5: the spec's example, a 2 GiB database at the example rates. Rev |
| 38 | // 7.5's full-snapshot transfer alone takes 205 s and could never fit; |
| 39 | // the delta within the bar and the target's build do, with one attempt |
| 40 | big := Estimate(2048, 0, ExampleRates) |
| 41 | fullTransfer := 2048.0 / ExampleRates.Cross |
| 42 | if fullTransfer < CutWindowDefault { |
| 43 | t.Fatalf("the example no longer illustrates R5-5") |
| 44 | } |
| 45 | if n := big.AttemptsAdmitted(CutWindowDefault); n != 1 { |
| 46 | t.Fatalf("a 2 GiB database at the example rates admits %d attempts, want 1 (fixed %.1f s, first attempt %.1f s)", n, big.Fixed(), big.Attempt(1)) |
| 47 | } |
| 48 | if math.Abs(big.Fixed()-53.6) > 0.1 { |
| 49 | t.Fatalf("the example's fixed costs are %.1f s; the spec says 53.6 s", big.Fixed()) |
| 50 | } |
| 51 | // rev 7.11 (R10-8): the first attempt restores the prepared generation |
| 52 | // again and pays the admission: 12.3 + 2 + 30 + 20 = 64.3 s, so the |
| 53 | // example runs to 117.9 s and admits one attempt by 2.1 s; a second |
| 54 | // attempt would add 76.6 s (a fresh generation's full upload too) |
| 55 | if a := big.Attempt(1); math.Abs(a-64.3) > 0.1 { |
| 56 | t.Fatalf("the example's first attempt costs %.1f s; the spec says 64.3 s", a) |
| 57 | } |
| 58 | if tot := big.Fixed() + big.Attempt(1); math.Abs(tot-117.9) > 0.1 { |
| 59 | t.Fatalf("the example runs to %.1f s with one attempt; the spec says 117.9 s", tot) |
| 60 | } |
| 61 | if a := big.Attempt(2); math.Abs(a-76.6) > 0.1 { |
| 62 | t.Fatalf("the example's second attempt would cost %.1f s; the spec says 76.6 s", a) |
| 63 | } |
| 64 | // the review's 2.5 GiB (R10-8): rev 7.10's arithmetic admitted it at |
| 65 | // 113.8 s; with the probe's own restore it is 129.2 s, past the window, |
| 66 | // and refused |
| 67 | bigger := Estimate(2560, 0, ExampleRates) |
| 68 | old := bigger.Fixed() + ProbeSeconds + SettleSeconds |
| 69 | if math.Abs(old-113.8) > 0.1 { |
| 70 | t.Fatalf("rev 7.10's arithmetic for 2.5 GiB is %.1f s; the review says 113.8 s", old) |
| 71 | } |
| 72 | if n := bigger.AttemptsAdmitted(CutWindowDefault); n != 0 { |
| 73 | t.Fatalf("2.5 GiB at the example rates admits %d attempts, want 0 (%.1f s with one attempt)", n, bigger.Fixed()+bigger.Attempt(1)) |
| 74 | } |
| 75 | // and a cross-region snapshot transfer, if a builder did that instead, |
| 76 | // is refused by the same arithmetic |
| 77 | naive := big |
| 78 | naive.Mirror, naive.RestoreStaged, naive.SnapshotBuild = fullTransfer, 0, 0 |
| 79 | if n := naive.AttemptsAdmitted(CutWindowDefault); n != 0 { |
| 80 | t.Fatalf("a full cross-region snapshot transfer admits %d attempts, want 0", n) |
| 81 | } |
| 82 | // R6-3: rev 7.6 priced three passes at one local rate and then restored |
| 83 | // the generation cold a fourth time, unpriced. The review's workload: 3 |
| 84 | // GiB, 200 MiB/s local, 10 MiB/s across regions, one probe. Three passes |
| 85 | // admit one attempt (108.68 s with the probe); the fourth pass takes the |
| 86 | // same workload to 124.04 s, past the window |
| 87 | r := Rates{Cross: 10, S3: 200, Disk: 200} |
| 88 | three := Estimate(3072, 0, r) |
| 89 | three.RestoreStaged, three.SnapshotBuild, three.Anchor, three.Verify = 3072/200.0, 3072/200.0, 3072/200.0, 0 // rev 7.6's arithmetic |
| 90 | three.RestoreAttempt, three.Admit, three.FreshUpload = 0, 0, 0 // rev 7.6's arithmetic knew no per-attempt restore |
| 91 | if n := three.AttemptsAdmitted(CutWindowDefault); n != 1 { |
| 92 | t.Fatalf("rev 7.6's three passes admit %d attempts for the review's workload, want 1 (the finding's premise; fixed %.2f s)", n, three.Fixed()) |
| 93 | } |
| 94 | four := three |
| 95 | four.Verify = 3072 / 200.0 // the unpriced cold restore, at the same rate |
| 96 | if n := four.AttemptsAdmitted(CutWindowDefault); n != 0 || four.Fixed()+ProbeSeconds+SettleSeconds < 124 { |
| 97 | t.Fatalf("with the fourth pass priced the review's workload admits %d attempts at %.2f s with one probe, want 0 past 124 s", n, four.Fixed()+ProbeSeconds+SettleSeconds) |
| 98 | } |
| 99 | // rev 7.7 verifies the built database instead of restoring it again: |
| 100 | // the same workload is refused honestly, by its own arithmetic |
| 101 | if n := Estimate(3072, 0, r).AttemptsAdmitted(CutWindowDefault); n != 0 { |
| 102 | t.Fatalf("rev 7.7 admits %d attempts for the review's workload, want 0 (fixed %.1f s)", n, Estimate(3072, 0, r).Fixed()) |
| 103 | } |
| 104 | } |
| 105 | |
| 106 | func TestBudgetTotalAndDeadline(t *testing.T) { |
| 107 | e := Estimate(2048, 0, ExampleRates) |
| 108 | n := e.AttemptsAdmitted(CutWindowDefault) |
| 109 | if n != 1 || math.Abs(e.Total(n)-117.9) > 0.1 { |
| 110 | t.Fatalf("attempts %d total %.1f", n, e.Total(n)) |
| 111 | } |
| 112 | // cut_deadline = cut_at + cut_budget - open_budget (§7.4). |
| 113 | at := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC) |
| 114 | if d := CutDeadline(at, CutWindowDefault); !d.Equal(at.Add(115 * time.Second)) { |
| 115 | t.Fatal(d) |
| 116 | } |
| 117 | } |
| 118 | |
| 119 | // The mirror bar is the one input Task 11 overrides |
| 120 | // (CARLOS_MOVE_READY_DELTA_BYTES, Ruling F28(e)): zero means the default, |
| 121 | // and the bar scales only the mirror. |
| 122 | func TestEstimateMirrorBar(t *testing.T) { |
| 123 | def := Estimate(2048, 0, ExampleRates) |
| 124 | if want := MirrorBarMiB / ExampleRates.Cross; def.Mirror != want { |
| 125 | t.Fatalf("default mirror %.2f s, want %.2f s", def.Mirror, want) |
| 126 | } |
| 127 | explicit := ExampleRates |
| 128 | explicit.MirrorBar = MirrorBarMiB |
| 129 | if got := Estimate(2048, 0, explicit); got != def { |
| 130 | t.Fatalf("an explicit default bar changed the estimate: %+v vs %+v", got, def) |
| 131 | } |
| 132 | wide := ExampleRates |
| 133 | wide.MirrorBar = 64 |
| 134 | w := Estimate(2048, 0, wide) |
| 135 | if math.Abs(w.Mirror-6.4) > 1e-9 { |
| 136 | t.Fatalf("a 64 MiB bar at 10 MiB/s mirrors in %.2f s, want 6.4", w.Mirror) |
| 137 | } |
| 138 | if math.Abs((w.Fixed()-def.Fixed())-(6.4-1.6)) > 1e-9 { |
| 139 | t.Fatalf("the bar moved more than the mirror: fixed %.2f vs %.2f", w.Fixed(), def.Fixed()) |
| 140 | } |
| 141 | if w.Attempt(1) != def.Attempt(1) { |
| 142 | t.Fatalf("the bar changed a per-attempt cost") |
| 143 | } |
| 144 | // the open phase is the §7.4 open budget, not a second literal |
| 145 | if def.Open != OpenBudget { |
| 146 | t.Fatalf("Open %.1f, want OpenBudget %.1f", def.Open, OpenBudget) |
| 147 | } |
| 148 | } |
| 149 | |
| 150 | // An unusable input prices the cut at infinity: refused, never admitted. A |
| 151 | // negative rate would otherwise price a phase below zero and let a move in. |
| 152 | func TestEstimateInvalidInputsRefuse(t *testing.T) { |
| 153 | nan, inf := math.NaN(), math.Inf(1) |
| 154 | good := ExampleRates |
| 155 | with := func(f func(r *Rates)) Rates { r := good; f(&r); return r } |
| 156 | cases := map[string]struct { |
| 157 | db float64 |
| 158 | r Rates |
| 159 | }{ |
| 160 | "cross zero": {1, with(func(r *Rates) { r.Cross = 0 })}, |
| 161 | "cross negative": {1, with(func(r *Rates) { r.Cross = -10 })}, |
| 162 | "cross NaN": {1, with(func(r *Rates) { r.Cross = nan })}, |
| 163 | "cross Inf": {1, with(func(r *Rates) { r.Cross = inf })}, |
| 164 | "s3 zero": {1, with(func(r *Rates) { r.S3 = 0 })}, |
| 165 | "s3 negative": {1, with(func(r *Rates) { r.S3 = -250 })}, |
| 166 | "s3 NaN": {1, with(func(r *Rates) { r.S3 = nan })}, |
| 167 | "s3 Inf": {1, with(func(r *Rates) { r.S3 = inf })}, |
| 168 | "disk zero": {1, with(func(r *Rates) { r.Disk = 0 })}, |
| 169 | "disk negative": {1, with(func(r *Rates) { r.Disk = -500 })}, |
| 170 | "disk NaN": {1, with(func(r *Rates) { r.Disk = nan })}, |
| 171 | "disk Inf": {1, with(func(r *Rates) { r.Disk = inf })}, |
| 172 | "bar negative": {1, with(func(r *Rates) { r.MirrorBar = -16 })}, |
| 173 | "bar NaN": {1, with(func(r *Rates) { r.MirrorBar = nan })}, |
| 174 | "bar Inf": {1, with(func(r *Rates) { r.MirrorBar = inf })}, |
| 175 | "db negative": {-2048, good}, |
| 176 | "db NaN": {nan, good}, |
| 177 | "db Inf": {inf, good}, |
| 178 | // a negative database against a negative rate: the quotient is |
| 179 | // positive, and must still be refused |
| 180 | "db and s3 negative": {-1, with(func(r *Rates) { r.S3 = -250 })}, |
| 181 | } |
| 182 | for name, c := range cases { |
| 183 | e := Estimate(c.db, 0, c.r) |
| 184 | if n := e.AttemptsAdmitted(CutWindowDefault); n != 0 { |
| 185 | t.Errorf("%s admits %d attempts, want 0", name, n) |
| 186 | } |
| 187 | if n := e.AttemptsAdmitted(1e9); n != 0 { |
| 188 | t.Errorf("%s admits %d attempts in a huge window, want 0", name, n) |
| 189 | } |
| 190 | if f := e.Fixed(); !math.IsInf(f, 1) { |
| 191 | t.Errorf("%s fixed cost %v, want +Inf", name, f) |
| 192 | } |
| 193 | } |
| 194 | // the boundary the refusals must not touch: an empty database is valid |
| 195 | if n := Estimate(0, 0, ExampleRates).AttemptsAdmitted(CutWindowDefault); n != 2 { |
| 196 | t.Errorf("an empty database admits %d attempts, want 2", n) |
| 197 | } |
| 198 | } |
| 199 | |
| 200 | // Ruling T7-budget: the landing's fsync scales with the archive's entry |
| 201 | // count, so the Archive term is max(2, entries/FsyncRate) seconds. Every |
| 202 | // new input keeps the invalid-input refusal. |
| 203 | func TestEstimateArchiveScalesWithEntries(t *testing.T) { |
| 204 | r := ExampleRates |
| 205 | if got := Estimate(2048, 0, r).Archive; got != 2 { |
| 206 | t.Fatalf("no entries: Archive %v, want 2", got) |
| 207 | } |
| 208 | if got := Estimate(2048, 100, r).Archive; got != 2 { |
| 209 | t.Fatalf("a few entries: Archive %v, want the 2 s floor", got) |
| 210 | } |
| 211 | want := 32000 / r.FsyncRate |
| 212 | if got := Estimate(2048, 32000, r).Archive; got != want || got <= 2 { |
| 213 | t.Fatalf("32000 entries: Archive %v, want %v", got, want) |
| 214 | } |
| 215 | if Estimate(2048, 32000, r).Fixed() <= Estimate(2048, 0, r).Fixed() { |
| 216 | t.Fatal("entries did not reach the fixed cost") |
| 217 | } |
| 218 | // No entries needs no fsync rate. |
| 219 | noRate := r |
| 220 | noRate.FsyncRate = 0 |
| 221 | if got := Estimate(2048, 0, noRate).Archive; got != 2 { |
| 222 | t.Fatalf("no entries, no rate: Archive %v, want 2", got) |
| 223 | } |
| 224 | nan, inf := math.NaN(), math.Inf(1) |
| 225 | for name, c := range map[string]struct { |
| 226 | entries int |
| 227 | fsync float64 |
| 228 | }{ |
| 229 | "negative entries": {-1, r.FsyncRate}, |
| 230 | "entries and no rate": {10, 0}, |
| 231 | "negative rate": {10, -1600}, |
| 232 | "negative rate, no files": {0, -1600}, |
| 233 | "NaN rate": {10, nan}, |
| 234 | "NaN rate, no files": {0, nan}, |
| 235 | "Inf rate": {10, inf}, |
| 236 | } { |
| 237 | rr := r |
| 238 | rr.FsyncRate = c.fsync |
| 239 | e := Estimate(2048, c.entries, rr) |
| 240 | if !math.IsInf(e.Fixed(), 1) || e.AttemptsAdmitted(1e9) != 0 { |
| 241 | t.Errorf("%s: fixed %v, %d attempts; want refused", name, e.Fixed(), e.AttemptsAdmitted(1e9)) |
| 242 | } |
| 243 | } |
| 244 | } |
| 245 | |