sim: hostlife: claim rev 6.9, no local database reuse, warm and cold only, writer_box and writes_lost, admission bound to the operation, close evidence by client outcome (rev 7.10)
Joint round 9. Every admitted writer purges its local database (an
error-returning purge; a transient fault) and restores from the certified
remote: warm up to the certificate's max_txid, cold from the predecessor
generation (a prepared install generation is opened by its probe's cold
admission and never re-entered; a fresh one starts empty and roots at the
writer's full first upload, before its marker). holder.writer_box names
the current writer; a cold admission of an uncertified session records
writes_lost {box, generation, session, remote_max_txid} and alerts, and
the acknowledged unuploaded writes are declared lost, never silently.
Only warm and cold survive: open, fresh, the installation generation and
the recovery journal are gone; holder.generation is the only authoritative
generation; a clean rollback's return names the source's generation again.
The admission CAS is bound to the observed op.id, phase and kind (INV-13).
Close evidence is client-observed success for every request class, with
a sticky uncertainty for a timeout or a lost response. The pre-cut source
certifies; the idle stop kills the cgroup. Rules remote-baseline,
writes-lost, admit-bind-op replace warm-provenance; witnesses for the
stale local database after a clean close, the silent loss, the stale
closure during a delete, a maintenance retry and a lost response; the
empty install witnessed through a move; the whole-start-check witness
retired (subsumed by regional restores). 92 rules, 52 witnesses.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
10 files changed,
+904
−539
internal/sim/hostlife/background_test.go+15 −5internal/sim/hostlife/console_test.go+5 −4internal/sim/hostlife/expected-outcomes.txt+2 −1internal/sim/hostlife/ltx_test.go+35 −12internal/sim/hostlife/moves_test.go+61 −94internal/sim/hostlife/protocol_test.go+493 −304internal/sim/hostlife/rules_test.go+3 −1internal/sim/hostlife/sim_test.go+2 −5internal/sim/hostlife/witness_test.go+205 −42internal/sim/hostlife/world_test.go+83 −71
diff --git a/internal/sim/hostlife/background_test.go b/internal/sim/hostlife/background_test.go| index 047da526d..a12609c38 100644 |
| --- a/internal/sim/hostlife/background_test.go |
| +++ b/internal/sim/hostlife/background_test.go |
| @@ -38,14 +38,17 @@ func (w *world) idleStop(b *box) { |
| return |
| } |
| w.replicate(b) |
| + // the unit stop kills the cgroup: the main process and every descendant |
| + // placed in it; an escaped descendant survives, and while it lives the |
| + // session cannot certify (rev 7.10) |
| var keep []*proc |
| for _, p := range b.Procs { |
| - if !p.Main { |
| + if !p.Main && !p.Strict { |
| keep = append(keep, p) |
| } |
| } |
| b.Procs = keep |
| - // the clean close (claim rev 6.6 §6.4a): drain, then closed.json |
| + // the clean close (claim rev 6.9 §6.4a): drain, then the certificate |
| if b.DB != nil { |
| b.DB.Closing = true |
| w.closeStep(b) |
| @@ -358,8 +361,7 @@ func (w *world) fault() { |
| } |
| } |
| b.DB = nil |
| - b.lineage = nil // the lineage file goes with the disk |
| - b.Procs = nil // nothing survives the disk: a descendant with no database would acknowledge writes into nowhere |
| + b.Procs = nil // nothing survives the disk: a descendant with no database would acknowledge writes into nowhere |
| w.stats["disk-lost with the crash"]++ |
| w.logf("box %d lost its disk with the crash", b.ID) |
| } |
| @@ -371,6 +373,15 @@ func (w *world) fault() { |
| b.plans = nil // a reboot ends every process |
| b.AgentDown, b.Frozen, b.AgentPaused = false, false, false |
| b.forget() |
| + if w.rng.Intn(6) == 0 { |
| + // the purge of the local database the next admitted writer must |
| + // make returns an error for a while (claim rev 6.9): the writer |
| + // does not start |
| + b.PurgeFails = !b.PurgeFails |
| + if b.PurgeFails { |
| + w.stats["purge failures injected"]++ |
| + } |
| + } |
| if w.rng.Intn(8) == 0 && len(b.Stamps) > 0 { |
| // the local stamp record is lost with the reboot: the box |
| // refuses every writer until it re-initializes from a fresh |
| @@ -438,7 +449,6 @@ func (w *world) fault() { |
| } |
| } |
| b.DB = nil |
| - b.lineage = nil |
| w.stats["disk-lost"]++ |
| w.logf("box %d lost its database file", b.ID) |
| } |
diff --git a/internal/sim/hostlife/console_test.go b/internal/sim/hostlife/console_test.go| index be458ceb8..61ab14485 100644 |
| --- a/internal/sim/hostlife/console_test.go |
| +++ b/internal/sim/hostlife/console_test.go |
| @@ -330,7 +330,7 @@ func (w *world) userDelete() { |
| if c == nil || c.Holder == nil { |
| return |
| } |
| - if on("claim-op-exclusive") && c.Op.Kind != "serving" || c.Op.Rec != nil { |
| + if on("claim-op-exclusive") && c.Op.Kind != "serving" { |
| return |
| } |
| id := w.newID("del") |
| @@ -363,7 +363,7 @@ func (w *world) userDelete() { |
| func (w *world) userMove() { |
| c := w.claim |
| - if c == nil || c.Holder == nil || c.Op.Kind != "serving" || c.Op.Rec != nil { |
| + if c == nil || c.Holder == nil || c.Op.Kind != "serving" { |
| return |
| } |
| if src := w.execBox(c); src == nil || src.Destroyed { |
| @@ -403,7 +403,8 @@ func (w *world) moveTo(r int) { |
| return false |
| } |
| n.Op = opState{Kind: "moving", ID: id, Phase: "leases", Exec: src.ID, Parts: []int{src.ID, tgt.ID}, |
| - Src: src.ID, Tgt: tgt.ID, SrcRegion: src.Region, TgtRegion: r, ReqAt: w.now, Auto: true, App: c.Holder.App} |
| + Src: src.ID, Tgt: tgt.ID, SrcRegion: src.Region, TgtRegion: r, ReqAt: w.now, Auto: true, App: c.Holder.App, |
| + SrcGen: c.Holder.Gen} // the source's generation, which a clean rollback's return names again (rev 7.10) |
| return true |
| }) { |
| w.logf("move %s inc %d from box %d to box %d", id, c.Holder.Inc, src.ID, tgt.ID) |
| @@ -412,7 +413,7 @@ func (w *world) moveTo(r int) { |
| func (w *world) userPush() { |
| c := w.claim |
| - if c == nil || c.Holder == nil || c.Op.Kind != "serving" || c.Op.Rec != nil { |
| + if c == nil || c.Holder == nil || c.Op.Kind != "serving" { |
| return |
| } |
| b := w.execBox(c) |
diff --git a/internal/sim/hostlife/expected-outcomes.txt b/internal/sim/hostlife/expected-outcomes.txt| index bd5afdee9..cfb23c5e5 100644 |
| --- a/internal/sim/hostlife/expected-outcomes.txt |
| +++ b/internal/sim/hostlife/expected-outcomes.txt |
| @@ -11,11 +11,12 @@ watchdog-independent missed caught with declare-lost-compute (witness; sweep 3 / |
| exec-admission missed one of four admission fences (claim rev 6.8.1 adds exec-writer); caught with hold-op-open, start-check-claim-gen and exec-writer |
| hold-op-open missed one of four admission fences; caught with exec-admission, start-check-claim-gen and exec-writer |
| start-check-claim-gen missed one of four admission fences; caught with exec-admission, hold-op-open and exec-writer |
| -start-check missed caught with exec-admission, hold-op-open and exec-writer |
| +start-check missed subsumed since rev 7.10: a box restores only its own region's remote of holder.generation, so with the whole start check gone the stale source of a cross-region move finds nothing to restore and cannot start beside the target; the witness is retired, the generation clause (start-check-claim-gen) keeps its witness |
| strict-spawn missed caught with evidence-whole-tree |
| touched-incomplete missed caught with legacy-discovery |
| door-reread missed subsumed by C3a and, since rev 7.8, by writer epochs (claim rev 6.6): a recreated incarnation writes into a fresh generation the listing never selects; its witness is retired |
| door-by-version missed subsumed by C3a and, since rev 7.8, by writer epochs (claim rev 6.6): a recreated incarnation writes into a fresh generation the listing never selects; its witness is retired |
| registration-check missed a cheap extra: subsumed by purge-claim-fence and fence-etag together |
| exec-writer missed one of four admission fences (claim rev 6.8.1): the AdmitWriter CAS refuses a box that is not op.exec; caught with exec-admission, hold-op-open and start-check-claim-gen (witness) |
| +admit-bind-op missed caught with claim-cas-opid: the model's general CAS condition on op.id (C2) already refuses a closure whose operation changed; the binding adds the phase and the kinds in which no writer opens, and its witness needs both gone |
| exec-admission,hold-op-open,start-check-claim-gen missed the three box-side fences alone (rev 7.5's admission triple): under writer sessions the old source's wake has no certificate for the current session, and the AdmitWriter CAS alone refuses a box that is not op.exec (claim rev 6.8.1, exec-writer); the four-rule combination with exec-writer is the one that fails |
diff --git a/internal/sim/hostlife/ltx_test.go b/internal/sim/hostlife/ltx_test.go| index 4bcd5f384..461a01d4b 100644 |
| --- a/internal/sim/hostlife/ltx_test.go |
| +++ b/internal/sim/hostlife/ltx_test.go |
| @@ -306,18 +306,29 @@ func isClosedKey(k string) bool { |
| return i >= 0 && strings.HasSuffix(k, ".json") |
| } |
| -// track records a request the writer issued that did not return at once, |
| -// for the clean close's evidence (R8-5). |
| +// track records a request the writer issued whose success the client did |
| +// not see at once, for the clean close's evidence (R8-5, R9-5): every |
| +// class, level-0, compaction, snapshot and retention. A request the server |
| +// refused is a failure the client saw: the session is uncertain for good. |
| func (db *localDB) track(r *request, v int) { |
| - if v <= 0 && !r.done { |
| - db.Issued = append(db.Issued, r) |
| + if v > 0 { |
| + return // client-observed success |
| } |
| + if r.done { |
| + db.Uncertain = true // refused |
| + return |
| + } |
| + db.Issued = append(db.Issued, r) |
| } |
| -// settled: every request the writer issued over its lifetime has returned |
| -// success and none was ever retried after a timeout (R8-5). The mutation |
| -// of close-evidence looks at the level-0 queue alone, as rev 7.8 did. |
| -func (db *localDB) settled() bool { |
| +// settled: every request the writer issued over its lifetime returned |
| +// success to the client, within the client's wait, and none was retried |
| +// after a timeout (R8-5, R9-5). A request still outstanding within the wait |
| +// is not yet settled; one the client gave up on, whether it later |
| +// completed on the server or not, makes the session uncertain for good, |
| +// since its outcome was never observed. The mutation of close-evidence |
| +// looks at the level-0 queue alone, as rev 7.8 did. |
| +func (db *localDB) settled(now int) bool { |
| queued := len(db.Queue) == 0 && (db.InFlight == nil || db.InFlight.done) |
| if !on("close-evidence") { |
| return queued |
| @@ -327,6 +338,14 @@ func (db *localDB) settled() bool { |
| } |
| for _, r := range db.Issued { |
| if !r.done { |
| + if now-r.at >= uploadTimeout { |
| + db.Uncertain = true // the client gave up: timeout |
| + return false |
| + } |
| + return false // still waiting |
| + } |
| + if r.doneAt-r.at >= uploadTimeout { |
| + db.Uncertain = true // completed after the client gave up: a lost response |
| return false |
| } |
| } |
| @@ -343,7 +362,11 @@ func (db *localDB) settled() bool { |
| // stops. |
| func (w *world) closeStep(b *box) { |
| db := b.DB |
| - if db == nil || !db.Closing || hasMain(b) { |
| + if db == nil || !db.Closing || len(b.Procs) > 0 { |
| + // nothing of the host may run when the session certifies: a |
| + // descendant that outlived the main process can still acknowledge |
| + // writes the certificate would not cover, which the next admission's |
| + // purge would lose silently (rev 7.10; seed 17 / 300) |
| return |
| } |
| // the certificate is the live generation's: once an operation has |
| @@ -356,8 +379,8 @@ func (w *world) closeStep(b *box) { |
| // rollback's source was admitted cold at every wake and the rollback |
| // never released) |
| c := w.claim |
| - inside := c != nil && (c.Op.Kind == "serving" || c.Op.Kind == "landing" || c.Op.Kind == "restoring" || c.Op.Kind == "reverting") |
| - if c == nil || c.Holder == nil || !inside || c.Op.Exec != b.ID || c.Holder.Inc != db.Inc || (db.Gen != c.Holder.Gen && db.Gen != w.opGen(&c.Op)) { |
| + inside := c != nil && (c.Op.Kind == "serving" || c.Op.Kind == "moving" || c.Op.Kind == "landing" || c.Op.Kind == "restoring" || c.Op.Kind == "reverting") |
| + if c == nil || c.Holder == nil || !inside || c.Op.Exec != b.ID || c.Holder.Inc != db.Inc || db.Gen != c.Holder.Gen { |
| // a push, a move's cut, a delete or a purge is taking this writer |
| // over: its generation is being replaced or parked, and a |
| // certificate written into it now would outlive the park (seed |
| @@ -367,7 +390,7 @@ func (w *world) closeStep(b *box) { |
| return |
| } |
| w.ship(b) |
| - if !db.settled() && on("close-waits") { |
| + if !db.settled(w.now) && on("close-waits") { |
| if db.Uncertain && on("close-evidence") { |
| // a request of this writer timed out and was retried: its outcome |
| // is unknown for good, so this session never certifies |
diff --git a/internal/sim/hostlife/moves_test.go b/internal/sim/hostlife/moves_test.go| index e79640d55..93ca71fb7 100644 |
| --- a/internal/sim/hostlife/moves_test.go |
| +++ b/internal/sim/hostlife/moves_test.go |
| @@ -172,32 +172,6 @@ func (w *world) role(b *box, c *claim) { |
| if c.Holder == nil { |
| return |
| } |
| - if r := c.Op.Rec; r != nil && r.Box != c.Op.Exec && (c.Op.Exec == b.ID || c.Op.Exec == -1 && w.isParticipant(c, b)) { |
| - // the admitting box lost op.exec before its build finished (a |
| - // rollback's release of the target, a declared loss): its cold |
| - // admission is abandoned by whoever holds op.exec next, and the |
| - // generation it was building is parked as a chore (seed 58795 / |
| - // 100,000: the source's rollback probe could not be admitted behind |
| - // the lost target's build) |
| - if w.cas(c, func(n *claim) bool { |
| - if n.Op.Rec == nil || n.Op.Rec.Gen != r.Gen || n.Op.Rec.Box == n.Op.Exec { |
| - return false |
| - } |
| - n.Obl = append(n.Obl, w.newObl(n, "park-generation", r.Region, -1, c.Holder.Location, r.Gen, w.now+horizonH)) |
| - n.Op.Rec = nil |
| - return true |
| - }) { |
| - w.logf("box %d: cold admission %s of box %d abandoned, its exec is gone (op %s/%s exec %d)", b.ID, r.Gen, r.Box, c.Op.Kind, c.Op.ID, c.Op.Exec) |
| - w.stats["cold admissions abandoned"]++ |
| - } |
| - return |
| - } |
| - if c.Op.Rec != nil && c.Op.Rec.Box == b.ID { |
| - // a cold admission's build runs under the operation, by the box it |
| - // admitted, before the operation's own step (claim rev 6.8 §6.4a, R8-2) |
| - w.recStep(b, c) |
| - return |
| - } |
| switch c.Op.Kind { |
| case "moving": |
| if b.ID == c.Op.Src { |
| @@ -719,6 +693,9 @@ func (w *world) revertSource(b *box, c *claim) { |
| w.cas(c, func(n *claim) bool { n.Op.RevPhase = "probed"; return sameOp(n.Op.ID, op.ID) }) |
| return |
| } |
| + if b.open != nil { |
| + return // admitted, waiting for its PUT: not a failed try |
| + } |
| w.cas(c, func(n *claim) bool { |
| n.Op.ProbeTries++ |
| if n.Op.ProbeTries >= probeTries*3 { |
| @@ -776,6 +753,7 @@ func (w *world) followRecord(c *claim) bool { |
| } |
| n.Op.Kind, n.Op.Exec, n.Op.LandPhase, n.Op.FlipAt = "landing", op.Tgt, "checking", w.now |
| n.Op.SrcStamp = n.Holder.Stamp // the source installation's stamp, kept in the journal for the return (claim rev 6.5) |
| + n.Op.SrcGen = n.Holder.Gen // the source's generation at the cut, which a clean rollback's return names again (rev 7.10) |
| n.Holder.Stamp = w.issueStamp(n, op.Tgt) // holder.stamp: the gen this CAS produces, the landing's installation stamp (claim rev 6.5 §2) |
| n.Op.FlipGen = n.Holder.Stamp |
| return true |
| @@ -785,9 +763,7 @@ func (w *world) followRecord(c *claim) bool { |
| if !sameOp(n.Op.ID, op.ID) || n.Op.Kind != "landing" || n.Op.Committed { |
| return false |
| } |
| - n.Op.Committed = true |
| - w.newGen(n.Holder.Location, n.Holder.Gen, w.opGen(&op)) |
| - n.Holder.Gen = w.opGen(&op) // the claim names the generation restore reads (C3a) |
| + n.Op.Committed = true // holder.generation moved at the probe's cold admission (claim rev 6.9) |
| q := op.StopAt + horizonH |
| if !on("quiet-before-ack") { |
| q = 0 |
| @@ -819,7 +795,7 @@ func (w *world) landTarget(b *box, c *claim) { |
| return |
| } |
| loc := c.Holder.Location |
| - g := w.opGen(&op) // the generation this landing installs (R8-2: a cold admission within it may have moved it) |
| + g := installGen(&op) // the generation this landing prepares; the probe's cold admission makes it holder.generation (claim rev 6.9) |
| if !op.Committed { |
| if w.rec != nil && w.rec.MoveID == op.ID && w.rec.MoveState != "landing" { |
| w.followRecord(c) // a commit or rollback the claim has not caught up with |
| @@ -919,10 +895,10 @@ 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.publishGen(b, loc, g, c.Holder.Inc, op.BaseVer, op.BaseKey, op.BaseTX) |
| - if v > 0 { |
| - w.cas(c, func(n *claim) bool { n.Op.LandPhase, n.Op.Fresh = "local", true; return sameOp(n.Op.ID, op.ID) }) |
| - } |
| + // the build is complete; generation.json is published by the probe's |
| + // cold admission, after readiness and before its first upload (claim |
| + // rev 6.9 §6.4a) |
| + w.cas(c, func(n *claim) bool { n.Op.LandPhase = "local"; return sameOp(n.Op.ID, op.ID) }) |
| case "local": |
| w.installLocal(b, c, g, op.SealFiles, op.LandPhase, func(n *claim) { n.Op.LandPhase = "config" }) |
| case "config": |
| @@ -941,24 +917,17 @@ func (w *world) landTarget(b *box, c *claim) { |
| b.Live, b.LiveInc = c.Holder.App, c.Holder.Inc |
| w.cas(c, func(n *claim) bool { n.Op.LandPhase = "restore"; return sameOp(n.Op.ID, op.ID) }) |
| case "restore": |
| - if b.DB == nil || b.DB.Gen != g { |
| - if b.DB != nil { |
| - // an earlier stay's database: retire-artifacts of its stamp, |
| - // never of the installation checking activated |
| - if b.DB.Stamp == b.stampOf(c.Holder.Inc) { |
| - w.violate("INV-6: landing on box %d would park a database tagged with its own active stamp %d", b.ID, b.DB.Stamp) |
| - } |
| - w.retireDatabase(b, c.Holder.Inc, b.DB.Stamp) |
| - } |
| - if b.DB = w.restoreInit(b, c.Holder.Inc, loc, g); b.DB == nil && w.restoreCalc(b.Region, c.Holder.Inc, loc, g) != nil { |
| - return // the anchor is outstanding: try again |
| - } |
| - } |
| - if b.DB == nil { |
| + // the installed digest, for the settle check's dirty verdict, from a |
| + // restore of the prepared generation computed and not kept: the |
| + // probe's admission restores the database it starts on (claim rev |
| + // 6.9: a box never reuses a local database) |
| + tmp := *b |
| + tmp.DB = w.restorePinned(b.Region, c.Holder.Inc, loc, g, op.BaseKey, op.BaseVer, op.BaseTX) |
| + if tmp.DB == nil { |
| w.rollback(c, "restore failed") |
| return |
| } |
| - d := w.digestOf(b) |
| + d := w.digestOf(&tmp) |
| w.cas(c, func(n *claim) bool { n.Op.Installed, n.Op.LandPhase = d, "probing"; return sameOp(n.Op.ID, op.ID) }) |
| case "probing": |
| if w.now > op.CutDeadline { |
| @@ -975,6 +944,9 @@ func (w *world) landTarget(b *box, c *claim) { |
| w.cas(c, func(n *claim) bool { n.Op.LandPhase, n.Op.ProbedAt = "probed", w.now; return sameOp(n.Op.ID, op.ID) }) |
| return |
| } |
| + if b.open != nil { |
| + return // admitted, waiting for its PUT: not a failed try |
| + } |
| tries := op.ProbeTries + 1 |
| w.cas(c, func(n *claim) bool { n.Op.ProbeTries = tries; return sameOp(n.Op.ID, op.ID) }) |
| if tries >= probeTries { |
| @@ -989,16 +961,16 @@ func (w *world) landTarget(b *box, c *claim) { |
| if !hasMain(b) { |
| // the probe died in its settle window (a crash, an agent |
| // restart): the landing restarts only the probe (§7.14), a |
| - // writer admitted like any other (claim rev 6.8 §6.4a, R8-2); |
| - // a cold admission's build runs first (recStep) |
| - if c.Op.Rec != nil { |
| - return |
| - } |
| - if w.spawn(b, c, g, true) { |
| + // writer admitted like any other (claim rev 6.9 §6.4a, R8-2): |
| + // cold, into a fresh generation restored from the dead probe's |
| + if w.spawn(b, c, c.Holder.Gen, true) { |
| w.stats["probe restarted"]++ |
| w.cas(c, func(n *claim) bool { n.Op.ProbedAt = w.now; return sameOp(n.Op.ID, op.ID) }) |
| return |
| } |
| + if b.open != nil { |
| + return // admitted, waiting for its PUT: not a failed try |
| + } |
| tries := op.ProbeTries + 1 |
| w.cas(c, func(n *claim) bool { n.Op.ProbeTries = tries; return sameOp(n.Op.ID, op.ID) }) |
| if tries >= probeTries { |
| @@ -1156,7 +1128,12 @@ func (w *world) releaseTarget(b *box, c *claim) { |
| return |
| } |
| dirty := false |
| - if op.LandPhase == "probing" || op.LandPhase == "probed" { |
| + if (op.LandPhase == "probing" || op.LandPhase == "probed") && b.DB != nil { |
| + // a target without a database never opened: nothing to return. |
| + // Since rev 7.10 the probe's admission restores the database, so |
| + // a target whose probe never started has none (seed 62 / 300: |
| + // a spurious dirty return sealed an empty database and the |
| + // source restored it) |
| dirty = w.digestOf(b) != op.Installed |
| } |
| if !on("dirty-return") { |
| @@ -1269,6 +1246,14 @@ func (w *world) recoverSource(b *box, c *claim) { |
| if on("return-stamp") { |
| n.Holder.Stamp = op.SrcStamp |
| } |
| + // and, for a clean rollback, the source's generation (rev |
| + // 7.10 §7.14): the target's probe moved holder.generation to |
| + // its own; the target wrote nothing, and the source's probe |
| + // restores from the predecessor generation's remote in its |
| + // own region |
| + if !op.Dirty && n.Holder.Gen != op.SrcGen { |
| + n.Holder.Gen = op.SrcGen // "" is the origin generation |
| + } |
| return true |
| }) |
| } |
| @@ -1306,7 +1291,7 @@ func (w *world) recoverSource(b *box, c *claim) { |
| if sv == nil { |
| return // the lane is gone: wait (stuck, in time) |
| } |
| - g := w.opGen(&op) |
| + g := installGen(&op) |
| if sv.B.Max == 0 && on("valid-ltx") { |
| w.cas(c, func(n *claim) bool { |
| n.Op.SrcPhase, n.Op.BaseVer, n.Op.BaseKey, n.Op.BaseTX = "published", 0, "", 0 |
| @@ -1327,16 +1312,13 @@ func (w *world) recoverSource(b *box, c *claim) { |
| }) |
| } |
| case "install-anchor": |
| - if w.anchorAcked(b, c.Holder.Location, w.opGen(&op), op.BaseKey, op.BaseVer, op.BaseTX) { |
| + if w.anchorAcked(b, c.Holder.Location, installGen(&op), op.BaseKey, op.BaseVer, op.BaseTX) { |
| w.cas(c, func(n *claim) bool { n.Op.SrcPhase = "published"; return sameOp(n.Op.ID, op.ID) }) |
| } |
| case "published": |
| - v := w.publishGen(b, c.Holder.Location, w.opGen(&op), c.Holder.Inc, op.BaseVer, op.BaseKey, op.BaseTX) |
| - if v > 0 { |
| - w.cas(c, func(n *claim) bool { n.Op.SrcPhase, n.Op.Fresh = "local", true; return sameOp(n.Op.ID, op.ID) }) |
| - } |
| + w.cas(c, func(n *claim) bool { n.Op.SrcPhase = "local"; return sameOp(n.Op.ID, op.ID) }) // the marker is the probe's admission's (claim rev 6.9) |
| case "local": |
| - w.installLocal(b, c, w.opGen(&op), op.ReturnFiles, "", func(n *claim) { n.Op.SrcPhase = "config" }) |
| + w.installLocal(b, c, installGen(&op), op.ReturnFiles, "", func(n *claim) { n.Op.SrcPhase = "config" }) |
| case "config": |
| b.Config, b.CfgVer, b.Live, b.LiveInc = c.Holder.Inc, w.cfgVer[c.Holder.App], c.Holder.App, c.Holder.Inc |
| w.cas(c, func(n *claim) bool { n.Op.SrcPhase = "probing"; return sameOp(n.Op.ID, op.ID) }) |
| @@ -1346,12 +1328,15 @@ func (w *world) recoverSource(b *box, c *claim) { |
| } |
| want := c.Holder.Gen |
| if op.Dirty { |
| - want = w.opGen(&op) |
| + want = installGen(&op) // the return generation prepared from the target's sealed copy |
| } |
| if hasMain(b) || w.spawn(b, c, want, true) { |
| w.cas(c, func(n *claim) bool { n.Op.SrcPhase = "probed"; return sameOp(n.Op.ID, op.ID) }) |
| return |
| } |
| + if b.open != nil { |
| + return // admitted, waiting for its PUT: not a failed try |
| + } |
| w.logf("box %d: the rollback's probe did not start (try %d, generation %q, db=%v)", b.ID, op.ProbeTries+1, want, b.DB != nil) |
| w.cas(c, func(n *claim) bool { |
| n.Op.ProbeTries++ |
| @@ -1361,11 +1346,11 @@ func (w *world) recoverSource(b *box, c *claim) { |
| return sameOp(n.Op.ID, op.ID) |
| }) |
| case "probed": |
| - if op.Dirty && c.Holder.Gen != w.opGen(&op) { |
| + if op.Dirty && !op.ReturnParked { |
| + // holder.generation and holder.stamp moved at the probe's cold |
| + // admission (claim rev 6.9); the return lane's park is owed |
| w.cas(c, func(n *claim) bool { |
| - w.newGen(n.Holder.Location, n.Holder.Gen, w.opGen(&n.Op)) |
| - n.Holder.Gen = w.opGen(&n.Op) |
| - n.Holder.Stamp = w.issueStamp(n, op.Src) // a dirty rollback installs a new generation: a new stamp, never the predecessor's (claim rev 6.5 §6.4) |
| + n.Op.ReturnParked = true |
| n.Obl = append(n.Obl, w.newObl(n, "park-staging", op.SrcRegion, -1, fmt.Sprintf("moves/%s/%s/", host, op.RollbackID), "", op.ReturnQuiet)) |
| return sameOp(n.Op.ID, op.ID) |
| }) |
| @@ -1384,7 +1369,7 @@ func (w *world) recoverSource(b *box, c *claim) { |
| func (w *world) pushStep(b *box, c *claim) { |
| op := c.Op |
| loc := c.Holder.Location |
| - g := w.opGen(&op) // the generation this push installs (R8-2) |
| + g := installGen(&op) // the generation this push prepares; its probe's cold admission makes it holder.generation (claim rev 6.9) |
| switch op.Phase { |
| case "stopping": |
| w.holdRow(b, "moving", op.ID, c) |
| @@ -1439,25 +1424,10 @@ 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.publishGen(b, loc, g, c.Holder.Inc, op.BaseVer, op.BaseKey, op.BaseTX) |
| - if v > 0 { |
| - w.cas(c, func(n *claim) bool { n.Op.Phase, n.Op.Fresh = "restore", true; return sameOp(n.Op.ID, op.ID) }) |
| - } |
| + w.cas(c, func(n *claim) bool { n.Op.Phase = "restore"; return sameOp(n.Op.ID, op.ID) }) // the marker is the probe's admission's (claim rev 6.9) |
| case "restore": |
| - if b.DB != nil && b.DB.Gen != g { |
| - w.retireDatabase(b, c.Holder.Inc, b.DB.Stamp) // the files are carried (F7) |
| - } |
| - if b.DB == nil { |
| - b.DB = w.restoreInit(b, c.Holder.Inc, loc, g) |
| - } |
| - if b.DB == nil { |
| - return |
| - } |
| - // .files-gen last: the files are unchanged, the marker names the |
| - // new generation (F7 on §5.1) |
| - if b.Files != nil { |
| - b.Files.Gen = g |
| - } |
| + // the probe's admission restores the database (claim rev 6.9); the |
| + // files are unchanged and carried (F7) |
| // every probe renders config and materializes the release for the |
| // held row first (the passes skip held rows): RenderHeld, MaterializeHeld |
| if !b.HasKey { |
| @@ -1471,10 +1441,7 @@ func (w *world) pushStep(b *box, c *claim) { |
| } |
| if hasMain(b) || w.spawn(b, c, g, true) { |
| w.cas(c, func(n *claim) bool { |
| - n.Op.Phase = "probed" |
| - w.newGen(n.Holder.Location, n.Holder.Gen, w.opGen(&n.Op)) |
| - n.Holder.Gen = w.opGen(&n.Op) |
| - n.Holder.Stamp = w.issueStamp(n, n.Op.Exec) // a push's commit installs a new generation: a new stamp (claim rev 6.5 §2) |
| + n.Op.Phase = "probed" // holder.generation and holder.stamp moved at the probe's cold admission (claim rev 6.9) |
| q := op.StopAt + horizonH |
| if q > n.Quiet[b.Region] { |
| n.Quiet[b.Region] = q |
| @@ -1509,7 +1476,7 @@ func sameStep(a, b *claim) bool { |
| if a.Holder != nil && (a.Holder.Session != b.Holder.Session || a.Holder.Gen != b.Holder.Gen || a.Holder.Stamp != b.Holder.Stamp) { |
| return false |
| } |
| - if (x.Rec == nil) != (y.Rec == nil) || x.Rec != nil && (x.Rec.Gen != y.Rec.Gen || x.Rec.Phase != y.Rec.Phase) { |
| + if a.Holder != nil && a.Holder.WriterBox != b.Holder.WriterBox { |
| return false |
| } |
| return len(x.Writers) == len(y.Writers) |
| @@ -1712,8 +1679,8 @@ func (w *world) publishGen(b *box, loc, gen string, inc int, base int, key strin |
| // mutation of restore-anchor writes none; the mutation of anchor-acked |
| // does not wait. |
| func (w *world) anchorAcked(b *box, loc, gen, snapKey string, snapVer, tx int) bool { |
| - if !on("restore-anchor") || tx == 0 { |
| - return true // no transactions yet: there is no position to anchor |
| + if !on("restore-anchor") || tx == 0 && on("valid-ltx") { |
| + return true // no transactions yet: there is no position to anchor (the mutation of valid-ltx writes [0,0], which the encoder refuses) |
| } |
| sv := w.buckets[b.Region].byID(snapKey, snapVer) |
| if sv == nil { |
diff --git a/internal/sim/hostlife/protocol_test.go b/internal/sim/hostlife/protocol_test.go| index 49f0a1294..6d18fb387 100644 |
| --- a/internal/sim/hostlife/protocol_test.go |
| +++ b/internal/sim/hostlife/protocol_test.go |
| @@ -171,6 +171,18 @@ func (w *world) evidence(b *box, opID string) bool { |
| // contract refuses. With restore-by-generation off it is the mutation: the |
| // newest object under the location. |
| func (w *world) restoreCalc(r int, inc int, loc, gen string) *localDB { |
| + return w.restoreWith(r, inc, loc, gen, "", 0, -1) |
| +} |
| + |
| +// restorePinned is the restore of an operation's prepared generation before |
| +// its marker is published (rev 7.10 §7.14): the snapshot identity comes |
| +// from the operation's build journal instead of generation.json; tx 0 is |
| +// an empty generation. |
| +func (w *world) restorePinned(r int, inc int, loc, gen, snapKey string, snapVer, tx int) *localDB { |
| + return w.restoreWith(r, inc, loc, gen, snapKey, snapVer, tx) |
| +} |
| + |
| +func (w *world) restoreWith(r int, inc int, loc, gen, pinKey string, pinVer, pinTX int) *localDB { |
| bk := w.buckets[r] |
| db := &localDB{Inc: inc, Loc: loc, Gen: gen, State: map[string]int{}} |
| if !on("restore-by-generation") { |
| @@ -200,7 +212,9 @@ func (w *world) restoreCalc(r int, inc int, loc, gen string) *localDB { |
| cur := 0 |
| var applied []*blob |
| pinnedTX := 0 |
| - if gen != "" { |
| + if pinTX >= 0 { |
| + pinnedTX = pinTX // the operation's build journal pins the root |
| + } else if gen != "" { |
| m := bk.current(genMarkerKey(loc)) |
| if m == nil || m.B.Gen != gen { |
| return nil // the marker does not name this generation: refuse |
| @@ -220,10 +234,14 @@ func (w *world) restoreCalc(r int, inc int, loc, gen string) *localDB { |
| } |
| } |
| if root == nil && gen != "" && pinnedTX > 0 { |
| - m := bk.current(genMarkerKey(loc)) |
| - snap := bk.byID(m.B.SnapKey, m.B.Base) |
| - if snap == nil || snap.Marker || snap.B.Max != m.B.TX { |
| - return nil // the snapshot version is missing or not at the marker's TXID |
| + key, ver := pinKey, pinVer |
| + if pinTX < 0 { |
| + m := bk.current(genMarkerKey(loc)) |
| + key, ver = m.B.SnapKey, m.B.Base |
| + } |
| + snap := bk.byID(key, ver) |
| + if snap == nil || snap.Marker || snap.B.Max != pinnedTX { |
| + return nil // the snapshot version is missing or not at the pinned TXID |
| } |
| root = snap.B |
| } |
| @@ -336,7 +354,7 @@ func (w *world) bucketGen(b *box, loc string) (string, int) { |
| func (w *world) spawn(b *box, c *claim, want string, probe bool) bool { |
| h := c.Holder |
| loc := h.Location |
| - admitted := "" // the admission mode this call decided (claim rev 6.8 §6.4a) |
| + var adm admitInput // the admission this call decided (claim rev 6.9 §6.4a) |
| if b.Old { |
| // an old executable: no claim, no markers, no locations; it |
| // restores whatever is newest at the legacy root |
| @@ -360,28 +378,28 @@ func (w *world) spawn(b *box, c *claim, want string, probe bool) bool { |
| } |
| } else { |
| bg, binc := w.bucketGen(b, loc) |
| - if !on("start-check-claim-gen") { |
| - // rev 5's start check: the box's own markers decide, so the |
| - // generation to restore is the one its region's generation.json |
| - // names |
| - want = bg |
| - } |
| - if on("start-check") { |
| - if bg != want || (bg != "" && binc != h.Inc) { |
| - w.logf("box %d start check refused: generation.json %q inc %d, claim wants %q", b.ID, bg, binc, want) |
| - return false |
| - } |
| - // a warm database from an earlier stay is parked, and the box |
| - // restores cold (§5.1): retire-artifacts of its stamp |
| - if b.DB != nil && (b.DB.Gen != bg || b.DB.Loc != loc) { |
| - w.retireDatabase(b, h.Inc, b.DB.Stamp) |
| - } |
| + if on("start-check") && bg != "" && binc != h.Inc { |
| + w.logf("box %d start check refused: generation.json %q inc %d, claim inc %d", b.ID, bg, binc, h.Inc) |
| + return false |
| } |
| - // every path that opens replication is admitted here (claim rev 6.8 |
| + // every path that opens replication is admitted here (claim rev 6.9 |
| // §6.4a, R8-2): an ordinary wake, a committed landing's serving, a |
| // probe and a restarted probe, a reverted source, an opened rollback |
| // recovery, a push's writer. A writer still closing holds the lease: |
| // the wake waits for it |
| + if o := b.open; o != nil { |
| + // an admitted writer whose start is in progress: it waits for the |
| + // PUT it needs acknowledged and starts without another CAS; an |
| + // admission that moved on (its session or generation changed) |
| + // abandons it |
| + if o.Gen != h.Gen || o.Session != h.Session { |
| + b.open = nil |
| + } else if !w.resumeOpen(b, c, o) { |
| + return false |
| + } else { |
| + return w.startProc(b, c, want, probe, o.Mode) |
| + } |
| + } |
| if b.DB != nil && b.DB.Closing { |
| w.closeStep(b) |
| if b.DB.Closing { |
| @@ -390,22 +408,126 @@ func (w *world) spawn(b *box, c *claim, want string, probe bool) bool { |
| return false |
| } |
| } |
| - if admitted = w.admitWriter(b, c, want, probe); admitted == "" { |
| + var ok bool |
| + if adm, ok = w.admitWriter(b, c, want, probe); !ok { |
| return false |
| } |
| - if b.DB == nil { |
| - b.DB = w.restoreInit(b, h.Inc, loc, want) |
| - if b.DB == nil { |
| + // the generation to restore from is holder.generation. generation.json |
| + // may lag it: an admitted writer that never published (it died between |
| + // its CAS and its marker, or the marker PUT was not acknowledged, rev |
| + // 7.10 §7.14). A lagging marker for an operation's prepared generation |
| + // is published again by op.exec before anything else, from the |
| + // operation's pinned snapshot; a generation with no file at all is an |
| + // abandoned fresh one, left to the sweep, and the baseline is the |
| + // generation the marker names, its parent, which holds the data |
| + source := h.Gen |
| + prepared := adm.Mode == "cold" && adm.NewGeneration == want && want != h.Gen |
| + if prepared { |
| + // a cold admission into the operation's prepared generation: the |
| + // marker names the predecessor and the admission publishes the |
| + // new one; the generation clause has nothing to compare |
| + bg = h.Gen |
| + } |
| + if !on("start-check-claim-gen") { |
| + // rev 5's start check: the box's own marker decides, so the |
| + // generation it restores is the one its region's generation.json |
| + // names, whatever the claim says |
| + source = bg |
| + bg = h.Gen |
| + } |
| + if bg != h.Gen && on("start-check") { |
| + switch { |
| + case h.WriterBox == b.ID && w.ancestorGen(loc, h.Gen, bg): |
| + // the marker lags behind this box's own admission: handled below. |
| + // Another box's lagging marker is the start check's refusal |
| + // (the generation clause, J2-R2-8): a stale box whose region |
| + // holds none of holder.generation's files is not the admitted |
| + // writer that died between its CAS and its marker |
| + case w.ancestorGen(loc, bg, h.Gen): |
| + // the marker names a descendant: a clean rollback returned the |
| + // claim to the source's generation and abandoned the target's; |
| + // the marker names the source's generation again, as it did |
| + // before, so the restore contract reads it (rev 7.10 §7.14) |
| + if !w.republishMarker(b, loc, h.Gen) { |
| + return false |
| + } |
| + bg = h.Gen |
| + default: |
| + w.logf("box %d start check refused: generation.json %q, claim %q", b.ID, bg, h.Gen) |
| + return false |
| + } |
| + } |
| + if bg != h.Gen && on("start-check") { |
| + if len(w.buckets[b.Region].keys(dataKey(loc, h.Gen, ""))) == 0 { |
| + // the baseline is the nearest ancestor that holds a file |
| + for source != bg && len(w.buckets[b.Region].keys(dataKey(loc, source, ""))) == 0 { |
| + source, _ = w.parentGen(loc, source) |
| + } |
| + w.stats["abandoned empty generation skipped"]++ |
| + } else { |
| + ver, key, tx := 0, "", 0 |
| + if h.Gen == installGen(&c.Op) && c.Op.BaseKey != "" { |
| + ver, key, tx = c.Op.BaseVer, c.Op.BaseKey, c.Op.BaseTX |
| + } |
| + if w.publishGen(b, loc, h.Gen, h.Inc, ver, key, tx) <= 0 { |
| + w.logf("box %d: generation.json for %q not acknowledged", b.ID, h.Gen) |
| + return false |
| + } |
| + w.stats["marker republished"]++ |
| + w.logf("box %d: generation.json published again for %q, which the admitted writer never published", b.ID, h.Gen) |
| + } |
| + } |
| + // a box never reuses its local database (claim rev 6.9, R9-1): the |
| + // admitted writer purges it, and if the purge fails it does not |
| + // start; then it restores from the certified remote baseline. The |
| + // mutation of remote-baseline keeps a local database of the |
| + // generation it opens, as rev 7.9 and today's activator do |
| + if b.DB != nil && (on("remote-baseline") || b.DB.Inc != h.Inc || b.DB.Gen != adm.NewGeneration) { |
| + if b.PurgeFails { |
| + b.PurgeFails = false // a transient error: the next attempt's purge succeeds |
| + w.stats["purge failed: writer not started"]++ |
| + w.logf("box %d: the purge of its local database failed; the writer does not start", b.ID) |
| return false |
| } |
| + b.DB = nil |
| + b.Parked++ |
| + w.stats["local database purged"]++ |
| } |
| + db := b.DB |
| + if db == nil { |
| + switch { |
| + case prepared: |
| + // the operation's prepared generation: its pinned snapshot and anchor |
| + db = w.restorePinned(b.Region, h.Inc, loc, want, c.Op.BaseKey, c.Op.BaseVer, c.Op.BaseTX) |
| + default: |
| + db = w.restoreCalc(b.Region, h.Inc, loc, source) |
| + if db != nil && adm.Mode == "warm" && db.Pos < adm.RemoteMaxTXID { |
| + w.logf("box %d: the restore of %q reached position %d, the certificate says %d", b.ID, source, db.Pos, adm.RemoteMaxTXID) |
| + db = nil |
| + } |
| + } |
| + if db == nil { |
| + w.stats["restore refused"]++ |
| + w.logf("box %d: nothing to restore for a %s admission from %q (generation.json %q)", b.ID, adm.Mode, source, bg) |
| + return false |
| + } |
| + db.Stamp = b.stampOf(h.Inc) |
| + w.logf("box %d restored %s: TXID %d at position %d (%s admission into %q)", b.ID, dataKey(loc, source, ""), db.TX, db.Pos, adm.Mode, adm.NewGeneration) |
| + } |
| + b.DB = db |
| if on("start-check") { |
| if b.DB.Inc != h.Inc && !(h.Legacy && b.DB.Inc == 0) { |
| w.logf("box %d start check refused: db inc %d", b.ID, b.DB.Inc) |
| return false |
| } |
| - if b.Files != nil && (b.Files.Gen != bg || b.Files.Inc != h.Inc) { |
| - w.logf("box %d start check refused: files gen %q inc %d, generation.json %q", b.ID, b.Files.Gen, b.Files.Inc, bg) |
| + // .files-gen names the generation the files were installed or |
| + // carried into (F7): the one the marker names, holder.generation, |
| + // or the one this admission opens (the admission retags them) |
| + // .files-gen (F7) is retagged by every admission with the |
| + // generation it opens; a stale tag from an earlier stay is not a |
| + // refusal, the incarnation is |
| + if b.Files != nil && b.Files.Inc != h.Inc { |
| + w.logf("box %d start check refused: files inc %d, claim inc %d", b.ID, b.Files.Inc, h.Inc) |
| return false |
| } |
| } |
| @@ -451,27 +573,69 @@ func (w *world) spawn(b *box, c *claim, want string, probe bool) bool { |
| w.logf("box %d unhealthy start inc %d: cfg=%d live=%s/%d fail=%v kind=%s", b.ID, h.Inc, b.Config, b.Live, b.LiveInc, b.FailApp, c.Op.Kind) |
| return false |
| } |
| - if admitted != "" && !b.Old { |
| - // the admitting CAS, as the process starts; the writer opens under |
| - // the session it produced and writes its lineage file (claim rev 6.8 |
| - // §6.4a). The replica reads its remote position at its first sync |
| - // (litestream's Replica.Sync initializes it lazily), RemotePos -1 |
| - if !w.admitCommit(b, c, want, admitted) { |
| + if !b.Old { |
| + // the admitting CAS, after readiness and before any upload (claim rev |
| + // 6.9 §6.4a); the writer then opens under the session and generation |
| + // it produced. The replica reads its remote position at its first |
| + // sync (litestream's Replica.Sync initializes it lazily), RemotePos -1 |
| + if !w.admitCommit(b, c, adm) { |
| return false |
| } |
| - b.DB.Session = w.claim.Holder.Session |
| - b.DB.RemotePos = -1 |
| - if b.lineage == nil { |
| - b.lineage = map[int]lineageRec{} |
| + db := b.DB |
| + nh := w.claim.Holder |
| + db.Gen, db.Session, db.Stamp = nh.Gen, nh.Session, nh.Stamp |
| + if b.Files != nil { |
| + // the carried files are tagged with the installing CAS's stamp |
| + // at the CAS (§6.4), whether or not the start completes: the |
| + // record activates that stamp from its next read (seed 62 / 300) |
| + b.Files.Gen, b.Files.Stamp = nh.Gen, nh.Stamp |
| + } |
| + db.Pending, db.Queue, db.InFlight, db.Issued, db.Uncertain, db.Closing, db.CloseReq = nil, nil, nil, nil, false, false, nil |
| + db.RemotePos, db.FirstFull = -1, true // litestream's first sync after a cold initialization is a full upload (fact R5-1c) |
| + o := &openState{Gen: nh.Gen, Want: want, Session: nh.Session, Mode: adm.Mode, Prepared: adm.Mode == "cold" && adm.NewGeneration == want && want != h.Gen, DB: db} |
| + switch { |
| + case adm.Mode == "cold" && o.Prepared: |
| + db.Pos = w.maxL0(w.buckets[b.Region], dataKey(loc, db.Gen, "")) // the prepared generation's anchor |
| + o.stage = "marker" |
| + case adm.Mode == "cold": |
| + // a fresh generation is empty until the writer's first upload, |
| + // the full file [1, 1] of the restored baseline (fact R5-1c), |
| + // made after the CAS and before the marker: generation.json |
| + // never names a generation without a root (INV-11), so a writer |
| + // that dies before its first upload leaves an empty, unpublished |
| + // generation whose parent stays the restore's baseline (§7.14) |
| + db.Pos = 0 |
| + o.stage = "marker" |
| + if len(db.Log) > 0 || len(db.State) > 0 { |
| + o.stage = "first" |
| + } |
| + case db.Pos > w.maxL0(w.buckets[b.Region], dataKey(loc, db.Gen, "")) && on("restore-anchor"): |
| + o.stage = "anchor" // the restored position is above the level-0 maximum: the anchor [N, N], after the CAS (§5.1 step 7) |
| + default: |
| + o.stage = "start" |
| + } |
| + b.DB = nil |
| + b.open = o |
| + if !w.resumeOpen(b, c, o) { |
| + return false |
| } |
| - b.lineage[h.Inc] = lineageRec{Gen: b.DB.Gen, Session: b.DB.Session} |
| } |
| + return w.startProc(b, c, want, probe, adm.Mode) |
| +} |
| + |
| +// startProc starts the host's processes on b for the admitted writer. |
| +func (w *world) startProc(b *box, c *claim, want string, probe bool, mode string) bool { |
| + h := c.Holder |
| strict := !b.Old && (on("strict-spawn") || w.rng.Intn(2) == 0) |
| - b.Procs = append(b.Procs, &proc{Main: true, Strict: strict, Inc: h.Inc, Gen: want, Box: b.ID}) |
| + gen := want |
| + if b.DB != nil { |
| + gen = b.DB.Gen |
| + } |
| + b.Procs = append(b.Procs, &proc{Main: true, Strict: strict, Inc: h.Inc, Gen: gen, Box: b.ID}) |
| if w.rng.Intn(2) == 0 { |
| - b.Procs = append(b.Procs, &proc{Main: false, Strict: strict, Inc: h.Inc, Gen: want, Box: b.ID}) |
| + b.Procs = append(b.Procs, &proc{Main: false, Strict: strict, Inc: h.Inc, Gen: gen, Box: b.ID}) |
| } |
| - w.logf("box %d spawned inc %d gen %q (probe=%v strict=%v) claim=%s/%s exec=%d", b.ID, h.Inc, want, probe, strict, c.Op.Kind, c.Op.ID, c.Op.Exec) |
| + w.logf("box %d spawned inc %d gen %q (%s, probe=%v strict=%v) claim=%s/%s exec=%d", b.ID, h.Inc, gen, mode, probe, strict, c.Op.Kind, c.Op.ID, c.Op.Exec) |
| return true |
| } |
| @@ -527,19 +691,21 @@ func (w *world) admits(b *box, c *claim, probe string) bool { |
| return false |
| } |
| -// warmCertificate is the clean-close certificate that permits a warm |
| -// admission (claim rev 6.8 §6.4a): closed/<holder.session>.json exists for |
| -// the generation, its max_txid is the generation's remote maximum TXID over |
| -// every level, and its box is this box. The mutation of writer-session |
| -// takes the newest certificate of any session (rev 7.8's single marker); |
| -// the mutation of closed-marker ignores max_txid. |
| -func (w *world) warmCertificate(b *box, c *claim, gen string) *version { |
| +// certificate is the clean-close certificate of the observed session |
| +// (claim rev 6.9 §6.4a): closed/<holder.session>.json exists for |
| +// holder.generation and its max_txid is the generation's remote maximum |
| +// TXID over every level. Any op.exec box may continue on it (claim rev |
| +// 6.8.2: the certificate proves every upload of the session completed, |
| +// whichever box made them). The mutation of writer-session takes the |
| +// newest certificate of any session (rev 7.8's single marker); the |
| +// mutation of closed-marker ignores max_txid. |
| +func (w *world) certificate(b *box, c *claim) *version { |
| h := c.Holder |
| bk := w.buckets[b.Region] |
| - prefix := dataKey(h.Location, gen, "") |
| + prefix := dataKey(h.Location, h.Gen, "") |
| var m *version |
| if on("writer-session") { |
| - m = bk.current(closedKey(h.Location, gen, h.Session)) |
| + m = bk.current(closedKey(h.Location, h.Gen, h.Session)) |
| } else { |
| for _, k := range bk.keys(prefix) { |
| if isClosedKey(k) { |
| @@ -554,9 +720,7 @@ func (w *world) warmCertificate(b *box, c *claim, gen string) *version { |
| return nil |
| } |
| if m.B.Seq != b.ID { |
| - w.stats["certificate of another box"]++ |
| - w.logf("box %d: certificate %d for %s is box %d's", b.ID, m.B.Session, prefix, m.B.Seq) |
| - return nil |
| + w.stats["certificate of another box"]++ // continued all the same (claim rev 6.8.2) |
| } |
| if on("closed-marker") && m.B.TX != w.remoteMax(bk, prefix) { |
| w.logf("box %d: certificate %d for %s is stale (max_txid %d, remote %d)", b.ID, m.B.Session, prefix, m.B.TX, w.remoteMax(bk, prefix)) |
| @@ -566,297 +730,322 @@ func (w *world) warmCertificate(b *box, c *claim, gen string) *version { |
| return m |
| } |
| -// lastWriter is the newest writers journal entry, or nil. |
| -func lastWriter(op *opState) *writerEntry { |
| - if len(op.Writers) == 0 { |
| - return nil |
| +// installGen is the generation an operation has prepared for its writer: a |
| +// landing's or a push's g-<op id>/, a dirty rollback's return generation. |
| +// Its probe's cold admission makes it holder.generation (claim rev 6.9: |
| +// holder.generation is the only authoritative generation, in every kind |
| +// and phase). |
| +func installGen(op *opState) string { |
| + if op.Kind == "restoring" { |
| + return op.RollbackID |
| } |
| - return &op.Writers[len(op.Writers)-1] |
| + return op.ID |
| } |
| -// admitWriter is AdmitWriter (claim rev 6.8 §6.4a, R8-1, R8-2): the one |
| -// claim CAS every path that opens replication makes before any upload. It |
| -// requires the observed holder.session and holder.generation, and op.exec |
| -// to be this box (claim rev 6.8.1, rule exec-writer); it writes |
| -// holder.session = its resulting gen and a writers journal entry. Three |
| -// modes, decided here and committed by admitCommit as the process starts |
| -// (so a start that fails after the decision leaves nothing admitted): |
| -// |
| -// - "open": the box's own cold admission has finished its build (the |
| -// newest entry is this box's, unopened, for gen): the writer opens it, |
| -// the CAS marking the entry opened; |
| -// - "warm": a certificate for the observed session permits a warm |
| -// admission; the CAS moves the session forward, so the certificate |
| -// names a dead session from then on; |
| -// - "fresh": the operation's freshly built generation has had no writer |
| -// (op.Fresh, an install's first probe): a cold admission whose build |
| -// was the install; |
| -// - otherwise lost replication state: a cold admission's CAS is made now |
| -// (op.Rec), its build runs under the operation (recStep), and this call |
| -// returns "" until the writer may open. |
| -func (w *world) admitWriter(b *box, c *claim, gen string, probe bool) string { |
| - h := c.Holder |
| - if on("exec-writer") && c.Op.Exec != b.ID { |
| - w.stats["admission refused: not op.exec"]++ |
| - w.logf("box %d: admission refused, op.exec is %d (%s/%s)", b.ID, c.Op.Exec, c.Op.Kind, c.Op.ID) |
| - return "" |
| - } |
| - if !on("admit-everywhere") && c.Op.Kind != "serving" { |
| - return "unguarded" // the mutation: writer epochs for ordinary wakes only (rev 7.8) |
| - } |
| - if c.Op.Rec != nil { |
| - return "" // a cold admission's build is in progress |
| +// republishMarker writes generation.json for gen again, with the content |
| +// its last marker had: a clean rollback's return to the source generation. |
| +func (w *world) republishMarker(b *box, loc, gen string) bool { |
| + bk := w.buckets[b.Region] |
| + var last *version |
| + for _, v := range bk.objs[genMarkerKey(loc)] { |
| + if !v.Marker && v.B != nil && v.B.Gen == gen && (last == nil || v.ID > last.ID) { |
| + last = v |
| + } |
| } |
| - if e := lastWriter(&c.Op); e != nil && e.Box == b.ID && e.Session == h.Session && e.Gen == gen && !e.Opened { |
| - return "open" |
| + if last == nil && gen != "" { |
| + w.logf("box %d: no earlier marker names %q", b.ID, gen) |
| + return false |
| } |
| - if !on("recovery-generation") { |
| - return "unguarded" // rev 7.7: a same-generation restart, no session |
| + inc, base, key, tx := w.claim.Holder.Inc, 0, "", 0 |
| + if last != nil { |
| + inc, base, key, tx = last.B.Inc, last.B.Base, last.B.SnapKey, last.B.TX |
| } |
| - if m := w.warmCertificate(b, c, gen); m != nil { |
| - return "warm" |
| + if w.publishGen(b, loc, gen, inc, base, key, tx) <= 0 { |
| + w.logf("box %d: generation.json for %q not acknowledged", b.ID, gen) |
| + return false |
| } |
| - if c.Op.Fresh && w.opGen(&c.Op) == gen { |
| - return "fresh" // the operation's install was this writer's build |
| + w.stats["marker republished after a clean rollback"]++ |
| + w.logf("box %d: generation.json names %q again after the rollback", b.ID, gen) |
| + return true |
| +} |
| + |
| +// parentGen is the generation gen was admitted from within loc, as newGen |
| +// recorded it; ok is false at the origin. |
| +func (w *world) parentGen(loc, gen string) (string, bool) { |
| + p, ok := w.genParent[dataKey(loc, gen, "")] |
| + if !ok { |
| + return "", false |
| } |
| - // cold: a fresh generation, built under this operation |
| - id := w.newID("rec") |
| - if w.cas(c, func(n *claim) bool { |
| - if n.Holder.Session != h.Session || n.Op.Rec != nil || n.Op.Exec != b.ID && on("exec-writer") { |
| - return false |
| - } |
| - n.Holder.Session = w.nextGen() |
| - p := pred{Session: h.Session, Box: w.predBox(c), Gen: gen, Stamp: h.Stamp} |
| - n.Op.Writers = append(n.Op.Writers, writerEntry{Session: n.Holder.Session, Box: b.ID, Gen: id, Mode: "cold", Pred: p}) |
| - n.Op.Rec = &recState{Gen: id, Base: gen, Session: n.Holder.Session, Box: b.ID, Region: b.Region, Phase: "build", Pred: p} |
| - w.newGen(n.Holder.Location, gen, id) |
| - if gen == n.Holder.Gen { |
| - n.Holder.Gen = id // the claim's generation moves with the writer it admits |
| - } else { |
| - n.Op.InstGen = id // an operation's install generation, not yet the claim's |
| - } |
| - n.Holder.Stamp = w.issueStamp(n, b.ID) // an installing CAS (claim rev 6.8 §6.4a) |
| - n.Op.Writers[len(n.Op.Writers)-1].Stamp = n.Holder.Stamp |
| - n.Op.Fresh = false |
| - return true |
| - }) { |
| - w.logf("box %d: cold admission %s from generation %q (lost replication state, op %s/%s)", b.ID, id, gen, c.Op.Kind, c.Op.ID) |
| - w.stats["admissions: cold"]++ |
| + if p == loc { |
| + return "", true |
| } |
| - return "" |
| + return strings.TrimSuffix(strings.TrimPrefix(p, loc+"g-"), "/"), true |
| } |
| -// admitCommit is the admitting CAS for the open, warm and fresh modes, made |
| -// as the process starts; false means the claim moved and the start is |
| -// abandoned. |
| -func (w *world) admitCommit(b *box, c *claim, gen, mode string) bool { |
| - h := c.Holder |
| - if mode == "unguarded" { |
| - return true |
| - } |
| - ok := w.cas(c, func(n *claim) bool { |
| - if n.Holder.Session != h.Session || n.Op.Rec != nil || n.Op.Exec != b.ID && on("exec-writer") { |
| - return false |
| - } |
| - if mode == "open" { |
| - le := lastWriter(&n.Op) |
| - if le == nil || le.Session != h.Session || le.Opened { |
| - return false |
| - } |
| - le.Opened = true |
| +// ancestorGen: anc is gen or one of its ancestors within loc. |
| +func (w *world) ancestorGen(loc, gen, anc string) bool { |
| + for { |
| + if gen == anc { |
| return true |
| } |
| - n.Holder.Session = w.nextGen() |
| - n.Op.Fresh = false |
| - n.Op.Writers = append(n.Op.Writers, writerEntry{Session: n.Holder.Session, Box: b.ID, Gen: gen, Stamp: n.Holder.Stamp, Mode: mode, Opened: true, |
| - Pred: pred{Session: h.Session, Box: w.predBox(c), Gen: gen, Stamp: h.Stamp}}) |
| - return true |
| - }) |
| - if ok { |
| - w.stats["admissions: "+mode]++ |
| - } else { |
| - w.logf("box %d: %s admission CAS refused (session %d, op %s/%s exec %d)", b.ID, mode, h.Session, c.Op.Kind, c.Op.ID, c.Op.Exec) |
| + p, ok := w.parentGen(loc, gen) |
| + if !ok { |
| + return false |
| + } |
| + gen = p |
| } |
| - return ok |
| } |
| -// predBox is the box of the newest writer session the claim records, or |
| -// op.exec when it records none. |
| -func (w *world) predBox(c *claim) int { |
| - if e := lastWriter(&c.Op); e != nil { |
| - return e.Box |
| - } |
| - return c.Op.Exec |
| +// phaseOf is the operation phase an admission observes and binds to (R9-4). |
| +func phaseOf(op *opState) string { |
| + return op.Phase + "/" + op.LandPhase + "/" + op.SrcPhase + "/" + op.RevPhase |
| } |
| -// opGen is the generation an operation installs: its install generation |
| -// when a cold admission within it moved it (R8-2), else its id, or the |
| -// rollback id for a rollback. |
| -func (w *world) opGen(op *opState) string { |
| - if op.InstGen != "" { |
| - return op.InstGen |
| +// admitInput is claim rev 6.9's AdmitWriter input (Admission in the claim library): what the box observed, |
| +// the mode it decided and the generation it opens. |
| +type admitInput struct { |
| + ObservedOpID string |
| + ObservedPhase string |
| + ObservedSession int |
| + ObservedGeneration string |
| + Box int |
| + Mode string // warm or cold; "unguarded" is the rev 7.7 mutation |
| + NewGeneration string |
| + PredecessorCertified bool |
| + RemoteMaxTXID int |
| +} |
| + |
| +// admitWriter decides the admission for a start on b under the claim it |
| +// read (claim rev 6.9 §6.4a, R8-2, R9-3): it makes no CAS and issues |
| +// nothing. It requires op.exec to be this box (rule exec-writer) and |
| +// refuses every kind in which no writer may open (deleting, parking, |
| +// purging, creating; R9-4). The mode is warm when the certificate of the |
| +// observed session is intact and the generation to open is |
| +// holder.generation; else cold, into the operation's prepared generation |
| +// (want, when it is not holder.generation) or a fresh one. |
| +func (w *world) admitWriter(b *box, c *claim, want string, probe bool) (admitInput, bool) { |
| + h := c.Holder |
| + a := admitInput{ObservedOpID: c.Op.ID, ObservedPhase: phaseOf(&c.Op), ObservedSession: h.Session, ObservedGeneration: h.Gen, Box: b.ID} |
| + if on("exec-writer") && c.Op.Exec != b.ID { |
| + w.stats["admission refused: not op.exec"]++ |
| + w.logf("box %d: admission refused, op.exec is %d (%s/%s)", b.ID, c.Op.Exec, c.Op.Kind, c.Op.ID) |
| + return a, false |
| } |
| - if op.Kind == "restoring" { |
| - return op.RollbackID |
| + switch c.Op.Kind { |
| + case "deleting", "parking", "purging", "creating": |
| + w.stats["admission refused: op kind"]++ |
| + return a, false |
| } |
| - return op.ID |
| + if !on("admit-everywhere") && c.Op.Kind != "serving" { |
| + a.Mode, a.NewGeneration = "unguarded", want // the mutation: writer sessions for ordinary wakes only (rev 7.8) |
| + if want == "" { |
| + a.NewGeneration = h.Gen |
| + } |
| + return a, true |
| + } |
| + bk := w.buckets[b.Region] |
| + a.RemoteMaxTXID = w.remoteMax(bk, dataKey(h.Location, h.Gen, "")) |
| + a.PredecessorCertified = w.certificate(b, c) != nil |
| + if want == "" || want == h.Gen || w.ancestorGen(h.Location, h.Gen, want) { |
| + // holder.generation, or an operation's prepared generation that a |
| + // writer already opened (it is holder.generation or an ancestor of |
| + // it): never re-entered, since a dead writer's uploads may still |
| + // land in it (seeds 43 and 87 / 300) |
| + if a.PredecessorCertified || !on("recovery-generation") { |
| + a.Mode, a.NewGeneration = "warm", h.Gen |
| + return a, true |
| + } |
| + a.Mode, a.NewGeneration = "cold", w.newID("rec") |
| + return a, true |
| + } |
| + a.Mode, a.NewGeneration = "cold", want // the operation's prepared generation, opened for the first time |
| + return a, true |
| } |
| -// recStep is a cold admission's build (claim rev 6.8 §6.4a; moves rev 7.9 |
| -// §7.14), run under whichever operation admitted the writer, by the box it |
| -// admitted, before that operation's own step: |
| -// |
| -// - build: stop evidence first. The baseline is the warm database only |
| -// with provenance (R8-4): its lineage file names the predecessor's |
| -// {generation, session}, the predecessor's box is this box, and it |
| -// passes its integrity check (the model's database is always |
| -// consistent, WAL included); else the predecessor generation restored |
| -// by the pure calculation, and the warm database's unreplicated writes |
| -// are declared lost. A snapshot [1, N] and an anchor [N, N] go into the |
| -// new generation and are acknowledged when N > 0; an empty generation |
| -// gets no LTX file (R8-3). A replay reuses a snapshot already current. |
| -// - publish: generation.json names the new generation; the predecessor |
| -// generation is parked as a chore after the dead writer's horizon; the |
| -// build is done, and the writer opens at its next admission. |
| -func (w *world) recStep(b *box, c *claim) { |
| - rec := c.Op.Rec |
| +// resumeOpen advances an admitted writer's start (rev 7.10 §7.14): each |
| +// stage issues one PUT and waits for its acknowledgement, retrying the |
| +// same bytes after the client's timeout; a stage whose effect is visible |
| +// (the file current, the marker naming the generation) is done whatever |
| +// became of the request. It returns true once the process may start, with |
| +// the database installed on the box. |
| +func (w *world) resumeOpen(b *box, c *claim, o *openState) bool { |
| h := c.Holder |
| loc := h.Location |
| bk := w.buckets[b.Region] |
| - switch rec.Phase { |
| - case "build": |
| - if !w.evidence(b, rec.Gen) { |
| - return |
| - } |
| - var base *localDB |
| - reused := false |
| - if snaps := w.currentLTX(bk, dataKey(loc, rec.Gen, ""), 9); len(snaps) > 0 { |
| - sv := snaps[len(snaps)-1].B |
| - base = &localDB{Inc: h.Inc, Loc: loc, Gen: rec.Gen, Key: sv.SealKey, Pos: sv.Max, State: cloneState(sv.Pages), Log: txsOf(sv), TX: w.lineageTX(sv)} |
| - reused = true |
| - w.stats["cold admission: snapshot reused on replay"]++ |
| - } else if b.DB != nil && b.DB.Inc == h.Inc && b.DB.Gen == rec.Base && w.warmProvenance(b, h.Inc, rec) { |
| - base = b.DB |
| - w.stats["cold admission: warm baseline"]++ |
| - } else { |
| - switch { |
| - case b.DB == nil: |
| - w.logf("box %d: cold admission %s has no local database; baseline from the remote of %q", b.ID, rec.Gen, rec.Base) |
| - case b.DB.Inc != h.Inc || b.DB.Gen != rec.Base: |
| - w.logf("box %d: cold admission %s finds a database of inc %d generation %q, not %q; baseline from the remote", b.ID, rec.Gen, b.DB.Inc, b.DB.Gen, rec.Base) |
| - } |
| - if b.DB != nil && b.DB.Inc == h.Inc && b.DB.Gen == rec.Base { |
| - // a warm database without provenance: the remote wins, and |
| - // its writes that never replicated are declared lost |
| - n := 0 |
| - for i := range w.acks { |
| - a := &w.acks[i] |
| - if a.Box == b.ID && a.Inc == h.Inc && !a.Exempt && !w.isDurable(a.W) { |
| - a.Exempt = true |
| - n++ |
| + db := o.DB |
| + for { |
| + var key string |
| + var mk func() *request |
| + visible := func() bool { return false } |
| + switch o.stage { |
| + case "first": |
| + key = ltxKey(loc, db.Gen, 0, 1, 1) |
| + mk = func() *request { return &request{kind: rPut, region: b.Region, key: key, b: w.deltaOf(db, h.Inc)} } |
| + visible = func() bool { return bk.current(key) != nil } |
| + case "marker": |
| + bg, _ := w.bucketGen(b, loc) |
| + visible = func() bool { bg, _ = w.bucketGen(b, loc); return bg == db.Gen } |
| + if bg != db.Gen { |
| + ver, k, tx := 0, "", 0 |
| + if o.Prepared { |
| + ver, k, tx = c.Op.BaseVer, c.Op.BaseKey, c.Op.BaseTX |
| + } |
| + if o.req == nil || o.req.done || w.now-o.req.at >= uploadTimeout { |
| + if o.req != nil && !o.req.done { |
| + w.stats["open: marker PUT retried"]++ |
| + } |
| + v := w.publishGen(b, loc, db.Gen, h.Inc, ver, k, tx) |
| + o.req = w.lastIssued |
| + if v > 0 { |
| + o.req = nil |
| } |
| } |
| - w.stats["cold admission: warm baseline refused, writes declared lost"] += n |
| - w.logf("box %d: warm database of %q has no provenance; %d unreplicated writes declared lost", b.ID, rec.Base, n) |
| - } |
| - base = w.restoreCalc(b.Region, h.Inc, loc, rec.Base) |
| - if base == nil { |
| - w.stats["cold admission: baseline unrestorable"]++ |
| - w.logf("box %d: cold admission %s cannot restore %q yet: %s", b.ID, rec.Gen, rec.Base, w.ltxShape(b.Region, dataKey(loc, rec.Base, ""))) |
| - return |
| - } |
| - w.stats["cold admission: remote baseline"]++ |
| - } |
| - snap := snapshotOf(base, h.Inc) |
| - n := snap.Max // the baseline's position; 1 for a database with transactions that never replicated; 0 only when empty (R8-3) |
| - if !on("valid-ltx") { |
| - n = base.Pos // the mutation: the position as is, a zero-TXID file for a never-replicated database |
| - snap.Max = n |
| - } |
| - w.logf("box %d: cold admission %s baseline: position %d, TXID %d, %d writes (reused snapshot %v, warm %v)", b.ID, rec.Gen, n, base.TX, len(base.Log), reused, base == b.DB && !reused) |
| - key := "" |
| - ver := 0 |
| - if n > 0 || !on("valid-ltx") { |
| - snap.Gen = rec.Gen |
| - if b.Files != nil { |
| - snap.SealKey = b.Files.Key |
| + if !visible() { |
| + w.stats["open: waits for the marker"]++ |
| + return false |
| + } |
| } |
| - key = snapKey(loc, rec.Gen, n) |
| - if !reused && !w.ackedPut(b, key, snap, false) { |
| - return |
| + o.stage, o.req = "start", nil |
| + continue |
| + case "anchor": |
| + key = ltxKey(loc, db.Gen, 0, db.Pos, db.Pos) |
| + mk = func() *request { return &request{kind: rPut, region: b.Region, key: key, b: anchorOf(db, h.Inc)} } |
| + visible = func() bool { return bk.current(key) != nil } |
| + case "start": |
| + if o.stage == "start" { |
| + b.DB = db |
| + b.open = nil |
| + if o.Mode == "cold" && !o.Prepared && (len(db.Log) > 0 || len(db.State) > 0) { |
| + db.Pos, db.FirstFull, db.Pending = 1, false, nil |
| + } |
| + return true |
| } |
| - sv := bk.current(key) |
| - if sv == nil { |
| - return |
| + } |
| + // first and anchor: one PUT, acknowledged before the next stage |
| + if !visible() { |
| + if o.req == nil || (!o.req.done && w.now-o.req.at >= uploadTimeout) { |
| + if o.req != nil { |
| + w.stats["open: PUT retried"]++ |
| + } |
| + r := mk() |
| + if o.stage == "anchor" { |
| + w.stats["restore anchors"]++ |
| + } else { |
| + w.stats["first uploads"]++ |
| + } |
| + v := w.issue(b.ID, r) |
| + o.req = r |
| + if v > 0 { |
| + o.req = nil |
| + } |
| } |
| - ver = sv.ID |
| - if !w.anchorAcked(b, loc, rec.Gen, key, ver, n) { |
| - return |
| + if !visible() { |
| + if o.stage == "anchor" && !on("anchor-acked") { |
| + // the mutation opens before the anchor is acknowledged |
| + } else { |
| + w.stats["open: waits for its "+o.stage]++ |
| + return false |
| + } |
| } |
| - base.Key = snap.SealKey |
| } |
| - db := base |
| - if db != b.DB { |
| - b.DB = db |
| + if o.stage == "first" { |
| + w.markDurable(key, bk.current(key).B) |
| + o.stage = "marker" |
| + } else { |
| + o.stage = "start" |
| } |
| - db.Gen, db.Pending, db.Queue, db.InFlight, db.FirstFull, db.Closing, db.CloseReq = rec.Gen, nil, nil, nil, true, false, nil |
| - db.Issued, db.Uncertain = nil, false |
| - db.Pos = w.maxL0(bk, dataKey(loc, rec.Gen, "")) |
| - db.RemotePos = db.Pos |
| - db.Stamp = h.Stamp |
| - if b.Files != nil { |
| - // the files are unchanged; .files-gen names the new generation (F7) |
| - // and the carried files are tagged with the installing CAS's |
| - // stamp, like the database (§6.4; seed 453 / 30,000) |
| - b.Files.Gen, b.Files.Stamp = rec.Gen, h.Stamp |
| + o.req = nil |
| + } |
| +} |
| + |
| +// admitCommit is the AdmitWriter CAS (claim rev 6.9 §6.4a), made after |
| +// readiness and before any upload. It is bound to the observed op.id and |
| +// phase (R9-4; rule admit-bind-op), the observed session and generation, |
| +// and op.exec. It writes holder.session, holder.writer_box and the journal |
| +// entry; in cold mode also holder.generation, holder.stamp (the installing |
| +// CAS), a writes_lost record when the observed session had no certificate |
| +// (R9-2), and a park-generation chore for a fresh generation's predecessor. |
| +func (w *world) admitCommit(b *box, c *claim, a admitInput) bool { |
| + if a.Mode == "unguarded" { |
| + return true |
| + } |
| + h := c.Holder |
| + var lost *writesLost |
| + ok := w.cas(c, func(n *claim) bool { |
| + if n.Holder == nil || n.Holder.Session != a.ObservedSession || n.Holder.Gen != a.ObservedGeneration { |
| + return false |
| } |
| - w.cas(c, func(nc *claim) bool { |
| - if nc.Op.Rec == nil || nc.Op.Rec.Gen != rec.Gen { |
| + if on("admit-bind-op") { |
| + // bound to the observed op.id and phase (serving's "" compared |
| + // literally) and to the kinds in which a writer may open: a |
| + // closure computed under serving fails once any operation |
| + // started, a delete included (R9-4) |
| + if n.Op.ID != a.ObservedOpID || phaseOf(&n.Op) != a.ObservedPhase { |
| return false |
| } |
| - nc.Op.Rec.Phase, nc.Op.Rec.BaseVer, nc.Op.Rec.BaseKey, nc.Op.Rec.BaseTX = "publish", ver, key, n |
| - return true |
| - }) |
| - case "publish": |
| - cur := bk.current(genMarkerKey(loc)) |
| - if cur == nil || cur.B.Gen != rec.Gen { |
| - if v := w.publishGen(b, loc, rec.Gen, h.Inc, rec.BaseVer, rec.BaseKey, rec.BaseTX); v <= 0 { |
| - return |
| + switch n.Op.Kind { |
| + case "deleting", "parking", "purging", "creating": |
| + return false |
| } |
| } |
| - w.cas(c, func(nc *claim) bool { |
| - if nc.Op.Rec == nil || nc.Op.Rec.Gen != rec.Gen { |
| - return false |
| + if on("exec-writer") && n.Op.Exec != b.ID { |
| + return false |
| + } |
| + n.Holder.Session = w.nextGen() |
| + n.Holder.WriterBox = b.ID |
| + e := writerEntry{Session: n.Holder.Session, Box: b.ID, Gen: a.NewGeneration, Stamp: n.Holder.Stamp, Mode: a.Mode, |
| + Pred: pred{Session: a.ObservedSession, Box: h.WriterBox, Gen: h.Gen, Stamp: h.Stamp}} |
| + if a.Mode == "cold" { |
| + w.newGen(n.Holder.Location, h.Gen, a.NewGeneration) |
| + n.Holder.Gen = a.NewGeneration |
| + n.Holder.Stamp = w.issueStamp(n, b.ID) // the installing CAS (claim rev 6.8 §6.4a, §6.4) |
| + e.Stamp = n.Holder.Stamp |
| + if !a.PredecessorCertified && on("writes-lost") { |
| + lost = &writesLost{Box: h.WriterBox, Gen: h.Gen, Session: h.Session, RemoteMaxTXID: a.RemoteMaxTXID} |
| + e.Lost = lost |
| } |
| - nc.Op.Rec = nil |
| - nc.Obl = append(nc.Obl, w.newObl(nc, "park-generation", b.Region, -1, loc, rec.Base, w.now+horizonH)) |
| - return true |
| - }) |
| - w.stats["cold admissions done"]++ |
| - } |
| -} |
| - |
| -// warmProvenance is claim rev 6.8 §6.4a's three conditions for taking the |
| -// warm database as a cold admission's baseline (R8-4): the local lineage |
| -// file, written by the writer that opened the database, names the |
| -// journalled predecessor's {generation, session}; the predecessor's box is |
| -// this box; and the database passes its integrity check with its WAL in |
| -// place, which here is that it stands at or beyond its own generation's |
| -// remote position (its uploads came from it; a shorter file is a truncated |
| -// one). The mutation of warm-provenance takes any database of the |
| -// predecessor generation. |
| -func (w *world) warmProvenance(b *box, inc int, rec *recState) bool { |
| - if !on("warm-provenance") { |
| + if a.NewGeneration != installGen(&n.Op) || n.Op.Kind == "serving" { |
| + // a recovery into a fresh generation: the predecessor is |
| + // dead and parked as a chore after the dead writer's horizon; |
| + // an operation's prepared generation leaves its predecessor |
| + // to the operation's own finalization or rollback |
| + // in this box's region: the predecessor's files are where this |
| + // box restored them from |
| + n.Obl = append(n.Obl, w.newObl(n, "park-generation", b.Region, -1, n.Holder.Location, h.Gen, w.now+horizonH)) |
| + } |
| + } |
| + n.Op.Writers = append(n.Op.Writers, e) |
| return true |
| - } |
| - l, ok := b.lineage[inc] |
| - if !ok || l.Gen != rec.Pred.Gen || l.Session != rec.Pred.Session || rec.Pred.Box != b.ID { |
| + }) |
| + if !ok { |
| + w.logf("box %d: %s admission CAS refused (session %d, op %s/%s exec %d)", b.ID, a.Mode, h.Session, c.Op.Kind, c.Op.ID, c.Op.Exec) |
| + w.stats["admission CAS refused"]++ |
| return false |
| } |
| - if remote := w.maxL0(w.buckets[b.Region], dataKey(b.DB.Loc, rec.Base, "")); b.DB.Pos < remote { |
| - w.logf("box %d: warm database of %q fails its integrity check: position %d behind its remote %d", b.ID, rec.Base, b.DB.Pos, remote) |
| - w.stats["cold admission: warm baseline failed integrity"]++ |
| - return false |
| + w.stats["admissions: "+a.Mode]++ |
| + switch w.claim.Op.Kind { |
| + case "deleting", "parking", "purging", "creating": |
| + // INV-13 (R9-4): no writer is admitted while the host is being |
| + // deleted, parked, purged or created |
| + w.violate("INV-13: box %d was admitted as a writer while the host is %s (%s)", b.ID, w.claim.Op.Kind, w.claim.Op.ID) |
| + } |
| + if a.Mode == "cold" { |
| + w.logf("box %d: cold admission %s from generation %q (session %d %scertified, op %s/%s)", b.ID, a.NewGeneration, h.Gen, h.Session, map[bool]string{true: "", false: "un"}[a.PredecessorCertified], c.Op.Kind, c.Op.ID) |
| + } |
| + if lost != nil { |
| + // the writes the predecessor acknowledged and never uploaded are |
| + // lost, declared and alerted (claim rev 6.9), never silently |
| + n := 0 |
| + for i := range w.acks { |
| + x := &w.acks[i] |
| + if x.Box == lost.Box && x.Inc == h.Inc && x.Kind == "write" && !x.Exempt && !w.isDurable(x.W) { |
| + x.Exempt = true |
| + n++ |
| + } |
| + } |
| + w.stats["writes_lost declared"]++ |
| + w.stats["writes declared lost"] += n |
| + w.logf("box %d: alert writes_lost {box %d, generation %q, session %d, remote_max_txid %d}: %d acknowledged writes", b.ID, lost.Box, lost.Gen, lost.Session, lost.RemoteMaxTXID, n) |
| } |
| return true |
| } |
diff --git a/internal/sim/hostlife/rules_test.go b/internal/sim/hostlife/rules_test.go| index 623c09a4b..234d091d3 100644 |
| --- a/internal/sim/hostlife/rules_test.go |
| +++ b/internal/sim/hostlife/rules_test.go |
| @@ -94,7 +94,9 @@ var allRules = []string{ |
| "writer-session", // claim rev 6.8 §6.4a (R8-1): a warm admission needs the certificate of the observed holder.session and moves the session forward, so a certificate is one-use; the mutation takes the newest certificate of any session and moves nothing |
| "admit-everywhere", // claim rev 6.8 §6.4a (R8-2): every path that opens replication is an AdmitWriter, a committed landing's serving, a probe restart, a reverted source, an opened rollback included; the mutation gates ordinary wakes only |
| "exec-writer", // claim rev 6.8.1 (D1): the AdmitWriter CAS requires op.exec to be this box, in every operation; the mutation admits any box the phase table reaches |
| - "warm-provenance", // claim rev 6.8 §6.4a (R8-4): the warm database is a cold admission's baseline only with its lineage file naming the journalled predecessor's generation and session and the predecessor's box this box; the mutation takes any database of the predecessor generation |
| + "remote-baseline", // claim rev 6.9 §6.4a (R9-1): a box never reuses its local database; every admitted writer purges it and restores from the certified remote baseline; the mutation keeps a local database of the generation it opens, as today's activator does |
| + "writes-lost", // claim rev 6.9 §6.4a (R9-2): a cold admission whose observed session had no certificate records writes_lost {box: holder.writer_box, generation, session, remote_max_txid} and alerts; the mutation loses the writes silently |
| + "admit-bind-op", // claim rev 6.9 §6.4a (R9-4): the AdmitWriter CAS is bound to the observed op.id and phase, serving's "" compared literally; the mutation compares session and generation alone |
| "valid-ltx", // rev 7.9 §5.1 step 9 (R8-3): an empty generation gets no LTX file, never a zero-TXID snapshot or anchor; the mutation writes [1,0] and [0,0] |
| "close-evidence", // claim rev 6.8 §6.4a (R8-5): a certificate needs every request of the writer's lifetime returned with success and no retry after a timeout; the mutation looks at the level-0 queue alone |
| "quiet-before-ack", // this model's finding (late writes after a park) |
diff --git a/internal/sim/hostlife/sim_test.go b/internal/sim/hostlife/sim_test.go| index ab4254c60..79e42919f 100644 |
| --- a/internal/sim/hostlife/sim_test.go |
| +++ b/internal/sim/hostlife/sim_test.go |
| @@ -211,7 +211,7 @@ func (w *world) heal(final bool) { |
| if linger { |
| continue |
| } |
| - b.Partitioned, b.Frozen, b.AgentPaused, b.AgentDown = false, false, false, false |
| + b.Partitioned, b.Frozen, b.AgentPaused, b.AgentDown, b.PurgeFails = false, false, false, false, false |
| } |
| if final { |
| for _, b := range w.boxes { |
| @@ -264,9 +264,6 @@ func (w *world) whyNotQuiescent() string { |
| } |
| } |
| if c := w.claim; c != nil && c.Holder != nil { |
| - if c.Op.Rec != nil { |
| - out = append(out, fmt.Sprintf("cold admission %s in phase %s", c.Op.Rec.Gen, c.Op.Rec.Phase)) |
| - } |
| if c.Op.Kind != "serving" { |
| out = append(out, "op "+c.Op.Kind) |
| } |
| @@ -313,7 +310,7 @@ func (w *world) atRest() bool { |
| if c.Holder == nil { |
| return c.Op.Kind == "" && w.rec == nil && len(c.Obl) == 0 |
| } |
| - if c.Op.Kind != "serving" || c.Op.Rec != nil || len(c.Obl) > 0 || w.rec == nil || w.rec.Inc != c.Holder.Inc || w.rec.MoveID != "" { |
| + if c.Op.Kind != "serving" || len(c.Obl) > 0 || w.rec == nil || w.rec.Inc != c.Holder.Inc || w.rec.MoveID != "" { |
| return false |
| } |
| e := w.execBox(c) |
diff --git a/internal/sim/hostlife/witness_test.go b/internal/sim/hostlife/witness_test.go| index 14b4bb733..c6d558ee5 100644 |
| --- a/internal/sim/hostlife/witness_test.go |
| +++ b/internal/sim/hostlife/witness_test.go |
| @@ -69,13 +69,12 @@ var witnesses = []witness{ |
| "a target that wrote during its probe is rolled back; without the return its writes are lost (INV-2)", wDirtyReturn}, |
| {"commit-before-open", "commit-before-open", "INV-5", |
| "a public request reaches the target's edge while its probe runs; a row opened at the probe routes it before the commit (INV-5, R3-5)", wCommitBeforeOpen}, |
| - {"exec-admission", "exec-admission,hold-op-open,start-check-claim-gen,exec-writer", "INV-6", |
| - "one of four admission fences (J2-R2-8, claim rev 6.8.1): a paused open from an earlier operation makes the old source ready after the commit; hold_op refuses the open, the exec check refuses the wake, the generation check refuses the start, AdmitWriter refuses a box that is not op.exec; with all four gone the source's wake is a cold admission by a non-exec box, whose installing CAS moves holder.stamp under the target (INV-6), and the source would then serve beside the target (INV-1)", wAdmissionTriple}, |
| - {"hold-op-open", "exec-admission,hold-op-open,start-check-claim-gen,exec-writer", "INV-6", "one of four admission fences (see exec-admission)", wAdmissionTriple}, |
| - {"start-check-claim-gen", "exec-admission,hold-op-open,start-check-claim-gen,exec-writer", "INV-6", "one of four admission fences (see exec-admission)", wAdmissionTriple}, |
| - {"exec-writer", "exec-admission,hold-op-open,start-check-claim-gen,exec-writer", "INV-6", |
| - "a paused open from an earlier operation lands after the commit while the source's row is still held; the source's AdmitWriter CAS requires op.exec to be the source, which it no longer is, and refuses (claim rev 6.8.1); with the three box-side fences gone as well, the non-exec source is admitted: its cold admission moves holder.stamp under the target (INV-6) on the way to two boxes running the host (INV-1)", wAdmissionTriple}, |
| - {"start-check", "exec-admission,hold-op-open,start-check,exec-writer", "INV-6", "the whole start check in place of its generation clause (see exec-admission)", wAdmissionTriple}, |
| + {"exec-admission", "exec-admission,hold-op-open,start-check-claim-gen,exec-writer", "INV-1", |
| + "one of four admission fences (J2-R2-8, claim rev 6.8.1): a paused open from an earlier operation makes the old source ready after the commit; hold_op refuses the open, the exec check refuses the wake, the generation check refuses the start, AdmitWriter refuses a box that is not op.exec; with all four gone the source's wake is a cold admission by a non-exec box into a fresh generation restored from its own region's old files, and it serves beside the target (INV-1; its installing CAS also moves holder.stamp under the target, INV-6 a step later)", wAdmissionTriple}, |
| + {"hold-op-open", "exec-admission,hold-op-open,start-check-claim-gen,exec-writer", "INV-1", "one of four admission fences (see exec-admission)", wAdmissionTriple}, |
| + {"start-check-claim-gen", "exec-admission,hold-op-open,start-check-claim-gen,exec-writer", "INV-1", "one of four admission fences (see exec-admission)", wAdmissionTriple}, |
| + {"exec-writer", "exec-admission,hold-op-open,start-check-claim-gen,exec-writer", "INV-1", |
| + "a paused open from an earlier operation lands after the commit while the source's row is still held; the source's AdmitWriter CAS requires op.exec to be the source, which it no longer is, and refuses (claim rev 6.8.1); with the three box-side fences gone as well, the non-exec source is admitted cold and two boxes run the host (INV-1)", wAdmissionTriple}, |
| {"strict-spawn", "strict-spawn,evidence-whole-tree", "INV-1", |
| "redundant alone: an escaped descendant is killed by the whole-tree cleanup; with evidence blind to escapees, a non-strict descendant survives the cut and writes beside the target (INV-1)", wStrictSpawn}, |
| {"touched-incomplete", "touched-incomplete,legacy-discovery", "freed with residue", |
| @@ -98,8 +97,8 @@ var witnesses = []witness{ |
| "a config pass selected under the first installation pauses across a retire-and-reinstall (UK→US→UK) and a bundle change; the host guard's fresh check refuses its stale bundle, the stamp refuses its retired installation; with both gone it writes host state for a retired installation (R4-2)", wStaleWriter}, |
| {"live-set-region", "live-set-region", "residue never retired", |
| "a legacy host serving in UK with an earlier replica in US; discovery parks US; an old US replication PUT lands after; the live set is region-qualified, so the US sweep parks it; exempted by key alone it stays for good (R4-3)", wLiveSetRegion}, |
| - {"restore-overlap", "restore-overlap", "INV-2", |
| - "a generation's snapshot ends at TXID 11 and the only continuation is a compacted file [10,15]; litestream's planner takes it and restores through 15; rev 7.4's rule (min == current+1) discards it and the wake loses four acknowledged writes (R4-1)", wRestoreOverlap}, |
| + {"restore-overlap", "restore-overlap", "INV-3", |
| + "a generation's snapshot ends at TXID 11 and the only continuation is a compacted file [10,15]; litestream's planner takes it and restores through 15; rev 7.4's rule (min == current+1) discards it, the restore stops short of the certificate's max_txid, and the box never starts again (R4-1; since rev 7.10 the certified position is checked before the start, where rev 7.4's rule lost four acknowledged writes)", wRestoreOverlap}, |
| {"restore-gap-closed", "restore-gap-closed", "INV-2 (cold restore)", |
| "a generation's snapshot ends at TXID n, the file [n+1,n+1] is missing and [n+2,n+2] is current: a genuine gap; the restore refuses; returning what it has would wake the host without durable transactions (R4-1)", wRestoreGap}, |
| // rev 7.6 |
| @@ -138,10 +137,19 @@ var witnesses = []witness{ |
| "a committed move's target serves while the claim is still landing; it dies with its disk and an upload in flight; the next wake must be an AdmitWriter within the landing, a cold one into a fresh generation; gated for ordinary wakes only, the target continues the generation and the late upload replaces its file (claim rev 6.8 §6.4a, R8-2)", wCommittedLandingCrash}, |
| {"admit-everywhere", "admit-everywhere", "INV-2", |
| "a landing's probe dies with its disk and an upload in flight; the restarted probe must be a cold AdmitWriter within the landing, into a fresh install generation; gated for ordinary wakes only, the restarted probe reuses the generation and the late upload replaces its file (R8-2)", wProbeRestart}, |
| - {"warm-provenance", "warm-provenance", "INV-2", |
| - "a cold admission finds a warm database of the predecessor generation whose lineage file names another session: a cached copy, behind the remote; without provenance it must restore from the remote; taken as the baseline, the new generation's snapshot lacks a durable write (claim rev 6.8 §6.4a, R8-4)", wWarmLineageMismatch}, |
| {"valid-ltx", "valid-ltx", "INV-12", |
| - "a created incarnation's first wake: an empty generation, position 0; the cold admission writes no LTX file and the app's first transaction is the first upload; a snapshot [1,0] or an anchor [0,0] is a file the pinned encoder refuses (R8-3)", wEmptyBootstrap}, |
| + "a created incarnation's first wake is a cold admission into an empty generation with no LTX file, and a move of the still-empty incarnation installs nothing at position 0; the mutation installs a snapshot [1,0] and an anchor [0,0], files the pinned encoder refuses (R8-3)", wEmptyBootstrap}, |
| + // rev 7.10 |
| + {"remote-baseline", "remote-baseline", "INV-2", |
| + "a session closes cleanly through N+1 and its certificate stands; the box's local database is a cached copy through N (R9-1's clean-close variant); the next wake must purge it and restore the certified remote; keeping it serves a database without an acknowledged write, and its own upload for N+1 is skipped at the remote position (claim rev 6.9)", wStaleLocalAfterClose}, |
| + {"writes-lost", "writes-lost", "INV-2", |
| + "a writer acknowledges a write and crashes before uploading it; the next wake is cold and restores the remote, which lacks the write: the admission must record writes_lost {box, generation, session, remote_max_txid} and alert; silently, the serving database differs from the acknowledged writes (claim rev 6.9, R9-2)", wSilentLoss}, |
| + {"admit-bind-op", "admit-bind-op,claim-cas-opid", "INV-13", |
| + "a wake's admission closure is computed under serving (op.id \"\"); a delete begins, preserving session, generation and executor; the stale closure resumes: bound to the observed op.id, phase and kind it is refused; the model's general CAS condition on op.id (C2, claim-cas-opid) refuses it too, so the two must go together for a writer to be admitted into a host being deleted (claim rev 6.9, R9-4)", wStaleClosureDelete}, |
| + {"close-evidence", "close-evidence", "certificate while", |
| + "a compaction's level-1 PUT is held past the client's timeout; the next compaction pass retries and lands; the original completes afterwards; the session's clean stop must write no certificate, since a request of its lifetime was retried after a timeout; looking at the level-0 queue alone it certifies (R9-5)", wMaintenanceRetry}, |
| + {"close-evidence", "close-evidence", "certificate while", |
| + "a snapshot PUT completes on the server after the client gave up on it, a lost response; the client never observed success, so the session must stay uncertain and certify nothing; counting server completion it certifies (R9-5)", wLostResponse}, |
| {"close-evidence", "close-evidence", "certificate while", |
| "an upload times out and its retry succeeds while the original stays unresolved; the writer's clean close must write no certificate, because a request of its lifetime is unaccounted for; looking at the level-0 queue alone it certifies (claim rev 6.8 §6.4a, R8-5)", wRetryUnresolved}, |
| {"build-completion", "build-completion", "before its snapshot", |
| @@ -327,7 +335,7 @@ func (w *world) serve(b *box, inc int, app string) { |
| n = w.claim.clone() |
| n.Ver++ |
| } |
| - n.Holder = &holder{Inc: inc, App: app, Serial: w.serial, Location: loc, Stamp: n.Ver, Session: n.Ver} // the serving CAS wrote holder.stamp; one writer session so far |
| + n.Holder = &holder{Inc: inc, App: app, Serial: w.serial, Location: loc, Stamp: n.Ver, Session: n.Ver, WriterBox: b.ID} // the serving CAS wrote holder.stamp; one writer session so far |
| n.Op = opState{Kind: "serving", Exec: b.ID, Parts: []int{b.ID}} |
| n.Regions, n.Boxes, n.Complete, n.Obl = []int{b.Region}, []int{b.ID}, true, nil |
| w.claim = n |
| @@ -535,6 +543,7 @@ func (w *world) completeHeld(key string) int { |
| var keep []*request |
| for _, r := range w.pending { |
| if (r.key == key || strings.HasSuffix(key, "/") && strings.HasPrefix(r.key, key)) && r.doneAt == 1<<30 { |
| + r.doneAt = w.now // the response arrives now (the close's evidence reads the completion time, R9-5) |
| w.complete(r) |
| w.logf("witness: held %v %s r%d completes now", r.kind, r.key, r.region) |
| n++ |
| @@ -586,12 +595,15 @@ func wGenerationConditional(t *testing.T, w *world) { |
| push := func(holdMarker bool) { |
| w.userPush() |
| id := w.claim.Op.ID |
| - if !w.reach(t, 400, "push "+id+" published", func() bool { return w.claim.Op.ID == id && w.claim.Op.Phase == "published" }, w.act(e)) { |
| + if !w.reach(t, 400, "push "+id+" probing", func() bool { return w.claim.Op.ID == id && w.claim.Op.Phase == "probing" }, w.act(e)) { |
| return |
| } |
| if holdMarker { |
| + // the probe's cold admission publishes generation.json for the |
| + // prepared generation (rev 7.10): accepted, response lost; the |
| + // writer does not start, and the next attempt publishes again |
| w.holdKey(genMarkerKey(loc)) |
| - w.tick(w.act(e)) // the first publication: accepted, response lost |
| + w.tick(w.act(e)) |
| w.hold = nil |
| } |
| w.reach(t, 400, "push "+id+" released", func() bool { return w.claim.Op.Kind == "serving" }, w.act(e)) |
| @@ -710,6 +722,10 @@ func wRollbackJournal(t *testing.T, w *world) { |
| c := w.claim |
| return c.Op.Kind == "restoring" && c.Op.SrcPhase == "probed" && hasMain(src) |
| }, w.act(src, tgt), w.recoveryPass) |
| + if !hasMain(src) { |
| + w.violate("INV-3 (witness): the source's probe did not run on the return: %s%s", w.state(), w.boxesState()) |
| + return |
| + } |
| w.appWrite(src) |
| w.replicate(src) // into the return generation: durable there |
| src.Procs = nil |
| @@ -794,6 +810,7 @@ func wDirtyReturn(t *testing.T, w *world) { |
| w.serve(src, 1, app0) |
| w.landProbed(t, src, tgt) |
| w.appWrite(tgt) |
| + w.replicate(tgt) // durable in the target's generation: no writes_lost declaration can cover it (rev 7.10) |
| w.rollback(w.claim, "settle check failed") |
| w.finish(t) |
| } |
| @@ -1814,8 +1831,9 @@ func wProbeRestart(t *testing.T, w *world) { |
| if w.completeHeld(key) == 0 { |
| t.Fatalf("witness: the dead probe's upload was not held") |
| } |
| - // the landing's install generation, as its planner would restore it now |
| - gen := w.opGen(&w.claim.Op) |
| + // the landing's generation, holder.generation since the probe's cold |
| + // admission (claim rev 6.9), as its planner would restore it now |
| + gen := w.claim.Holder.Gen |
| db := w.restoreCalc(tgt.Region, 1, loc, gen) |
| if db == nil || !contains(db.Log, wr) { |
| w.violate("INV-2 (witness): the landing's generation %q lost the restarted probe's write %d after the dead probe's upload landed", gen, wr) |
| @@ -1824,25 +1842,6 @@ func wProbeRestart(t *testing.T, w *world) { |
| w.finish(t) |
| } |
| -// wWarmLineageMismatch (claim rev 6.8 §6.4a, R8-4): a warm database of the |
| -// predecessor generation whose lineage names another session. |
| -func wWarmLineageMismatch(t *testing.T, w *world) { |
| - b := w.boxes[0] |
| - w.serve(b, 1, app0) |
| - w.wake(b) |
| - w.appWrite(b) |
| - w.replicate(b) |
| - stale := b.DB.clone() // a cached copy at this position |
| - w.appWrite(b) |
| - w.replicate(b) // one more durable write the copy lacks |
| - w.crash(b) |
| - b.DB = stale |
| - b.lineage[1] = lineageRec{Gen: stale.Gen, Session: 999} // a lineage no session of this claim wrote |
| - w.logf("witness: box %d's database is a stale copy with a foreign lineage", b.ID) |
| - w.recovered(t, b, "running after the crash") |
| - w.finish(t) |
| -} |
| - |
| // wEmptyBootstrap (R8-3): a created incarnation's first wake. |
| func wEmptyBootstrap(t *testing.T, w *world) { |
| w.console = append(w.console, &consolePlan{Kind: "create", Host: "h", App: app0, OpID: w.newID("cr"), Inc: 1, Region: 0}) |
| @@ -1852,8 +1851,8 @@ func wEmptyBootstrap(t *testing.T, w *world) { |
| return c != nil && c.Holder != nil && c.Holder.Inc == 1 && c.Op.Kind == "serving" && b.Row != nil |
| }, w.runConsole, w.actAll(), w.recoveryPass) |
| b.Config, b.CfgVer, b.Live, b.LiveInc = 1, w.cfgVer[app0], app0, 1 |
| - // the first wake: a cold admission into an empty generation, which the |
| - // mutation builds with files the encoder refuses (INV-12 at the PUT) |
| + // the first wake: a cold admission into an empty generation (claim rev |
| + // 6.9), no LTX file at all |
| w.reach(t, 120, "first wake", func() bool { return hasMain(b) }, w.act(b), w.recoveryPass, func() { w.wake(b) }) |
| if len(w.violations) > 0 { |
| return |
| @@ -1866,11 +1865,28 @@ func wEmptyBootstrap(t *testing.T, w *world) { |
| if n := len(w.currentLTX(w.buckets[b.Region], dataKey(loc, gen, ""), 0)) + len(w.currentLTX(w.buckets[b.Region], dataKey(loc, gen, ""), 9)); n != 0 { |
| t.Fatalf("witness: an empty generation has %d LTX files before the first transaction", n) |
| } |
| - w.appWrite(b) |
| - w.replicate(b) // the first transaction is the first upload, [1,1] |
| + // a move of the empty incarnation: its seal at the cut is at position 0 |
| + // with no transaction, and the landing installs nothing; the mutation |
| + // installs a snapshot [1,0] and an anchor [0,0], which the encoder |
| + // refuses (INV-12 at the PUT). A push cannot be empty: its seal carries |
| + // the push's own mark |
| w.idleStop(b) |
| - w.loseDisk(b) |
| - w.recovered(t, b, "woken cold") |
| + w.reach(t, 400, "the first wake's chores done", func() bool { return len(w.claim.Obl) == 0 }, w.act(b), w.recoveryPass) |
| + tgt := w.regionBox(1) |
| + w.moveTo(tgt.Region) |
| + if w.claim.Op.Kind != "moving" { |
| + t.Fatalf("witness: the move of the empty incarnation did not start: %s", w.state()) |
| + } |
| + w.reach(t, 800, "move of the empty incarnation finalized", func() bool { return w.claim.Op.Kind == "serving" && w.claim.Op.Exec == tgt.ID }, w.act(b, tgt), w.recoveryPass) |
| + if len(w.violations) > 0 { |
| + return |
| + } |
| + w.recovered(t, tgt, "woken after the move") |
| + w.appWrite(tgt) |
| + w.replicate(tgt) // the first transaction is the first upload, [1,1] |
| + w.idleStop(tgt) |
| + w.loseDisk(tgt) |
| + w.recovered(t, tgt, "woken cold") |
| w.finish(t) |
| } |
| @@ -1911,3 +1927,150 @@ func wRetryUnresolved(t *testing.T, w *world) { |
| w.completeHeld(key) |
| w.finish(t) |
| } |
| + |
| +// ---------- rev 7.10 ---------- |
| + |
| +// wStaleLocalAfterClose (claim rev 6.9, R9-1): a valid certificate and a |
| +// stale local database. |
| +func wStaleLocalAfterClose(t *testing.T, w *world) { |
| + b := w.boxes[0] |
| + w.serve(b, 1, app0) |
| + w.wake(b) |
| + stale := b.DB.clone() // a cached copy through N |
| + w.appWrite(b) |
| + w.replicate(b) // N+1, durable |
| + w.idleStop(b) // the certificate names N+1 as the remote maximum |
| + loc := w.claim.Holder.Location |
| + if w.buckets[b.Region].current(closedKey(loc, w.claim.Holder.Gen, b.DB.Session)) == nil { |
| + t.Fatalf("witness: no certificate after the clean close") |
| + } |
| + b.DB = stale |
| + w.logf("witness: box %d's local database is a cached copy without the last acknowledged write", b.ID) |
| + w.recovered(t, b, "woken on the certificate") |
| + w.appWrite(b) |
| + w.replicate(b) // the next upload: skipped at the remote position if the stale copy was kept |
| + w.checkLocal(w.claim, b, b.DB) |
| + w.checkDurable(w.claim, "after the wake on the stale copy") |
| + w.finish(t) |
| +} |
| + |
| +// wSilentLoss (claim rev 6.9, R9-2): an acknowledged, unuploaded write and |
| +// a crash; the next admission declares the loss. |
| +func wSilentLoss(t *testing.T, w *world) { |
| + b := w.boxes[0] |
| + w.serve(b, 1, app0) |
| + w.wake(b) |
| + w.appWrite(b) // acknowledged, never uploaded |
| + w.crash(b) |
| + w.recovered(t, b, "woken after the crash") |
| + if w.stats["writes_lost declared"] == 0 && on("writes-lost") { |
| + t.Fatalf("witness: no writes_lost record after the crash") |
| + } |
| + w.checkLocal(w.claim, b, b.DB) |
| + w.finish(t) |
| +} |
| + |
| +// wStaleClosureDelete (claim rev 6.9, R9-4): an admission closure computed |
| +// under serving resumes after a delete began. |
| +func wStaleClosureDelete(t *testing.T, w *world) { |
| + b := w.boxes[0] |
| + w.serve(b, 1, app0) |
| + w.wake(b) |
| + w.idleStop(b) |
| + stale := w.claim.clone() // the wake's read, under serving |
| + w.userDelete() |
| + if w.claim.Op.Kind != "deleting" { |
| + t.Fatalf("witness: the delete did not begin: %s", w.state()) |
| + } |
| + // the claim library's BeginDelete preserves session, generation and the |
| + // executor (R9-4); this model's delete names no executor, so the |
| + // witness keeps the serving box as op.exec, as the library does |
| + w.cas(w.claim, func(n *claim) bool { n.Op.Exec = b.ID; return true }) |
| + w.logf("witness: a stalled wake resumes with its serving-time read of the claim") |
| + if w.spawn(b, stale, stale.Holder.Gen, false) { |
| + w.logf("witness: the stale closure admitted a writer during the delete") |
| + } |
| + w.finish(t) |
| +} |
| + |
| +// wMaintenanceRetry (R9-5): a compaction PUT retried after a timeout. |
| +func wMaintenanceRetry(t *testing.T, w *world) { |
| + b := w.boxes[0] |
| + w.serve(b, 1, app0) |
| + w.wake(b) |
| + loc := w.claim.Holder.Location |
| + gen := w.claim.Holder.Gen |
| + var original *request |
| + w.hold = func(r *request) bool { |
| + if r.kind == rPut && r.b != nil && r.b.Level == 1 && original == nil { |
| + original = r |
| + return true // the first compaction attempt alone is held |
| + } |
| + return r == original |
| + } |
| + for i := 0; i < 3; i++ { |
| + w.appWrite(b) |
| + w.replicate(b) // three level-0 files: compaction issues its level-1 PUT, held |
| + } |
| + if original == nil { |
| + t.Fatalf("witness: no compaction PUT was issued: %s", w.ltxShape(b.Region, dataKey(loc, gen, ""))) |
| + } |
| + for i := 0; i <= uploadTimeout; i++ { |
| + w.tick(func() { w.replicate(b) }) // the client times out; the next compaction pass retries and lands |
| + } |
| + if len(w.currentLTX(w.buckets[b.Region], dataKey(loc, gen, ""), 1)) == 0 { |
| + // the retry may compact a wider range than the original, under its own key |
| + t.Fatalf("witness: the compaction retry did not land: %s", w.ltxShape(b.Region, dataKey(loc, gen, ""))) |
| + } |
| + w.idleStop(b) |
| + sk := closedKey(loc, gen, b.DB.Session) |
| + for i := 0; i < 10; i++ { |
| + w.tick(func() { w.replicate(b) }) |
| + if w.buckets[b.Region].current(sk) != nil { |
| + w.violate("INV-2 (witness): box %d wrote its certificate while its original compaction PUT %s was unresolved (R9-5)", b.ID, original.key) |
| + return |
| + } |
| + } |
| + w.hold = nil |
| + w.completeHeld(original.key) |
| + w.finish(t) |
| +} |
| + |
| +// wLostResponse (R9-5): a snapshot PUT whose response is lost. |
| +func wLostResponse(t *testing.T, w *world) { |
| + b := w.boxes[0] |
| + w.serve(b, 1, app0) |
| + w.wake(b) |
| + loc := w.claim.Holder.Location |
| + gen := w.claim.Holder.Gen |
| + var original *request |
| + w.hold = func(r *request) bool { |
| + if r.kind == rPut && r.b != nil && r.b.Level == 9 && original == nil { |
| + original = r |
| + return true // the snapshot PUT alone is held |
| + } |
| + return r == original |
| + } |
| + for i := 0; i < 3; i++ { |
| + w.appWrite(b) |
| + w.replicate(b) // compaction rotates a snapshot, whose PUT is held |
| + } |
| + if original == nil { |
| + t.Fatalf("witness: no snapshot PUT was issued: %s", w.ltxShape(b.Region, dataKey(loc, gen, ""))) |
| + } |
| + for i := 0; i <= uploadTimeout; i++ { |
| + w.tick(func() { w.replicate(b) }) // the client gives up on it |
| + } |
| + w.hold = nil |
| + w.completeHeld(original.key) // the server completes it after the client gave up: a lost response |
| + w.idleStop(b) |
| + sk := closedKey(loc, gen, b.DB.Session) |
| + for i := 0; i < 10; i++ { |
| + w.tick(func() { w.replicate(b) }) |
| + if w.buckets[b.Region].current(sk) != nil { |
| + w.violate("INV-2 (witness): box %d wrote its certificate while the response to its snapshot PUT %s was lost (R9-5)", b.ID, original.key) |
| + return |
| + } |
| + } |
| + w.finish(t) |
| +} |
diff --git a/internal/sim/hostlife/world_test.go b/internal/sim/hostlife/world_test.go| index d321f3ad9..18028fcb0 100644 |
| --- a/internal/sim/hostlife/world_test.go |
| +++ b/internal/sim/hostlife/world_test.go |
| @@ -359,11 +359,14 @@ type box struct { |
| // writer is admitted only if its stamp equals the active one. |
| Stamps map[int]*stamps |
| Parked int // count of locally parked artifacts |
| - // lineage is the local lineage file per incarnation: the generation and |
| - // session the writer that opened this database was admitted to (claim |
| - // rev 6.8 §6.4a, R8-4). Lost with the disk; never written by a stamp |
| - // re-initialization. |
| - lineage map[int]lineageRec |
| + // open is an admitted writer's start in progress (rev 7.10 §7.14): the |
| + // claim CAS is done and the PUT it must have acknowledged before the |
| + // process starts (its first upload, its marker, its anchor) is |
| + // outstanding; local bookkeeping, not a claim transition |
| + open *openState |
| + // PurgeFails is the fault: the purge of the local database an admitted |
| + // writer must make before it starts returns an error (claim rev 6.9) |
| + PurgeFails bool |
| // anchors are the PUTs this box's activator must see acknowledged before |
| // it goes on (an anchor, a recovery's snapshot, a closed.json), by key |
| // (rev 7.7, R6-1): a request the client has waited uploadTimeout steps |
| @@ -564,6 +567,10 @@ type holder struct { |
| // current (moves rev 6 §5.1): "" is origin; a move, a push and a dirty |
| // rollback each install a generation named by their op id. |
| Gen string |
| + // WriterBox is holder.writer_box (claim rev 6.9): the box every |
| + // admission names as the current writer; it survives operation |
| + // replacement, so a later cold admission's writes_lost names it (R9-2). |
| + WriterBox int |
| // Session is holder.session (claim rev 6.8 §6.4a, R8-1): the gen of the |
| // AdmitWriter CAS that admitted the current writer session. Every path |
| // that opens replication goes through that CAS, which moves the |
| @@ -596,41 +603,42 @@ type writerEntry struct { |
| Session, Box int |
| Gen string |
| Stamp int |
| - Mode string |
| - Opened bool |
| + Mode string // warm or cold (claim rev 6.9) |
| Pred pred |
| + Lost *writesLost // a cold admission whose observed session had no certificate (claim rev 6.9, R9-2) |
| } |
| -// pred is the predecessor a cold admission journals before the recovery |
| -// stamp activates (claim rev 6.8 §6.4a, R8-4). |
| +// pred is the predecessor an admission journals: the session, writer box, |
| +// generation and stamp it observed. |
| type pred struct { |
| Session, Box int |
| Gen string |
| Stamp int |
| } |
| -// recState is a cold admission's build in progress (moves rev 7.9 §7.14): |
| -// the new generation, the predecessor generation it restores from, the |
| -// session and box admitted, the phase, the predecessor, and the snapshot |
| -// identity once written. |
| -type recState struct { |
| - Region int // the admitting box's region, where the generation under construction lives |
| - |
| - Gen, Base string |
| - Session, Box int |
| - Phase string |
| - Pred pred |
| - BaseVer int |
| - BaseKey string |
| - BaseTX int |
| +// openState is an admitted writer between its AdmitWriter CAS and its |
| +// process start (rev 7.10 §7.14): the generation and session it was |
| +// admitted to, the database it restored, and the stage whose PUT it waits |
| +// for: "first" (a fresh generation's full first upload), "marker" |
| +// (generation.json), "anchor" (a warm restore above the level-0 maximum). |
| +type openState struct { |
| + Gen, Want string |
| + Session int |
| + Mode string |
| + Prepared bool |
| + DB *localDB |
| + stage string |
| + req *request |
| } |
| -// lineageRec is the local lineage file a writer writes as it opens (claim |
| -// rev 6.8 §6.4a): the generation and session it was admitted to. A stamp |
| -// re-initialization never writes it (R8-4). |
| -type lineageRec struct { |
| - Gen string |
| - Session int |
| +// writesLost is claim rev 6.9's writes_lost record: the writer whose |
| +// acknowledged, unuploaded writes a cold admission leaves behind, named by |
| +// holder.writer_box, never inferred from op.exec (R9-2). The box alerts. |
| +type writesLost struct { |
| + Box int |
| + Gen string |
| + Session int |
| + RemoteMaxTXID int |
| } |
| type opState struct { |
| @@ -649,46 +657,44 @@ type opState struct { |
| NewSerial int |
| // journal |
| - ReportAt int |
| - ReportOK bool |
| - CutAt int |
| - CutDeadline int |
| - StopAt int |
| - StopDigest digest |
| - StopFiles *filesDir |
| - SealDB ref |
| - SealFiles ref |
| - SealQuiet int |
| - LandPhase string |
| - Installed digest |
| - ProbeTries int |
| - ProbedAt int |
| - RevPhase string |
| - SrcPhase string |
| - Dirty bool |
| - DirtyKnown bool |
| - ReturnDB ref |
| - ReturnFiles ref |
| - ReturnQuiet int |
| - LostBox int |
| - LostAt int |
| - RollbackID string |
| - PushSnap *blob // a push's installed snapshot (the final checkpoint plus the push's mark) |
| - BaseKey string |
| - BaseTX int |
| - RecVer int // a delete's or purge's intent: the record version it will DeleteIfMatch (claim §5.2 step 1, §5.3 step 4) |
| - FlipGen int // the holder.stamp the flip CAS wrote: the landing's installation stamp (claim rev 6.5 §6.4) |
| - SrcStamp int // the source installation's stamp before the flip, for the return to source (claim rev 6.5) |
| - // Writers is op.journal.writers (claim rev 6.8 §6.4a): one entry per |
| - // AdmitWriter CAS. Rec is a cold admission whose build (the new |
| - // generation's snapshot and anchor, generation.json) is still in |
| - // progress; it never replaces the operation (R8-2). InstGen is the |
| - // generation an operation is installing when a cold admission within it |
| - // moved it off the op id; "" means the op id (or the rollback id). |
| + ReportAt int |
| + ReportOK bool |
| + CutAt int |
| + CutDeadline int |
| + StopAt int |
| + StopDigest digest |
| + StopFiles *filesDir |
| + SealDB ref |
| + SealFiles ref |
| + SealQuiet int |
| + LandPhase string |
| + Installed digest |
| + ProbeTries int |
| + ProbedAt int |
| + RevPhase string |
| + SrcPhase string |
| + Dirty bool |
| + DirtyKnown bool |
| + ReturnDB ref |
| + ReturnFiles ref |
| + ReturnQuiet int |
| + ReturnParked bool // the return lane's park-staging chore is owed (rev 7.10) |
| + LostBox int |
| + LostAt int |
| + RollbackID string |
| + PushSnap *blob // a push's installed snapshot (the final checkpoint plus the push's mark) |
| + BaseKey string |
| + BaseTX int |
| + RecVer int // a delete's or purge's intent: the record version it will DeleteIfMatch (claim §5.2 step 1, §5.3 step 4) |
| + FlipGen int // the holder.stamp the flip CAS wrote: the landing's installation stamp (claim rev 6.5 §6.4) |
| + SrcStamp int // the source installation's stamp before the flip, for the return to source (claim rev 6.5) |
| + // Writers is op.journal.writers (claim rev 6.9 §6.4a): one entry per |
| + // AdmitWriter CAS. It is the operation's, dropped with it; the writer's |
| + // box lives in holder.writer_box (R9-2). SrcGen is the source's |
| + // generation at the move's start, which a clean rollback's return CAS |
| + // names again (rev 7.10 §7.14). |
| Writers []writerEntry |
| - Rec *recState |
| - InstGen string |
| - Fresh bool // the operation's freshly built generation has had no writer yet: its first probe is admitted cold without another build |
| + SrcGen string |
| Committed bool |
| Opened bool |
| ParkLocalNow bool |
| @@ -817,6 +823,9 @@ type world struct { |
| // for is accepted and completes only when the witness says so |
| // (witness_test.go). The random sweep never sets it. |
| hold func(r *request) bool |
| + // lastIssued is the request issue saw last, for a caller that needs |
| + // the request object behind a helper's return value (resumeOpen) |
| + lastIssued *request |
| // stampBox records, for every installation stamp the claim issued, the |
| // box whose installation it names (claim rev 6.5: holder.stamp always |
| // names the installation op.exec should run). The oracle refuses an |
| @@ -961,9 +970,11 @@ func (c *claim) clone() *claim { |
| n.Op.Parts = append([]int(nil), c.Op.Parts...) |
| n.Op.StopFiles = c.Op.StopFiles.clone() |
| n.Op.Writers = append([]writerEntry(nil), c.Op.Writers...) |
| - if c.Op.Rec != nil { |
| - r := *c.Op.Rec |
| - n.Op.Rec = &r |
| + for i := range n.Op.Writers { |
| + if l := n.Op.Writers[i].Lost; l != nil { |
| + cp := *l |
| + n.Op.Writers[i].Lost = &cp |
| + } |
| } |
| n.Regions = append([]int(nil), c.Regions...) |
| n.Boxes = append([]int(nil), c.Boxes...) |
| @@ -1008,6 +1019,7 @@ func (w *world) iamCheck(actorBox int, r *request) bool { |
| // (the request may still complete later). |
| func (w *world) issue(actorBox int, r *request) int { |
| r.at = w.now |
| + w.lastIssued = r |
| if r.kind == rPut && r.b != nil && isDataKey(r.key) && (r.b.Min == 0 || r.b.Max == 0) { |
| // INV-12 (R8-3): the pinned LTX encoder refuses a zero TXID, so no |
| // such file can exist; a position-0 snapshot [1,0] or anchor [0,0] |