carlos / platform Public

sim: hostlife: replication position, level-0 anchor, snapshot rotation, claim rev 6.3 stamps (rev 7.6)

Joint round 5 on the model, from the pinned-library facts in
internal/sim/litestreamfacts:

R5-1 the replication position (Pos) is separate from the lineage TXID. A
cold restore reads its position from the newest current level-0 file and,
when it restored past that, writes the level-0 anchor [cur,cur] before
replication starts (rule restore-anchor; witness: snapshot-only install,
write, replicate, local loss, cold restore).

R5-2 no whole-state checksum: the pinned writers set NoChecksum, so the
model verifies by per-file ranges and the restored state only.

R5-3 snapshot rotation and retention as the library does them: a
compaction at the snapshot level writes a new snapshot, keeps only the
newest, and expires level-1 and level-0 files below the previous
snapshot's Max. The restore roots at the newest current successor snapshot
at or past the pinned one (rule root-advance; witness).

R5-4 claim rev 6.3 (8faa89a4): holder.stamp is written by every installing
CAS (create step 4, adoption, flip, reinstall, dirty rollback, push
commit); a box's local record is initialized only from a fresh claim read
under the guard that names it op.exec or landing participant; no local
record refuses the write; two retirements, retire-artifacts(S) and
retire-installation.

R5-5 the readiness budget prices snapshot construction and the
cross-region transfer, with the sealed snapshot built on the target from
staged material.

R5-6 the retention fixture populates 2..33 and asserts the injected
incarnation absent; LTX keys follow the S3 client's
<prefix>/<4-hex-level>/<min>-<max>.ltx.

The ship step advances the position as soon as the upload is issued (a
retry carries the same bytes): seed 143 showed a late-landing upload
leaving a numbering gap.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 29245f697e44a742e9253ac0d43eb35f8399e93b parent d96f781
10 files changed, +601 −228
  • internal/sim/hostlife/background_test.go +3 −5
  • internal/sim/hostlife/budget_test.go +46 −7
  • internal/sim/hostlife/console_test.go +7 −2
  • internal/sim/hostlife/ltx_test.go +206 −67
  • internal/sim/hostlife/moves_test.go +60 −18
  • internal/sim/hostlife/protocol_test.go +84 −37
  • internal/sim/hostlife/rules_test.go +5 −3
  • internal/sim/hostlife/sim_test.go +26 −17
  • internal/sim/hostlife/witness_test.go +99 −39
  • internal/sim/hostlife/world_test.go +65 −33
diff --git a/internal/sim/hostlife/background_test.go b/internal/sim/hostlife/background_test.go
index 9945c580d..7988c317a 100644
--- a/internal/sim/hostlife/background_test.go
+++ b/internal/sim/hostlife/background_test.go
@@ -150,7 +150,7 @@ func (w *world) adopt() bool {
if eb == nil {
return false
}
- n := &claim{Ver: 1, Holder: &holder{Inc: w.rec.Inc, App: w.rec.App, Legacy: true, Location: locationFor(w.rec.Inc, true)},
+ n := &claim{Ver: 1, Holder: &holder{Inc: w.rec.Inc, App: w.rec.App, Legacy: true, Location: locationFor(w.rec.Inc, true), Stamp: 1}, // adoption writes holder.stamp (claim rev 6.3 §2)
Op: opState{Kind: "serving", Exec: eb.ID, Parts: []int{eb.ID}}, Regions: []int{w.rec.Region}, Boxes: []int{eb.ID}, Complete: false}
if on("legacy-discovery") {
// every other region parks whatever it holds: it cannot be the
@@ -197,9 +197,7 @@ 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"}
- 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)
- }
+ w.initRecord(b, c) // the local record from holder.stamp, on this fresh read (claim rev 6.3 §6.4)
}
} else if b.Row != nil && !(on("participant-rows") && w.ownsLocalObligation(c, b)) {
w.retireRow(b)
@@ -257,7 +255,7 @@ func (w *world) passExec(b *box) {
return // the selected bundle is no longer the held one
}
}
- // the local record (claim rev 6.2 §6.4): the writer carries the stamp
+ // the local record (claim rev 6.3 §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
diff --git a/internal/sim/hostlife/budget_test.go b/internal/sim/hostlife/budget_test.go
index 7208e9c7c..54f5142a0 100644
--- a/internal/sim/hostlife/budget_test.go
+++ b/internal/sim/hostlife/budget_test.go
@@ -2,15 +2,21 @@ package hostlife
import "testing"
-// The readiness arithmetic of moves spec §7.3 T11 (rev 7.4, R3-7), in
+// The readiness arithmetic of moves spec §7.3 T11 (rev 7.6, R3-7, R5-5), in
// seconds. Rev 7.3's T11 required every one of three probe attempts, each
// with its settle, to fit the window beside the fixed costs: 150 s of
// probing alone, against a default window of 120 s, so no default move
-// was ever eligible. Rev 7.4 makes the permitted attempt count depend on
-// the admitted budget.
+// was ever eligible. Rev 7.4 made the permitted attempt count depend on
+// the admitted budget. Rev 7.5 then made the sealed transfer a full
+// snapshot but still priced the final mirror at the 16 MiB bar (R5-5).
+// Rev 7.6 prices what the cut really does: the final delta mirror within
+// the bar, then on the target a restore of the staged replica, the
+// construction of the generation's snapshot and its level-0 anchor, each
+// at the measured local rate for the whole database.
type cutEstimate struct {
- Stop, Checkpoint, Mirror, Archive, Copy, Restore, Render, Open float64
- Probe, Settle float64 // one attempt
+ Stop, Checkpoint, Mirror, Archive, Render, Open float64
+ RestoreStaged, SnapshotBuild, Anchor float64 // target-side, local, whole database
+ Probe, Settle float64 // one attempt
}
const (
@@ -18,10 +24,11 @@ const (
probeAttemptsMax = 3 // §7.7: never more than three attempts
probeSeconds = 30.0
settleSeconds = 20.0
+ mirrorBarMiB = 16.0 // §7.3: the final round is at most this much
)
func (e cutEstimate) fixed() float64 {
- return e.Stop + e.Checkpoint + e.Mirror + e.Archive + e.Copy + e.Restore + e.Render + e.Open
+ return e.Stop + e.Checkpoint + e.Mirror + e.Archive + e.RestoreStaged + e.SnapshotBuild + e.Anchor + e.Render + e.Open
}
// attemptsAdmitted is T11 as rev 7.4 states it: the attempts permitted are
@@ -44,9 +51,22 @@ func (e cutEstimate) fitsRev73(window float64) bool {
return e.fixed()+probeAttemptsMax*(e.Probe+e.Settle) <= window
}
+// estimate prices a cut for a database of dbMiB over a cross-region path
+// of crossMiBps and a local disk of localMiBps, as rev 7.6 §7.3 does.
+func estimate(dbMiB, crossMiBps, localMiBps float64) cutEstimate {
+ return cutEstimate{
+ Stop: 2, Checkpoint: 1, Archive: 2, Render: 1, Open: 5,
+ Mirror: mirrorBarMiB / crossMiBps,
+ RestoreStaged: dbMiB / localMiBps,
+ SnapshotBuild: dbMiB / localMiBps,
+ Anchor: dbMiB / localMiBps,
+ Probe: probeSeconds, Settle: settleSeconds,
+ }
+}
+
func TestReadinessBudget(t *testing.T) {
// a tiny instance: every fixed cost at the floor of its measured rate
- tiny := cutEstimate{Stop: 2, Checkpoint: 1, Mirror: 2, Archive: 2, Copy: 1, Restore: 1, Render: 1, Open: 5, Probe: probeSeconds, Settle: settleSeconds}
+ tiny := estimate(1, 10, 200)
if tiny.fitsRev73(cutWindowDefault) {
t.Fatalf("rev 7.3's formula admits a tiny instance in the default window: R3-7 is stale")
}
@@ -66,4 +86,23 @@ func TestReadinessBudget(t *testing.T) {
if n := tiny.attemptsAdmitted(600); n != probeAttemptsMax {
t.Fatalf("a 600 s window admits %d attempts, want %d", n, probeAttemptsMax)
}
+ // R5-5: a 2 GiB database over a 10 MiB/s cross-region path. Rev 7.5's
+ // full-snapshot transfer alone takes 205 s and could never fit; rev
+ // 7.6 ships the final delta within the bar and builds the snapshot on
+ // the target at the local rate
+ big := estimate(2048, 10, 200)
+ fullTransfer := 2048.0 / 10
+ if fullTransfer < cutWindowDefault {
+ t.Fatalf("the example no longer illustrates R5-5")
+ }
+ if n := big.attemptsAdmitted(cutWindowDefault); n != 1 {
+ t.Fatalf("a 2 GiB database over 10 MiB/s with the snapshot built on the target admits %d attempts, want 1 (fixed %.1f s)", n, big.fixed())
+ }
+ // and a cross-region snapshot transfer, if a builder did that instead,
+ // is refused by the same arithmetic
+ naive := big
+ naive.Mirror, naive.RestoreStaged, naive.SnapshotBuild = fullTransfer, 0, 0
+ if n := naive.attemptsAdmitted(cutWindowDefault); n != 0 {
+ t.Fatalf("a full cross-region snapshot transfer admits %d attempts, want 0", n)
+ }
}
diff --git a/internal/sim/hostlife/console_test.go b/internal/sim/hostlife/console_test.go
index 31a1f339c..3b3244b7a 100644
--- a/internal/sim/hostlife/console_test.go
+++ b/internal/sim/hostlife/console_test.go
@@ -315,6 +315,7 @@ func (w *world) createSteps(p *consolePlan) bool {
return false
}
n.Op = opState{Kind: "serving", Exec: b.ID, Parts: []int{b.ID}}
+ n.Holder.Stamp = w.nextGen() // create step 4: holder.stamp is this CAS's gen (claim rev 6.3 §2)
return true
}) {
delete(ad.Reg, p.OpID)
@@ -569,7 +570,7 @@ func (w *world) orphanSteps(p *consolePlan) bool {
// 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,
+// acknowledged (claim §5.2). Under a purge (claim rev 6.3 §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
@@ -896,7 +897,10 @@ func (w *world) doorAuthorize() {
// 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/")) {
+ for _, k := range bk.keys(dataKey(c.Holder.Location, c.Holder.Gen, "")) {
+ if !isDataKey(k) || genPrefixOf(k) != dataKey(c.Holder.Location, c.Holder.Gen, "") {
+ continue
+ }
if v := bk.current(k); v != nil && (key == "" || v.ID > bk.current(key).ID) {
key = k
}
@@ -1021,6 +1025,7 @@ func (w *world) recoveryPass() {
return false
}
n.Op = opState{Kind: "serving", Exec: b.ID, Parts: []int{b.ID}}
+ n.Holder.Stamp = w.nextGen() // the recovery pass finishing create step 4 writes holder.stamp too
return true
}) {
delete(w.admit[c.Holder.App].Reg, c.Op.ID)
diff --git a/internal/sim/hostlife/ltx_test.go b/internal/sim/hostlife/ltx_test.go
index 8d4a1ac7d..54d97a9c8 100644
--- a/internal/sim/hostlife/ltx_test.go
+++ b/internal/sim/hostlife/ltx_test.go
@@ -3,24 +3,43 @@ package hostlife
import (
"fmt"
"hash/fnv"
+ "regexp"
"sort"
"strings"
)
-// The database and its replica, with litestream's LTX semantics (rev 7.5,
-// R4-1).
+// The database and its replica, with the pinned litestream's LTX
+// behaviour (rev 7.6, R4-1, R5-1 to R5-3; the facts in
+// internal/sim/litestreamfacts).
//
-// 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.
+// A database is a map of keys to values with two counters: the lineage
+// transaction id (TX), the oracle's order of acknowledged writes, and the
+// replication position (Pos), litestream's TXID, which counts shipped LTX
+// files and is what every LTX range is numbered in. A replica object is an
+// LTX file: a level (0 and 1, 9 for a snapshot), a range in replication
+// numbering, the final page image of every key it touches, and, for the
+// oracle only, the writes it covers. Pinned litestream writes no database
+// checksum into any file (R5-2): a file carries a CRC-64 of itself, which
+// the model's files never fail, and nothing else; restore verifies by
+// decoding, not by a state checksum.
+//
+// Replication (R5-1): each shipped file carries one TXID, Pos+1; a
+// database with position 0 ships a full-page level-0 file [1, 1] first,
+// as litestream's first sync does. Litestream takes a fresh box's position
+// from the newest remote level-0 file, and from nothing else: a sidecar is
+// follow-mode only, and a snapshot does not count. So a cold restore
+// writes a level-0 anchor [cur, cur] with every page before replication
+// starts, and the position is cur; without it the position is the newest
+// remote level-0 file's, or 0, and replication restarts below the
+// snapshot, where the planner never looks.
+//
+// Rotation and retention (R5-3): the executor's litestream compacts three
+// level-0 files into one level-1 file (deleting the inputs), writes a new
+// snapshot after each compaction, keeps only the newest snapshot, and
+// expires level-1 and level-0 files whose range ends below the previous
+// snapshot's TXID. A restore roots at the newest current snapshot of the
+// generation at or past the pinned one, and falls back to the pinned
+// version only when no successor is current.
type tx struct {
W int // the global write id, which is what the application acknowledged
@@ -29,8 +48,8 @@ type tx struct {
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.
+// sum is a digest of the state, for the model's own digests (stop
+// evidence, the settle check); no replica file carries it (R5-2).
func sum(state map[string]int) string {
keys := make([]string, 0, len(state))
for k, v := range state {
@@ -127,51 +146,86 @@ func oldestKey(state map[string]int) string {
return best
}
-// ltxKey and snapKey are litestream's layout under a generation:
-// ltx/<level>/<min>-<max>; a snapshot is level 9 from TXID 1.
+// ltxKey is the pinned S3 client's layout under a generation prefix:
+// <prefix>/<level as four hex digits>/<min>-<max>.ltx, sixteen hex digits
+// each (litestream s3/replica_client.go, ltx.FormatFilename). 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)
+ return fmt.Sprintf("%s%04x/%016x-%016x.ltx", dataKey(loc, gen, ""), level, min, max)
}
func snapKey(loc, gen string, max int) string { return ltxKey(loc, gen, 9, 1, max) }
+var ltxKeyRe = regexp.MustCompile(`^(.*/)([0-9a-f]{4})/([0-9a-f]{16})-([0-9a-f]{16})\.ltx$`)
+
// 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 {
+ m := ltxKeyRe.FindStringSubmatch(k)
+ if m == nil {
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
+ fmt.Sscanf(m[2], "%x", &level)
+ fmt.Sscanf(m[3], "%x", &min)
+ fmt.Sscanf(m[4], "%x", &max)
+ return level, min, max, true
}
-// genPrefixOf is the generation prefix an LTX key belongs to.
+// genPrefixOf is the generation prefix an LTX key belongs to: the key
+// without its level directory and file name.
func genPrefixOf(k string) string {
- i := strings.Index(k, "/ltx/")
- if i < 0 {
+ m := ltxKeyRe.FindStringSubmatch(k)
+ if m == nil {
return ""
}
- return k[:i+1]
+ return m[1]
+}
+
+// isLevelDir: a four-hex-digit child is litestream's level directory.
+func isLevelDir(child string) bool {
+ if len(child) != 4 {
+ return false
+ }
+ for _, r := range child {
+ if !(r >= '0' && r <= '9' || r >= 'a' && r <= 'f') {
+ return false
+ }
+ }
+ return true
}
-// 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.
+// snapshotOf is the snapshot file of a database at its replication
+// position: every page, TXIDs 1 to Pos, and the oracle's list of the
+// writes it holds. The caller ships pending writes first, as litestream
+// syncs before it snapshots.
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...)}
+ return &blob{Kind: "snap", Level: 9, Min: 1, Max: db.Pos, Inc: inc, Gen: db.Gen, SealKey: db.Key,
+ Pages: cloneState(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.
+// deltaOf is the next level-0 file: one TXID, Pos+1, with the final
+// image of every key the pending writes touched. At position 0 it is the
+// first sync, a full-page file [1, 1], as litestream writes it.
func (w *world) deltaOf(db *localDB, inc int) *blob {
pages := map[string]int{}
+ if db.Pos == 0 {
+ pages = cloneState(db.State)
+ }
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...)}
+ return &blob{Kind: "ltx", Level: 0, Min: db.Pos + 1, Max: db.Pos + 1, Inc: inc, Gen: db.Gen, SealKey: db.Key,
+ Pages: pages, TXs: append([]int(nil), db.Pending...)}
+}
+
+// anchorOf is the level-0 anchor a cold restore writes before replication
+// starts (R5-1, the fact in litestreamfacts TestFact_R5_1_AnchorL0ContinuesFromN):
+// every page of the restored database as the file [cur, cur], which
+// litestream's open takes as its position.
+func anchorOf(db *localDB, inc int) *blob {
+ return &blob{Kind: "ltx", Level: 0, Min: db.Pos, Max: db.Pos, Inc: inc, Gen: db.Gen, SealKey: db.Key,
+ Pages: cloneState(db.State), TXs: append([]int(nil), db.Log...)}
}
// apply overlays an LTX file's page images onto state.
@@ -186,10 +240,10 @@ func apply(state map[string]int, f *blob) {
}
// 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.
+// exception: plain PUTs). It ships the pending writes as one level-0 file
+// at Pos+1 (an upload that times out is retried with the same bytes, so
+// the position advances at once), then runs compaction, snapshot
+// rotation and retention as the store's monitors would.
func (w *world) replicate(b *box) {
if !w.agentUp(b) || !hasMain(b) || b.DB == nil {
return
@@ -199,41 +253,53 @@ func (w *world) replicate(b *box) {
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.issue(b.ID, &request{kind: rPut, region: b.Region, key: key, b: bl})
+ // the file is this TXID's whether the upload has completed or is
+ // still in flight: litestream retries the same bytes, and later
+ // writes go to the next TXID. A retry that lands after the
+ // original is an identical version of the same key
+ db.Pos, db.Pending = bl.Max, 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
+// currentLTX lists the current LTX files of a generation at one level, by
+// range.
+func (w *world) currentLTX(bk *bucket, prefix string, level int) []*version {
+ var out []*version
for _, k := range bk.keys(prefix) {
+ l, _, _, ok := ltxLevel(k)
+ if !ok || l != level || genPrefixOf(k) != prefix {
+ continue
+ }
if v := bk.current(k); v != nil {
- inputs = append(inputs, in{k, v})
+ out = append(out, v)
}
}
+ sort.Slice(out, func(i, j int) bool { return out[i].B.Min < out[j].B.Min })
+ return out
+}
+
+// compact merges the current level-0 files of b's generation into one
+// level-1 file when there are at least three, deletes the inputs, then
+// writes a new snapshot and enforces retention (litestream's store after
+// a compaction at the snapshot level): only the newest snapshot stays,
+// and level-1 and level-0 files whose range ends below the previous
+// snapshot's TXID are deleted. Every delete is a key delete: the versions
+// stay noncurrent, which is what keeps the pinned snapshot version
+// reachable (R5-3). Contiguity is checked as ltx.Compactor does.
+func (w *world) compact(b *box) {
+ db := b.DB
+ bk := w.buckets[b.Region]
+ prefix := dataKey(db.Loc, db.Gen, "")
+ inputs := w.currentLTX(bk, prefix, 0)
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{}}
+ out := &blob{Kind: "ltx", Level: 1, Min: inputs[0].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)
+ for _, v := range inputs {
if max != 0 && !(v.B.Min <= max+1 && v.B.Max > max) {
return // non-contiguous inputs: litestream refuses to compact them
}
@@ -244,17 +310,46 @@ func (w *world) compact(b *box) {
}
}
out.TXs = append(out.TXs, v.B.TXs...)
- out.Post = v.B.Post
if v.B.Max > max {
max = v.B.Max
}
+ keys = append(keys, ltxKey(db.Loc, db.Gen, 0, v.B.Min, 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
+ 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 {
+ return
+ }
+ for _, k := range keys {
+ w.issue(b.ID, &request{kind: rDelKey, region: b.Region, key: k})
+ }
+ w.stats["ltx compactions"]++
+ // snapshot rotation: a new snapshot at the position, then retention
+ prev := 0
+ for _, s := range w.currentLTX(bk, prefix, 9) {
+ if s.B.Max > prev {
+ prev = s.B.Max
+ }
+ }
+ snap := snapshotOf(db, db.Inc)
+ snap.Kind = "ltx"
+ if snap.Max <= prev || w.issue(b.ID, &request{kind: rPut, region: b.Region, key: snapKey(db.Loc, db.Gen, snap.Max), b: snap}) <= 0 {
+ return
+ }
+ w.stats["snapshot rotations"]++
+ // keep the newest snapshot; minSnapshotTXID is the previous one's
+ // MaxTXID (litestream db.EnforceSnapshotRetention); expire levels 1
+ // and 0 below it
+ for _, s := range w.currentLTX(bk, prefix, 9) {
+ if s.B.Max < snap.Max {
+ w.issue(b.ID, &request{kind: rDelKey, region: b.Region, key: snapKey(db.Loc, db.Gen, s.B.Max)})
+ }
+ }
+ for _, level := range []int{1, 0} {
+ for _, f := range w.currentLTX(bk, prefix, level) {
+ if f.B.Max < prev {
+ w.issue(b.ID, &request{kind: rDelKey, region: b.Region, key: ltxKey(db.Loc, db.Gen, level, f.B.Min, f.B.Max)})
+ }
}
- w.stats["ltx compactions"]++
}
}
@@ -272,8 +367,8 @@ func (w *world) plan(bk *bucket, prefix string, cur int) (files []*blob, gap boo
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 !ok || level == 9 || genPrefixOf(k) != prefix {
+ continue // snapshots are the root, never planned
}
if v := bk.current(k); v != nil {
cands = append(cands, v.B)
@@ -307,6 +402,18 @@ func (w *world) plan(bk *bucket, prefix string, cur int) (files []*blob, gap boo
return files, gap
}
+// maxL0 is the newest current level-0 file's TXID of a generation: what
+// litestream's open takes as a fresh box's position (R5-1).
+func (w *world) maxL0(bk *bucket, prefix string) int {
+ max := 0
+ for _, f := range w.currentLTX(bk, prefix, 0) {
+ if f.B.Max > max {
+ max = f.B.Max
+ }
+ }
+ return max
+}
+
// 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
@@ -335,3 +442,35 @@ func (w *world) expectState(inc int, upto int) map[string]int {
}
return state
}
+
+// txsOf is the union, in order, of the writes the files cover: the oracle's
+// log of a restored database.
+func txsOf(files ...*blob) []int {
+ seen := map[int]bool{}
+ var out []int
+ for _, f := range files {
+ for _, wr := range f.TXs {
+ if !seen[wr] {
+ seen[wr] = true
+ out = append(out, wr)
+ }
+ }
+ }
+ return out
+}
+
+// lineageTX is the oracle's lineage TXID of a restored state: the highest
+// transaction id among the writes the applied files cover.
+func (w *world) lineageTX(files ...*blob) int {
+ max := 0
+ for _, f := range files {
+ for _, wr := range f.TXs {
+ if t := w.txs[wr].TX; t > max {
+ max = t
+ }
+ }
+ }
+ return max
+}
+
+var _ = strings.HasPrefix
diff --git a/internal/sim/hostlife/moves_test.go b/internal/sim/hostlife/moves_test.go
index 023ee3aab..b2799cd0f 100644
--- a/internal/sim/hostlife/moves_test.go
+++ b/internal/sim/hostlife/moves_test.go
@@ -48,6 +48,31 @@ func (w *world) workerMode(b *box, obl bool) {
w.role(b, c)
}
+// initRecord is claim rev 6.3 §6.4's initialization (R5-4): a box creates
+// or refreshes its local stamp record only from a fresh claim read, and
+// only when that claim names it as op.exec or as the landing participant;
+// it sets active_stamp = holder.stamp. A create's first wake, adoption on
+// the serving box, and a lost record on restart all go through here. A
+// stamp this box has retired is never made active again.
+func (w *world) initRecord(b *box, c *claim) {
+ if c == nil || c.Holder == nil || c.Holder.Stamp == 0 || c.Ver != w.claim.Ver {
+ return
+ }
+ inc := c.Holder.Inc
+ if !(c.Op.Exec == b.ID || c.Op.Kind == "landing" && c.Op.Tgt == b.ID) {
+ return
+ }
+ if b.stampOf(inc) == c.Holder.Stamp {
+ return
+ }
+ if s := b.Stamps[inc]; s != nil && contains(s.Retired, c.Holder.Stamp) {
+ return
+ }
+ b.install(inc, c.Holder.Stamp)
+ c.readStamp = c.Holder.Stamp
+ w.logf("box %d: local record for inc %d initialized from holder.stamp %d", b.ID, inc, c.Holder.Stamp)
+}
+
// resumeStale is a stalled worker goroutine of b resuming with a claim it
// read long ago (rev 4's actor). With claim C3's revalidation it re-reads
// under its lease, sees the world moved, and stops; the mutation acts on
@@ -66,6 +91,7 @@ func (w *world) resumeStale(b *box, old *claim) {
// role is the worker's exec-lease renewal, quarantine and operation role,
// acting on the claim c it read.
func (w *world) role(b *box, c *claim) {
+ w.initRecord(b, c)
// exec lease (claim §7.1)
if w.isParticipant(c, b) && !contains(c.Lost, b.ID) {
if b.ExecUntil == 0 || b.ExecUntil-w.localNow(b) < execRenew {
@@ -177,11 +203,19 @@ func (w *world) snapshotBlobOf(b *box, c *claim, gen string) (*blob, *blob) {
// woken) the replica restored as it stands
var db *blob
if b.DB != nil {
+ // the final checkpoint ships the pending writes as the next level-0
+ // file and waits for the upload (litestream SyncAndWait); the
+ // snapshot is then at that position
+ if len(b.DB.Pending) > 0 {
+ bl := w.deltaOf(b.DB, b.DB.Inc)
+ w.issue(b.ID, &request{kind: rPut, region: b.Region, key: ltxKey(b.DB.Loc, b.DB.Gen, 0, bl.Min, bl.Max), b: bl})
+ b.DB.Pos, b.DB.Pending = bl.Max, nil
+ }
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)}
+ db = &blob{Kind: "snap", Level: 9, Min: 1, Inc: c.Holder.Inc, Pages: map[string]int{}}
}
files := &blob{Kind: "files", Inc: c.Holder.Inc, Files: b.Files.clone(), Timers: append([]int(nil), b.Timers...)}
return db, files
@@ -656,7 +690,8 @@ 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)
+ n.Holder.Stamp = w.nextGen() // holder.stamp: the gen this CAS produces, the landing's installation stamp (claim rev 6.3 §2)
+ n.Op.FlipGen = n.Holder.Stamp
return true
})
case r.MoveState == "committed" && op.Kind == "landing" && !op.Committed:
@@ -714,15 +749,15 @@ func (w *world) landTarget(b *box, c *claim) {
// 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
+ // current and this box the landing participant (claim rev 6.3
// §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)
+ b.install(c.Holder.Inc, fc.Holder.Stamp)
+ w.logf("box %d: installation stamp %d for inc %d (%s), from holder.stamp", b.ID, fc.Holder.Stamp, c.Holder.Inc, op.ID)
}
}
w.cas(c, func(n *claim) bool {
@@ -802,6 +837,7 @@ func (w *world) landTarget(b *box, c *claim) {
case "restore":
if b.DB == nil || b.DB.Gen != op.ID {
if b.DB != nil {
+ b.retireArtifacts(c.Holder.Inc, b.DB.Stamp)
b.Parked++
}
b.DB = w.restore(b, c.Holder.Inc, loc, op.ID)
@@ -887,8 +923,15 @@ func (w *world) installLocal(b *box, c *claim, gen string, seal ref, _ string, n
return
}
if b.Files == nil || b.Files.Gen != gen {
- // fresh extraction: park whatever is there first
- if b.DB != nil || b.Files != nil {
+ // fresh extraction: park whatever is there first, as
+ // retire-artifacts of the predecessor's stamp (claim rev 6.3
+ // §6.4): the new installation's active stamp is untouched
+ if b.DB != nil {
+ b.retireArtifacts(c.Holder.Inc, b.DB.Stamp)
+ b.Parked++
+ }
+ if b.Files != nil {
+ b.retireArtifacts(c.Holder.Inc, b.Files.Stamp)
b.Parked++
}
b.DB = nil
@@ -909,7 +952,7 @@ func (w *world) installLocal(b *box, c *claim, gen string, seal ref, _ string, n
return
}
f := src.B.Files.clone()
- f.Gen, f.Done = gen, false
+ f.Gen, f.Done, f.Stamp = gen, false, b.stampOf(c.Holder.Inc)
if !on("markers-last") {
f.Done = true
}
@@ -1155,6 +1198,7 @@ func (w *world) recoverSource(b *box, c *claim) {
w.cas(c, func(n *claim) bool {
w.newGen(n.Holder.Location, n.Holder.Gen, op.RollbackID)
n.Holder.Gen = op.RollbackID
+ n.Holder.Stamp = w.nextGen() // a dirty rollback installs a new generation: a new stamp, never the predecessor's (claim rev 6.3 §6.4)
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)
})
@@ -1189,7 +1233,6 @@ func (w *world) pushStep(b *box, c *claim) {
w.writeInc[mark] = c.Holder.Inc
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 {
@@ -1226,6 +1269,7 @@ func (w *world) pushStep(b *box, c *claim) {
}
case "restore":
if b.DB != nil && b.DB.Gen != op.ID {
+ b.retireArtifacts(c.Holder.Inc, b.DB.Stamp)
b.Parked++
b.DB = nil
}
@@ -1256,6 +1300,7 @@ func (w *world) pushStep(b *box, c *claim) {
n.Op.Phase = "probed"
w.newGen(n.Holder.Location, n.Holder.Gen, op.ID)
n.Holder.Gen = op.ID
+ n.Holder.Stamp = w.nextGen() // a push's commit installs a new generation: a new stamp (claim rev 6.3 §2)
q := op.StopAt + horizonH
if q > n.Quiet[b.Region] {
n.Quiet[b.Region] = q
@@ -1362,13 +1407,10 @@ func locationOf(k string) string {
rest := strings.TrimPrefix(k, p)
if i := strings.Index(rest, "/"); i > 0 {
child := rest[:i]
- digits := len(child) > 0
- for _, r := range child {
- if r < '0' || r > '9' {
- digits = false
- }
- }
- if digits {
+ // a level directory (0000 to 0009, litestream's layout) directly
+ // under the host is the legacy location; anything else is an
+ // incarnation location (claim rev 6 §6.2a)
+ if !isLevelDir(child) {
return p + child + "/"
}
}
@@ -1377,7 +1419,7 @@ func locationOf(k string) string {
// 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
+// holder.generation} (claim rev 6.3 §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
@@ -1393,7 +1435,7 @@ func (w *world) deadGeneration(c *claim, r int, k string) bool {
v := w.buckets[r].current(k)
return v != nil && (v.B.Gen != h.Gen || !authoritative)
}
- if strings.HasPrefix(k, dataKey(h.Location, h.Gen, "ltx/")) {
+ if isDataKey(k) && genPrefixOf(k) == dataKey(h.Location, h.Gen, "") {
return !authoritative
}
return isDataKey(k)
diff --git a/internal/sim/hostlife/protocol_test.go b/internal/sim/hostlife/protocol_test.go
index 5a293f31c..daefde62f 100644
--- a/internal/sim/hostlife/protocol_test.go
+++ b/internal/sim/hostlife/protocol_test.go
@@ -23,7 +23,7 @@ func (w *world) readClaim(b *box) (*claim, bool) {
}
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)
+ c.readStamp = b.stampOf(c.Holder.Inc) // the installation stamp this read carries (claim rev 6.3 §6.4)
}
b.snap = c.clone()
return c, true
@@ -165,16 +165,24 @@ 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 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.
+// bucket (claim C3a; moves rev 7.6 §5.1, R4-1, R5-1, R5-3), as the pinned
+// litestream does: it roots at the newest current snapshot of the
+// generation at or past the pinned one (the executor's own rotation
+// writes successors; the pinned version is the fallback when none is
+// current), applies the files the planner selects (ltx_test.go plan),
+// and fails closed on a gap or a missing root. There is no database
+// checksum to compare (R5-2): a file is verified by decoding, and the
+// restored database by SQLite's integrity check, which the model's files
+// never fail. Then, when no level-0 file at or past the restored position
+// is current (a snapshot-only install), the activator writes the level-0
+// anchor [cur, cur] before replication starts, so litestream's position
+// is cur (R5-1); the mutation of restore-anchor leaves the position to
+// litestream's open, which takes the newest remote level-0 file, or
+// nothing. The mutation of root-advance roots at the pinned snapshot only
+// (rev 7.5), and refuses once retention has expired its continuation. 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, State: map[string]int{}}
@@ -192,36 +200,62 @@ func (w *world) restore(b *box, inc int, loc, gen string) *localDB {
}
if best != nil {
apply(db.State, best.B)
- db.TX, db.Rep, db.Key = best.B.Max, best.B.Max, best.B.SealKey
+ db.Pos, db.Key = best.B.Max, best.B.SealKey
+ db.Log = txsOf(best.B)
+ db.TX = w.lineageTX(best.B)
if best.B.Inc != 0 {
db.Inc = best.B.Inc
}
}
+ db.Stamp = b.stampOf(inc)
return db
}
+ prefix := dataKey(loc, gen, "")
cur := 0
- var last *blob
+ var applied []*blob
+ pinnedTX := 0
if gen != "" {
m := bk.current(genMarkerKey(loc))
if m == nil || m.B.Gen != gen {
return nil // the marker does not name this generation: refuse
}
+ pinnedTX = m.B.TX
+ }
+ // the root: the newest current snapshot of the generation at or past the
+ // pinned one (for the origin generation, which has no pinned snapshot,
+ // litestream's own rule: the newest snapshot); the pinned version only
+ // when no successor is current
+ var root *blob
+ if on("root-advance") || gen == "" {
+ for _, s := range w.currentLTX(bk, prefix, 9) {
+ if s.B.Max >= pinnedTX && (root == nil || s.B.Max > root.Max) {
+ root = s.B
+ }
+ }
+ }
+ if root == nil && gen != "" {
+ 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 {
w.stats["restore refused: snapshot missing or not at the marker's TXID"]++
return nil
}
- 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
+ root = snap.B
+ }
+ if root != nil {
+ apply(db.State, root)
+ cur = root.Max
+ applied = append(applied, root)
+ db.Key = root.SealKey
+ if root.Inc != 0 {
+ db.Inc = root.Inc
}
}
- files, gap := w.plan(bk, dataKey(loc, gen, "ltx/"), cur)
+ files, gap := w.plan(bk, prefix, cur)
for _, f := range files {
apply(db.State, f)
- cur, last = f.Max, f
+ cur = f.Max
+ applied = append(applied, f)
if db.Key == 0 {
db.Key = f.SealKey
}
@@ -230,27 +264,33 @@ func (w *world) restore(b *box, inc int, loc, gen string) *localDB {
}
}
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.logf("box %d restore refused: a gap after TXID %d under %s", b.ID, cur, prefix)
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"]++
+ if len(applied) == 0 && gen != "" {
return nil
}
- if last == nil && gen != "" {
- return nil
+ // the oracle's bookkeeping: the writes this database holds, which its
+ // own later snapshots and anchors carry forward
+ db.Log = txsOf(applied...)
+ db.TX = w.lineageTX(applied...)
+ db.Stamp = b.stampOf(inc)
+ // restore-to-replication initialization (R5-1, litestreamfacts
+ // TestFact_R5_1_*): litestream's open takes a fresh box's position from
+ // the newest remote level-0 file, or 0. When that is below the restored
+ // position (a snapshot-only install), the activator writes the anchor,
+ // every page as the level-0 file [cur, cur], before replication starts
+ db.Pos = w.maxL0(bk, prefix)
+ if cur > 0 && db.Pos < cur && on("restore-anchor") {
+ db.Pos = cur
+ w.issue(b.ID, &request{kind: rPut, region: b.Region, key: ltxKey(loc, gen, 0, cur, cur), b: anchorOf(db, inc)})
+ w.stats["restore anchors"]++
}
- // the restored database's replication position is where the restore
- // reached: its next LTX file starts at cur+1 (litestream's -txid
- // sidecar). Rev 7.5's first run shipped [1, TX] with only the new pages
- // after a cold restore; the checksum check refused it, correctly
- db.TX, db.Rep = cur, cur
return db
}
-// stampOK is admission's local check (claim rev 6.2 §6.4): the worker's
+// stampOK is admission's local check (claim rev 6.3 §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
@@ -295,7 +335,9 @@ func (w *world) spawn(b *box, c *claim, want string, probe bool) bool {
}
if newest != nil {
apply(b.DB.State, newest.B)
- b.DB.TX, b.DB.Rep, b.DB.Key = newest.B.Max, newest.B.Max, newest.B.SealKey
+ b.DB.Pos, b.DB.Key = newest.B.Max, newest.B.SealKey
+ b.DB.Log = txsOf(newest.B)
+ b.DB.TX = w.lineageTX(newest.B)
if newest.B.Inc != 0 {
b.DB.Inc = newest.B.Inc
}
@@ -315,8 +357,9 @@ func (w *world) spawn(b *box, c *claim, want string, probe bool) bool {
return false
}
// a warm database from an earlier stay is parked, and the box
- // restores cold (§5.1)
+ // restores cold (§5.1): retire-artifacts of its stamp
if b.DB != nil && (b.DB.Gen != bg || b.DB.Loc != loc) {
+ b.retireArtifacts(h.Inc, b.DB.Stamp)
b.Parked++
b.DB = nil
}
@@ -351,7 +394,7 @@ func (w *world) spawn(b *box, c *claim, want string, probe bool) bool {
}
if b.Files == nil {
w.nextID++
- b.Files = &filesDir{Inc: h.Inc, Key: w.nextID, Gen: want, Env: w.nextID, Done: true}
+ b.Files = &filesDir{Inc: h.Inc, Key: w.nextID, Gen: want, Env: w.nextID, Done: true, Stamp: b.stampOf(h.Inc)}
}
if b.DB.Key == 0 {
b.DB.Key = b.Files.Key // unsealed data (never booted) adopts the key
@@ -401,7 +444,7 @@ func (w *world) admits(b *box, c *claim, probe string) bool {
return false
}
if !w.stampOK(b, c, probe) {
- return false // a retired installation's worker (claim rev 6.2 §6.4)
+ return false // a retired installation's worker (claim rev 6.3 §6.4)
}
if w.isParticipant(c, b) && b.ExecUntil > 0 && w.localNow(b) > b.ExecUntil {
return false // self-fenced until the lease is renewed
@@ -548,9 +591,13 @@ 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.
+// localPark is claim rev 6.3's retire-installation (§6.3): the active
+// stamp moves into retired_stamps and becomes none, then every artifact is
+// parked. It serves a delete, a move away and the quarantine; a landing
+// parks its predecessor's leftovers with retire-artifacts instead.
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)
+ b.retireStamp(inc) // retire-installation: active moves into retired_stamps (claim rev 6.3 §6.4)
return false
}
switch {
diff --git a/internal/sim/hostlife/rules_test.go b/internal/sim/hostlife/rules_test.go
index 5d2eeec70..8fc006790 100644
--- a/internal/sim/hostlife/rules_test.go
+++ b/internal/sim/hostlife/rules_test.go
@@ -54,8 +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.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
+ "fence-by-installation", // claim rev 6.3 §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.3 §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
@@ -82,11 +82,13 @@ var allRules = []string{
"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
+ "restore-anchor", // rev 7.6 §5.1 (R5-1): a cold restore with no level-0 file at its position writes the anchor [cur,cur] before replication starts; the mutation leaves the position to litestream's open
+ "root-advance", // rev 7.6 §5.1 (R5-3): the restore roots at the newest current successor snapshot at or past the pinned one; the mutation roots at the pinned snapshot only
"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
+ "live-set-region", // claim rev 6.3 §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 e6e8687ac..a544e3019 100644
--- a/internal/sim/hostlife/sim_test.go
+++ b/internal/sim/hostlife/sim_test.go
@@ -48,24 +48,27 @@ func newWorld(seed int64, verbose bool) *world {
}
// retentionStart: the host has been recreated more times than
-// retired_incarnations remembers (claim rev 6.2 §2 caps it at 32), and a
+// retired_incarnations remembers (claim rev 6.3 §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++ {
+ old := 1 // an incarnation older than every one the list remembers
+ w.nextInc = old
+ for i := 0; i < retiredCap+1; i++ {
w.nextInc++
- w.claim.Retired = retire(w.claim.Retired, w.nextInc)
+ w.claim.Retired = retire(w.claim.Retired, w.nextInc) // 2 to 33: the cap drops nothing but 1 was never listed
+ }
+ if contains(w.claim.Retired, old) || len(w.claim.Retired) != retiredCap {
+ panic("retention fixture: the injected incarnation must be absent from a full list (R5-6)")
}
- 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.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}}}}
w.logf("retention fixture: incarnation %d's replica in region %d, past the cap", old, r)
}
@@ -93,13 +96,13 @@ func (w *world) legacyStart() {
// 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)
+ b.DB.Pos, b.DB.Pending = 1, nil
+ k := ltxKey(b.DB.Loc, "", 0, 1, 1)
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}}
+ part := &blob{Kind: "ltx", Level: 0, Min: 1, Max: 1, Inc: 0, SealKey: key, Pages: map[string]int{first.Key: first.Val}, TXs: []int{first.W}}
other := (r + 1 + w.rng.Intn(nRegions-1)) % nRegions
w.buckets[other].objs[ltxKey(b.DB.Loc, "", 0, 1, 1)] = []*version{{ID: w.newVer(), B: part}}
if w.rng.Intn(2) == 0 {
@@ -109,7 +112,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, State: cloneState(part.Pages), TX: 1, Rep: 1}
+ ob.DB = &localDB{Inc: inc, Loc: locationFor(inc, true), Key: key, State: cloneState(part.Pages), TX: 1, Pos: 1}
ob.Files = &filesDir{Inc: inc, Key: key, Env: 1, Done: true}
}
}
@@ -335,7 +338,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, "ltx/")] = true // a prefix: every current LTX file of the generation
+ live[dataKey(loc, gen, "")] = true // the generation's prefix: every LTX file of the generation
live[genMarkerKey(loc)] = true
}
}
@@ -343,7 +346,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] || live[genPrefixOf(k)+"ltx/"] && genPrefixOf(k) != "")) {
+ if strings.HasSuffix(k, "lock.json") || (r == serving && (live[k] || isDataKey(k) && live[genPrefixOf(k)])) {
continue
}
if exceptStragglers && w.owed(r, k) {
@@ -480,7 +483,7 @@ func (w *world) checkDurable(c *claim, when string) {
continue
}
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)
+ w.violate("INV-2 (cold restore, %s): durable write %d (box %d, t=%d) lost (%s)", when, a.W, a.Box, a.At, w.ltxShape(e.Region, dataKey(c.Holder.Location, c.Holder.Gen, "")))
return
}
}
@@ -498,12 +501,18 @@ 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")
+ if !isDataKey(k) {
continue
}
- parts = append(parts, fmt.Sprintf("%s:%v", strings.TrimPrefix(k, prefix), v.B.Pages))
+ var vs []string
+ for _, v := range bk.objs[k] {
+ if v.Marker {
+ vs = append(vs, "M")
+ } else {
+ vs = append(vs, fmt.Sprintf("v%d", v.ID))
+ }
+ }
+ parts = append(parts, strings.TrimPrefix(k, prefix)+":"+strings.Join(vs, ","))
}
return "files " + strings.Join(parts, " ")
}
diff --git a/internal/sim/hostlife/witness_test.go b/internal/sim/hostlife/witness_test.go
index 000688553..f90054a65 100644
--- a/internal/sim/hostlife/witness_test.go
+++ b/internal/sim/hostlife/witness_test.go
@@ -103,7 +103,12 @@ var witnesses = []witness{
{"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},
+ "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
+ {"restore-anchor", "restore-anchor", "INV-2",
+ "a snapshot-only install (a push), then writes and replication, then the disk is lost: without the level-0 anchor litestream's position restarts at 1, the new files sit below the snapshot, and the cold restore loses the writes (R5-1; litestreamfacts TestFact_R5_1_*)", wSnapshotOnlyInstall},
+ {"root-advance", "root-advance", "INV-3 (witness): woken cold",
+ "after a push, four compaction-and-snapshot rotations with retention; the disk is lost; a restore rooted at the pinned snapshot finds its continuation expired and refuses, while one rooted at the newest current successor snapshot succeeds (R5-3; litestreamfacts TestFact_R5_3_SnapshotRetention)", wRootAdvance},
}
// TestWitnesses is the deterministic half of the mutation evidence.
@@ -285,7 +290,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}
+ n.Holder = &holder{Inc: inc, App: app, Serial: w.serial, Location: loc, Stamp: n.Ver} // the serving CAS wrote holder.stamp
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
@@ -294,9 +299,10 @@ func (w *world) serve(b *box, inc int, app string) {
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
- b.install(inc, n.Ver) // the create's installation stamp
+ b.install(inc, n.Ver) // the create's installation stamp, initialized from holder.stamp
+ db.Stamp = n.Ver
bl := w.deltaOf(db, inc)
- db.Rep, db.Pending = db.TX, nil
+ db.Pos, db.Pending = 1, 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)
@@ -322,12 +328,12 @@ func (w *world) legacy(b *box, other int) {
b.Files = &filesDir{Inc: inc, Key: key, Env: 1, Done: true}
b.Config, b.CfgVer, b.Live, b.LiveInc = inc, 1, app0, inc
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)
+ b.DB.Pos, b.DB.Pending = 1, nil
+ k := ltxKey(b.DB.Loc, "", 0, 1, 1)
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}}
+ part := &blob{Kind: "ltx", Level: 0, Min: 1, Max: 1, Inc: 0, SealKey: key, Pages: 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}}
}
@@ -939,7 +945,7 @@ func wSweepNamespace(t *testing.T, w *world) {
t.Fatalf("witness: the host did not wake: %s", w.state())
}
w.appWrite(b)
- key := dataKey(w.claim.Holder.Location, "", "ltx/")
+ key := dataKey(w.claim.Holder.Location, "", "") + "0000/"
w.holdKey(key)
w.replicate(b)
w.hold = nil
@@ -1004,7 +1010,7 @@ func (w *world) roundTrip(t *testing.T, uk, us *box) {
}, w.act(uk, us), w.recoveryPass, w.operator)
}
-// wStaleWriter (claim rev 6.2 §6.4, R4-2): a config-sync pass selects the
+// wStaleWriter (claim rev 6.3 §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) {
@@ -1026,7 +1032,7 @@ func wStaleWriter(t *testing.T, w *world) {
w.finish(t)
}
-// wLiveSetRegion (claim rev 6.2 §6.2a, R4-3): a legacy host serves in UK
+// wLiveSetRegion (claim rev 6.3 §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.
@@ -1034,14 +1040,14 @@ 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"})
+ late := &blob{Kind: "ltx", Level: 0, Min: 2, Max: 2, Inc: 0, SealKey: 7, Pages: cloneState(uk.DB.State)}
+ w.pending = append(w.pending, &request{kind: rPut, region: us.Region, key: ltxKey("instances/"+host+"/", "", 0, 2, 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 {
+ if w.completeHeld(ltxKey("instances/"+host+"/", "", 0, 2, 2)) != 1 {
t.Fatalf("witness: no held PUT")
}
w.finish(t)
@@ -1062,20 +1068,19 @@ func (w *world) pushed(t *testing.T, b *box) (loc, gen string) {
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 {
+// compactedPending builds the level-1 file [min, max], in replication
+// numbering, that compaction would have left for b's pending writes: the
+// final image of every key they touched. The witness places it where
+// compaction would and advances the position past it.
+func (w *world) compactedPending(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 {
+ out := &blob{Kind: "ltx", Level: 1, Min: min, Max: max, Inc: db.Inc, Gen: db.Gen, SealKey: db.Key, Pages: map[string]int{}}
+ for _, wr := range db.Pending {
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)
- }
+ out.Pages[t.Key] = db.State[t.Key]
+ out.TXs = append(out.TXs, wr)
}
+ db.Pos, db.Pending = max, nil
return out
}
@@ -1095,19 +1100,17 @@ func wRestoreOverlap(t *testing.T, w *world) {
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)
- }
+ n := b.DB.Pos // the snapshot's TXID after the push, in replication numbering
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
+ // compaction has left one file [n-1, n+2] overlapping the snapshot
+ f := w.compactedPending(b, n-1, n+2)
+ w.buckets[b.Region].objs[ltxKey(loc, gen, 1, n-1, n+2)] = []*version{{ID: w.newVer(), B: f}}
+ w.markDurable(ltxKey(loc, gen, 1, n-1, n+2), f)
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.logf("witness: box %d lost its database; the generation holds the snapshot at %d and [%d,%d]", b.ID, n, n-1, n+2)
w.reach(t, 20, "woken cold", func() bool { return hasMain(b) }, func() { w.wake(b) })
w.finish(t)
}
@@ -1126,15 +1129,72 @@ func wRestoreGap(t *testing.T, w *world) {
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
+ n := b.DB.Pos
+ w.appWrite(b) // its file, [n+1, n+1], never lands
+ b.DB.Pos, b.DB.Pending = n+1, nil
+ w.appWrite(b)
+ f := w.compactedPending(b, n+2, n+2) // [n+2, n+2] is current
+ w.buckets[b.Region].objs[ltxKey(loc, gen, 0, n+2, n+2)] = []*version{{ID: w.newVer(), B: f}}
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)
+ w.violate("INV-2 (cold restore): a restore across the gap after TXID %d returned a database at position %d", n, db.Pos)
+ }
+}
+
+// ---------- rev 7.6 ----------
+
+// wSnapshotOnlyInstall (R5-1): the push installs a snapshot; the host
+// wakes, writes, replicates, loses its disk and wakes cold.
+func wSnapshotOnlyInstall(t *testing.T, w *world) {
+ b := w.boxes[0]
+ w.serve(b, 1, app0)
+ w.wake(b)
+ for i := 0; i < 3; i++ {
+ w.appWrite(b)
+ }
+ w.replicate(b)
+ w.pushed(t, b)
+ if len(w.violations) > 0 {
+ return
+ }
+ for i := 0; i < 2; i++ {
+ w.appWrite(b)
+ w.replicate(b)
+ }
+ w.idleStop(b)
+ b.DB = nil // the disk is lost
+ w.logf("witness: box %d lost its database after replicating past the push's snapshot", b.ID)
+ w.reach(t, 20, "woken cold", func() bool { return hasMain(b) }, func() { w.wake(b) })
+ w.finish(t)
+}
+
+// wRootAdvance (R5-3): after the push, enough writes and replication for
+// compaction to rotate snapshots four times, retention expiring the files
+// that continued from the pinned snapshot; then the disk is lost.
+func wRootAdvance(t *testing.T, w *world) {
+ b := w.boxes[0]
+ w.serve(b, 1, app0)
+ w.wake(b)
+ w.appWrite(b)
+ w.replicate(b)
+ loc, gen := w.pushed(t, b)
+ if len(w.violations) > 0 {
+ return
+ }
+ for round := 0; round < 4; round++ {
+ for i := 0; i < 3; i++ {
+ w.appWrite(b)
+ w.replicate(b)
+ }
}
+ snaps := w.currentLTX(w.buckets[b.Region], dataKey(loc, gen, ""), 9)
+ if len(snaps) == 0 || snaps[len(snaps)-1].B.Max <= w.buckets[b.Region].current(genMarkerKey(loc)).B.TX {
+ t.Fatalf("witness: no successor snapshot rotated in: %d snapshots", len(snaps))
+ }
+ w.idleStop(b)
+ b.DB = nil // the disk is lost
+ w.logf("witness: box %d lost its database after four rotations", b.ID)
+ w.reach(t, 20, "woken cold", func() bool { return hasMain(b) }, func() { w.wake(b) })
+ w.finish(t)
}
diff --git a/internal/sim/hostlife/world_test.go b/internal/sim/hostlife/world_test.go
index 372891001..023196568 100644
--- a/internal/sim/hostlife/world_test.go
+++ b/internal/sim/hostlife/world_test.go
@@ -1,7 +1,7 @@
// Package hostlife is a deterministic, randomized model of the host
// lifecycle claim (docs/superpowers/specs/2026-10-02-host-lifecycle-claim-design.md,
-// rev 6.2, 30d572c4) and Phase 1 of move-any-app
-// (docs/superpowers/specs/2026-10-02-move-any-app-design.md, rev 7.5).
+// rev 6.3, 8faa89a4) and Phase 1 of move-any-app
+// (docs/superpowers/specs/2026-10-02-move-any-app-design.md, rev 7.6).
//
// Since joint round 2 the model encodes what the specs say and nothing
// stronger: restore reads the holder's location and the generation the
@@ -72,15 +72,15 @@ type blob struct {
Gen string // lineage that wrote it
Seq int
SealKey int // the instance.key the data is sealed with
- // 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
+ // an LTX file (ltx_test.go, R4-1, R5-2): its level (0 and 1, 9 for a
+ // snapshot), its range in replication numbering, the final image of
+ // every key it touches (0 is a delete), and, for the oracle only, the
+ // writes it covers. No database checksum: the pinned writers put none
+ // in a file (litestreamfacts, R5-2)
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
@@ -196,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.Contains(k, "/ltx/")
+ return strings.HasPrefix(k, "instances/") && ltxKeyRe.MatchString(k)
}
// ---------- requests (late completion) ----------
@@ -232,11 +232,12 @@ type request struct {
// ---------- local box state ----------
type filesDir struct {
- Inc int
- Key int // the app's instance.key identity
- Gen string // .files-gen marker
- Env int // admin token identity in <host>.env
- Done bool // markers written last
+ Inc int
+ Key int // the app's instance.key identity
+ Gen string // .files-gen marker
+ Env int // admin token identity in <host>.env
+ Done bool // markers written last
+ Stamp int // the installation stamp that produced it (claim rev 6.3 §6.3)
}
func (f *filesDir) clone() *filesDir {
@@ -253,10 +254,11 @@ type localDB struct {
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)
+ TX int // the lineage's transaction id: the oracle's order of acknowledged writes
+ Pos int // the replication position: litestream's TXID, the count of files shipped, which every LTX range is numbered in (R5-1)
+ Pending []int // writes since Pos (what the next LTX file ships)
Log []int // every write applied here, for the oracle
+ Stamp int // the installation stamp that produced this artifact (claim rev 6.3 §6.3)
Seq int
}
@@ -315,7 +317,7 @@ type box struct {
History []int // delivered history (disposable)
Attempt int // runner bookkeeping
Procs []*proc
- // Stamps is the local record per host incarnation (claim rev 6.2 §6.4,
+ // Stamps is the local record per host incarnation (claim rev 6.3 §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.
@@ -347,13 +349,13 @@ type plan struct {
Ver int // the config bundle version it selected
At int // it takes the guard no earlier than this
// 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
+ // row (claim rev 6.3 §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
+// rev 6.3 §6.4): the active installation's stamp (0 is none) and the
// stamps of the installations retired here.
type stamps struct {
Active int
@@ -367,9 +369,11 @@ func (b *box) stampOf(inc int) int {
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.
+// install sets the active stamp for inc to holder.stamp as the installing
+// CAS wrote it (claim rev 6.3 §6.4: a create's serving CAS, a landing's
+// flip, an adoption, a reinstall, a dirty rollback's install, a push
+// commit), or as initRecord read it fresh under the guard. Under the
+// guard.
func (b *box) install(inc, gen int) {
if b.Stamps == nil {
b.Stamps = map[int]*stamps{}
@@ -380,7 +384,28 @@ func (b *box) install(inc, gen int) {
b.Stamps[inc].Active = gen
}
-// retireStamp moves the active stamp into the retired set and sets none.
+// retireArtifacts is claim rev 6.3's retire-artifacts(S): the artifacts
+// tagged S are parked (the caller does that) and S joins retired_stamps;
+// active_stamp is untouched. A landing uses it on its predecessor's
+// leftovers.
+func (b *box) retireArtifacts(inc, stamp int) {
+ if stamp == 0 {
+ return
+ }
+ if b.Stamps == nil {
+ b.Stamps = map[int]*stamps{}
+ }
+ if b.Stamps[inc] == nil {
+ b.Stamps[inc] = &stamps{}
+ }
+ if !contains(b.Stamps[inc].Retired, stamp) && stamp != b.Stamps[inc].Active {
+ b.Stamps[inc].Retired = append(b.Stamps[inc].Retired, stamp)
+ }
+}
+
+// retireStamp is claim rev 6.3's retire-installation: the active stamp
+// moves into retired_stamps and becomes none; the caller parks every
+// artifact. Used at a delete, a move away, and the quarantine.
func (b *box) retireStamp(inc int) {
if b.Stamps == nil {
b.Stamps = map[int]*stamps{}
@@ -394,16 +419,17 @@ func (b *box) retireStamp(inc int) {
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.
+// admitsStamp is the local record's admission (claim rev 6.3 §6.4): the
+// writer's stamp must equal the active stamp; none active refuses all,
+// and so does no record at all (R5-4: a writer is never admitted before
+// initialization). 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
+ return false
}
if !on("fence-by-installation") && len(s.Retired) > 0 {
return false
@@ -441,6 +467,12 @@ 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
+ // Stamp is holder.stamp (claim rev 6.3 §2, R5-4): the gen produced by
+ // the CAS that made the current installation current, written by that
+ // same CAS: a create's serving CAS, adoption, a flip, a reinstall, a
+ // dirty rollback's install, a push's commit. The claim is where a box
+ // recovers its local record from.
+ Stamp int
}
type obligation struct {
@@ -498,7 +530,7 @@ type opState struct {
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)
+ FlipGen int // the holder.stamp the flip CAS wrote: the landing's installation stamp (claim rev 6.3 §6.4)
Committed bool
Opened bool
ParkLocalNow bool
@@ -549,7 +581,7 @@ type claim struct {
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)
+ // carries with the claim it validated under (claim rev 6.3 §6.4)
readStamp int
}
@@ -705,7 +737,7 @@ func (w *world) newVer() int {
}
// 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).
+// captures as its installation stamp (claim rev 6.3 §6.4).
func (w *world) nextGen() int {
if w.claim == nil {
return 1