carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package rollout
2
3import (
4 "encoding/json"
5 "errors"
6 "math"
7 "os"
8 "path/filepath"
9 "testing"
10 "time"
11
12 "github.com/carlosframework/platform/internal/activator"
13)
14
15// The crash-epoch hard limit (zero-downtime flag readiness round 2, Task 1;
16// Paul's ruling 2026-10-12): a stale signed retry grant never clears a later
17// failure. A restriction's generation is per (account, app), moves on every
18// change of the app's restrictions, is published only once it is on disk,
19// never repeats across a restart, and counts in a book identity that comes
20// from crypto/rand and is rotated when the rule is adopted.
21
22// diskBook is a book saved under a data dir of its own, its owed save
23// landed, and the dir.
24func diskBook(t *testing.T) (*FailureBook, string) {
25 t.Helper()
26 dd := t.TempDir()
27 b, err := OpenFailureBook(dd)
28 if err != nil {
29 t.Fatal(err)
30 }
31 if err := b.Flush(); err != nil {
32 t.Fatal(err)
33 }
34 return b, dd
35}
36
37// published is the generation status publishes for build's restriction of
38// host in bab/blog.
39func published(b *FailureBook, build, host string) uint64 {
40 return b.PublishedEpoch("bab", "blog", build, host)
41}
42
43// grantAt applies a signed host retry of build on host naming b's identity
44// at epoch.
45func grantAt(b *FailureBook, build, host string, epoch uint64) (rec, mark bool) {
46 rec, mark, _, _ = b.RetryHostAt("bab", "blog", build, host, b.ID(), epoch)
47 return rec, mark
48}
49
50func mustRecord(t *testing.T, b *FailureBook, r FailureRecord) RecordResult {
51 t.Helper()
52 res, err := b.Record(r)
53 if err != nil {
54 t.Fatal(err)
55 }
56 return res
57}
58
59func mustMark(t *testing.T, b *FailureBook, host string, h HoldRan) {
60 t.Helper()
61 if err := b.MarkHoldRan(host, h); err != nil {
62 t.Fatal(err)
63 }
64}
65
66// Plan 8: a book whose save fails publishes nothing, and a grant for the
67// generation published before the change clears nothing. Once the owed save
68// lands the new generation is published and a grant at it clears.
69func TestAFailingDiskPublishesNoEpoch(t *testing.T) {
70 b, dd := diskBook(t)
71 mustRecord(t, b, FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "h1", Mode: string(activator.ModeHold), HeldDown: []string{"h1"}})
72 e1 := published(b, "v2", "h1")
73 if e1 == 0 {
74 t.Fatal("a saved restriction published no generation")
75 }
76 readOnly(t, dd)
77 if _, err := b.Record(FailureRecord{Account: "bab", App: "blog", Build: "v3", Host: "h2"}); err == nil {
78 t.Fatal("the save of v3's record did not fail: the case needs a failing disk")
79 }
80 if e := published(b, "v2", "h1"); e != 0 {
81 t.Fatalf("published %d while the book's save is owed, want 0", e)
82 }
83 if rec, mark, _, err := b.RetryHostAt("bab", "blog", "v2", "h1", b.ID(), e1); rec || mark {
84 t.Fatalf("a grant from before the unsaved change cleared record %v, marker %v (%v)", rec, mark, err)
85 }
86 if !b.HeldDown("bab", "blog", "v2", "h1") {
87 t.Fatal("h1 is no longer held down")
88 }
89 writable(t, dd)
90 if err := b.SaveIfDirty(); err != nil {
91 t.Fatalf("the owed save did not land: %v", err)
92 }
93 e2 := published(b, "v2", "h1")
94 if e2 <= e1 {
95 t.Fatalf("published %d after the save landed, want past %d", e2, e1)
96 }
97 if rec, _ := grantAt(b, "v2", "h1", e2); !rec {
98 t.Fatal("a grant at the published generation cleared nothing")
99 }
100 again, err := OpenFailureBook(dd)
101 if err != nil {
102 t.Fatal(err)
103 }
104 if again.HeldDown("bab", "blog", "v2", "h1") {
105 t.Fatal("the retry was not saved")
106 }
107}
108
109// Plan 1: every removal of a restriction of the app moves the app's
110// generation, so a grant signed before it clears nothing.
111func TestEveryRemovalMovesTheGeneration(t *testing.T) {
112 const host = "h1"
113 // v2's record (from a sibling's failure) holds h1, and v2's marker
114 // stands on h1.
115 setup := func(t *testing.T) *FailureBook {
116 b, _ := diskBook(t)
117 mustRecord(t, b, FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "sib", PromotedAt: promotedAt, At: t0})
118 mustMark(t, b, host, HoldRan{Account: "bab", App: "blog", Build: "v2", Exec: "a1", At: t0})
119 return b
120 }
121 for name, change := range map[string]func(t *testing.T, b *FailureBook){
122 "the marker replaced by another build's": func(t *testing.T, b *FailureBook) {
123 mustMark(t, b, host, HoldRan{Account: "bab", App: "blog", Build: "v3", Exec: "a2", At: t0})
124 },
125 "the record cleared by a pointer": func(t *testing.T, b *FailureBook) {
126 if ok, err := b.ClearOnPointer("bab", "blog", "v2", promotedAt.Add(time.Minute)); !ok || err != nil {
127 t.Fatalf("ClearOnPointer = %v, %v", ok, err)
128 }
129 },
130 "the record pruned": func(t *testing.T, b *FailureBook) {
131 if err := b.Prune(func(string, string, string) bool { return false }); err != nil {
132 t.Fatal(err)
133 }
134 if _, ok := b.Lookup("bab", "blog", "v2"); ok {
135 t.Fatal("the record was not pruned")
136 }
137 },
138 "the marker cleared by a good write": func(t *testing.T, b *FailureBook) {
139 if ok, err := b.ClearHoldRanBefore(host, b.Snapshot()); !ok || err != nil {
140 t.Fatalf("ClearHoldRanBefore = %v, %v", ok, err)
141 }
142 },
143 "the marker cleared by a rollback": func(t *testing.T, b *FailureBook) {
144 if ok, err := b.ClearHoldRanOnPointer(host, "bab", "blog", t0.Add(time.Minute)); !ok || err != nil {
145 t.Fatalf("ClearHoldRanOnPointer = %v, %v", ok, err)
146 }
147 },
148 "the marker cleared": func(t *testing.T, b *FailureBook) {
149 if ok, err := b.ClearHoldRan(host); !ok || err != nil {
150 t.Fatalf("ClearHoldRan = %v, %v", ok, err)
151 }
152 },
153 "the record merged with a newer pointer": func(t *testing.T, b *FailureBook) {
154 if res := mustRecord(t, b, FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "sib2", PromotedAt: promotedAt.Add(time.Minute)}); res != Merged {
155 t.Fatalf("Record = %v, want merged", res)
156 }
157 },
158 } {
159 t.Run(name, func(t *testing.T) {
160 b := setup(t)
161 movesAcross(t, b, "blog", "v2", host, func() { change(t, b) })
162 })
163 }
164 // Fix round 1, finding 1a: a grant that clears only a marker (no record
165 // of its build holds the host) moves the app's generation, so a grant at
166 // the value published before it for another build of the app clears
167 // nothing.
168 t.Run("a marker-only grant for another build", func(t *testing.T) {
169 b, _ := diskBook(t)
170 mustRecord(t, b, FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "sib", PromotedAt: promotedAt, At: t0})
171 mustMark(t, b, host, HoldRan{Account: "bab", App: "blog", Build: "v3", Exec: "a1", At: t0})
172 movesAcross(t, b, "blog", "v2", "h2", func() {
173 if rec, mark := grantAt(b, "v3", host, published(b, "v3", host)); rec || !mark {
174 t.Fatalf("the grant for v3 on h1 cleared record %v, marker %v; want the marker only", rec, mark)
175 }
176 })
177 })
178 // Fix round 1, finding 1b: a host's marker of one app replaced by another
179 // app's (the host now runs another app) moves the first app's generation.
180 t.Run("the marker replaced by another app's", func(t *testing.T) {
181 b := setup(t)
182 movesAcross(t, b, "blog", "v2", host, func() {
183 mustMark(t, b, host, HoldRan{Account: "bab", App: "shop", Build: "w1", Exec: "a2", At: t0})
184 })
185 })
186}
187
188// movesAcross checks that change moves bab/app's generation as build's
189// restriction of host publishes it, that build still restricts host after
190// it, and that a grant at the value published before it clears nothing.
191func movesAcross(t *testing.T, b *FailureBook, app, build, host string, change func()) {
192 t.Helper()
193 e := b.PublishedEpoch("bab", app, build, host)
194 if e == 0 {
195 t.Fatal("no generation published before the change")
196 }
197 change()
198 now := b.PublishedEpoch("bab", app, build, host)
199 if now == e {
200 t.Fatalf("the generation stayed at %d across the change", e)
201 }
202 if now == 0 {
203 t.Fatalf("%s still restricts %s through its record or marker, but no generation is published", build, host)
204 }
205 if rec, mark, _, _ := b.RetryHostAt("bab", app, build, host, b.ID(), e); rec || mark {
206 t.Fatalf("a grant from before the change cleared record %v, marker %v", rec, mark)
207 }
208}
209
210// A call that changes nothing (a stale failure, an unchanged one, a repeated
211// marker of the same execution) moves nothing: a grant at the generation
212// published before it still clears.
213func TestANoOpOutcomeDoesNotMoveTheGeneration(t *testing.T) {
214 b, _ := diskBook(t)
215 mustRecord(t, b, FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "sib", Mode: string(activator.ModeHold),
216 HeldDown: []string{"sib"}, MarkHoldRan: true, HoldRanExec: "a1", HoldRanAt: t0})
217 snap := b.Snapshot() // an execution of v2 on h1 starts
218 if rec, _ := grantAt(b, "v2", "h1", published(b, "v2", "h1")); !rec {
219 t.Fatal("the retry of h1 cleared nothing")
220 }
221 e := published(b, "v2", "h2")
222 if e == 0 {
223 t.Fatal("no generation published for h2")
224 }
225 steady := func(what string) {
226 t.Helper()
227 if now := published(b, "v2", "h2"); now != e {
228 t.Fatalf("%s moved the generation from %d to %d", what, e, now)
229 }
230 }
231 if res := mustRecord(t, b, FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "h1", Seq: snap}); res != PreRetry {
232 t.Fatalf("the failure that began before the retry = %v, want pre-retry", res)
233 }
234 steady("a pre-retry failure")
235 if res := mustRecord(t, b, FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "sib"}); res != Unchanged {
236 t.Fatalf("a repeated failure = %v, want unchanged", res)
237 }
238 steady("an unchanged failure")
239 mustMark(t, b, "sib", HoldRan{Account: "bab", App: "blog", Build: "v2", Exec: "a1", At: t0})
240 steady("a repeated marker of the same execution")
241 if res := mustRecord(t, b, FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "sib", Mode: string(activator.ModeHold),
242 HeldDown: []string{"sib"}, MarkHoldRan: true, HoldRanExec: "a1", HoldRanAt: t0}); res != Unchanged {
243 t.Fatalf("a repeated hold failure = %v, want unchanged", res)
244 }
245 steady("a repeated hold failure of the same execution")
246 if rec, _ := grantAt(b, "v2", "h2", e); !rec {
247 t.Fatal("a grant at the unchanged generation cleared nothing")
248 }
249}
250
251// Plan 2: a file an earlier binary wrote (no durable_epochs) may hold
252// epochs that binary published and never saved. Opening it under the rule
253// rotates the book's identity, so no grant issued before clears anything,
254// and nothing is published until the rotation is on disk.
255func TestABookFromBeforeTheRuleGetsANewIdentity(t *testing.T) {
256 dd := t.TempDir()
257 raw, err := json.Marshal(map[string]any{
258 "schema": 2,
259 "book": "old",
260 "seq": 10,
261 "records": []map[string]any{{"account": "bab", "app": "blog", "build": "v2", "host": "h1",
262 "held_down": []string{"h1"}, "epochs": map[string]uint64{"": 11, "h1": 11}}},
263 })
264 if err != nil {
265 t.Fatal(err)
266 }
267 if err := os.WriteFile(filepath.Join(dd, FailureFile), raw, 0o644); err != nil {
268 t.Fatal(err)
269 }
270 b, err := OpenFailureBook(dd)
271 if err != nil {
272 t.Fatal(err)
273 }
274 id := b.ID()
275 if id == "old" || id == "" {
276 t.Fatalf("identity %q after adopting the rule, want a new one", id)
277 }
278 if e := published(b, "v2", "h1"); e != 0 {
279 t.Fatalf("published %d before the rotation was saved, want 0", e)
280 }
281 for _, epoch := range []uint64{11, 12} {
282 if rec, mark, _, _ := b.RetryHostAt("bab", "blog", "v2", "h1", "old", epoch); rec || mark {
283 t.Fatalf("a grant naming the old identity at %d cleared record %v, marker %v", epoch, rec, mark)
284 }
285 }
286 if err := b.Flush(); err != nil {
287 t.Fatal(err)
288 }
289 e := published(b, "v2", "h1")
290 if e <= 11 {
291 t.Fatalf("published %d after the save, want a generation past the file's 11", e)
292 }
293 again, err := OpenFailureBook(dd)
294 if err != nil {
295 t.Fatal(err)
296 }
297 if again.ID() != id || published(again, "v2", "h1") != e {
298 t.Fatalf("reopened as %q at %d, want %q at %d", again.ID(), published(again, "v2", "h1"), id, e)
299 }
300 if f := fileOf(t, dd); !f.Durable || f.Book != id {
301 t.Fatalf("file durable_epochs %v, book %q: want true and %q", f.Durable, f.Book, id)
302 }
303 if rec, _ := grantAt(again, "v2", "h1", e); !rec {
304 t.Fatal("a grant naming the new identity at the published generation cleared nothing")
305 }
306}
307
308// Fix round 1, finding 2: the book is written as schema 3, which an agent
309// before the rule (it accepts schema 1 and 2) refuses, so it can never keep
310// the identity while publishing unsaved epochs. A schema-3 file round-trips
311// with its identity and owes no save. A file of schema 1 or 2 still opens,
312// with a new identity, even one that claims durable_epochs: only an agent of
313// this rule writes schema 3.
314func TestTheBookIsSchema3AndOlderSchemasOpenRotated(t *testing.T) {
315 t.Run("schema 3 round-trips", func(t *testing.T) {
316 b, dd := diskBook(t)
317 mustRecord(t, b, FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "h1", HeldDown: []string{"h1"}})
318 e := published(b, "v2", "h1")
319 if e == 0 {
320 t.Fatal("a saved restriction published no generation")
321 }
322 if f := fileOf(t, dd); f.Schema != 3 || !f.Durable || f.Book != b.ID() {
323 t.Fatalf("file schema %d, durable_epochs %v, book %q: want 3, true, %q", f.Schema, f.Durable, f.Book, b.ID())
324 }
325 again, err := OpenFailureBook(dd)
326 if err != nil {
327 t.Fatalf("a schema-3 file did not open: %v", err)
328 }
329 if again.ID() != b.ID() || again.SaveOwed() || published(again, "v2", "h1") != e {
330 t.Fatalf("reopened as %q, save owed %v, at %d: want %q, false, %d",
331 again.ID(), again.SaveOwed(), published(again, "v2", "h1"), b.ID(), e)
332 }
333 if rec, _ := grantAt(again, "v2", "h1", e); !rec {
334 t.Fatal("a grant at the generation published before the reopen cleared nothing")
335 }
336 })
337 for name, file := range map[string]map[string]any{
338 "schema 1": {"schema": 1},
339 "schema 2": {"schema": 2},
340 "schema 2 with durable_epochs": {"schema": 2, "durable_epochs": true, "gens": map[string]uint64{"bab/blog": 10}},
341 } {
342 t.Run(name, func(t *testing.T) {
343 dd := t.TempDir()
344 file["book"], file["seq"] = "old", 10
345 file["records"] = []map[string]any{{"account": "bab", "app": "blog", "build": "v2", "host": "h1", "held_down": []string{"h1"}}}
346 raw, err := json.Marshal(file)
347 if err != nil {
348 t.Fatal(err)
349 }
350 if err := os.WriteFile(filepath.Join(dd, FailureFile), raw, 0o644); err != nil {
351 t.Fatal(err)
352 }
353 b, err := OpenFailureBook(dd)
354 if err != nil {
355 t.Fatalf("the file did not open: %v", err)
356 }
357 if !b.HeldDown("bab", "blog", "v2", "h1") {
358 t.Fatal("the file's record was lost")
359 }
360 if id := b.ID(); id == "old" || id == "" {
361 t.Fatalf("identity %q, want a new one", id)
362 }
363 if !b.SaveOwed() || published(b, "v2", "h1") != 0 {
364 t.Fatalf("save owed %v, published %d: want the rotation owed and nothing published", b.SaveOwed(), published(b, "v2", "h1"))
365 }
366 if rec, mark, _, _ := b.RetryHostAt("bab", "blog", "v2", "h1", "old", 10); rec || mark {
367 t.Fatalf("a grant naming the old identity cleared record %v, marker %v", rec, mark)
368 }
369 if err := b.Flush(); err != nil {
370 t.Fatal(err)
371 }
372 if f := fileOf(t, dd); f.Schema != 3 || !f.Durable || f.Book != b.ID() {
373 t.Fatalf("written as schema %d, durable_epochs %v, book %q: want 3, true, %q", f.Schema, f.Durable, f.Book, b.ID())
374 }
375 e := published(b, "v2", "h1")
376 if e == 0 {
377 t.Fatal("published nothing after the rotation was saved")
378 }
379 if rec, _ := grantAt(b, "v2", "h1", e); !rec {
380 t.Fatal("a grant naming the new identity at the published generation cleared nothing")
381 }
382 })
383 }
384}
385
386// Rule 3: a reopen starts from the disk's sequence, so every generation
387// handed out after it is larger than every one published before it.
388func TestAReopenNeverReissuesAPublishedGeneration(t *testing.T) {
389 b, dd := diskBook(t)
390 var before uint64
391 read := func(b *FailureBook) uint64 {
392 t.Helper()
393 e := max(published(b, "v2", "h1"), published(b, "v3", "h1"), published(b, "v4", "h1"), published(b, "v5", "h2"))
394 if e == 0 {
395 t.Fatal("nothing published")
396 }
397 return e
398 }
399 mustRecord(t, b, FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "sib", PromotedAt: promotedAt})
400 before = max(before, read(b))
401 mustRecord(t, b, FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "sib", HeldDown: []string{"h1"}})
402 before = max(before, read(b))
403 mustMark(t, b, "h1", HoldRan{Account: "bab", App: "blog", Build: "v3", Exec: "a1", At: t0})
404 before = max(before, read(b))
405 mustRecord(t, b, FailureRecord{Account: "bab", App: "blog", Build: "v4", Host: "sib"})
406 before = max(before, read(b))
407
408 again, err := OpenFailureBook(dd)
409 if err != nil {
410 t.Fatal(err)
411 }
412 if err := again.Flush(); err != nil {
413 t.Fatal(err)
414 }
415 for i, change := range []func(){
416 func() { mustRecord(t, again, FailureRecord{Account: "bab", App: "blog", Build: "v5", Host: "sib"}) },
417 func() {
418 mustMark(t, again, "h2", HoldRan{Account: "bab", App: "blog", Build: "v5", Exec: "a2", At: t0})
419 },
420 func() {
421 if ok, err := again.ClearOnPointer("bab", "blog", "v4", t0); !ok || err != nil {
422 t.Fatalf("ClearOnPointer = %v, %v", ok, err)
423 }
424 },
425 } {
426 change()
427 for _, c := range [][2]string{{"v2", "h1"}, {"v3", "h1"}, {"v5", "h2"}} {
428 if e := published(again, c[0], c[1]); e <= before {
429 t.Fatalf("after change %d of the reopened book, %s on %s published %d, not past %d", i+1, c[0], c[1], e, before)
430 }
431 }
432 }
433}
434
435// Rule 5 and finding 3: two books opened with equal numeric state publish
436// the same generation; a grant for one clears nothing in the other.
437func TestAGrantForAnotherBookClearsNothing(t *testing.T) {
438 one, _ := diskBook(t)
439 other, _ := diskBook(t)
440 for _, b := range []*FailureBook{one, other} {
441 mustRecord(t, b, FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "h", Mode: string(activator.ModeHold),
442 HeldDown: []string{"h"}, MarkHoldRan: true, HoldRanExec: "a1", HoldRanAt: t0})
443 }
444 e := published(one, "v2", "h")
445 if e == 0 || e != published(other, "v2", "h") {
446 t.Fatalf("published %d and %d: the case needs equal generations", e, published(other, "v2", "h"))
447 }
448 if one.ID() == other.ID() {
449 t.Fatalf("two books share the identity %q", one.ID())
450 }
451 if rec, mark, _, _ := other.RetryHostAt("bab", "blog", "v2", "h", one.ID(), e); rec || mark {
452 t.Fatalf("a grant for one book cleared record %v, marker %v in another", rec, mark)
453 }
454 if !other.HeldDown("bab", "blog", "v2", "h") {
455 t.Fatal("the other book's hold-down was lifted")
456 }
457 if rec, mark := grantAt(one, "v2", "h", e); !rec || !mark {
458 t.Fatalf("the grant cleared record %v, marker %v in its own book; want both", rec, mark)
459 }
460}
461
462// Rule 5: a book's identity comes from crypto/rand only. A book that needs
463// one and cannot get it does not open; one already on disk under the rule
464// needs none.
465func TestABookThatCannotGetAnIdentityDoesNotOpen(t *testing.T) {
466 durable, ddDurable := diskBook(t)
467 mustRecord(t, durable, FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "h"})
468 ddOld := t.TempDir()
469 if err := os.WriteFile(filepath.Join(ddOld, FailureFile), []byte(`{"schema": 2, "book": "old", "records": []}`), 0o644); err != nil {
470 t.Fatal(err)
471 }
472 broken := errors.New("no entropy")
473 old := randRead
474 randRead = func([]byte) (int, error) { return 0, broken }
475 t.Cleanup(func() { randRead = old })
476 for name, dd := range map[string]string{"a new book": t.TempDir(), "a book from before the rule": ddOld} {
477 if b, err := OpenFailureBook(dd); !errors.Is(err, broken) || b != nil {
478 t.Fatalf("%s opened (%v, %v) without an identity", name, b, err)
479 }
480 }
481 b, err := OpenFailureBook(ddDurable)
482 if err != nil {
483 t.Fatalf("a book with its identity on disk did not open: %v", err)
484 }
485 if b.ID() != durable.ID() {
486 t.Fatalf("identity %q, want %q", b.ID(), durable.ID())
487 }
488}
489
490// Fix wave 1, item 5 (Astra final review 1, finding 5): the sequence never
491// wraps under one identity. The largest value it hands out is
492// MaxUint64-2. A change that needs another refuses to save, so nothing is
493// published and no grant clears; a snapshot at the boundary reads "not
494// known" (0); and the file keeps the last value that landed, so a reopen
495// refuses the same way.
496func TestAnExhaustedSequenceFailsClosed(t *testing.T) {
497 b, dd := diskBook(t)
498 b.mu.Lock()
499 b.seq = math.MaxUint64 - 3
500 b.mu.Unlock()
501 mustRecord(t, b, FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "h1"})
502 e := published(b, "v2", "h1")
503 if e != math.MaxUint64-2 {
504 t.Fatalf("published %d, want the last usable value %d", e, uint64(math.MaxUint64-2))
505 }
506 if s := b.Snapshot(); s != 0 {
507 t.Fatalf("Snapshot = %d at the boundary, want 0 (not known)", s)
508 }
509 if err := b.MarkHoldRan("h1", HoldRan{Account: "bab", App: "blog", Build: "v2", Exec: "e1"}); err == nil {
510 t.Fatal("a change that needs a value past the boundary was saved")
511 }
512 if p := published(b, "v2", "h1"); p != 0 {
513 t.Fatalf("published %d after the sequence ran out, want 0", p)
514 }
515 if rec, mark := grantAt(b, "v2", "h1", e); rec || mark {
516 t.Fatalf("a grant cleared record %v, marker %v on an exhausted book", rec, mark)
517 }
518 again, err := OpenFailureBook(dd)
519 if err != nil {
520 t.Fatal(err)
521 }
522 again.mu.Lock()
523 seq, gen := again.seq, again.gens[genKey("bab", "blog")]
524 again.mu.Unlock()
525 if seq != math.MaxUint64-2 || gen != e {
526 t.Fatalf("the file holds seq %d and generation %d, want the last landed %d", seq, gen, e)
527 }
528 if _, err := again.Record(FailureRecord{Account: "bab", App: "blog", Build: "v3", Host: "h2"}); err == nil {
529 t.Fatal("the reopened book saved a change past the boundary")
530 }
531 if p := published(again, "v2", "h1"); p != 0 {
532 t.Fatalf("the reopened book published %d after it ran out, want 0", p)
533 }
534}
535