sim: hostlife: LTX restore, installation stamps, region-qualified live set, purge retirement (rev 7.5)
Joint round 4 and claim rev 6.2 (30d572c4):
- R4-1: the database is a map of keys to values; transactions insert,
update or delete; replica objects are LTX files with litestream's
semantics (ltx_test.go): a transaction range, the final page image of
every key touched, the checksum after Max, levels 0 and 1, a level-9
snapshot; compaction merges level 0 into level 1 and deletes the
inputs. Restore is litestream v0.5.14's planner over the generation's
current files from the snapshot generation.json names (key, version,
TXID): overlap allowed, highest max first, a gap refused, the checksum
verified. The sealed transfer is a verified final snapshot. The oracle
replays acknowledged transactions into the state a restore must equal.
Rules restore-overlap and restore-gap-closed, with witnesses (a
compacted [10,15] over a snapshot at 11 restores through 15; a genuine
gap refuses).
- R4-2: installation stamps per claim rev 6.2 §6.4: each box keeps
{active_stamp | none, retired_stamps} per incarnation; a writer carries
the stamp it selected under and is admitted only if it equals the
active one; retirement moves active to retired; a landing sets its own
stamp (the flip CAS's gen) under the guard on a fresh read. The op
counter ordering and the empty-op-means-now rule are gone. Rule
fence-stamp, witness: a stale config pass across UK->US->UK. The oracle
records a landing probe refused by its own installation record.
- R4-3: the live set is {authoritative region, location, generation};
matching keys elsewhere are residue. Rule live-set-region, witness: an
old US replication PUT for a legacy host serving in UK.
- R4-4: deleteSteps records the record version in op.intent, deletes
conditionally, and moves purging -> parking only with the journal at
torn-down; a purged host never returns to serving.
- Random generator: marker-hold and targeted freeze schedules, a
retention-boundary fixture, the probe-fence oracle.
- The absent-claim branch of orphan cleanup is explicit (legacy, never
an orphan; claim rev 6.2 §5.1).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
10 files changed,
+1022
−287
internal/sim/hostlife/background_test.go+35 −15internal/sim/hostlife/console_test.go+47 −8internal/sim/hostlife/expected-outcomes.txt+1 −0internal/sim/hostlife/ltx_test.go+337 −0internal/sim/hostlife/moves_test.go+94 −60internal/sim/hostlife/protocol_test.go+77 −87internal/sim/hostlife/rules_test.go+5 −1internal/sim/hostlife/sim_test.go+89 −38internal/sim/hostlife/witness_test.go+200 −23internal/sim/hostlife/world_test.go+137 −55
diff --git a/internal/sim/hostlife/background_test.go b/internal/sim/hostlife/background_test.go| index 120d2ced5..9945c580d 100644 |
| --- a/internal/sim/hostlife/background_test.go |
| +++ b/internal/sim/hostlife/background_test.go |
| @@ -15,7 +15,9 @@ func (w *world) traffic(b *box) { |
| w.nextW++ |
| wr := w.nextW |
| if b.DB != nil && b.DB.Inc == p.Inc { |
| - b.DB.Writes = append(b.DB.Writes, wr) |
| + w.commit(b.DB, wr) |
| + } else { |
| + w.txs[wr] = tx{W: wr} // into a purged or foreign database: nowhere |
| } |
| // acknowledged to the client or the outside world either way: a |
| // write into a purged or foreign database is a lost write |
| @@ -29,17 +31,6 @@ func (w *world) traffic(b *box) { |
| } |
| } |
| -// replicate: the serving process's ordinary litestream replication. The |
| -// claim's C3 exception: these are plain PUTs to reused keys. |
| -func (w *world) replicate(b *box) { |
| - if !w.agentUp(b) || !hasMain(b) || b.DB == nil { |
| - return |
| - } |
| - bl := &blob{Kind: "ltx", Inc: b.DB.Inc, Gen: b.DB.Gen, Writes: append([]int(nil), b.DB.Writes...), SealKey: b.DB.Key} |
| - // into the location and generation the database was restored from |
| - w.issue(b.ID, &request{kind: rPut, region: b.Region, key: dataKey(b.DB.Loc, b.DB.Gen, "ltx"), b: bl}) |
| -} |
| - |
| // idleStop: the activator's ordinary idle stop (main only; a descendant |
| // keeps running, as today). |
| func (w *world) idleStop(b *box) { |
| @@ -206,7 +197,9 @@ func (w *world) reconcileClaimed(b *box, c *claim) { |
| } |
| if b.Row == nil && c.Op.Kind == "serving" { |
| b.Row = &row{Inc: h.Inc, App: h.App, Status: "ready"} |
| - delete(b.Fence, h.Inc) // the serving executor, on a fresh read: the fence is replaced |
| + if b.stampOf(h.Inc) == 0 { |
| + b.install(h.Inc, c.Ver) // a create's installation: its stamp is the gen that made it serving here (claim rev 6.2 §6.4) |
| + } |
| } |
| } else if b.Row != nil && !(on("participant-rows") && w.ownsLocalObligation(c, b)) { |
| w.retireRow(b) |
| @@ -233,7 +226,7 @@ func (w *world) passPlan(b *box, kind string) { |
| if kind == "config" && !b.HasKey { |
| return |
| } |
| - b.plans = append(b.plans, &plan{Kind: kind, Inc: b.Row.Inc, App: b.Row.App, Ver: w.cfgVer[b.Row.App], At: w.now + w.pauseLen(), Seq: w.nextID}) |
| + b.plans = append(b.plans, &plan{Kind: kind, Inc: b.Row.Inc, App: b.Row.App, Ver: w.cfgVer[b.Row.App], At: w.now + w.pauseLen(), Stamp: b.stampOf(b.Row.Inc)}) |
| } |
| // passExec runs one independent process's write. It needs the box running, |
| @@ -257,13 +250,22 @@ func (w *world) passExec(b *box) { |
| return |
| } |
| c := w.claim |
| - if c == nil || c.Holder == nil || c.Holder.Inc != p.Inc || b.Row == nil || b.Row.Inc != p.Inc || w.fenced(b, p.Inc, p.Seq) { |
| + if c == nil || c.Holder == nil || c.Holder.Inc != p.Inc || b.Row == nil || b.Row.Inc != p.Inc { |
| return |
| } |
| if p.Kind == "config" && p.Ver != w.cfgVer[p.App] { |
| return // the selected bundle is no longer the held one |
| } |
| } |
| + // the local record (claim rev 6.2 §6.4): the writer carries the stamp |
| + // it selected under, admitted only if it is still the active one |
| + if !b.admitsStamp(p.Inc, p.Stamp) { |
| + return |
| + } |
| + if p.Stamp != b.stampOf(p.Inc) { |
| + // the oracle: a writer of a retired installation wrote host state |
| + w.violate("INV-6: a %s pass of a retired installation (stamp %d, active %d) wrote host state for inc %d on box %d", p.Kind, p.Stamp, b.stampOf(p.Inc), p.Inc, b.ID) |
| + } |
| switch p.Kind { |
| case "config": |
| b.Config, b.CfgVer = p.Inc, p.Ver |
| @@ -354,6 +356,24 @@ func (w *world) fault() { |
| b.Partitioned = !b.Partitioned |
| } |
| case 3: |
| + if w.fail["freezetgt"] { |
| + // the biased schedule (rev 7.5): freeze the landing target in |
| + // its probe or settle window, and keep it frozen until the |
| + // source runs again, if a declare-lost let it |
| + c := w.claim |
| + if c != nil && c.Op.Kind == "landing" && b.ID == c.Op.Tgt && !b.Frozen && (c.Op.LandPhase == "probing" || c.Op.LandPhase == "probed") { |
| + b.Frozen = true |
| + w.logf("box %d frozen in its settle window (biased schedule)", b.ID) |
| + break |
| + } |
| + if b.Frozen && c != nil && (c.Op.Kind == "landing" || c.Op.Kind == "restoring") && b.ID == c.Op.Tgt { |
| + if s := w.boxByID(c.Op.Src); s != nil && hasMain(s) { |
| + b.Frozen = false |
| + w.logf("box %d resumed beside the recovered source (biased schedule)", b.ID) |
| + } |
| + break |
| + } |
| + } |
| if w.fail["freeze"] { |
| b.Frozen = !b.Frozen |
| } |
diff --git a/internal/sim/hostlife/console_test.go b/internal/sim/hostlife/console_test.go| index b2c701142..31a1f339c 100644 |
| --- a/internal/sim/hostlife/console_test.go |
| +++ b/internal/sim/hostlife/console_test.go |
| @@ -341,13 +341,18 @@ func (w *world) userDelete() { |
| if e := w.execBox(c); e != nil && e.DB != nil && hasMain(e) && w.rng.Intn(2) == 0 { |
| // the replicator's last PUT before the delete's stop evidence: |
| // accepted, then completing after the host is recreated |
| - bl := &blob{Kind: "ltx", Inc: e.DB.Inc, Gen: e.DB.Gen, Writes: append([]int(nil), e.DB.Writes...), SealKey: e.DB.Key} |
| - w.pending = append(w.pending, &request{kind: rPut, region: e.Region, key: dataKey(e.DB.Loc, e.DB.Gen, "ltx"), b: bl, |
| + bl := snapshotOf(e.DB, e.DB.Inc) |
| + bl.Kind, bl.Level = "ltx", 0 // a level-0 file covering everything so far |
| + w.pending = append(w.pending, &request{kind: rPut, region: e.Region, key: ltxKey(e.DB.Loc, e.DB.Gen, 0, 1, bl.Max), b: bl, |
| doneAt: w.now + 40*horizonH, beyond: true, by: fmt.Sprintf("box:%d", e.ID)}) |
| } |
| } |
| + recVer := 0 |
| + if w.rec != nil { |
| + recVer = w.rec.Ver |
| + } |
| if w.cas(c, func(n *claim) bool { |
| - n.Op = opState{Kind: "deleting", ID: id, Exec: -1, ReqAt: w.now} |
| + n.Op = opState{Kind: "deleting", ID: id, Exec: -1, ReqAt: w.now, RecVer: recVer} // intent: the record's ETag (claim §5.2 step 1) |
| return true |
| }) { |
| w.console = append(w.console, &consolePlan{Kind: "delete", OpID: id}) |
| @@ -562,13 +567,36 @@ func (w *world) orphanSteps(p *consolePlan) bool { |
| } |
| } |
| -// deleteSteps: record DeleteIfMatch, then parking with obligations, then |
| -// free once every obligation is acknowledged (claim §5.2). |
| +// deleteSteps: record DeleteIfMatch on the version the intent names, then |
| +// parking with obligations, then free once every obligation is |
| +// acknowledged (claim §5.2). Under a purge (claim rev 6.2 §5.3 step 4, |
| +// R4-4) the same steps run under the purge's own claim: the record version |
| +// is recorded in op.intent first, the delete is conditional on it, and |
| +// purging → parking is legal only with the record authoritatively gone |
| +// and the purge journal at torn-down or later. A purged host never returns |
| +// to serving. |
| func (w *world) deleteSteps(c *claim) bool { |
| switch c.Op.Kind { |
| case "deleting", "purging": |
| + if c.Op.Kind == "purging" && on("purge-journal") && w.purgeStep[c.Holder.App] < 4 { |
| + return false // the journal is not at torn-down yet |
| + } |
| if w.rec != nil && w.rec.Inc == c.Holder.Inc { |
| - w.rec = nil |
| + if c.Op.RecVer == 0 { |
| + // the intent: the exact record version to delete |
| + ver := w.rec.Ver |
| + w.cas(c, func(n *claim) bool { |
| + if !sameOp(n.Op.ID, c.Op.ID) || n.Op.Kind != c.Op.Kind { |
| + return false |
| + } |
| + n.Op.RecVer = ver |
| + return true |
| + }) |
| + return false |
| + } |
| + if w.rec.Ver == c.Op.RecVer { |
| + w.rec = nil // DeleteIfMatch |
| + } |
| return false |
| } |
| if w.rec != nil { |
| @@ -576,7 +604,7 @@ func (w *world) deleteSteps(c *claim) bool { |
| } |
| inc := c.Holder.Inc |
| w.cas(c, func(n *claim) bool { |
| - if !sameOp(n.Op.ID, c.Op.ID) { |
| + if !sameOp(n.Op.ID, c.Op.ID) || n.Op.Kind != c.Op.Kind { |
| return false |
| } |
| n.Op.Kind = "parking" |
| @@ -865,8 +893,19 @@ func (w *world) doorAuthorize() { |
| if b == nil { |
| return |
| } |
| + // the listing will select the generation's newest LTX object |
| + bk := w.buckets[b.Region] |
| + key := "" |
| + for _, k := range bk.keys(dataKey(c.Holder.Location, c.Holder.Gen, "ltx/")) { |
| + if v := bk.current(k); v != nil && (key == "" || v.ID > bk.current(key).ID) { |
| + key = k |
| + } |
| + } |
| + if key == "" { |
| + return |
| + } |
| d := &doorPlan{Region: b.Region, Loc: c.Holder.Location, Gen: c.Holder.Gen, Inc: c.Holder.Inc, OpID: c.Op.ID, |
| - Key: dataKey(c.Holder.Location, c.Holder.Gen, "ltx"), Until: w.now + 1 + w.pauseLen()} |
| + Key: key, Until: w.now + 1 + w.pauseLen()} |
| if w.fail["recreate"] && w.rng.Intn(2) == 0 { |
| d.Adv = true |
| if w.rng.Intn(2) == 0 { |
diff --git a/internal/sim/hostlife/expected-outcomes.txt b/internal/sim/hostlife/expected-outcomes.txt| index 6cc69ce95..c81903152 100644 |
| --- a/internal/sim/hostlife/expected-outcomes.txt |
| +++ b/internal/sim/hostlife/expected-outcomes.txt |
| @@ -17,3 +17,4 @@ touched-incomplete missed caught with legacy-discovery |
| door-reread missed subsumed by C3a: caught with location-per-incarnation |
| door-by-version missed subsumed by C3a: caught with location-per-incarnation (witness), and with door-reread as well (sweep) |
| registration-check missed a cheap extra: subsumed by purge-claim-fence and fence-etag together |
| +fence-stamp missed caught with hostguard (witness): alone, the host guard's fresh re-validation refuses the stale writer's bundle; the stamp is what refuses it when that check is gone |
diff --git a/internal/sim/hostlife/ltx_test.go b/internal/sim/hostlife/ltx_test.go| new file mode 100644 |
| index 000000000..8d4a1ac7d |
| --- /dev/null |
| +++ b/internal/sim/hostlife/ltx_test.go |
| @@ -0,0 +1,337 @@ |
| +package hostlife |
| + |
| +import ( |
| + "fmt" |
| + "hash/fnv" |
| + "sort" |
| + "strings" |
| +) |
| + |
| +// The database and its replica, with litestream's LTX semantics (rev 7.5, |
| +// R4-1). |
| +// |
| +// A database is a map of keys to values. A transaction inserts, updates or |
| +// deletes one key and advances the database's TXID, the lineage's own |
| +// counter (litestream's TXID), which a restore continues. A replica object |
| +// is an LTX file: a transaction range [Min, Max], the final page image of |
| +// every key touched within it, and the checksum of the whole database |
| +// after Max. It never carries the transaction list: compaction merges |
| +// files into one covering [Min of first, Max of last] with the final |
| +// images, and deletes the inputs, so individual transactions disappear |
| +// from the current listing. A snapshot is the file [1, TX] with every |
| +// page. The oracle alone knows which acknowledged transaction each object |
| +// covers (blob.TXs), for durability; restore never reads it. |
| + |
| +type tx struct { |
| + W int // the global write id, which is what the application acknowledged |
| + TX int // the lineage's transaction id; 0 if the write never reached a database |
| + Key string // the key written |
| + Val int // the value; 0 is a delete |
| +} |
| + |
| +// sum is the database checksum: litestream's rolling checksum stands in |
| +// for a digest of the sorted state. |
| +func sum(state map[string]int) string { |
| + keys := make([]string, 0, len(state)) |
| + for k, v := range state { |
| + if v != 0 { |
| + keys = append(keys, k) |
| + } |
| + } |
| + sort.Strings(keys) |
| + h := fnv.New64a() |
| + for _, k := range keys { |
| + fmt.Fprintf(h, "%s=%d;", k, state[k]) |
| + } |
| + return fmt.Sprintf("%016x", h.Sum64()) |
| +} |
| + |
| +func cloneState(m map[string]int) map[string]int { |
| + out := make(map[string]int, len(m)) |
| + for k, v := range m { |
| + if v != 0 { |
| + out[k] = v |
| + } |
| + } |
| + return out |
| +} |
| + |
| +func statesEqual(a, b map[string]int) (string, bool) { |
| + for k, v := range a { |
| + if v != 0 && b[k] != v { |
| + return fmt.Sprintf("key %s expected %d, got %d", k, v, b[k]), false |
| + } |
| + } |
| + for k, v := range b { |
| + if v != 0 && a[k] != v { |
| + return fmt.Sprintf("key %s expected %d, got %d", k, a[k], v), false |
| + } |
| + } |
| + return "", true |
| +} |
| + |
| +// commit applies acknowledged write w to db as a transaction: mostly an |
| +// insert of a new key named by the write; every fifth write updates the |
| +// newest live key, every fifth-but-one deletes the oldest, so restore |
| +// fidelity depends on final page images, not on a list of ids. It records |
| +// the transaction for the oracle. |
| +func (w *world) commit(db *localDB, wr int) { |
| + if db.State == nil { |
| + db.State = map[string]int{} |
| + } |
| + t := tx{W: wr, TX: db.TX + 1, Key: fmt.Sprintf("k%d", wr), Val: wr} |
| + switch wr % 5 { |
| + case 3: |
| + if k := newestKey(db.State); k != "" { |
| + t.Key, t.Val = k, wr |
| + } |
| + case 4: |
| + if k := oldestKey(db.State); k != "" { |
| + t.Key, t.Val = k, 0 |
| + } |
| + } |
| + if t.Val == 0 { |
| + delete(db.State, t.Key) |
| + } else { |
| + db.State[t.Key] = t.Val |
| + } |
| + db.TX++ |
| + db.Pending = append(db.Pending, wr) |
| + db.Log = append(db.Log, wr) |
| + w.txs[wr] = t |
| +} |
| + |
| +func keyID(k string) int { |
| + n := 0 |
| + fmt.Sscanf(k, "k%d", &n) |
| + return n |
| +} |
| + |
| +func newestKey(state map[string]int) string { |
| + best, id := "", 0 |
| + for k, v := range state { |
| + if v != 0 && keyID(k) > id { |
| + best, id = k, keyID(k) |
| + } |
| + } |
| + return best |
| +} |
| + |
| +func oldestKey(state map[string]int) string { |
| + best, id := "", 0 |
| + for k, v := range state { |
| + if v != 0 && (best == "" || keyID(k) < id) { |
| + best, id = k, keyID(k) |
| + } |
| + } |
| + return best |
| +} |
| + |
| +// ltxKey and snapKey are litestream's layout under a generation: |
| +// ltx/<level>/<min>-<max>; a snapshot is level 9 from TXID 1. |
| +func ltxKey(loc, gen string, level, min, max int) string { |
| + return fmt.Sprintf("%sltx/%d/%06d-%06d", dataKey(loc, gen, ""), level, min, max) |
| +} |
| + |
| +func snapKey(loc, gen string, max int) string { return ltxKey(loc, gen, 9, 1, max) } |
| + |
| +// ltxLevel parses the level and range from an LTX key; ok is false for |
| +// any other key. |
| +func ltxLevel(k string) (level, min, max int, ok bool) { |
| + i := strings.Index(k, "/ltx/") |
| + if i < 0 { |
| + return 0, 0, 0, false |
| + } |
| + n, err := fmt.Sscanf(k[i+5:], "%d/%d-%d", &level, &min, &max) |
| + return level, min, max, err == nil && n == 3 |
| +} |
| + |
| +// genPrefixOf is the generation prefix an LTX key belongs to. |
| +func genPrefixOf(k string) string { |
| + i := strings.Index(k, "/ltx/") |
| + if i < 0 { |
| + return "" |
| + } |
| + return k[:i+1] |
| +} |
| + |
| +// snapshotOf is the snapshot file of a database: every page, TXIDs 1 to |
| +// its current one, and the oracle's list of the writes it applied. |
| +func snapshotOf(db *localDB, inc int) *blob { |
| + return &blob{Kind: "snap", Level: 9, Min: 1, Max: db.TX, Inc: inc, Gen: db.Gen, SealKey: db.Key, |
| + Pages: cloneState(db.State), Post: sum(db.State), TXs: append([]int(nil), db.Log...)} |
| +} |
| + |
| +// deltaOf is the level-0 file of the transactions db has not replicated |
| +// yet: the final image of every key they touched. |
| +func (w *world) deltaOf(db *localDB, inc int) *blob { |
| + pages := map[string]int{} |
| + for _, wr := range db.Pending { |
| + t := w.txs[wr] |
| + pages[t.Key] = db.State[t.Key] // the image now: 0 when deleted since |
| + } |
| + return &blob{Kind: "ltx", Level: 0, Min: db.Rep + 1, Max: db.TX, Inc: inc, Gen: db.Gen, SealKey: db.Key, |
| + Pages: pages, Post: sum(db.State), TXs: append([]int(nil), db.Pending...)} |
| +} |
| + |
| +// apply overlays an LTX file's page images onto state. |
| +func apply(state map[string]int, f *blob) { |
| + for k, v := range f.Pages { |
| + if v == 0 { |
| + delete(state, k) |
| + } else { |
| + state[k] = v |
| + } |
| + } |
| +} |
| + |
| +// replicate is the serving process's litestream replication (claim C3's |
| +// exception: plain PUTs). It ships the pending transactions as one level-0 |
| +// file, retries the same range widened if the PUT timed out, and compacts |
| +// level 0 into level 1 once three files are current, deleting the inputs |
| +// after the compacted file is written, as litestream does. |
| +func (w *world) replicate(b *box) { |
| + if !w.agentUp(b) || !hasMain(b) || b.DB == nil { |
| + return |
| + } |
| + db := b.DB |
| + inc := db.Inc |
| + if len(db.Pending) > 0 { |
| + bl := w.deltaOf(db, inc) |
| + key := ltxKey(db.Loc, db.Gen, 0, bl.Min, bl.Max) |
| + if v := w.issue(b.ID, &request{kind: rPut, region: b.Region, key: key, b: bl}); v > 0 { |
| + db.Rep, db.Pending = db.TX, nil |
| + } |
| + } |
| + w.compact(b) |
| +} |
| + |
| +// compact merges the current level-0 files of b's generation into one |
| +// level-1 file when there are at least three, then deletes the inputs. |
| +// Contiguity is checked as ltx.Compactor does; a compacted file covers |
| +// [Min of first, Max of last] with the final image of every page touched. |
| +func (w *world) compact(b *box) { |
| + db := b.DB |
| + bk := w.buckets[b.Region] |
| + prefix := dataKey(db.Loc, db.Gen, "") + "ltx/0/" |
| + type in struct { |
| + k string |
| + v *version |
| + } |
| + var inputs []in |
| + for _, k := range bk.keys(prefix) { |
| + if v := bk.current(k); v != nil { |
| + inputs = append(inputs, in{k, v}) |
| + } |
| + } |
| + if len(inputs) < 3 { |
| + return |
| + } |
| + sort.Slice(inputs, func(i, j int) bool { return inputs[i].v.B.Min < inputs[j].v.B.Min }) |
| + out := &blob{Kind: "ltx", Level: 1, Min: inputs[0].v.B.Min, Inc: db.Inc, Gen: db.Gen, SealKey: db.Key, Pages: map[string]int{}} |
| + max := 0 |
| + var keys []string |
| + for _, x := range inputs { |
| + v := x.v |
| + keys = append(keys, x.k) |
| + if max != 0 && !(v.B.Min <= max+1 && v.B.Max > max) { |
| + return // non-contiguous inputs: litestream refuses to compact them |
| + } |
| + apply(out.Pages, v.B) |
| + for k, val := range v.B.Pages { |
| + if val == 0 { |
| + out.Pages[k] = 0 // a delete stays a delete in the compacted image |
| + } |
| + } |
| + out.TXs = append(out.TXs, v.B.TXs...) |
| + out.Post = v.B.Post |
| + if v.B.Max > max { |
| + max = v.B.Max |
| + } |
| + } |
| + out.Max = max |
| + if v := w.issue(b.ID, &request{kind: rPut, region: b.Region, key: ltxKey(db.Loc, db.Gen, 1, out.Min, out.Max), b: out}); v > 0 { |
| + for _, k := range keys { |
| + w.issue(b.ID, &request{kind: rDelKey, region: b.Region, key: k}) // a key delete: the versions stay noncurrent |
| + } |
| + w.stats["ltx compactions"]++ |
| + } |
| +} |
| + |
| +// plan is litestream's restore planner (replica.go CalcRestorePlan, |
| +// v0.5.14) over the current files under prefix, from TXID cur: take the |
| +// file that extends the range, overlap allowed (ltx.IsContiguous: |
| +// min <= cur+1 && max > cur), preferring the highest max, then the lowest |
| +// min, then the higher level; stop when nothing extends it. The mutation |
| +// of restore-overlap is rev 7.4's rule, min == cur+1, which discards a |
| +// compacted continuation. With restore-gap-closed, a current file that |
| +// starts beyond cur+1 once nothing extends the range is a gap and the |
| +// restore refuses (litestream: "non-contiguous ltx files"); the mutation |
| +// returns what it has. |
| +func (w *world) plan(bk *bucket, prefix string, cur int) (files []*blob, gap bool) { |
| + var cands []*blob |
| + for _, k := range bk.keys(prefix) { |
| + level, _, _, ok := ltxLevel(k) |
| + if !ok || level == 9 { |
| + continue // snapshots are named by the marker, never planned |
| + } |
| + if v := bk.current(k); v != nil { |
| + cands = append(cands, v.B) |
| + } |
| + } |
| + for { |
| + var best *blob |
| + for _, f := range cands { |
| + ext := f.Min <= cur+1 && f.Max > cur |
| + if !on("restore-overlap") { |
| + ext = f.Min == cur+1 |
| + } |
| + if !ext { |
| + continue |
| + } |
| + if best == nil || f.Max > best.Max || f.Max == best.Max && (f.Min < best.Min || f.Min == best.Min && f.Level > best.Level) { |
| + best = f |
| + } |
| + } |
| + if best == nil { |
| + break |
| + } |
| + files = append(files, best) |
| + cur = best.Max |
| + } |
| + for _, f := range cands { |
| + if f.Min > cur+1 { |
| + gap = true |
| + } |
| + } |
| + return files, gap |
| +} |
| + |
| +// expectState replays the incarnation's acknowledged, non-exempt writes, |
| +// in acknowledgement order, into the database a correct restore must |
| +// equal. With upto >= 0 it replays the transactions with lineage TXID 1 |
| +// to upto, what a cold restore that reached upto must hold; with upto < |
| +// 0 it replays every acknowledged write, a write that never reached a |
| +// database counting as the insert the application believed it made, which |
| +// is what the serving box's own database must hold. |
| +func (w *world) expectState(inc int, upto int) map[string]int { |
| + state := map[string]int{} |
| + for _, a := range w.acks { |
| + if a.Inc != inc || a.Exempt || a.Kind != "write" { |
| + continue |
| + } |
| + t := w.txs[a.W] |
| + if upto >= 0 && (t.TX == 0 || t.TX > upto) { |
| + continue |
| + } |
| + if t.Key == "" { |
| + t.Key, t.Val = fmt.Sprintf("k%d", a.W), a.W |
| + } |
| + if t.Val == 0 { |
| + delete(state, t.Key) |
| + } else { |
| + state[t.Key] = t.Val |
| + } |
| + } |
| + return state |
| +} |
diff --git a/internal/sim/hostlife/moves_test.go b/internal/sim/hostlife/moves_test.go| index 0df9ad223..023ee3aab 100644 |
| --- a/internal/sim/hostlife/moves_test.go |
| +++ b/internal/sim/hostlife/moves_test.go |
| @@ -86,7 +86,7 @@ func (w *world) role(b *box, c *claim) { |
| if b.DB != nil { |
| inc = b.DB.Inc |
| } |
| - if w.localPark(b, inc, w.nextID) { // a quarantine retires every installation so far |
| + if w.localPark(b, inc) { // the quarantine retires the installation |
| b.Row = nil |
| } |
| } |
| @@ -171,25 +171,17 @@ func (w *world) snapshotBlob(b *box, c *claim) (*blob, *blob) { |
| } |
| func (w *world) snapshotBlobOf(b *box, c *claim, gen string) (*blob, *blob) { |
| - db := &blob{Kind: "snap", Inc: c.Holder.Inc} |
| - if r := w.restore(b, c.Holder.Inc, c.Holder.Location, gen); r != nil { |
| - db.Writes, db.SealKey = r.Writes, r.Key |
| - } |
| + // the sealed transfer is a verified final snapshot (rev 7.5 §5.1, R4-1): |
| + // the local database after its final checkpoint, every page, TXIDs 1 |
| + // to its current one; with no local database (a lost disk, or never |
| + // woken) the replica restored as it stands |
| + var db *blob |
| if b.DB != nil { |
| - // the final checkpoint: the local database continues the replica's |
| - // history (R3-1); a local history that does not is never staged |
| - switch { |
| - case extends(b.DB.Writes, db.Writes): |
| - db.Writes = append([]int(nil), b.DB.Writes...) |
| - case extends(db.Writes, b.DB.Writes): |
| - default: |
| - w.logf("box %d snapshot: the local database does not continue the replica", b.ID) |
| - w.stats["snapshot refused: conflicting history"]++ |
| - } |
| - if b.DB.Key != 0 { |
| - db.SealKey = b.DB.Key |
| - } |
| - db.Inc = b.DB.Inc |
| + db = snapshotOf(b.DB, b.DB.Inc) |
| + } else if r := w.restore(b, c.Holder.Inc, c.Holder.Location, gen); r != nil { |
| + db = snapshotOf(r, c.Holder.Inc) |
| + } else { |
| + db = &blob{Kind: "snap", Level: 9, Min: 1, Inc: c.Holder.Inc, Pages: map[string]int{}, Post: sum(nil)} |
| } |
| files := &blob{Kind: "files", Inc: c.Holder.Inc, Files: b.Files.clone(), Timers: append([]int(nil), b.Timers...)} |
| return db, files |
| @@ -664,6 +656,7 @@ func (w *world) followRecord(c *claim) bool { |
| return false |
| } |
| n.Op.Kind, n.Op.Exec, n.Op.LandPhase, n.Op.FlipAt = "landing", op.Tgt, "checking", w.now |
| + n.Op.FlipGen = w.nextGen() // the gen this CAS will have: the landing's installation stamp (claim rev 6.2 §6.4) |
| return true |
| }) |
| case r.MoveState == "committed" && op.Kind == "landing" && !op.Committed: |
| @@ -718,14 +711,18 @@ func (w *world) landTarget(b *box, c *claim) { |
| } |
| switch op.LandPhase { |
| case "checking": |
| - // a new installation of this incarnation here replaces the local |
| - // retired marker, under the guard, on a fresh read showing this |
| - // operation current and this box its target (claim rev 6 §6.4, |
| - // R3-3): a move back to a region the instance left |
| - if _, fenced := b.Fence[c.Holder.Inc]; fenced && on("fence-by-installation") { |
| - if fc, ok := w.readClaim(b); ok && fc.Op.ID == op.ID && fc.Op.Kind == "landing" && fc.Op.Tgt == b.ID { |
| - b.Fence[c.Holder.Inc] = w.opSeq(op.ID) - 1 |
| - w.logf("box %d: the fence for inc %d now admits %s", b.ID, c.Holder.Inc, op.ID) |
| + // the new installation sets this box's active stamp to its own |
| + // captured stamp, the claim gen of the CAS that made the landing |
| + // current, under the guard, on a fresh read showing its operation |
| + // current and this box the landing participant (claim rev 6.2 |
| + // §6.4, R3-3, R4-2): a move back to a region the instance left is |
| + // admitted at its own stamp while the retired installation's |
| + // writers stay refused |
| + if fc, ok := w.readClaim(b); ok && fc.Op.ID == op.ID && fc.Op.Kind == "landing" && fc.Op.Tgt == b.ID { |
| + retiredHere := b.Stamps[c.Holder.Inc] != nil && len(b.Stamps[c.Holder.Inc].Retired) > 0 |
| + if on("fence-by-installation") || !retiredHere { |
| + b.install(c.Holder.Inc, op.FlipGen) |
| + w.logf("box %d: installation stamp %d for inc %d (%s)", b.ID, op.FlipGen, c.Holder.Inc, op.ID) |
| } |
| } |
| w.cas(c, func(n *claim) bool { |
| @@ -751,7 +748,7 @@ func (w *world) landTarget(b *box, c *claim) { |
| src := op.SealDB |
| if cur := w.buckets[b.Region].current(src.Key); cur != nil && cur.ID != src.Ver { |
| w.stats["diag: staging newer than seal at install"]++ |
| - if sv := w.buckets[b.Region].byID(src.Key, src.Ver); sv != nil && len(cur.B.Writes) < len(sv.B.Writes) { |
| + if sv := w.buckets[b.Region].byID(src.Key, src.Ver); sv != nil && cur.B.Max < sv.B.Max { |
| w.stats["diag: ...and older content"]++ |
| } |
| } |
| @@ -763,15 +760,25 @@ func (w *world) landTarget(b *box, c *claim) { |
| } |
| src.Ver = cur.ID |
| } |
| - // into this move's own generation: a copy that completes late can |
| - // only ever add an object to this generation |
| - v := w.issue(b.ID, &request{kind: rCopy, region: b.Region, key: dataKey(loc, op.ID, "base"), srcRegion: b.Region, srcKey: src.Key, srcVer: src.Ver}) |
| + // into this move's own generation, as its snapshot at the sealed |
| + // TXID: a copy that completes late can only ever add an object to |
| + // this generation |
| + sv := w.buckets[b.Region].byID(src.Key, src.Ver) |
| + if sv == nil { |
| + w.rollback(c, "staging missing") |
| + return |
| + } |
| + key := snapKey(loc, op.ID, sv.B.Max) |
| + v := w.issue(b.ID, &request{kind: rCopy, region: b.Region, key: key, srcRegion: b.Region, srcKey: src.Key, srcVer: src.Ver}) |
| if v > 0 { |
| - // the install manifest records the snapshot's version (R3-1) |
| - w.cas(c, func(n *claim) bool { n.Op.LandPhase, n.Op.BaseVer = "published", v; return sameOp(n.Op.ID, op.ID) }) |
| + // the install manifest records the snapshot's key, version and TXID (R3-1, R4-1) |
| + w.cas(c, func(n *claim) bool { |
| + n.Op.LandPhase, n.Op.BaseVer, n.Op.BaseKey, n.Op.BaseTX = "published", v, key, sv.B.Max |
| + return sameOp(n.Op.ID, op.ID) |
| + }) |
| } |
| case "published": |
| - v := w.publishGen(b, loc, op.ID, c.Holder.Inc, op.BaseVer) |
| + v := w.publishGen(b, loc, op.ID, c.Holder.Inc, op.BaseVer, op.BaseKey, op.BaseTX) |
| if v > 0 { |
| w.cas(c, func(n *claim) bool { n.Op.LandPhase = "local"; return sameOp(n.Op.ID, op.ID) }) |
| } |
| @@ -1102,12 +1109,20 @@ func (w *world) recoverSource(b *box, c *claim) { |
| w.cas(c, func(n *claim) bool { n.Op.SrcPhase = "install-copy"; return sameOp(n.Op.ID, op.ID) }) |
| } |
| case "install-copy": |
| - v := w.issue(b.ID, &request{kind: rCopy, region: b.Region, key: dataKey(c.Holder.Location, op.RollbackID, "base"), srcRegion: b.Region, srcKey: op.ReturnDB.Key, srcVer: op.ReturnDB.Ver}) |
| + sv := w.buckets[b.Region].byID(op.ReturnDB.Key, op.ReturnDB.Ver) |
| + if sv == nil { |
| + return // the lane is gone: wait (stuck, in time) |
| + } |
| + key := snapKey(c.Holder.Location, op.RollbackID, sv.B.Max) |
| + v := w.issue(b.ID, &request{kind: rCopy, region: b.Region, key: key, srcRegion: b.Region, srcKey: op.ReturnDB.Key, srcVer: op.ReturnDB.Ver}) |
| if v > 0 { |
| - w.cas(c, func(n *claim) bool { n.Op.SrcPhase, n.Op.BaseVer = "published", v; return sameOp(n.Op.ID, op.ID) }) |
| + w.cas(c, func(n *claim) bool { |
| + n.Op.SrcPhase, n.Op.BaseVer, n.Op.BaseKey, n.Op.BaseTX = "published", v, key, sv.B.Max |
| + return sameOp(n.Op.ID, op.ID) |
| + }) |
| } |
| case "published": |
| - v := w.publishGen(b, c.Holder.Location, op.RollbackID, c.Holder.Inc, op.BaseVer) |
| + v := w.publishGen(b, c.Holder.Location, op.RollbackID, c.Holder.Inc, op.BaseVer, op.BaseKey, op.BaseTX) |
| if v > 0 { |
| w.cas(c, func(n *claim) bool { n.Op.SrcPhase = "local"; return sameOp(n.Op.ID, op.ID) }) |
| } |
| @@ -1164,23 +1179,24 @@ func (w *world) pushStep(b *box, c *claim) { |
| if !w.evidence(b, op.ID) { |
| return |
| } |
| - var writes []int |
| - if r := w.restore(b, c.Holder.Inc, loc, c.Holder.Gen); r != nil { |
| - writes = r.Writes // the replica, under the final checkpoint |
| - } |
| - if b.DB != nil { |
| - writes = unionInts(writes, b.DB.Writes) |
| - } |
| + // the push's snapshot: the replica under the final checkpoint, plus |
| + // the push's own mark, one more acknowledged transaction |
| + snap, _ := w.snapshotBlobOf(b, c, c.Holder.Gen) |
| + snap.Gen = op.ID |
| w.nextW++ |
| mark := w.nextW |
| w.acks = append(w.acks, ack{W: mark, Inc: c.Holder.Inc, Box: b.ID, At: w.now, Kind: "write"}) |
| w.writeInc[mark] = c.Holder.Inc |
| - writes = append(writes, mark) |
| + snap.Max++ |
| + snap.Pages[fmt.Sprintf("k%d", mark)] = mark |
| + snap.Post = sum(snap.Pages) |
| + snap.TXs = append(snap.TXs, mark) |
| + w.txs[mark] = tx{W: mark, TX: snap.Max, Key: fmt.Sprintf("k%d", mark), Val: mark} |
| w.cas(c, func(n *claim) bool { |
| if !sameOp(n.Op.ID, op.ID) || n.Op.Phase != "stopping" { |
| return false |
| } |
| - n.Op.Phase, n.Op.StopAt, n.Op.PushWrites = "park", w.now, writes |
| + n.Op.Phase, n.Op.StopAt, n.Op.PushSnap = "park", w.now, snap |
| return true |
| }) |
| case "park": |
| @@ -1191,16 +1207,20 @@ func (w *world) pushStep(b *box, c *claim) { |
| if on("quiet-before-ack") && w.now < c.Quiet[b.Region] { |
| return // installs wait out quiet_after (claim §6.2a) |
| } |
| - key := 0 |
| + snap := op.PushSnap |
| if b.Files != nil { |
| - key = b.Files.Key |
| + snap.SealKey = b.Files.Key |
| } |
| - v := w.issue(b.ID, &request{kind: rPut, region: b.Region, key: dataKey(loc, op.ID, "base"), b: &blob{Kind: "base", Inc: c.Holder.Inc, Gen: op.ID, Writes: op.PushWrites, SealKey: key}}) |
| + key := snapKey(loc, op.ID, snap.Max) |
| + v := w.issue(b.ID, &request{kind: rPut, region: b.Region, key: key, b: snap}) |
| if v > 0 { |
| - w.cas(c, func(n *claim) bool { n.Op.Phase, n.Op.BaseVer = "published", v; return sameOp(n.Op.ID, op.ID) }) |
| + w.cas(c, func(n *claim) bool { |
| + n.Op.Phase, n.Op.BaseVer, n.Op.BaseKey, n.Op.BaseTX = "published", v, key, snap.Max |
| + return sameOp(n.Op.ID, op.ID) |
| + }) |
| } |
| case "published": |
| - v := w.publishGen(b, loc, op.ID, c.Holder.Inc, op.BaseVer) |
| + v := w.publishGen(b, loc, op.ID, c.Holder.Inc, op.BaseVer, op.BaseKey, op.BaseTX) |
| if v > 0 { |
| w.cas(c, func(n *claim) bool { n.Op.Phase = "restore"; return sameOp(n.Op.ID, op.ID) }) |
| } |
| @@ -1355,17 +1375,26 @@ func locationOf(k string) string { |
| return p |
| } |
| -// deadGeneration: within holder.location, a generation other than |
| -// holder.generation, or a marker that no longer names it. |
| +// deadGeneration: within holder.location, everything but the live set. |
| +// The live set is {authoritative region, holder.location, |
| +// holder.generation} (claim rev 6.2 §6.2a, R4-3): the generation's |
| +// objects are live only in the authoritative region's bucket, the region |
| +// of the holder's current installation; a matching key in any other |
| +// region is residue. The mutation of live-set-region exempts matching keys |
| +// in every region. |
| func (w *world) deadGeneration(c *claim, r int, k string) bool { |
| h := c.Holder |
| - if k == dataKey(h.Location, h.Gen, "base") || k == dataKey(h.Location, h.Gen, "ltx") { |
| - return false |
| + e := w.execBox(c) |
| + authoritative := e != nil && e.Region == r |
| + if !on("live-set-region") { |
| + authoritative = true |
| } |
| if k == genMarkerKey(h.Location) { |
| v := w.buckets[r].current(k) |
| - e := w.execBox(c) |
| - return v != nil && (v.B.Gen != h.Gen || e == nil || e.Region != r) |
| + return v != nil && (v.B.Gen != h.Gen || !authoritative) |
| + } |
| + if strings.HasPrefix(k, dataKey(h.Location, h.Gen, "ltx/")) { |
| + return !authoritative |
| } |
| return isDataKey(k) |
| } |
| @@ -1386,9 +1415,14 @@ func (w *world) legacyRetired(c *claim) bool { |
| // (moves rev 7.1 §5.1, model 29). generation.json is a reused key, so an |
| // earlier publication that completes late would otherwise put an older |
| // generation back, and every wake would refuse. |
| -func (w *world) publishGen(b *box, loc, gen string, inc int, base int) int { |
| - // the marker names the generation's snapshot by version id (R3-1) |
| - r := &request{kind: rPut, region: b.Region, key: genMarkerKey(loc), b: &blob{Kind: "gen", Gen: gen, Inc: inc, Base: base}} |
| +func (w *world) publishGen(b *box, loc, gen string, inc int, base int, key string, tx int) int { |
| + // the marker names the generation's snapshot by key, version id and TXID (R3-1, R4-1) |
| + r := &request{kind: rPut, region: b.Region, key: genMarkerKey(loc), b: &blob{Kind: "gen", Gen: gen, Inc: inc, Base: base, SnapKey: key, TX: tx}} |
| + if w.fail["markerhold"] && w.rng.Intn(2) == 0 { |
| + // the biased schedule (rev 7.5): this publication is accepted and |
| + // completes at its worst moment, once a newer marker is current |
| + r.hold = true |
| + } |
| if on("generation-conditional") { |
| r.ifSet = true |
| if cur := w.buckets[b.Region].current(r.key); cur != nil { |
diff --git a/internal/sim/hostlife/protocol_test.go b/internal/sim/hostlife/protocol_test.go| index 5b41f7687..6ee60967c 100644 |
| --- a/internal/sim/hostlife/protocol_test.go |
| +++ b/internal/sim/hostlife/protocol_test.go |
| @@ -22,6 +22,9 @@ func (w *world) readClaim(b *box) (*claim, bool) { |
| return nil, false |
| } |
| c := w.claim.clone() |
| + if c != nil && c.Holder != nil { |
| + c.readStamp = b.stampOf(c.Holder.Inc) // the installation stamp this read carries (claim rev 6.2 §6.4) |
| + } |
| b.snap = c.clone() |
| return c, true |
| } |
| @@ -110,7 +113,7 @@ func (w *world) timerDigest(b *box) string { |
| func (w *world) digestOf(b *box) digest { |
| d := digest{Valid: true} |
| if b.DB != nil { |
| - d.Writes = setKey(b.DB.Writes) |
| + d.Writes = sum(b.DB.State) |
| } |
| if b.Files != nil { |
| d.Files = fmt.Sprintf("%d/%d/%d", b.Files.Inc, b.Files.Key, b.Files.Env) |
| @@ -162,15 +165,19 @@ func (w *world) evidence(b *box, opID string) bool { |
| // ---------- restore and spawn ---------- |
| // restore rebuilds a database from location loc, generation gen, in b's own |
| -// bucket (claim C3a; moves rev 6 §5.1). It replays every replica object of |
| -// that generation, as litestream replays every LTX file of a generation: |
| -// base snapshots and later LTX alike, current and noncurrent. It never |
| -// looks at another location or generation. The mutation restores the |
| -// newest single object anywhere under the location instead, which is what |
| -// a prefix restore without generation identity does. |
| +// bucket (claim C3a; moves rev 7.5 §5.1, R4-1), exactly as litestream's |
| +// restore would: it opens the snapshot that generation.json names (by key, |
| +// version id and TXID), then applies the LTX files the planner selects |
| +// (ltx_test.go plan), verifies the database checksum against the last |
| +// file's, and fails closed on a gap, a missing or mismatched snapshot, or |
| +// a checksum mismatch. It never reads another location or generation, and |
| +// it never knows which transactions a file holds. The mutation of |
| +// restore-by-generation restores the newest single object anywhere under |
| +// the location, which is what a prefix restore without generation identity |
| +// does. |
| func (w *world) restore(b *box, inc int, loc, gen string) *localDB { |
| bk := w.buckets[b.Region] |
| - db := &localDB{Inc: inc, Loc: loc, Gen: gen} |
| + db := &localDB{Inc: inc, Loc: loc, Gen: gen, State: map[string]int{}} |
| if !on("restore-by-generation") { |
| var best *version |
| for _, k := range bk.keys(loc) { |
| @@ -184,93 +191,74 @@ func (w *world) restore(b *box, inc int, loc, gen string) *localDB { |
| } |
| } |
| if best != nil { |
| - db.Writes = append([]int(nil), best.B.Writes...) |
| - db.Key = best.B.SealKey |
| + apply(db.State, best.B) |
| + db.TX, db.Key = best.B.Max, best.B.SealKey |
| if best.B.Inc != 0 { |
| db.Inc = best.B.Inc |
| } |
| } |
| return db |
| } |
| - // the chain (rev 7.4 §5.1, R3-1): the generation's snapshot, named by |
| - // version id in generation.json, then the LTX objects of the |
| - // generation whose history extends what the chain holds, longest last. |
| - // A version whose history is a prefix of the chain is an older |
| - // checkpoint or a duplicate and is skipped; one that is neither is a |
| - // conflict, and the restore refuses rather than guess. Rev 7.3's model |
| - // took the union of every version's writes, which no restore can do. |
| - var history []int |
| - found := false |
| + cur := 0 |
| + var last *blob |
| if gen != "" { |
| m := bk.current(genMarkerKey(loc)) |
| if m == nil || m.B.Gen != gen { |
| return nil // the marker does not name this generation: refuse |
| } |
| - base := bk.byID(dataKey(loc, gen, "base"), m.B.Base) |
| - if base == nil || base.Marker { |
| - w.stats["restore refused: snapshot missing"]++ |
| + snap := bk.byID(m.B.SnapKey, m.B.Base) |
| + if snap == nil || snap.Marker || snap.B.Max != m.B.TX { |
| + w.stats["restore refused: snapshot missing or not at the marker's TXID"]++ |
| return nil |
| } |
| - history = append([]int(nil), base.B.Writes...) |
| - db.Key = base.B.SealKey |
| - if base.B.Inc != 0 { |
| - db.Inc = base.B.Inc |
| + apply(db.State, snap.B) |
| + cur, last = snap.B.Max, snap.B |
| + db.Key = snap.B.SealKey |
| + if snap.B.Inc != 0 { |
| + db.Inc = snap.B.Inc |
| } |
| - found = true |
| } |
| - for _, v := range bk.objs[dataKey(loc, gen, "ltx")] { |
| - if v.Marker { |
| - continue |
| + files, gap := w.plan(bk, dataKey(loc, gen, "ltx/"), cur) |
| + for _, f := range files { |
| + apply(db.State, f) |
| + cur, last = f.Max, f |
| + if db.Key == 0 { |
| + db.Key = f.SealKey |
| } |
| - found = true |
| - switch { |
| - case extends(v.B.Writes, history): |
| - history = append([]int(nil), v.B.Writes...) |
| - if db.Key == 0 { |
| - db.Key = v.B.SealKey |
| - } |
| - if v.B.Inc != 0 { |
| - db.Inc = v.B.Inc |
| - } |
| - case extends(history, v.B.Writes): |
| - // an older checkpoint, or a duplicate: skipped |
| - default: |
| - w.logf("box %d restore refused: %s version %d does not continue the chain", b.ID, dataKey(loc, gen, "ltx"), v.ID) |
| - w.stats["restore refused: conflicting history"]++ |
| - return nil |
| + if f.Inc != 0 { |
| + db.Inc = f.Inc |
| } |
| } |
| - if !found && gen != "" { |
| - return nil // an installed generation with nothing in it: refuse |
| + if gap && on("restore-gap-closed") { |
| + w.logf("box %d restore refused: a gap after TXID %d under %s", b.ID, cur, dataKey(loc, gen, "ltx/")) |
| + w.stats["restore refused: gap"]++ |
| + return nil |
| + } |
| + if last != nil && sum(db.State) != last.Post { |
| + w.logf("box %d restore refused: checksum mismatch after TXID %d", b.ID, cur) |
| + w.stats["restore refused: checksum"]++ |
| + return nil |
| } |
| - db.Writes = history |
| + if last == nil && gen != "" { |
| + return nil |
| + } |
| + db.TX = cur |
| return db |
| } |
| -// fenced: the local retired marker refuses a writer for incarnation inc |
| -// carrying operation sequence seq (claim rev 6 §6.4, R3-3). The mutation |
| -// is rev 5.2's marker, which fences the whole incarnation for good. |
| -func (w *world) fenced(b *box, inc, seq int) bool { |
| - f, ok := b.Fence[inc] |
| - if !ok { |
| - return false |
| - } |
| - if !on("fence-by-installation") { |
| +// stampOK is admission's local check (claim rev 6.2 §6.4): the worker's |
| +// read of the claim carries the installation stamp active on this box at |
| +// that read; it is admitted only if that is still the active stamp. A |
| +// landing probe refused here by its own installation is the fault R3-3 |
| +// named: the oracle records it rather than let the move roll back. |
| +func (w *world) stampOK(b *box, c *claim, probe string) bool { |
| + if b.admitsStamp(c.Holder.Inc, c.readStamp) { |
| return true |
| } |
| - return seq <= f |
| -} |
| - |
| -func unionInts(a, b []int) []int { |
| - seen := map[int]bool{} |
| - var out []int |
| - for _, x := range append(append([]int(nil), a...), b...) { |
| - if !seen[x] { |
| - seen[x] = true |
| - out = append(out, x) |
| - } |
| + if probe != "" && c.Op.Kind == "landing" && c.Op.Tgt == b.ID && c.Ver == w.claim.Ver { |
| + w.violate("INV-3: the landing probe of %s on box %d was refused by the box's own installation record (stamp %d, active %d)", c.Op.ID, b.ID, c.readStamp, b.stampOf(c.Holder.Inc)) |
| } |
| - return out |
| + return false |
| } |
| // bucketGen reads generation.json at loc in b's own bucket: the generation |
| @@ -294,11 +282,18 @@ func (w *world) spawn(b *box, c *claim, want string, probe bool) bool { |
| // an old executable: no claim, no markers, no locations; it |
| // restores whatever is newest at the legacy root |
| if b.DB == nil { |
| - b.DB = &localDB{Inc: h.Inc, Loc: "instances/" + host + "/"} |
| - if v := w.buckets[b.Region].current("instances/" + host + "/ltx"); v != nil { |
| - b.DB.Writes, b.DB.Key = append([]int(nil), v.B.Writes...), v.B.SealKey |
| - if v.B.Inc != 0 { |
| - b.DB.Inc = v.B.Inc |
| + b.DB = &localDB{Inc: h.Inc, Loc: "instances/" + host + "/", State: map[string]int{}} |
| + var newest *version |
| + for _, k := range w.buckets[b.Region].keys("instances/" + host + "/ltx/") { |
| + if v := w.buckets[b.Region].current(k); v != nil && (newest == nil || v.ID > newest.ID) { |
| + newest = v |
| + } |
| + } |
| + if newest != nil { |
| + apply(b.DB.State, newest.B) |
| + b.DB.TX, b.DB.Key = newest.B.Max, newest.B.SealKey |
| + if newest.B.Inc != 0 { |
| + b.DB.Inc = newest.B.Inc |
| } |
| } |
| } |
| @@ -401,8 +396,8 @@ func (w *world) admits(b *box, c *claim, probe string) bool { |
| if on("exec-admission") && c.Op.Exec != b.ID { |
| return false |
| } |
| - if w.fenced(b, c.Holder.Inc, w.opSeq(c.Op.ID)) { |
| - return false // retired here by this operation or a later one (claim rev 6 §6.4) |
| + if !w.stampOK(b, c, probe) { |
| + return false // a retired installation's worker (claim rev 6.2 §6.4) |
| } |
| if w.isParticipant(c, b) && b.ExecUntil > 0 && w.localNow(b) > b.ExecUntil { |
| return false // self-fenced until the lease is renewed |
| @@ -549,15 +544,10 @@ func (w *world) parkDepth(b *box, prefix, dest string, quiet int, keep func(stri |
| // guard: a retired marker first, then each artifact renamed aside, one per |
| // call, so a crash can fall between any two (claim §6.3). It reports |
| // whether nothing is left. |
| -func (w *world) localPark(b *box, inc int, seq int) bool { |
| - if on("hostguard") { |
| - if f, ok := b.Fence[inc]; !ok || f < seq { |
| - if b.Fence == nil { |
| - b.Fence = map[int]int{} |
| - } |
| - b.Fence[inc] = seq // {incarnation, op of the installation being retired} |
| - return false |
| - } |
| +func (w *world) localPark(b *box, inc int) bool { |
| + if on("hostguard") && b.stampOf(inc) != 0 { |
| + b.retireStamp(inc) // the active stamp moves into retired_stamps (claim rev 6.2 §6.4) |
| + return false |
| } |
| switch { |
| case b.DB != nil: |
| @@ -604,7 +594,7 @@ func (w *world) doObligation(b *box, c *claim, o obligation) { |
| if b.DB != nil && b.DB.Inc != 0 { |
| inc = b.DB.Inc |
| } |
| - if !w.localPark(b, inc, w.opSeq(o.Op)) { |
| + if !w.localPark(b, inc) { |
| return // the next artifact on a later step |
| } |
| if b.Row != nil && b.Row.HoldOp == o.Op || b.Row != nil && (c.Holder == nil || b.ID != c.Op.Exec) { |
diff --git a/internal/sim/hostlife/rules_test.go b/internal/sim/hostlife/rules_test.go| index 4071d38f5..5d2eeec70 100644 |
| --- a/internal/sim/hostlife/rules_test.go |
| +++ b/internal/sim/hostlife/rules_test.go |
| @@ -54,7 +54,8 @@ var allRules = []string{ |
| "park-skip-lock", // §6.2 step 2/J-N6 |
| "retirement-ids", // §6.2/J-D5/J-N7 |
| "hostguard", // §6.4/J-N8 |
| - "fence-by-installation", // claim rev 6 §6.4 (R3-3): the retired marker is {incarnation, op}; a new installation replaces it; the mutation fences the whole incarnation for good |
| + "fence-by-installation", // claim rev 6.2 §6.4 (R3-3): a new installation of a retired incarnation sets its own active stamp; the mutation is rev 5.2's marker, retired for good |
| + "fence-stamp", // claim rev 6.2 §6.4 (R4-2): a writer is admitted only if its stamp equals the active stamp; the mutation admits any writer for the incarnation while one is active |
| "purge-reserve-first", // §5.3/J-N9 |
| "purge-adopts", // this model's finding: purge claims unclaimed legacy records first |
| "purge-journal", // this model's finding: the reservation is the purge's journal |
| @@ -79,10 +80,13 @@ var allRules = []string{ |
| "participant-rows", // claim §2/R2-4 |
| "stale-hold-release", // this model's finding: release a hold whose op ended |
| "restore-by-generation", // C3a + moves rev 6 §5.1: restore replays the claim's generation only |
| + "restore-overlap", // rev 7.5 §5.1 (R4-1): the planner takes a file whose range overlaps the one restored so far (litestream's rule); the mutation requires min == current+1 and discards a compacted continuation |
| + "restore-gap-closed", // rev 7.5 §5.1 (R4-1): a current file that starts beyond current+1 once nothing extends the range is a gap, and the restore refuses; the mutation returns what it has |
| "quiet-before-ack", // this model's finding (late writes after a park) |
| "straggler-sweep", // claim rev 5 §6.2a (CC15): the at-rest sweep |
| "sweep-staging", // CC20: the sweep also covers moves/<host>/ |
| "sweep-namespace", // claim rev 6 §6.2a (R3-4): the sweep discovers locations from the namespace; the mutation reads the capped retired_incarnations |
| + "live-set-region", // claim rev 6.2 §6.2a (R4-3): the live set is {authoritative region, location, generation}; the mutation exempts matching keys in every region |
| // moves spec |
| "seal-by-version", // J-N2: landing copies sealed versions |
| "staging-quiet", // J-N2: staging retired after its writers' horizon |
diff --git a/internal/sim/hostlife/sim_test.go b/internal/sim/hostlife/sim_test.go| index 557a99bc9..e6e8687ac 100644 |
| --- a/internal/sim/hostlife/sim_test.go |
| +++ b/internal/sim/hostlife/sim_test.go |
| @@ -17,12 +17,12 @@ const ( |
| func newWorld(seed int64, verbose bool) *world { |
| w := &world{seed: seed, rng: rand.New(rand.NewSource(seed)), verbose: verbose, |
| stores: map[string]bool{app0: true, app1: true}, cfgVer: map[string]int{app0: 1, app1: 1}, purgeRes: map[string]bool{}, purgeStep: map[string]int{}, |
| - control: newBucket(), delivered: map[int]bool{}, durable: map[string]map[int]bool{}, writeInc: map[int]int{}, legacyIncs: map[int]bool{}, purgeOp: map[string]string{}, genParent: map[string]string{}, execLease: map[int]execLease{}, fail: map[string]bool{}, stats: map[string]int{}} |
| + control: newBucket(), delivered: map[int]bool{}, durable: map[string]map[int]bool{}, writeInc: map[int]int{}, txs: map[int]tx{}, legacyIncs: map[int]bool{}, purgeOp: map[string]string{}, genParent: map[string]string{}, execLease: map[int]execLease{}, fail: map[string]bool{}, stats: map[string]int{}} |
| for r := 0; r < nRegions; r++ { |
| w.buckets[r] = newBucket() |
| } |
| w.admit = map[string]*admission{app0: {Reg: map[string]regEntry{}}, app1: {Reg: map[string]regEntry{}}} |
| - for _, f := range []string{"partition", "freeze", "pause", "destroy", "disk", "recreate", "pausecreate", "lostreturn"} { |
| + for _, f := range []string{"partition", "freeze", "pause", "destroy", "disk", "recreate", "pausecreate", "lostreturn", "markerhold", "freezetgt", "retention"} { |
| w.fail[f] = w.rng.Intn(2) == 0 |
| } |
| for r := 0; r < nRegions; r++ { |
| @@ -41,10 +41,34 @@ func newWorld(seed int64, verbose bool) *world { |
| } |
| if w.rng.Intn(3) == 0 { |
| w.legacyStart() |
| + } else if w.fail["retention"] { |
| + w.retentionStart() |
| } |
| return w |
| } |
| +// retentionStart: the host has been recreated more times than |
| +// retired_incarnations remembers (claim rev 6.2 §2 caps it at 32), and a |
| +// replication object of an incarnation the list forgot is still in a |
| +// bucket (rev 7.5, R4 §3: the retention-boundary fixture). Only a sweep |
| +// that discovers locations from the namespace parks it. |
| +func (w *world) retentionStart() { |
| + w.claim = &claim{Ver: w.newVer()} |
| + for i := 0; i < retiredCap; i++ { |
| + w.nextInc++ |
| + w.claim.Retired = retire(w.claim.Retired, w.nextInc) |
| + } |
| + old := 1 // an incarnation older than every one the list remembers |
| + w.nextInc++ |
| + r := w.rng.Intn(nRegions) |
| + k := ltxKey(locationFor(old, false), "", 0, 1, 1) |
| + // a request that completed long after its incarnation was retired: a |
| + // straggler, which only the sweep retires (INV-10 at an operation's end |
| + // exempts it, as it exempts every completion after H) |
| + w.buckets[r].objs[k] = []*version{{ID: w.newVer(), Straggler: true, B: &blob{Kind: "ltx", Level: 0, Min: 1, Max: 1, Inc: old, Pages: map[string]int{"k0": 1}, Post: sum(map[string]int{"k0": 1})}}} |
| + w.logf("retention fixture: incarnation %d's replica in region %d, past the cap", old, r) |
| +} |
| + |
| // legacyStart: a host that exists before the release. Its data is untagged, |
| // a best-effort-placed descendant may be running, and an earlier |
| // (pre-release) move left a replica in another region. |
| @@ -56,21 +80,28 @@ func (w *world) legacyStart() { |
| w.rec = &record{Ver: w.newVer(), Inc: inc, App: app0, Region: r} |
| w.legacyIncs[inc] = true |
| key := 7 |
| - var writes []int |
| + b.Row = &row{Inc: inc, App: app0, Status: "ready"} |
| + b.DB = &localDB{Inc: inc, Loc: locationFor(inc, true), Key: key, State: map[string]int{}} |
| for i := 0; i < 3; i++ { |
| w.nextW++ |
| - writes = append(writes, w.nextW) |
| w.acks = append(w.acks, ack{W: w.nextW, Inc: inc, Box: b.ID, At: 0, Kind: "write"}) |
| w.writeInc[w.nextW] = inc |
| + w.commit(b.DB, w.nextW) |
| } |
| - b.Row = &row{Inc: inc, App: app0, Status: "ready"} |
| - b.DB = &localDB{Inc: inc, Loc: locationFor(inc, true), Key: key, Writes: writes} |
| b.Files = &filesDir{Inc: inc, Key: key, Env: 1, Done: true} |
| b.Config, b.CfgVer, b.Live, b.LiveInc = inc, 1, app0, inc |
| - w.buckets[r].objs["instances/"+host+"/ltx"] = []*version{{ID: w.newVer(), B: &blob{Kind: "ltx", Inc: 0, Writes: writes, SealKey: key}}} |
| - w.markDurable("instances/"+host+"/ltx", &blob{Writes: writes}) |
| + // the pre-release replica: one level-0 file of everything, untagged |
| + full := w.deltaOf(b.DB, 0) |
| + full.Inc = 0 |
| + b.DB.Rep, b.DB.Pending = b.DB.TX, nil |
| + k := ltxKey(b.DB.Loc, "", 0, 1, b.DB.TX) |
| + w.buckets[r].objs[k] = []*version{{ID: w.newVer(), B: full}} |
| + w.markDurable(k, full) |
| + // an earlier move's replica in another region: the first transaction only |
| + first := w.txs[b.DB.Log[0]] |
| + part := &blob{Kind: "ltx", Level: 0, Min: 1, Max: 1, Inc: 0, SealKey: key, Pages: map[string]int{first.Key: first.Val}, Post: sum(map[string]int{first.Key: first.Val}), TXs: []int{first.W}} |
| other := (r + 1 + w.rng.Intn(nRegions-1)) % nRegions |
| - w.buckets[other].objs["instances/"+host+"/ltx"] = []*version{{ID: w.newVer(), B: &blob{Kind: "ltx", Inc: 0, Writes: writes[:1], SealKey: key}}} |
| + w.buckets[other].objs[ltxKey(b.DB.Loc, "", 0, 1, 1)] = []*version{{ID: w.newVer(), B: part}} |
| if w.rng.Intn(2) == 0 { |
| b.Procs = []*proc{{Main: true, Strict: false, Inc: inc, Box: b.ID}, {Main: false, Strict: false, Inc: inc, Box: b.ID}} |
| } |
| @@ -78,7 +109,7 @@ func (w *world) legacyStart() { |
| // that earlier move also left the old box's local state behind: |
| // a database, a files directory and env files, and no row |
| ob := w.regionBox(other) |
| - ob.DB = &localDB{Inc: inc, Loc: locationFor(inc, true), Key: key, Writes: writes[:1]} |
| + ob.DB = &localDB{Inc: inc, Loc: locationFor(inc, true), Key: key, State: cloneState(part.Pages), TX: 1, Rep: 1} |
| ob.Files = &filesDir{Inc: inc, Key: key, Env: 1, Done: true} |
| } |
| } |
| @@ -304,8 +335,7 @@ func (w *world) residueOf(exceptStragglers bool) []string { |
| if e := w.execBox(c); e != nil { |
| serving = e.Region |
| loc, gen := c.Holder.Location, c.Holder.Gen |
| - live[dataKey(loc, gen, "base")] = true |
| - live[dataKey(loc, gen, "ltx")] = true |
| + live[dataKey(loc, gen, "ltx/")] = true // a prefix: every current LTX file of the generation |
| live[genMarkerKey(loc)] = true |
| } |
| } |
| @@ -313,7 +343,7 @@ func (w *world) residueOf(exceptStragglers bool) []string { |
| bk := w.buckets[r] |
| for _, p := range []string{"instances/" + host + "/", "moves/" + host + "/"} { |
| for _, k := range bk.keys(p) { |
| - if strings.HasSuffix(k, "lock.json") || (r == serving && live[k]) { |
| + if strings.HasSuffix(k, "lock.json") || (r == serving && (live[k] || live[genPrefixOf(k)+"ltx/"] && genPrefixOf(k) != "")) { |
| continue |
| } |
| if exceptStragglers && w.owed(r, k) { |
| @@ -356,6 +386,8 @@ func hasNonMarker(vs []*version) bool { |
| // canStart is the start check and admission, computed without starting |
| // anything: the final oracle never writes. |
| func (w *world) canStart(e *box, c *claim) string { |
| + c = c.clone() |
| + c.readStamp = e.stampOf(c.Holder.Inc) // as a fresh read by e would carry it |
| if !w.admits(e, c, "") { |
| return "not admitted" |
| } |
| @@ -416,14 +448,16 @@ func (w *world) final() { |
| w.violate("final: the serving box %d has no database and nothing to restore", e.ID) |
| return |
| } |
| - w.checkAcks(c, e, local.Writes, "local") |
| + w.checkLocal(c, e, local) |
| w.checkDurable(c, "final") |
| } |
| // checkDurable is the cold-restore promise, checked at every release to |
| // serving and at the end: a restore of the claim's location and generation |
| -// in the serving region holds every acknowledged write that a completed |
| -// replica object ever held. It reads the bucket and writes nothing. |
| +// in the serving region holds the effect of every acknowledged write that |
| +// a completed replica object ever covered, and equals the database the |
| +// acknowledged writes up to its TXID produce. It reads the bucket and |
| +// writes nothing. |
| func (w *world) checkDurable(c *claim, when string) { |
| if c == nil || c.Holder == nil { |
| return |
| @@ -441,53 +475,70 @@ func (w *world) checkDurable(c *claim, when string) { |
| need[x] = true |
| } |
| } |
| - have := map[int]bool{} |
| - if db != nil { |
| - for _, x := range db.Writes { |
| - have[x] = true |
| - } |
| - w.foreignWrites(db, c.Holder.Inc, "cold restore, "+when) |
| - } |
| for _, a := range w.acks { |
| if a.Inc != c.Holder.Inc || a.Exempt || a.Kind != "write" || !need[a.W] { |
| continue |
| } |
| - if !have[a.W] { |
| + if db == nil || w.txs[a.W].TX > db.TX { |
| w.violate("INV-2 (cold restore, %s): durable write %d (box %d, t=%d) lost", when, a.W, a.Box, a.At) |
| return |
| } |
| } |
| + if db == nil { |
| + return |
| + } |
| + w.foreignWrites(db, c.Holder.Inc, "cold restore, "+when) |
| + if why, ok := statesEqual(w.expectState(c.Holder.Inc, db.TX), db.State); !ok { |
| + w.violate("INV-2 (cold restore, %s): the restored database differs from the acknowledged writes: %s (restored TXID %d; %s)", when, why, db.TX, w.ltxShape(e.Region, dataKey(c.Holder.Location, c.Holder.Gen, "ltx/"))) |
| + } |
| +} |
| + |
| +// ltxShape describes the current LTX files under prefix, for diagnosis. |
| +func (w *world) ltxShape(r int, prefix string) string { |
| + var parts []string |
| + bk := w.buckets[r] |
| + for _, k := range bk.keys(prefix) { |
| + v := bk.current(k) |
| + if v == nil { |
| + parts = append(parts, strings.TrimPrefix(k, prefix)+":deleted") |
| + continue |
| + } |
| + parts = append(parts, fmt.Sprintf("%s:%v", strings.TrimPrefix(k, prefix), v.B.Pages)) |
| + } |
| + return "files " + strings.Join(parts, " ") |
| } |
| // foreignWrites: a database restored or started for incarnation inc holds |
| -// no write another incarnation acknowledged (C1, INV-4, claim C3a). |
| +// no key another incarnation wrote (C1, INV-4, claim C3a). |
| func (w *world) foreignWrites(db *localDB, inc int, how string) { |
| - for _, x := range db.Writes { |
| - if i, ok := w.writeInc[x]; ok && i != inc { |
| - w.violate("C3a (%s): incarnation %d's database holds write %d of incarnation %d", how, inc, x, i) |
| + for k, v := range db.State { |
| + if v == 0 { |
| + continue |
| + } |
| + if i, ok := w.writeInc[keyID(k)]; ok && i != inc { |
| + w.violate("C3a (%s): incarnation %d's database holds key %s of incarnation %d", how, inc, k, i) |
| return |
| } |
| } |
| } |
| -func (w *world) checkAcks(c *claim, e *box, writes []int, how string) { |
| - have := map[int]bool{} |
| - for _, x := range writes { |
| - have[x] = true |
| +// checkLocal: the serving box's database holds the effect of every |
| +// acknowledged, non-exempt write of the incarnation (INV-2), and every |
| +// acknowledged timer is delivered or still registered. |
| +func (w *world) checkLocal(c *claim, e *box, db *localDB) { |
| + if why, ok := statesEqual(w.expectState(c.Holder.Inc, -1), db.State); !ok { |
| + w.violate("INV-2 (local): the serving database differs from the acknowledged writes: %s", why) |
| + return |
| } |
| timers := map[int]bool{} |
| for _, x := range e.Timers { |
| timers[x] = true |
| } |
| for _, a := range w.acks { |
| - if a.Inc != c.Holder.Inc || a.Exempt { |
| + if a.Inc != c.Holder.Inc || a.Exempt || a.Kind != "timer" { |
| continue |
| } |
| - if a.Kind == "write" && !have[a.W] { |
| - w.violate("INV-2 (%s): acknowledged write %d (box %d, t=%d) lost", how, a.W, a.Box, a.At) |
| - return |
| - } |
| - if a.Kind == "timer" && how == "local" && !w.delivered[a.W] && !timers[a.W] { |
| + if !w.delivered[a.W] && !timers[a.W] { |
| w.violate("INV-2: acknowledged timer %d (box %d, t=%d) lost", a.W, a.Box, a.At) |
| return |
| } |
diff --git a/internal/sim/hostlife/witness_test.go b/internal/sim/hostlife/witness_test.go| index ee3bb080b..000688553 100644 |
| --- a/internal/sim/hostlife/witness_test.go |
| +++ b/internal/sim/hostlife/witness_test.go |
| @@ -87,7 +87,7 @@ var witnesses = []witness{ |
| {"registration-check", "registration-check,purge-claim-fence", "store of app", |
| "the same, with the fence gone instead", wRegistrationCheck}, |
| // rev 7.4 |
| - {"fence-by-installation", "fence-by-installation", "INV-3 (witness): instance back on box 0", |
| + {"fence-by-installation", "fence-by-installation", "refused by the box's own installation record", |
| "a round trip UK→US→UK (R3-3): the second landing replaces the retired marker UK wrote after the first move; a marker for the whole incarnation refuses the probe, the move back rolls back, and the instance never serves on UK again", wRoundTrip}, |
| {"orphan-order", "orphan-order", "orphan cleanup deleted the record", |
| "the cleanup reads the claim first and pauses; a create removes the orphan, claims and publishes; the cleanup reads that record, judges it by the stale claim and deletes it (R3-2)", wOrphanOrder}, |
| @@ -95,6 +95,15 @@ var witnesses = []witness{ |
| "a replication PUT of incarnation 1 completes after 33 recreates dropped it from retired_incarnations; a sweep reading that list never finds it (R3-4)", wSweepNamespace}, |
| {"recovery-current-inputs", "recovery-current-inputs", "stuck: source could not recover", |
| "a bundle is published after admission and the target writes; the dirty recovery renders current inputs; gated on move.inputs it never passes its probe (R2-5, §7.8)", wRecoveryInputs}, |
| + // rev 7.5 |
| + {"fence-stamp", "fence-stamp,hostguard", "INV-6", |
| + "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-gap-closed", "restore-gap-closed", "INV-2 (cold restore)", |
| + "a generation's snapshot ends at TXID 11, the file [12,12] is missing and [13,13] is current: a genuine gap; the restore refuses; returning what it has would wake the host without durable transactions (R4-1)", wRestoreGap}, |
| } |
| // TestWitnesses is the deterministic half of the mutation evidence. |
| @@ -243,7 +252,7 @@ func dump(t *testing.T, w *world) { |
| func newWitness() *world { |
| w := &world{seed: -1, rng: quiet{}, verbose: true, now: 100, |
| stores: map[string]bool{app0: true, app1: true}, cfgVer: map[string]int{app0: 1, app1: 1}, purgeRes: map[string]bool{}, purgeStep: map[string]int{}, |
| - control: newBucket(), delivered: map[int]bool{}, durable: map[string]map[int]bool{}, writeInc: map[int]int{}, legacyIncs: map[int]bool{}, purgeOp: map[string]string{}, genParent: map[string]string{}, execLease: map[int]execLease{}, fail: map[string]bool{}, stats: map[string]int{}} |
| + control: newBucket(), delivered: map[int]bool{}, durable: map[string]map[int]bool{}, writeInc: map[int]int{}, txs: map[int]tx{}, legacyIncs: map[int]bool{}, purgeOp: map[string]string{}, genParent: map[string]string{}, execLease: map[int]execLease{}, fail: map[string]bool{}, stats: map[string]int{}} |
| for r := 0; r < nRegions; r++ { |
| w.buckets[r] = newBucket() |
| b := &box{ID: r, Region: r, HasKey: true} |
| @@ -260,12 +269,12 @@ func newWitness() *world { |
| func (w *world) serve(b *box, inc int, app string) { |
| loc := locationFor(inc, false) |
| key := 1000 + inc |
| - var writes []int |
| + db := &localDB{Inc: inc, Loc: loc, Key: key, State: map[string]int{}} |
| for i := 0; i < 2; i++ { |
| w.nextW++ |
| - writes = append(writes, w.nextW) |
| w.acks = append(w.acks, ack{W: w.nextW, Inc: inc, Box: b.ID, At: w.now, Kind: "write"}) |
| w.writeInc[w.nextW] = inc |
| + w.commit(db, w.nextW) |
| } |
| if inc > w.nextInc { |
| w.nextInc = inc |
| @@ -282,11 +291,13 @@ func (w *world) serve(b *box, inc int, app string) { |
| w.claim = n |
| w.rec = &record{Ver: w.newVer(), Inc: inc, App: app, Region: b.Region} |
| b.Row = &row{Inc: inc, App: app, Status: "ready"} |
| - b.DB = &localDB{Inc: inc, Loc: loc, Key: key, Writes: append([]int(nil), writes...)} |
| + b.DB = db |
| b.Files = &filesDir{Inc: inc, Key: key, Env: key, Done: true} |
| b.Config, b.CfgVer, b.Live, b.LiveInc = inc, w.cfgVer[app], app, inc |
| - k := dataKey(loc, "", "ltx") |
| - bl := &blob{Kind: "ltx", Inc: inc, Writes: append([]int(nil), writes...), SealKey: key} |
| + b.install(inc, n.Ver) // the create's installation stamp |
| + bl := w.deltaOf(db, inc) |
| + db.Rep, db.Pending = db.TX, nil |
| + k := ltxKey(loc, "", 0, bl.Min, bl.Max) |
| w.buckets[b.Region].objs[k] = append(w.buckets[b.Region].objs[k], &version{ID: w.newVer(), B: bl}) |
| w.markDurable(k, bl) |
| w.logf("witness: inc %d of app %s serving on box %d", inc, app, b.ID) |
| @@ -300,21 +311,24 @@ func (w *world) legacy(b *box, other int) { |
| w.rec = &record{Ver: w.newVer(), Inc: inc, App: app0, Region: b.Region} |
| w.legacyIncs[inc] = true |
| key := 7 |
| - var writes []int |
| + b.Row = &row{Inc: inc, App: app0, Status: "ready"} |
| + b.DB = &localDB{Inc: inc, Loc: locationFor(inc, true), Key: key, State: map[string]int{}} |
| for i := 0; i < 2; i++ { |
| w.nextW++ |
| - writes = append(writes, w.nextW) |
| w.acks = append(w.acks, ack{W: w.nextW, Inc: inc, Box: b.ID, At: w.now, Kind: "write"}) |
| w.writeInc[w.nextW] = inc |
| + w.commit(b.DB, w.nextW) |
| } |
| - b.Row = &row{Inc: inc, App: app0, Status: "ready"} |
| - b.DB = &localDB{Inc: inc, Loc: locationFor(inc, true), Key: key, Writes: writes} |
| b.Files = &filesDir{Inc: inc, Key: key, Env: 1, Done: true} |
| b.Config, b.CfgVer, b.Live, b.LiveInc = inc, 1, app0, inc |
| - k := "instances/" + host + "/ltx" |
| - w.buckets[b.Region].objs[k] = []*version{{ID: w.newVer(), B: &blob{Kind: "ltx", Inc: 0, Writes: writes, SealKey: key}}} |
| - w.markDurable(k, &blob{Writes: writes}) |
| - w.buckets[other].objs[k] = []*version{{ID: w.newVer(), B: &blob{Kind: "ltx", Inc: 0, Writes: writes[:1], SealKey: key}}} |
| + full := w.deltaOf(b.DB, 0) |
| + b.DB.Rep, b.DB.Pending = b.DB.TX, nil |
| + k := ltxKey(b.DB.Loc, "", 0, 1, b.DB.TX) |
| + w.buckets[b.Region].objs[k] = []*version{{ID: w.newVer(), B: full}} |
| + w.markDurable(k, full) |
| + first := w.txs[b.DB.Log[0]] |
| + part := &blob{Kind: "ltx", Level: 0, Min: 1, Max: 1, Inc: 0, SealKey: key, Pages: map[string]int{first.Key: first.Val}, Post: sum(map[string]int{first.Key: first.Val}), TXs: []int{first.W}} |
| + w.buckets[other].objs[ltxKey(b.DB.Loc, "", 0, 1, 1)] = []*version{{ID: w.newVer(), B: part}} |
| } |
| // ---------- the schedule ---------- |
| @@ -447,7 +461,9 @@ func (w *world) appWrite(b *box) int { |
| w.nextW++ |
| wr := w.nextW |
| if b.DB != nil && b.DB.Inc == p.Inc { |
| - b.DB.Writes = append(b.DB.Writes, wr) |
| + w.commit(b.DB, wr) |
| + } else { |
| + w.txs[wr] = tx{W: wr} |
| } |
| w.acks = append(w.acks, ack{W: wr, Inc: p.Inc, Box: b.ID, At: w.now, Kind: "write"}) |
| w.writeInc[wr] = p.Inc |
| @@ -455,17 +471,21 @@ func (w *world) appWrite(b *box) int { |
| return wr |
| } |
| -// holdKey makes the witness hold every request for key until completeHeld. |
| +// holdKey makes the witness hold every request for key, or under a prefix |
| +// ending in "/", until completeHeld. |
| func (w *world) holdKey(key string) { |
| - w.hold = func(r *request) bool { return r.key == key } |
| + w.hold = func(r *request) bool { |
| + return r.key == key || strings.HasSuffix(key, "/") && strings.HasPrefix(r.key, key) |
| + } |
| } |
| -// completeHeld completes the requests the witness held for key, now. |
| +// completeHeld completes the requests the witness held for key (or |
| +// prefix), now. |
| func (w *world) completeHeld(key string) int { |
| n := 0 |
| var keep []*request |
| for _, r := range w.pending { |
| - if r.key == key && r.doneAt == 1<<30 { |
| + if (r.key == key || strings.HasSuffix(key, "/") && strings.HasPrefix(r.key, key)) && r.doneAt == 1<<30 { |
| w.complete(r) |
| w.logf("witness: held %v %s r%d completes now", r.kind, r.key, r.region) |
| n++ |
| @@ -792,6 +812,7 @@ func (w *world) recreate(t *testing.T, r, inc int) { |
| b.Config, b.CfgVer, b.Live, b.LiveInc = inc, w.cfgVer[app0], app0, inc // config sync and adoption have run |
| w.reach(t, 200, "new incarnation running", func() bool { return hasMain(b) }, func() { w.wake(b) }) |
| w.appWrite(b) |
| + w.appWrite(b) // two transactions: its first LTX file has the same key as the fixture's |
| w.replicate(b) |
| } |
| @@ -857,8 +878,8 @@ func wRoundTrip(t *testing.T, w *world) { |
| c := w.claim |
| return c.Op.Kind == "serving" && c.Op.Exec == us.ID && w.rec != nil && w.rec.MoveID == "" && len(c.Obl) == 0 |
| }, w.act(uk, us), w.recoveryPass) |
| - if _, fenced := uk.Fence[1]; !fenced { |
| - t.Fatalf("witness: box 0 wrote no retired marker: %s%s", w.state(), w.boxesState()) |
| + if st := uk.Stamps[1]; st == nil || st.Active != 0 || len(st.Retired) == 0 { |
| + t.Fatalf("witness: box 0 did not retire its installation: %+v %s%s", st, w.state(), w.boxesState()) |
| } |
| w.moveTo(uk.Region) |
| if w.claim.Op.Kind != "moving" { |
| @@ -917,7 +938,8 @@ func wSweepNamespace(t *testing.T, w *world) { |
| if !hasMain(b) { |
| t.Fatalf("witness: the host did not wake: %s", w.state()) |
| } |
| - key := dataKey(w.claim.Holder.Location, "", "ltx") |
| + w.appWrite(b) |
| + key := dataKey(w.claim.Holder.Location, "", "ltx/") |
| w.holdKey(key) |
| w.replicate(b) |
| w.hold = nil |
| @@ -961,3 +983,158 @@ func wRecoveryInputs(t *testing.T, w *world) { |
| w.rollback(w.claim, "settle check failed") |
| w.finish(t) |
| } |
| + |
| +// ---------- rev 7.5 ---------- |
| + |
| +// roundTrip moves the host UK→US→UK and requires it to serve on UK again, |
| +// running. |
| +func (w *world) roundTrip(t *testing.T, uk, us *box) { |
| + w.moveTo(us.Region) |
| + w.reach(t, 1500, "serving on box 1 after the first move", func() bool { |
| + c := w.claim |
| + return c.Op.Kind == "serving" && c.Op.Exec == us.ID && w.rec != nil && w.rec.MoveID == "" && len(c.Obl) == 0 |
| + }, w.act(uk, us), w.recoveryPass) |
| + w.moveTo(uk.Region) |
| + if w.claim.Op.Kind != "moving" { |
| + t.Fatalf("witness: the move back did not start: %s", w.state()) |
| + } |
| + w.reach(t, 1500, "instance back on box 0, running", func() bool { |
| + c := w.claim |
| + return c.Op.Kind == "serving" && c.Op.Exec == uk.ID && hasMain(uk) |
| + }, w.act(uk, us), w.recoveryPass, w.operator) |
| +} |
| + |
| +// wStaleWriter (claim rev 6.2 §6.4, R4-2): a config-sync pass selects the |
| +// row under the first installation and pauses; the host moves away and |
| +// back; a new bundle is published; the pass takes the guard. |
| +func wStaleWriter(t *testing.T, w *world) { |
| + uk, us := w.boxes[0], w.boxes[1] |
| + w.serve(uk, 1, app0) |
| + w.passPlan(uk, "config") |
| + if len(uk.plans) != 1 { |
| + t.Fatalf("witness: no plan") |
| + } |
| + p := uk.plans[0] |
| + p.At = 1 << 30 // paused |
| + w.roundTrip(t, uk, us) |
| + if len(w.violations) > 0 { |
| + return |
| + } |
| + w.cfgVer[app0]++ |
| + p.At = w.now // the pass resumes and takes the guard |
| + w.tick(func() { w.passExec(uk) }) |
| + w.finish(t) |
| +} |
| + |
| +// wLiveSetRegion (claim rev 6.2 §6.2a, R4-3): a legacy host serves in UK |
| +// (box 0) with a pre-release replica in US (box 1). Adoption's discovery |
| +// parks US. A replication PUT the old US executor issued before the |
| +// retirement completes after it. |
| +func wLiveSetRegion(t *testing.T, w *world) { |
| + uk, us := w.boxes[0], w.boxes[1] |
| + w.legacy(uk, us.Region) |
| + // the old US executor's last replication PUT, accepted, completing late |
| + late := &blob{Kind: "ltx", Level: 0, Min: 1, Max: 2, Inc: 0, SealKey: 7, Pages: cloneState(uk.DB.State), Post: sum(uk.DB.State)} |
| + w.pending = append(w.pending, &request{kind: rPut, region: us.Region, key: ltxKey("instances/"+host+"/", "", 0, 1, 2), b: late, doneAt: 1 << 30, by: "box:1"}) |
| + w.tick(func() { w.reconcile(uk) }) // adoption |
| + if w.claim == nil { |
| + t.Fatalf("witness: not adopted") |
| + } |
| + w.reach(t, 400, "discovery settled", func() bool { return w.claim.oblFor("adopt") == 0 }, w.actAll(), w.recoveryPass) |
| + if w.completeHeld(ltxKey("instances/"+host+"/", "", 0, 1, 2)) != 1 { |
| + t.Fatalf("witness: no held PUT") |
| + } |
| + w.finish(t) |
| +} |
| + |
| +// pushed installs a push generation on b: the host serves from a snapshot |
| +// at its current TXID, then runs again, so the witness can shape the |
| +// generation's LTX files by hand. |
| +func (w *world) pushed(t *testing.T, b *box) (loc, gen string) { |
| + w.userPush() |
| + gen = w.claim.Op.ID |
| + w.reach(t, 400, "push released", func() bool { return w.claim.Op.Kind == "serving" }, w.act(b)) |
| + w.idleStop(b) |
| + w.wake(b) |
| + if !hasMain(b) { |
| + t.Fatalf("witness: the host did not wake after the push: %s%s", w.state(), w.boxesState()) |
| + } |
| + return w.claim.Holder.Location, gen |
| +} |
| + |
| +// compacted builds the level-1 file [min, max] of b's lineage from the |
| +// oracle's transactions: the final image of every key touched in the |
| +// range, the checksum at max. The witness places it where compaction |
| +// would, and removes the level-0 files it replaces. |
| +func (w *world) compacted(b *box, min, max int) *blob { |
| + db := b.DB |
| + out := &blob{Kind: "ltx", Level: 1, Min: min, Max: max, Inc: db.Inc, Gen: db.Gen, SealKey: db.Key, Pages: map[string]int{}, Post: sum(db.State)} |
| + for _, wr := range db.Log { |
| + t := w.txs[wr] |
| + if t.TX >= min && t.TX <= max { |
| + out.Pages[t.Key] = db.State[t.Key] |
| + out.TXs = append(out.TXs, wr) |
| + } |
| + } |
| + return out |
| +} |
| + |
| +// wRestoreOverlap (R4-1): after a push the generation's snapshot ends at |
| +// TXID 11 (two writes, eight more, the push's mark). The host writes four |
| +// more; compaction has left one current file [10,15] and no level-0 |
| +// files. The disk is lost and the host wakes cold. |
| +func wRestoreOverlap(t *testing.T, w *world) { |
| + b := w.boxes[0] |
| + w.serve(b, 1, app0) |
| + w.wake(b) |
| + for i := 0; i < 8; i++ { |
| + w.appWrite(b) |
| + } |
| + w.replicate(b) |
| + loc, gen := w.pushed(t, b) |
| + if len(w.violations) > 0 { |
| + return |
| + } |
| + if b.DB.TX != 11 { |
| + t.Fatalf("witness: expected TXID 11 after the push, got %d", b.DB.TX) |
| + } |
| + for i := 0; i < 4; i++ { |
| + w.appWrite(b) |
| + } |
| + f := w.compacted(b, 10, 15) |
| + w.buckets[b.Region].objs[ltxKey(loc, gen, 1, 10, 15)] = []*version{{ID: w.newVer(), B: f}} |
| + w.markDurable(ltxKey(loc, gen, 1, 10, 15), f) |
| + b.DB.Rep, b.DB.Pending = 15, nil |
| + w.idleStop(b) |
| + b.DB = nil // the disk is lost; the next wake restores cold |
| + w.logf("witness: box %d lost its database; the generation holds the snapshot at 11 and [10,15]", b.ID) |
| + w.reach(t, 20, "woken cold", func() bool { return hasMain(b) }, func() { w.wake(b) }) |
| + w.finish(t) |
| +} |
| + |
| +// wRestoreGap (R4-1): the same generation with a genuine gap: the file |
| +// [12,12] never landed and [13,13] is current. The restore must refuse. |
| +func wRestoreGap(t *testing.T, w *world) { |
| + b := w.boxes[0] |
| + w.serve(b, 1, app0) |
| + w.wake(b) |
| + for i := 0; i < 8; i++ { |
| + w.appWrite(b) |
| + } |
| + w.replicate(b) |
| + loc, gen := w.pushed(t, b) |
| + if len(w.violations) > 0 { |
| + return |
| + } |
| + w.appWrite(b) // TXID 12: its file never lands |
| + b.DB.Rep, b.DB.Pending = 12, nil |
| + w.appWrite(b) // TXID 13 |
| + f := w.compacted(b, 13, 13) |
| + w.buckets[b.Region].objs[ltxKey(loc, gen, 0, 13, 13)] = []*version{{ID: w.newVer(), B: f}} |
| + b.DB.Rep, b.DB.Pending = 13, nil |
| + w.idleStop(b) |
| + db := w.restore(b, 1, loc, gen) |
| + if db != nil { |
| + w.violate("INV-2 (cold restore): a restore across the gap after TXID 11 returned a database at TXID %d", db.TX) |
| + } |
| +} |
diff --git a/internal/sim/hostlife/world_test.go b/internal/sim/hostlife/world_test.go| index beaae28e6..7e1299163 100644 |
| --- a/internal/sim/hostlife/world_test.go |
| +++ b/internal/sim/hostlife/world_test.go |
| @@ -64,31 +64,29 @@ func retire(xs []int, inc int) []int { |
| return xs |
| } |
| -// extends: history a is b with zero or more later transactions appended |
| -// (R3-1: a replica object continues the chain only if its history is an |
| -// extension of what the chain holds). |
| -func extends(a, b []int) bool { |
| - if len(a) < len(b) { |
| - return false |
| - } |
| - for i := range b { |
| - if a[i] != b[i] { |
| - return false |
| - } |
| - } |
| - return true |
| -} |
| - |
| // ---------- storage ---------- |
| type blob struct { |
| Kind string // "ltx", "base", "files", "snap", "lock", "manifest", "lease", "report", "gen" |
| Inc int // incarnation tag; 0 = untagged legacy |
| Gen string // lineage that wrote it |
| - Base int // a "gen" marker: the version id of the generation's snapshot (R3-1) |
| Seq int |
| SealKey int // the instance.key the data is sealed with |
| - Writes []int |
| + // an LTX file (ltx_test.go, R4-1): its level (0 and 1, 9 for a snapshot), |
| + // transaction range, the final image of every key it touches (0 is a |
| + // delete), the database checksum after Max, and, for the oracle only, |
| + // the writes it covers |
| + Level int |
| + Min int |
| + Max int |
| + Pages map[string]int |
| + Post string |
| + TXs []int |
| + // a "gen" marker (R3-1, R4-1): the generation's snapshot by key, version |
| + // id and TXID |
| + Base int |
| + SnapKey string |
| + TX int |
| Files *filesDir |
| Timers []int |
| Refs []ref // a park manifest's archive set |
| @@ -198,7 +196,7 @@ func dataKey(loc, gen, kind string) string { |
| func genMarkerKey(loc string) string { return loc + "generation.json" } |
| func isDataKey(k string) bool { |
| - return strings.HasPrefix(k, "instances/") && (strings.HasSuffix(k, "/base") || strings.HasSuffix(k, "/ltx")) |
| + return strings.HasPrefix(k, "instances/") && strings.Contains(k, "/ltx/") |
| } |
| // ---------- requests (late completion) ---------- |
| @@ -225,6 +223,7 @@ type request struct { |
| beyond bool // completes after H |
| ifSet bool // a conditional PUT: If-Match ifVer, or If-None-Match when ifVer is 0 |
| ifVer int |
| + hold bool // the biased schedule holds it until its worst moment (rev 7.5: a generation marker) |
| by string |
| // result, for an immediate completion |
| resultVer int |
| @@ -249,12 +248,16 @@ func (f *filesDir) clone() *filesDir { |
| } |
| type localDB struct { |
| - Inc int |
| - Loc string // the holder location it was restored from (claim C3a) |
| - Gen string // <dbPath>.gen |
| - Key int // the key it was sealed with |
| - Writes []int |
| - Seq int |
| + Inc int |
| + Loc string // the holder location it was restored from (claim C3a) |
| + Gen string // <dbPath>.gen |
| + Key int // the key it was sealed with |
| + State map[string]int |
| + TX int // the lineage's transaction id (litestream TXID) |
| + Rep int // replicated through this TXID |
| + Pending []int // writes since Rep (what the next LTX file ships) |
| + Log []int // every write applied here, for the oracle |
| + Seq int |
| } |
| func (d *localDB) clone() *localDB { |
| @@ -262,7 +265,9 @@ func (d *localDB) clone() *localDB { |
| return nil |
| } |
| c := *d |
| - c.Writes = append([]int(nil), d.Writes...) |
| + c.State = cloneState(d.State) |
| + c.Pending = append([]int(nil), d.Pending...) |
| + c.Log = append([]int(nil), d.Log...) |
| return &c |
| } |
| @@ -310,12 +315,11 @@ type box struct { |
| History []int // delivered history (disposable) |
| Attempt int // runner bookkeeping |
| Procs []*proc |
| - // Fence is the local retired marker, per incarnation (claim rev 6 §6.4, |
| - // R3-3): {incarnation, op}. It refuses a writer for that incarnation |
| - // whose operation is at or before the fence's; a new installation of |
| - // the incarnation by a later operation replaces it under the guard. |
| - // The value is the sequence of the fence's op (opSeq). |
| - Fence map[int]int |
| + // Stamps is the local record per host incarnation (claim rev 6.2 §6.4, |
| + // R4-2): {active_stamp | none, retired_stamps}. A stamp is the claim |
| + // gen captured at the CAS that made an installation current here. A |
| + // writer is admitted only if its stamp equals the active one. |
| + Stamps map[int]*stamps |
| Parked int // count of locally parked artifacts |
| ExecUntil int // local exec-lease file (watchdog input); 0 none |
| @@ -342,27 +346,75 @@ type plan struct { |
| App string |
| Ver int // the config bundle version it selected |
| At int // it takes the guard no earlier than this |
| - // Seq is the operation identity the pass validated under (R2-3): the |
| - // sequence of the newest operation when it selected the row. The |
| - // host guard refuses it once the local fence is at or past it. |
| - Seq int |
| -} |
| - |
| -// opSeq orders operations: every op id the model mints carries the |
| -// global counter, so a later operation has a larger sequence. The serving |
| -// state has no op id and sorts as "now". Claim rev 6's marker names the |
| -// op id of the installation; the ordering it needs is this one. |
| -func (w *world) opSeq(id string) int { |
| - if id == "" || id == "sweep" || id == "adopt" { |
| - return w.nextID |
| - } |
| - n := 0 |
| - for _, r := range id { |
| - if r >= '0' && r <= '9' { |
| - n = n*10 + int(r-'0') |
| - } |
| + // Stamp is the installation stamp the pass read when it selected the |
| + // row (claim rev 6.2 §6.4, R2-3): the host guard admits the write only |
| + // if it is still the active stamp. |
| + Stamp int |
| +} |
| + |
| +// stamps is a box's local record for one incarnation of the host (claim |
| +// rev 6.2 §6.4): the active installation's stamp (0 is none) and the |
| +// stamps of the installations retired here. |
| +type stamps struct { |
| + Active int |
| + Retired []int |
| +} |
| + |
| +func (b *box) stampOf(inc int) int { |
| + if s := b.Stamps[inc]; s != nil { |
| + return s.Active |
| + } |
| + return 0 |
| +} |
| + |
| +// install sets the active stamp for inc to the claim gen captured at the |
| +// CAS that made this installation current here (a create's serving CAS, a |
| +// landing's flip). Under the guard; the caller has read the claim fresh. |
| +func (b *box) install(inc, gen int) { |
| + if b.Stamps == nil { |
| + b.Stamps = map[int]*stamps{} |
| + } |
| + if b.Stamps[inc] == nil { |
| + b.Stamps[inc] = &stamps{} |
| } |
| - return n |
| + b.Stamps[inc].Active = gen |
| +} |
| + |
| +// retireStamp moves the active stamp into the retired set and sets none. |
| +func (b *box) retireStamp(inc int) { |
| + if b.Stamps == nil { |
| + b.Stamps = map[int]*stamps{} |
| + } |
| + if b.Stamps[inc] == nil { |
| + b.Stamps[inc] = &stamps{} |
| + } |
| + if a := b.Stamps[inc].Active; a != 0 { |
| + b.Stamps[inc].Retired = append(b.Stamps[inc].Retired, a) |
| + } |
| + b.Stamps[inc].Active = 0 |
| +} |
| + |
| +// admitsStamp is the local record's admission (claim rev 6.2 §6.4): the |
| +// writer's stamp must equal the active stamp; none active refuses all. |
| +// The mutation of fence-stamp admits any writer for the incarnation while |
| +// an installation is active (rev 7.4's behaviour); the mutation of |
| +// fence-by-installation is rev 5.2's marker: once retired, never active |
| +// again. |
| +func (b *box) admitsStamp(inc, stamp int) bool { |
| + s := b.Stamps[inc] |
| + if s == nil { |
| + return true // never installed or retired here: nothing to compare with |
| + } |
| + if !on("fence-by-installation") && len(s.Retired) > 0 { |
| + return false |
| + } |
| + if s.Active == 0 { |
| + return false |
| + } |
| + if !on("fence-stamp") { |
| + return true |
| + } |
| + return stamp == s.Active |
| } |
| func (b *box) forget() { |
| @@ -442,7 +494,11 @@ type opState struct { |
| LostBox int |
| LostAt int |
| RollbackID string |
| - PushWrites []int |
| + 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 claim gen of the flip CAS: the landing's installation stamp (claim rev 6.2 §6.4) |
| Committed bool |
| Opened bool |
| ParkLocalNow bool |
| @@ -491,6 +547,10 @@ type claim struct { |
| // Cancelled is cancelled_creates (claim rev 4 §5.1, J2-R2-7): creates |
| // recovery cancelled, so a late record PUT can be removed. It survives free. |
| Cancelled []cancelledCreate |
| + // readStamp is model bookkeeping on a box's read of the claim: the |
| + // installation stamp active on that box at the read, which a worker |
| + // carries with the claim it validated under (claim rev 6.2 §6.4) |
| + readStamp int |
| } |
| type cancelledCreate struct { |
| @@ -591,6 +651,7 @@ type world struct { |
| pending []*request |
| acks []ack |
| writeInc map[int]int // which incarnation acknowledged each write |
| + txs map[int]tx // every write as a transaction (ltx_test.go) |
| durable map[string]map[int]bool // per generation prefix: writes a completed replica object holds |
| genParent map[string]string // generation prefix -> the one it superseded |
| nextW int |
| @@ -643,6 +704,15 @@ func (w *world) newVer() int { |
| return w.ver |
| } |
| +// nextGen is the gen the claim CAS in progress will carry: what a writer |
| +// captures as its installation stamp (claim rev 6.2 §6.4). |
| +func (w *world) nextGen() int { |
| + if w.claim == nil { |
| + return 1 |
| + } |
| + return w.claim.Ver + 1 |
| +} |
| + |
| func (w *world) boxByID(id int) *box { |
| for _, b := range w.boxes { |
| if b.ID == id { |
| @@ -694,7 +764,6 @@ func (c *claim) clone() *claim { |
| n.Holder = &h |
| } |
| n.Op.Parts = append([]int(nil), c.Op.Parts...) |
| - n.Op.PushWrites = append([]int(nil), c.Op.PushWrites...) |
| n.Op.StopFiles = c.Op.StopFiles.clone() |
| n.Regions = append([]int(nil), c.Regions...) |
| n.Boxes = append([]int(nil), c.Boxes...) |
| @@ -748,6 +817,14 @@ func (w *world) issue(actorBox int, r *request) int { |
| w.logf("box %d issued %v %s r%d (held by the witness)", actorBox, r.kind, r.key, r.region) |
| return 0 |
| } |
| + if r.hold { |
| + // the biased schedule: accepted, completing at its worst moment or |
| + // long after everything else |
| + r.doneAt, r.by, r.beyond = w.now+40*horizonH, fmt.Sprintf("box:%d", actorBox), true |
| + w.pending = append(w.pending, r) |
| + w.logf("box %d issued %v %s r%d (held for its worst moment)", actorBox, r.kind, r.key, r.region) |
| + return 0 |
| + } |
| if w.rng.Intn(10) < 7 { |
| return w.complete(r) |
| } |
| @@ -830,11 +907,11 @@ func (w *world) markDurable(key string, b *blob) { |
| if b == nil || !isDataKey(key) { |
| return |
| } |
| - p := key[:strings.LastIndex(key, "/")+1] |
| + p := genPrefixOf(key) |
| if w.durable[p] == nil { |
| w.durable[p] = map[int]bool{} |
| } |
| - for _, x := range b.Writes { |
| + for _, x := range b.TXs { |
| w.durable[p][x] = true |
| } |
| } |
| @@ -863,6 +940,11 @@ func (w *world) newGen(loc, old, gen string) { |
| // is the interleaving C3a exists for (a recreate while the previous |
| // incarnation's requests are still in flight). |
| func (w *world) worstMoment(r *request) bool { |
| + if r.hold && r.b != nil && r.b.Kind == "gen" { |
| + // a held marker publication lands once a newer marker is current |
| + cur := w.buckets[r.region].current(r.key) |
| + return cur != nil && cur.B.Gen != r.b.Gen |
| + } |
| if !w.fail["recreate"] || r.kind != rPut || r.b == nil || r.b.Inc == 0 || !isDataKey(r.key) { |
| return false |
| } |