sim: hostlife: generation.json written If-Match the marker it supersedes (model 29)
Seeds 95625 and one other of 100,000: an earlier push's generation.json PUT completed late over the later push's, so the claim named push10, the bucket's marker said push7, and every recovery probe refused. The marker is a reused key; its write is now conditional on the version it replaces.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3 files changed,
+31
−3
internal/sim/hostlife/moves_test.go+19 −3internal/sim/hostlife/rules_test.go+1 −0internal/sim/hostlife/world_test.go+11 −0
diff --git a/internal/sim/hostlife/moves_test.go b/internal/sim/hostlife/moves_test.go| index c127debfa..d74f75b44 100644 |
| --- a/internal/sim/hostlife/moves_test.go |
| +++ b/internal/sim/hostlife/moves_test.go |
| @@ -732,7 +732,7 @@ func (w *world) landTarget(b *box, c *claim) { |
| w.cas(c, func(n *claim) bool { n.Op.LandPhase = "published"; return sameOp(n.Op.ID, op.ID) }) |
| } |
| case "published": |
| - v := w.issue(b.ID, &request{kind: rPut, region: b.Region, key: genMarkerKey(loc), b: &blob{Kind: "gen", Gen: op.ID, Inc: c.Holder.Inc}}) |
| + v := w.publishGen(b, loc, op.ID, c.Holder.Inc) |
| if v > 0 { |
| w.cas(c, func(n *claim) bool { n.Op.LandPhase = "local"; return sameOp(n.Op.ID, op.ID) }) |
| } |
| @@ -1043,7 +1043,7 @@ func (w *world) recoverSource(b *box, c *claim) { |
| w.cas(c, func(n *claim) bool { n.Op.SrcPhase = "published"; return sameOp(n.Op.ID, op.ID) }) |
| } |
| case "published": |
| - v := w.issue(b.ID, &request{kind: rPut, region: b.Region, key: genMarkerKey(c.Holder.Location), b: &blob{Kind: "gen", Gen: op.RollbackID, Inc: c.Holder.Inc}}) |
| + v := w.publishGen(b, c.Holder.Location, op.RollbackID, c.Holder.Inc) |
| if v > 0 { |
| w.cas(c, func(n *claim) bool { n.Op.SrcPhase = "local"; return sameOp(n.Op.ID, op.ID) }) |
| } |
| @@ -1136,7 +1136,7 @@ func (w *world) pushStep(b *box, c *claim) { |
| w.cas(c, func(n *claim) bool { n.Op.Phase = "published"; return sameOp(n.Op.ID, op.ID) }) |
| } |
| case "published": |
| - v := w.issue(b.ID, &request{kind: rPut, region: b.Region, key: genMarkerKey(loc), b: &blob{Kind: "gen", Gen: op.ID, Inc: c.Holder.Inc}}) |
| + v := w.publishGen(b, loc, op.ID, c.Holder.Inc) |
| if v > 0 { |
| w.cas(c, func(n *claim) bool { n.Op.Phase = "restore"; return sameOp(n.Op.ID, op.ID) }) |
| } |
| @@ -1277,3 +1277,19 @@ func (w *world) legacyRetired(c *claim) bool { |
| } |
| return false |
| } |
| + |
| +// publishGen writes generation.json, conditional on the marker it |
| +// supersedes: If-Match its version, or If-None-Match when there is none |
| +// (moves rev 7.1 §5.1, model 29). generation.json is a reused key, so an |
| +// earlier publication that completes late would otherwise put an older |
| +// generation back, and every wake would refuse. |
| +func (w *world) publishGen(b *box, loc, gen string, inc int) int { |
| + r := &request{kind: rPut, region: b.Region, key: genMarkerKey(loc), b: &blob{Kind: "gen", Gen: gen, Inc: inc}} |
| + if on("generation-conditional") { |
| + r.ifSet = true |
| + if cur := w.buckets[b.Region].current(r.key); cur != nil { |
| + r.ifVer = cur.ID |
| + } |
| + } |
| + return w.issue(b.ID, r) |
| +} |
diff --git a/internal/sim/hostlife/rules_test.go b/internal/sim/hostlife/rules_test.go| index f1ebb2637..7c733e307 100644 |
| --- a/internal/sim/hostlife/rules_test.go |
| +++ b/internal/sim/hostlife/rules_test.go |
| @@ -86,6 +86,7 @@ var allRules = []string{ |
| "hold-op-open", // §5.2 conditional open |
| "start-check", // INV-4: markers agree |
| "start-check-claim-gen", // rev 6 §5.1 (R2-8): and name the generation the claim expects |
| + "generation-conditional", // rev 7.1 §5.1 (model 29): generation.json is written If-Match the marker it supersedes |
| "markers-last", // R2-6 |
| "revert-journal", // J-N11 |
| "rollback-journal", // R2-3 |
diff --git a/internal/sim/hostlife/world_test.go b/internal/sim/hostlife/world_test.go| index 20db542ad..bedcb4fe1 100644 |
| --- a/internal/sim/hostlife/world_test.go |
| +++ b/internal/sim/hostlife/world_test.go |
| @@ -198,6 +198,8 @@ type request struct { |
| srcVer int |
| doneAt int |
| beyond bool // completes after H |
| + ifSet bool // a conditional PUT: If-Match ifVer, or If-None-Match when ifVer is 0 |
| + ifVer int |
| by string |
| // result, for an immediate completion |
| resultVer int |
| @@ -699,6 +701,15 @@ func (w *world) complete(r *request) int { |
| bk := w.buckets[r.region] |
| switch r.kind { |
| case rPut: |
| + if r.ifSet { |
| + cur := 0 |
| + if v := bk.current(r.key); v != nil { |
| + cur = v.ID |
| + } |
| + if cur != r.ifVer { |
| + return 0 // 412 Precondition Failed |
| + } |
| + } |
| v := &version{ID: w.newVer(), B: r.b, Straggler: r.beyond} |
| bk.objs[r.key] = append(bk.objs[r.key], v) |
| w.markDurable(r.key, r.b) |