carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1//go:build unix
2
3package host
4
5import (
6 "bytes"
7 "context"
8 "encoding/json"
9 "errors"
10 "os"
11 "path/filepath"
12 "strconv"
13 "strings"
14 "testing"
15 "time"
16
17 "github.com/carlosframework/platform/internal/hostbox"
18 "github.com/carlosframework/platform/internal/hostclaim"
19 "github.com/carlosframework/platform/internal/hostguard"
20 "github.com/carlosframework/platform/internal/hostwrite"
21 "github.com/carlosframework/platform/internal/park"
22 "github.com/carlosframework/platform/internal/registry"
23 "github.com/carlosframework/platform/internal/release"
24)
25
26const sourceHoldHost = "shop.example"
27
28// sourceHoldBox is the relocation's source "box" with its installation
29// active in the host guard, one cold writer session admitted, a ready row
30// owned by that installation, and the reconciler the claim assembly
31// builds (the WorkSource selector, park.Local), its hooks set by
32// relocationSourceRows under getenv and counted.
33type sourceHoldBox struct {
34 t *testing.T
35 store release.Store
36 reg *registry.Registry
37 guard hostguard.Guard
38 r hostbox.Reconciler
39 selected hostbox.RecordSelection
40 owner registry.ClaimOwnership
41 asked int // FenceClosed calls
42 holds int // HoldRow calls
43 writes int // the activation path's row writes
44}
45
46func newSourceHoldBox(t *testing.T, getenv func(string) string) *sourceHoldBox {
47 t.Helper()
48 ctx := t.Context()
49 b := &sourceHoldBox{t: t, store: release.NewDirStore(t.TempDir())}
50 dir := t.TempDir()
51 locks := filepath.Join(dir, "locks")
52 for _, p := range []string{locks, filepath.Join(dir, "hostguard")} {
53 if err := os.Mkdir(p, 0770); err != nil {
54 t.Fatal(err)
55 }
56 }
57 obs, err := hostclaim.Read(ctx, b.store, sourceHoldHost)
58 if err != nil {
59 t.Fatal(err)
60 }
61 obs, err = hostclaim.Update(ctx, b.store, obs, hostclaim.Adopt(hostclaim.NewHolder{Family: "instance", Key: "apps/acct/app/instances/" + sourceHoldHost + ".json", Account: "acct", App: "app", Host: sourceHoldHost, Region: "ie"}, "box", nil, nil, "adopt", func() string { return "id" }))
62 if err != nil {
63 t.Fatal(err)
64 }
65 b.guard = hostguard.Guard{DataDir: dir, LockDir: locks, Claims: hostguard.StoreReader{Store: b.store}}
66 if err := b.guard.Activate(ctx, sourceHoldHost, "box", nil); err != nil {
67 t.Fatal(err)
68 }
69 obs, err = hostclaim.Update(ctx, b.store, obs, hostclaim.AdmitWriter(hostclaim.Admission{Box: "box", Mode: "cold", NewGeneration: "g1", Region: "ie", KeepStamp: true}))
70 if err != nil {
71 t.Fatal(err)
72 }
73 captured, err := hostguard.Capture(dir, sourceHoldHost)
74 if err != nil {
75 t.Fatal(err)
76 }
77 if b.reg, err = registry.Open(filepath.Join(t.TempDir(), "registry.db")); err != nil {
78 t.Fatal(err)
79 }
80 t.Cleanup(func() { b.reg.Close() })
81 b.owner = registry.ClaimOwnership{ClaimIncarnation: captured.Incarnation, ClaimStamp: captured.Stamp, ClaimOwner: "owner-1", ClaimRowID: "row-1"}
82 if err := b.reg.Upsert(registry.Route{Host: sourceHoldHost, Status: "ready", Socket: "/run/shop.sock", ClaimOwnership: b.owner}); err != nil {
83 t.Fatal(err)
84 }
85 now := func() time.Time { return time.Unix(100, 0) }
86 source := hostbox.WorkSource{Guard: b.guard, Box: "box", Region: "ie", Now: now,
87 Policy: hostbox.Policy{Held: func(context.Context, string) (string, error) { return "", nil }, Relocation: func(context.Context, *hostclaim.Claim, hostbox.Selection, hostwrite.Identity) error { return nil }}.Check,
88 Local: func(context.Context, *hostclaim.Claim, hostbox.Selection) error { return nil }}
89 h := obs.Claim.Holder
90 b.selected = hostbox.RecordSelection{Host: sourceHoldHost, Key: h.Key, ETag: "record-etag", Incarnation: h.Incarnation, Family: h.Family, Layout: "flat", Paths: []string{"shop.example.db"}}
91 b.r = hostbox.Reconciler{Claims: b.store, Guard: b.guard, Local: park.Local{Guard: b.guard, Claims: b.store, DataDir: dir, Box: "box"}, Writes: hostwrite.Config{Enabled: true, Selector: source}, Box: "box", Region: "ie", Now: now,
92 CheckRecord: func(context.Context, hostbox.RecordSelection) error { return nil },
93 CheckLocal: func(context.Context, hostclaim.Observed, hostbox.RecordSelection) error { return nil },
94 CheckHeld: func(context.Context, hostclaim.Observed, hostbox.RecordSelection) error { return nil }}
95 relocationSourceRows(getenv, &b.r, b.guard, b.reg)
96 if fence := b.r.FenceClosed; fence != nil {
97 b.r.FenceClosed = func(ctx context.Context, o hostclaim.Observed) (bool, error) { b.asked++; return fence(ctx, o) }
98 }
99 if hold := b.r.HoldRow; hold != nil {
100 b.r.HoldRow = func(ctx context.Context, host, op string) error { b.holds++; return hold(ctx, host, op) }
101 }
102 return b
103}
104
105func (b *sourceHoldBox) update(change func(*hostclaim.Claim)) hostclaim.Observed {
106 b.t.Helper()
107 ctx := b.t.Context()
108 obs, err := hostclaim.Read(ctx, b.store, sourceHoldHost)
109 if err != nil {
110 b.t.Fatal(err)
111 }
112 obs, err = hostclaim.Update(ctx, b.store, obs, func(c *hostclaim.Claim) error { change(c); return nil })
113 if err != nil {
114 b.t.Fatal(err)
115 }
116 return obs
117}
118
119// releasing is a drain from "box" to "dst" in releasing, both
120// acknowledged.
121func (b *sourceHoldBox) releasing() hostclaim.Observed {
122 return b.update(func(c *hostclaim.Claim) {
123 c.Op = hostclaim.Op{Kind: hostclaim.KindRelocating, ID: "rel-1", Phase: hostclaim.PhaseReleasing, Exec: "box", Participants: []string{"box", "dst"},
124 Intent: map[string]string{"region": "ie", "reason": "drain", "source_box": "box", "target_box": "dst"}, Journal: map[string]string{"exec_ack.box": "{}", "exec_ack.dst": "{}"}}
125 })
126}
127
128// closeFence is root beginning the source's release stop: the durable
129// admission fence in the guard record (hostguard.Guard.BeginFence).
130func (b *sourceHoldBox) closeFence() {
131 b.t.Helper()
132 ctx := b.t.Context()
133 captured, err := hostguard.Capture(b.guard.DataDir, sourceHoldHost)
134 if err != nil {
135 b.t.Fatal(err)
136 }
137 obs, err := hostclaim.Read(ctx, b.store, sourceHoldHost)
138 if err != nil {
139 b.t.Fatal(err)
140 }
141 target := hostguard.FenceTarget{FenceID: "fence-1", Host: sourceHoldHost, Incarnation: captured.Incarnation, Stamp: captured.Stamp, Session: obs.Claim.Holder.Session,
142 Op: "rel-1", Phase: hostclaim.PhaseReleasing, Box: "box", Enrollment: "enrollment-1", BootID: "boot", ControllerID: "ctl", ClaimETag: obs.ETag, DirectoryRevision: "4"}
143 if _, err := b.guard.BeginFence(ctx, target); err != nil {
144 b.t.Fatal(err)
145 }
146}
147
148func (b *sourceHoldBox) install(dry bool) error {
149 sel := b.selected
150 sel.DryRun = dry
151 return b.r.Install(b.t.Context(), sel, func() error { b.writes++; return nil })
152}
153
154func (b *sourceHoldBox) row() *registry.Route {
155 b.t.Helper()
156 row, err := b.reg.Get(sourceHoldHost)
157 if err != nil {
158 b.t.Fatal(err)
159 }
160 return row
161}
162
163func (b *sourceHoldBox) guardRecord() []byte {
164 b.t.Helper()
165 raw, err := os.ReadFile(filepath.Join(b.guard.DataDir, "hostguard", sourceHoldHost+".json"))
166 if err != nil {
167 b.t.Fatal(err)
168 }
169 return raw
170}
171
172// retireRow is the source's park-local retiring its row: the typed
173// retirement's registry step (hostbox.RegistryRows, the one park-local
174// row write), by the row's claim ownership, held or not.
175func (b *sourceHoldBox) retireRow() {
176 b.t.Helper()
177 rows := hostbox.RegistryRows{Registry: b.reg, Inventory: hostbox.RetirementInventory{Host: sourceHoldHost, Captured: hostguard.Captured{Incarnation: b.owner.ClaimIncarnation, Stamp: b.owner.ClaimStamp}, Ownership: b.owner.ClaimOwner}}
178 route := hostbox.Artifact{Kind: hostbox.ArtifactRoute, Root: "registry", Name: sourceHoldHost, Owner: b.owner.ClaimOwner, RowID: b.owner.ClaimRowID}
179 if err := rows.RetireRowsJournal(b.t.Context(), "retire-1", b.owner.ClaimOwner, []hostbox.Artifact{route}, func(json.RawMessage) error { return nil }); err != nil {
180 b.t.Fatalf("the park-local's row retirement: %v", err)
181 }
182}
183
184func regionHA(on bool) func(string) string {
185 return func(k string) string {
186 if k == "CARLOS_REGION_HA" && on {
187 return "1"
188 }
189 return ""
190 }
191}
192
193// complete is the target's probe passing and root's stop of the probe
194// session, then the real serving CAS (hostclaim.CompleteRelocation),
195// which queues the source's park-local unless a fence discharged it.
196func (b *sourceHoldBox) complete() hostclaim.Observed {
197 b.t.Helper()
198 obs := b.completeOwing()
199 if !hostclaim.OwesLocal(obs.Claim, "box") {
200 b.t.Fatalf("after the completion: obligations %+v", obs.Claim.Obligations)
201 }
202 return obs
203}
204
205// completeOwing is complete without asking which boxes owe a park-local.
206func (b *sourceHoldBox) completeOwing() hostclaim.Observed {
207 b.t.Helper()
208 b.update(func(c *hostclaim.Claim) {
209 h := c.Holder
210 h.Stamp, h.Session, h.Generation, h.WriterBox = h.Stamp+5, h.Session+1, "P1", "dst"
211 h.Admissions = append(h.Admissions, hostclaim.AdmissionRecord{Session: h.Session, Box: "dst", Generation: "P1", Predecessor: "g1", Mode: "cold", Region: "ie"})
212 probe, _ := json.Marshal(hostclaim.Probe{Box: "dst", Session: h.Session, At: time.Unix(90, 0).UTC()})
213 stop, _ := json.Marshal(hostclaim.ProbeStop{Box: "dst", Session: h.Session, Receipt: "probe-fence-1", ReceiptSHA256: strings.Repeat("a", 64)})
214 c.Op.Phase, c.Op.Exec = hostclaim.PhaseRelocatingProbing, "dst"
215 c.Op.Journal["probe"], c.Op.Journal["probe_stop"] = string(probe), string(stop)
216 })
217 ctx := b.t.Context()
218 obs, err := hostclaim.Read(ctx, b.store, sourceHoldHost)
219 if err != nil {
220 b.t.Fatal(err)
221 }
222 n := 0
223 obs, err = hostclaim.Update(ctx, b.store, obs, hostclaim.CompleteRelocation("rel-1", func() string { n++; return "id-" + strconv.Itoa(n) }))
224 if err != nil {
225 b.t.Fatalf("the serving CAS: %v", err)
226 }
227 if obs.Claim.Op.Kind != hostclaim.KindServing || obs.Claim.Op.Exec != "dst" {
228 b.t.Fatalf("after the completion: op %+v obligations %+v", obs.Claim.Op, obs.Claim.Obligations)
229 }
230 return obs
231}
232
233// leaves asserts reconcile, dry run or not, refuses with nothing done:
234// the row exactly as before, the fence not asked, nothing held or
235// written, and the guard record as it was.
236func (b *sourceHoldBox) leaves(what string, before registry.Route, record []byte) {
237 b.t.Helper()
238 asked, holds, writes := b.asked, b.holds, b.writes
239 for _, dry := range []bool{true, false} {
240 if err := b.install(dry); !errors.Is(err, hostbox.ErrChanged) {
241 b.t.Fatalf("%s (dry run %v): %v", what, dry, err)
242 }
243 }
244 if got := b.row(); got == nil || *got != before || b.asked != asked || b.holds != holds || b.writes != writes || !bytes.Equal(b.guardRecord(), record) {
245 b.t.Fatalf("%s: row %+v (was %+v) fence asked %d holds %d writes %d", what, got, before, b.asked-asked, b.holds-holds, b.writes-writes)
246 }
247}
248
249// replays asserts reconcile, dry run or not, answers the catch-up hold's
250// replay (L18 finding 3): nil, with the row exactly as before, the fence
251// not asked, nothing activated or written, and the guard record as it was.
252func (b *sourceHoldBox) replays(what string, before registry.Route, record []byte) {
253 b.t.Helper()
254 asked, writes := b.asked, b.writes
255 for _, dry := range []bool{true, false} {
256 if err := b.install(dry); err != nil {
257 b.t.Fatalf("%s (dry run %v): %v", what, dry, err)
258 }
259 }
260 if got := b.row(); got == nil || *got != before || b.asked != asked || b.writes != writes || !bytes.Equal(b.guardRecord(), record) {
261 b.t.Fatalf("%s: row %+v (was %+v) fence asked %d writes %d", what, got, before, b.asked-asked, b.writes-writes)
262 }
263}
264
265// Task 10b test 3. The completion: the source's row, held at its fence's
266// close, stays held through the real serving CAS. Reconcile never
267// releases it: under the serving claim the source owes the completed op's
268// park-local, so reconcile's catch-up hold (L18 finding 3) replays the
269// hold and writes nothing. The park-local's retirement deletes the held
270// row by its ownership: that ends the hold, and nothing is minted after. A failover
271// from a fenced source: the source is on lost, so reconcile neither asks
272// its fence nor holds its row, whether the row was ready or already held
273// when the box was fenced, both while the failover runs and after its
274// completion queues the park-local; the row is left exactly as it was,
275// and the park-local's retirement removes it.
276func TestRelocationSourceHoldEndsWithTheCompletionOrTheParkLocal(t *testing.T) {
277 t.Parallel()
278 t.Run("completion", func(t *testing.T) {
279 b := newSourceHoldBox(t, regionHA(true))
280 b.releasing()
281 b.closeFence()
282 if err := b.install(false); err != nil {
283 t.Fatalf("the fenced source's hold: %v", err)
284 }
285 held := *b.row()
286 if held.Status != "relocating" || held.HoldOp != "rel-1" || b.holds != 1 {
287 t.Fatalf("held row %+v holds %d", held, b.holds)
288 }
289 b.complete()
290 b.replays("the source after the completion", held, b.guardRecord())
291 b.retireRow()
292 if err := b.install(false); !errors.Is(err, hostbox.ErrChanged) || b.row() != nil || b.writes != 0 {
293 t.Fatalf("after the park-local: %v row %+v writes %d", err, b.row(), b.writes)
294 }
295 })
296 for _, fencedHeld := range []bool{false, true} {
297 name := "failover, row ready when fenced"
298 if fencedHeld {
299 name = "failover, row already held when fenced"
300 }
301 t.Run(name, func(t *testing.T) {
302 b := newSourceHoldBox(t, regionHA(true))
303 b.releasing()
304 if fencedHeld {
305 b.closeFence()
306 if err := b.install(false); err != nil {
307 t.Fatalf("the fenced source's hold: %v", err)
308 }
309 }
310 before, record := *b.row(), b.guardRecord()
311 // hostclaim.FenceSource on a source terminated before its
312 // release is journaled: a failover in pinning, the source off
313 // the participants and on lost, its fence journaled.
314 b.update(func(c *hostclaim.Claim) {
315 c.Op.Phase, c.Op.Exec, c.Op.Participants = hostclaim.PhasePinning, "none", []string{"dst"}
316 c.Op.Journal["reason"], c.Op.Journal["fence.box"] = hostclaim.ReasonFailover, `{"key":"fences/box.json"}`
317 c.Lost = append(c.Lost, "box")
318 })
319 b.leaves("the fenced source during the failover", before, record)
320 // No qualified discharge: the completion queues its park-local.
321 b.complete()
322 b.leaves("the fenced source after the failover's completion", before, record)
323 b.retireRow()
324 if got := b.row(); got != nil {
325 t.Fatalf("the park-local left the row: %+v", got)
326 }
327 })
328 }
329}
330
331// Task 10b test 4. With CARLOS_REGION_HA off, or any value that is not
332// true, relocationSourceRows sets neither hook and reconcile is main's:
333// the serving executor writes its row through the activation path, and a
334// relocating claim is refused by Serving with the row, the claim and the
335// guard record unchanged, even once the source's fence has closed. With
336// it on, the same box holds the row at the fence's close.
337func TestRelocationSourceHoldFollowsTheFlag(t *testing.T) {
338 t.Parallel()
339 for _, v := range []string{"", "0", "false", "off", "yes please", "1", "true"} {
340 on := v == "1" || v == "true"
341 t.Run("CARLOS_REGION_HA="+v, func(t *testing.T) {
342 b := newSourceHoldBox(t, func(k string) string {
343 if k == "CARLOS_REGION_HA" {
344 return v
345 }
346 return ""
347 })
348 if (b.r.FenceClosed != nil) != on || (b.r.HoldRow != nil) != on {
349 t.Fatalf("hooks set %v %v with the flag %q", b.r.FenceClosed != nil, b.r.HoldRow != nil, v)
350 }
351 if err := b.install(false); err != nil || b.writes != 1 {
352 t.Fatalf("the serving executor: %v writes %d", err, b.writes)
353 }
354 b.releasing()
355 b.closeFence()
356 before, record := *b.row(), b.guardRecord()
357 obs, err := hostclaim.Read(t.Context(), b.store, sourceHoldHost)
358 if err != nil {
359 t.Fatal(err)
360 }
361 if on {
362 if err := b.install(false); err != nil {
363 t.Fatalf("region HA on: %v", err)
364 }
365 if got := b.row(); got.Status != "relocating" || got.HoldOp != "rel-1" {
366 t.Fatalf("region HA on: row %+v", got)
367 }
368 return
369 }
370 for _, dry := range []bool{true, false} {
371 if err := b.install(dry); !errors.Is(err, hostbox.ErrChanged) {
372 t.Fatalf("region HA off (dry run %v): %v", dry, err)
373 }
374 }
375 now, err := hostclaim.Read(t.Context(), b.store, sourceHoldHost)
376 if err != nil {
377 t.Fatal(err)
378 }
379 if got := b.row(); *got != before || b.writes != 1 || now.ETag != obs.ETag || !bytes.Equal(b.guardRecord(), record) {
380 t.Fatalf("region HA off changed something: row %+v (was %+v) writes %d claim %v", got, before, b.writes, now.ETag != obs.ETag)
381 }
382 })
383 }
384}
385
386// L18 joint round, finding 3: the source releases and its fence closes,
387// its reconcile is delayed, the target completes, and only then does the
388// source reconcile. The claim serves on another box, so Task 10b's
389// relocating scope no longer reaches it; the catch-up hold does. Through
390// the production hooks (relocationSourceRows) and the real reconcile: the
391// row ends held for the completed op, never ready, its ownership and
392// every other column as they were, the fence not asked, nothing activated
393// and the claim and the guard record unchanged; a replay changes nothing;
394// the park-local's retirement then ends it and nothing is minted.
395//
396// The guards: a row another op holds is never taken; with region HA off
397// reconcile is main's (refused, the row ready and unchanged); a box that
398// was not the relocation's source, or owes a park-local for another op
399// only, does nothing; a box on lost is left to its park-local (Task 10b
400// decision 3, the failover test above); a completion record that cannot
401// be read is its error, never "no completion", with the row unchanged.
402func TestRelocationSourceCatchUpHoldAfterTheCompletion(t *testing.T) {
403 t.Parallel()
404 t.Run("delayed source reconcile", func(t *testing.T) {
405 b := newSourceHoldBox(t, regionHA(true))
406 minted := *b.row()
407 b.releasing()
408 b.closeFence()
409 done := b.complete()
410 record := b.guardRecord()
411 if err := b.install(false); err != nil {
412 t.Fatalf("the source's first reconcile after the completion: %v", err)
413 }
414 got := *b.row()
415 if got.Status != "relocating" || got.HoldOp != "rel-1" || b.holds != 1 || b.asked != 0 || b.writes != 0 {
416 t.Fatalf("the source's row %+v holds %d fence asked %d writes %d", got, b.holds, b.asked, b.writes)
417 }
418 held := got
419 held.Status, held.HoldOp = minted.Status, minted.HoldOp
420 if held != minted {
421 t.Fatalf("the hold changed more than status and hold_op: %+v (minted %+v)", got, minted)
422 }
423 if now, err := hostclaim.Read(t.Context(), b.store, sourceHoldHost); err != nil || now.ETag != done.ETag || !bytes.Equal(b.guardRecord(), record) {
424 t.Fatalf("the catch-up hold wrote the claim or the guard record: %v", err)
425 }
426 b.replays("the replay", got, record)
427 b.retireRow()
428 if err := b.install(false); !errors.Is(err, hostbox.ErrChanged) || b.row() != nil || b.writes != 0 {
429 t.Fatalf("after the park-local: %v row %+v writes %d", err, b.row(), b.writes)
430 }
431 })
432 t.Run("another op's row is never taken", func(t *testing.T) {
433 b := newSourceHoldBox(t, regionHA(true))
434 b.releasing()
435 b.closeFence()
436 if ok, err := b.reg.SetHoldIf(sourceHoldHost, "moving", "mv-9", ""); err != nil || !ok {
437 t.Fatalf("holding the row for another op: %v %v", ok, err)
438 }
439 before := *b.row()
440 b.complete()
441 for _, dry := range []bool{true, false} {
442 if err := b.install(dry); !dry && !errors.Is(err, hostbox.ErrChanged) {
443 t.Fatalf("another op's row: %v", err)
444 }
445 }
446 if got := b.row(); *got != before || b.writes != 0 {
447 t.Fatalf("another op's row was taken: %+v (was %+v) writes %d", got, before, b.writes)
448 }
449 })
450 t.Run("region HA off", func(t *testing.T) {
451 b := newSourceHoldBox(t, regionHA(false))
452 b.releasing()
453 b.closeFence()
454 b.complete()
455 b.leaves("region HA off after the completion", *b.row(), b.guardRecord())
456 if got := b.row(); got.Status != "ready" || got.HoldOp != "" {
457 t.Fatalf("region HA off: row %+v", got)
458 }
459 })
460 t.Run("not the source", func(t *testing.T) {
461 b := newSourceHoldBox(t, regionHA(true))
462 before, record := *b.row(), b.guardRecord()
463 // A drain of the host from "other" to "dst": this box holds a
464 // stale local record and row, but was never a participant.
465 b.update(func(c *hostclaim.Claim) {
466 c.Op = hostclaim.Op{Kind: hostclaim.KindRelocating, ID: "rel-1", Phase: hostclaim.PhaseReleasing, Exec: "other", Participants: []string{"other", "dst"},
467 Intent: map[string]string{"region": "ie", "reason": "drain", "source_box": "other", "target_box": "dst"}, Journal: map[string]string{"exec_ack.other": "{}", "exec_ack.dst": "{}"}}
468 })
469 done := b.completeOwing()
470 if hostclaim.OwesLocal(done.Claim, "box") || !hostclaim.OwesLocal(done.Claim, "other") {
471 t.Fatalf("the completion's park-locals: %+v", done.Claim.Obligations)
472 }
473 b.leaves("a box that was not the source", before, record)
474 })
475 t.Run("a park-local for another op only", func(t *testing.T) {
476 b := newSourceHoldBox(t, regionHA(true))
477 b.releasing()
478 b.closeFence()
479 b.complete()
480 b.update(func(c *hostclaim.Claim) {
481 for i := range c.Obligations {
482 if c.Obligations[i].Kind == "park-local" && c.Obligations[i].Box == "box" {
483 c.Obligations[i].Op = "rel-0"
484 }
485 }
486 })
487 b.leaves("a park-local for another op", *b.row(), b.guardRecord())
488 })
489 t.Run("an unreadable completion record", func(t *testing.T) {
490 b := newSourceHoldBox(t, regionHA(true))
491 b.releasing()
492 b.closeFence()
493 b.complete()
494 b.update(func(c *hostclaim.Claim) { c.Op.Journal[hostclaim.JournalCompleted] = "{" })
495 before, record := *b.row(), b.guardRecord()
496 for _, dry := range []bool{true, false} {
497 if err := b.install(dry); err == nil || errors.Is(err, hostbox.ErrChanged) || !errors.Is(err, hostclaim.ErrRelocation) {
498 t.Fatalf("an unreadable completion record (dry run %v): %v (want the read's error)", dry, err)
499 }
500 }
501 if got := b.row(); *got != before || b.holds != 0 || b.writes != 0 || !bytes.Equal(b.guardRecord(), record) {
502 t.Fatalf("an unreadable completion record changed something: row %+v holds %d writes %d", got, b.holds, b.writes)
503 }
504 })
505}
506