carlos / platform Public

provision: the fenced, journal-driven import rollback (Plan 7 Task 5)

rollbackImport replaces Task 4's stub with the same signature. From the
journal's state: close the gate (fence generation read back from the
journal), fence and stop, remove the import's local data under the fence,
restore the original route (journal cleanup-pending), lift the fence;
then delete this seed's snapshot and the host's replica, retire the
journal row, and CAS the record to rolled-back if it names this seed.

- Acts only on the import's own row; a row that is anyone else's holds
  the rollback, touching nothing. A missing row skips the restore and
  carries on with the cleanup.
- The replica delete re-reads the record first and runs only while it
  names this seed in an abandoned state (or is gone) and no other record
  holds the host; anything it cannot tell defers the cleanup.
- seeds.Check gates both bucket deletes (preflight M3).
- SeedHooks gains FenceForRollback, LiftFence, RemoveLocal, DeleteReplica
  and NextFenceGen; without all five nothing is rolled back.
- Tests: the brief's five, a step-fault table (error and crash between
  every pair of steps), the stale journal row, the deferred fence with a
  deleted record, the removal phase keeping the restored row, the
  under-fence re-read, and Task 4's stub-log tests switched to effects.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev a1973a07065f568f0610942f8aafae3a4be05b35 parent dcb60d7
5 files changed, +1311 −60
  • internal/provision/seedjournal_test.go +84 −43
  • internal/provision/seedreconcile.go +23 −15
  • internal/provision/seedrollback.go +256 −0
  • internal/provision/seedrollback_test.go +931 −0
  • internal/provision/seedswap_test.go +17 −2
diff --git a/internal/provision/seedjournal_test.go b/internal/provision/seedjournal_test.go
index 2df8fdb97..7d7780c30 100644
--- a/internal/provision/seedjournal_test.go
+++ b/internal/provision/seedjournal_test.go
@@ -27,10 +27,6 @@ func captureLog(t *testing.T) *bytes.Buffer {
return &b
}
-// The rollback is Task 5's; until then journalPass hands it to a stub
-// that logs this.
-const rollbackStubLine = "needs a rollback (not built yet)"
-
// journaled is an import this box has swapped: the gated exec row in
// place of the addr row, and the journal at swapped under the record's
// key. The record is whatever state was asked for.
@@ -64,6 +60,32 @@ func journaledImport(t *testing.T, state string) journaled {
return journaled{s: s, reg: reg, rec: rec, row: *mustGet(t, reg, rec.Host), j: *j, cfg: importCfg(t, nil)}
}
+// box wires the rollback's box hooks (seedrollback_test.go) into the
+// scene, so a rollback shows by its effects and the lack of one by none.
+func (x *journaled) box(t *testing.T) *rollbackBox {
+ t.Helper()
+ b := newRollbackBox(t, x.reg, x.j.Original)
+ b.hooks(x.cfg.Seeds)
+ x.cfg.Seeds.Data = faultData{x.cfg.Seeds.Data, b}
+ return b
+}
+
+// rolledBack asserts the rollback ran to its end: the original route back
+// exactly, the journal row gone, the host fenced before its local data
+// went, and its replica deleted or kept as replicaGone says.
+func (x journaled) rolledBack(t *testing.T, b *rollbackBox, replicaGone bool) {
+ t.Helper()
+ if got := mustGet(t, x.reg, x.rec.Host); !reflect.DeepEqual(*got, x.j.Original) {
+ t.Fatalf("row = %+v, want the original %+v", *got, x.j.Original)
+ }
+ if j, _ := x.reg.ImportJournal(x.rec.Host); j != nil {
+ t.Fatalf("journal = %+v", j)
+ }
+ if b.local || b.replica == replicaGone || count(b.steps, "fence") != 1 {
+ t.Fatalf("local %v replica %v steps %q", b.local, b.replica, b.steps)
+ }
+}
+
// untouched asserts the journal owned the host this pass: the swapped row
// and the journal row exactly as they were.
func (x journaled) untouched(t *testing.T) {
@@ -145,8 +167,12 @@ func TestJournalDecidesEachRecordState(t *testing.T) {
state string
edit func(t *testing.T, x *journaled)
want want
+ // for a rollback: the record's seed state after it, and whether
+ // the host's replica was this import's to delete
+ after string
+ replicaGone bool
}{
- {name: "record gone", state: seeds.StateReady, want: rollback, edit: func(t *testing.T, x *journaled) {
+ {name: "record gone", state: seeds.StateReady, want: rollback, replicaGone: true, edit: func(t *testing.T, x *journaled) {
if err := x.s.Delete(ctx, RecordKey(x.rec.Account, x.rec.App, x.rec.Host)); err != nil {
t.Fatal(err)
}
@@ -163,11 +189,11 @@ func TestJournalDecidesEachRecordState(t *testing.T) {
other.Seed = &seed
putReplace(t, x.s, RecordKey(x.rec.Account, x.rec.App, x.rec.Host), mustJSON(t, other))
}},
- {name: "aborted", state: seeds.StateAborted, want: rollback},
+ {name: "aborted", state: seeds.StateAborted, want: rollback, after: seeds.StateRolledBack, replicaGone: true},
// m-c: a refusal that lost the race to this box's swap. The
// journal wins.
- {name: "refused", state: seeds.StateRefused, want: rollback},
- {name: "rolled back", state: seeds.StateRolledBack, want: rollback},
+ {name: "refused", state: seeds.StateRefused, want: rollback, after: seeds.StateRefused, replicaGone: true},
+ {name: "rolled back", state: seeds.StateRolledBack, want: rollback, after: seeds.StateRolledBack, replicaGone: true},
// m12: states that come before any swap. Unreachable, so the
// host is held and logged.
{name: "reserving", state: seeds.StateReserving, want: nothing},
@@ -181,34 +207,47 @@ func TestJournalDecidesEachRecordState(t *testing.T) {
if c.edit != nil {
c.edit(t, &x)
}
+ b := x.box(t)
before, _ := GetRecord(ctx, x.s, x.rec.Account, x.rec.App, x.rec.Host)
- // Converging the row as an ordinary owned row would rewrite its
- // seed column: make it stale so ordinary handling shows.
- if err := x.reg.SetSeedSource(x.rec.Host, "{}"); err != nil {
- t.Fatal(err)
+ if c.want != rollback {
+ // Converging the row as an ordinary owned row would rewrite
+ // its seed column: make it stale so ordinary handling shows.
+ // (A rollback acts only on the import's row as the swap
+ // wrote it, so its cases keep the column.)
+ if err := x.reg.SetSeedSource(x.rec.Host, "{}"); err != nil {
+ t.Fatal(err)
+ }
+ x.row.SeedSource = "{}"
}
- x.row.SeedSource = "{}"
logged := captureLog(t)
if err := Reconcile(ctx, x.s, x.reg, x.cfg); err != nil {
t.Fatal(err)
}
- rolledBack := strings.Contains(logged.String(), x.rec.Host+": import "+x.rec.Seed.ID+" "+rollbackStubLine)
+ after, _ := GetRecord(ctx, x.s, x.rec.Account, x.rec.App, x.rec.Host)
switch c.want {
- case rollback, nothing:
+ case rollback:
+ x.rolledBack(t, b, c.replicaGone)
+ if c.after != "" {
+ if after == nil || after.Seed == nil || after.Seed.State != c.after {
+ t.Fatalf("record after the rollback = %+v, want seed %s", after, c.after)
+ }
+ } else if !reflect.DeepEqual(after, before) {
+ t.Fatalf("record changed: %+v -> %+v", before, after)
+ }
+ case nothing:
x.untouched(t)
- if rolledBack != (c.want == rollback) {
- t.Fatalf("rollback started = %v, want %v\n%s", rolledBack, c.want == rollback, logged)
+ if len(b.steps) != 0 {
+ t.Fatalf("a rollback started: %q", b.steps)
}
- if c.want == nothing && !strings.Contains(logged.String(), "is journaled but its record says "+c.state) {
+ if !strings.Contains(logged.String(), "is journaled but its record says "+c.state) {
t.Fatalf("the host was not held with a log line:\n%s", logged)
}
- after, _ := GetRecord(ctx, x.s, x.rec.Account, x.rec.App, x.rec.Host)
if !reflect.DeepEqual(after, before) {
t.Fatalf("record changed: %+v -> %+v", before, after)
}
case ordinary:
- if rolledBack {
- t.Fatalf("rolled back a live import:\n%s", logged)
+ if len(b.steps) != 0 {
+ t.Fatalf("rolled back a live import: %q", b.steps)
}
got := mustGet(t, x.reg, x.rec.Host)
if got.SeedSource != SeedColumn(x.rec) || got.ImportGate != x.rec.Seed.VerifyHash {
@@ -225,7 +264,8 @@ func TestJournalDecidesEachRecordState(t *testing.T) {
// The redo and the commit act only for the record the journal names. A
// record holding this seed ID under another key moves nothing forward;
// the journal's own record is the one that decides, and here it is gone,
-// so the import goes to the rollback.
+// so the import is rolled back. The record under the other key holds the
+// host, so the host's replica is left to it, and the record is not moved.
func TestJournalNeedsItsRecordKeyToMoveForward(t *testing.T) {
for _, state := range []string{seeds.StateReady, seeds.StateCommitted} {
t.Run(state, func(t *testing.T) {
@@ -241,22 +281,23 @@ func TestJournalNeedsItsRecordKeyToMoveForward(t *testing.T) {
RecordKey: RecordKey(sqid, "other", rec.Host), At: 1}, importRow(rec, "/data")); err != nil {
t.Fatal(err)
}
- row := mustGet(t, reg, rec.Host)
- logged := captureLog(t)
- if err := Reconcile(ctx, s, reg, importCfg(t, nil)); err != nil {
+ cfg := importCfg(t, nil)
+ b := newRollbackBox(t, reg, *original)
+ b.hooks(cfg.Seeds)
+ if err := Reconcile(ctx, s, reg, cfg); err != nil {
t.Fatal(err)
}
- if !strings.Contains(logged.String(), rec.Host+": import "+rec.Seed.ID+" "+rollbackStubLine) {
- t.Fatalf("the journal's own record is gone, but no rollback started:\n%s", logged)
- }
if st := seedState(t, s, rec); st.State != state {
t.Fatalf("record = %s, want %s", st.State, state)
}
- if got := mustGet(t, reg, rec.Host); !reflect.DeepEqual(*got, *row) {
- t.Fatalf("row changed: %+v", got)
+ if got := mustGet(t, reg, rec.Host); !reflect.DeepEqual(*got, *original) {
+ t.Fatalf("row = %+v, want the original", got)
+ }
+ if j, _ := reg.ImportJournal(rec.Host); j != nil {
+ t.Fatalf("journal = %+v", j)
}
- if j, _ := reg.ImportJournal(rec.Host); j == nil {
- t.Fatal("the journal row went")
+ if b.local || !b.replica {
+ t.Fatalf("local %v replica %v: steps %q", b.local, b.replica, b.steps)
}
})
}
@@ -282,6 +323,7 @@ func TestJournalDryRunWritesNothing(t *testing.T) {
// m4: a dry run starts no rollback either.
func TestJournalDryRunStartsNoRollback(t *testing.T) {
x := journaledImport(t, seeds.StateSwapped)
+ b := x.box(t)
x.cfg.DryRun = true
if err := x.s.Delete(ctx, RecordKey(x.rec.Account, x.rec.App, x.rec.Host)); err != nil {
t.Fatal(err)
@@ -290,8 +332,8 @@ func TestJournalDryRunStartsNoRollback(t *testing.T) {
if err := Reconcile(ctx, x.s, x.reg, x.cfg); err != nil {
t.Fatal(err)
}
- if strings.Contains(logged.String(), rollbackStubLine) || !strings.Contains(logged.String(), "dry-run: would roll back "+x.rec.Host) {
- t.Fatalf("a dry run started a rollback, or did not say it would:\n%s", logged)
+ if len(b.steps) != 0 || !strings.Contains(logged.String(), "dry-run: would roll back "+x.rec.Host) {
+ t.Fatalf("a dry run started a rollback (%q), or did not say it would:\n%s", b.steps, logged)
}
x.untouched(t)
}
@@ -333,6 +375,7 @@ func TestJournalIgnoresAnotherRecordForItsHost(t *testing.T) {
if _, err := x.s.Put(ctx, otherKey, mustJSON(t, other), ""); err != nil {
t.Fatal(err)
}
+ b := x.box(t)
logged := captureLog(t)
if err := Reconcile(ctx, x.s, x.reg, x.cfg); err != nil {
t.Fatal(err)
@@ -341,8 +384,8 @@ func TestJournalIgnoresAnotherRecordForItsHost(t *testing.T) {
if c.dup && !strings.Contains(out, "duplicate host "+x.rec.Host+": keeping "+otherKey) {
t.Fatalf("the scene did not put the other record in the pass:\n%s", out)
}
- if strings.Contains(out, rollbackStubLine) {
- t.Fatalf("another record sent the import to rollback:\n%s", out)
+ if len(b.steps) != 0 || !b.local || !b.replica {
+ t.Fatalf("another record sent the import to rollback: %q\n%s", b.steps, out)
}
if len(seen) != 0 {
t.Fatalf("the loop took up %v for a host the journal owns", seen)
@@ -491,14 +534,12 @@ func TestJournalIsReadWithSeedHooksUnarmed(t *testing.T) {
t.Run("committed", func(t *testing.T) {
x := journaledImport(t, seeds.StateCommitted)
x.cfg.Seeds = nil
- logged := captureLog(t)
if err := Reconcile(ctx, x.s, x.reg, x.cfg); err != nil {
t.Fatal(err)
}
+ // Neither committed nor rolled back: the gated row and the
+ // journal row exactly as they were.
x.untouched(t)
- if strings.Contains(logged.String(), rollbackStubLine) {
- t.Fatalf("a rollback started with hooks unarmed:\n%s", logged)
- }
})
}
@@ -594,12 +635,12 @@ func TestJournalReadsAnOutOfScopeRecordByItsKey(t *testing.T) {
moved := x.rec
moved.Region = "us"
putReplace(t, x.s, RecordKey(x.rec.Account, x.rec.App, x.rec.Host), mustJSON(t, moved))
- logged := captureLog(t)
+ b := x.box(t)
if err := Reconcile(ctx, x.s, x.reg, x.cfg); err != nil {
t.Fatal(err)
}
- if strings.Contains(logged.String(), rollbackStubLine) {
- t.Fatalf("rolled back a committed import:\n%s", logged)
+ if len(b.steps) != 0 || !b.local || !b.replica {
+ t.Fatalf("rolled back a committed import: %q", b.steps)
}
if got := mustGet(t, x.reg, x.rec.Host); got.ImportGate != "" {
t.Fatalf("gate = %q, want it cleared", got.ImportGate)
diff --git a/internal/provision/seedreconcile.go b/internal/provision/seedreconcile.go
index 1d33eef12..60b348ce6 100644
--- a/internal/provision/seedreconcile.go
+++ b/internal/provision/seedreconcile.go
@@ -27,6 +27,27 @@ type SeedHooks struct {
// the next pass; it never refuses (only the intent does). Nil: the
// intent alone decides.
NestedOK func() error
+
+ // The rollback's box effects (spec §5.6, seedrollback.go). All five or
+ // none: a box without them leaves every abandoned import journaled,
+ // gated and untouched.
+ //
+ // FenceForRollback fences and stops host under the rollback's fence
+ // generation, by the layout pass's rule: a fence already holding gen is
+ // this rollback's own from an earlier pass. proceed false defers the
+ // rollback, and the hook has lifted any fence it installed.
+ FenceForRollback func(ctx context.Context, host string, gen uint64) (proceed bool, err error)
+ // LiftFence lifts the fence gen, if it is the one host holds.
+ LiftFence func(host string, gen uint64)
+ // RemoveLocal removes the import's local data for its row (the
+ // database, -wal, -shm, <db>.seed, litestream's metadata and any seed
+ // fetch leftovers) through instancefs. Nothing there is not an error.
+ RemoveLocal func(rt registry.Route) error
+ // DeleteReplica deletes host's instances/ replica in the region data
+ // bucket. Nothing there is not an error.
+ DeleteReplica func(ctx context.Context, host string) error
+ // NextFenceGen mints a fence generation no fence on this box holds.
+ NextFenceGen func() uint64
}
type seedVerdict int
@@ -312,23 +333,10 @@ func journalRecord(ctx context.Context, s release.Store, j registry.ImportJourna
if readFailed {
return nil, false, false
}
- raw, _, err := s.Get(ctx, j.RecordKey)
- if errors.Is(err, os.ErrNotExist) {
- return nil, false, true
- }
+ r, err := journalRecordByKey(ctx, s, j)
if err != nil {
- log.Printf("provision: %s: read the import's record %s: %v", j.Host, j.RecordKey, err)
- return nil, false, false
- }
- r, err := DecodeRecord(raw)
- if err != nil || r.Host != j.Host || RecordKey(r.Account, r.App, r.Host) != j.RecordKey {
log.Printf("provision: %s: the import's record %s is not one this pass can use (%v)", j.Host, j.RecordKey, err)
return nil, false, false
}
- return &r, false, true
-}
-
-// rollbackImport is Task 5's. Until then, a stub that logs.
-func rollbackImport(ctx context.Context, s release.Store, reg *registry.Registry, cfg ReconcileConfig, j registry.ImportJournal, rec *Record) {
- log.Printf("provision: %s: import %s needs a rollback (not built yet)", j.Host, j.SeedID)
+ return r, false, true
}
diff --git a/internal/provision/seedrollback.go b/internal/provision/seedrollback.go
new file mode 100644
index 000000000..68d8df5b5
--- /dev/null
+++ b/internal/provision/seedrollback.go
@@ -0,0 +1,256 @@
+package provision
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log"
+ "os"
+
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/seeds"
+)
+
+// rollbackImport is spec §5.6's rollback, driven by the journal and not the
+// record (codex R2-7). Each step is idempotent, and the journal's state is
+// where a later pass resumes:
+//
+// swapped 1. close the gate (journal rolling-back, with a fence generation)
+// rolling-back 2. fence and stop the host under the journal's generation
+// 3. remove the import's local data
+// 4. put the original route back (journal cleanup-pending)
+// 5. lift the fence
+// cleanup-pending 6. delete this seed's snapshot and the host's replica
+// 7. delete the journal row
+// 8. CAS the record to rolled-back, if it names this seed
+//
+// The route steps act only on the import's own row (importRowOf). Any other
+// row at the host belongs to someone else, a later instance when a stale
+// journal row came back from a snapshot, and the rollback holds there,
+// touching nothing, until an operator looks. A host with no row at all is
+// rolled back without a restore: whatever removed the import's row decided
+// the host should not be served.
+//
+// rec is the pass's view of the journal's record (nil when gone). It chose
+// the rollback; the destructive cleanup reads the record again (step 6).
+func rollbackImport(ctx context.Context, s release.Store, reg *registry.Registry, cfg ReconcileConfig, j registry.ImportJournal, rec *Record) {
+ h := cfg.Seeds
+ if cfg.DryRun {
+ log.Printf("provision: dry-run: would roll back import %s of %s (%s)", j.SeedID, j.Host, j.State)
+ return
+ }
+ if h == nil || h.FenceForRollback == nil || h.LiftFence == nil || h.RemoveLocal == nil || h.DeleteReplica == nil || h.NextFenceGen == nil {
+ log.Printf("provision: %s: import %s cannot roll back on this box: the activator is not wired; it stays journaled and gated", j.Host, j.SeedID)
+ return
+ }
+ switch j.State {
+ case registry.JournalSwapped, registry.JournalRollingBack:
+ var ok bool
+ if j, ok = rollBackRoute(ctx, reg, cfg, j); !ok {
+ return
+ }
+ case registry.JournalCleanupPending:
+ default:
+ log.Printf("provision: %s: import %s is journaled in an unknown state %q; leaving it alone", j.Host, j.SeedID, j.State)
+ return
+ }
+ cleanUpImport(ctx, s, reg, cfg, j)
+}
+
+// importRowOf reports whether rt is j's import row as the swap made it:
+// the registry's test (gated, provisioned, naming the seed, whose ids are
+// never reused), at the nested database path the swap gave it. The path is
+// what step 3 removes at, so a row pointing anywhere else is not acted on.
+func importRowOf(cfg ReconcileConfig, rt registry.Route, j registry.ImportJournal) bool {
+ return registry.IsImportRow(rt, j) && rt.DBPath == NestedDBPath(cfg.DataDir, j.Host)
+}
+
+// rollBackRoute is steps 1 to 5. It returns the journal as it now stands
+// and whether the route steps are done (the journal is cleanup-pending).
+func rollBackRoute(ctx context.Context, reg *registry.Registry, cfg ReconcileConfig, j registry.ImportJournal) (registry.ImportJournal, bool) {
+ h := cfg.Seeds
+ // Nothing is closed, fenced or removed for a row that is not the
+ // import's. The registry refuses the gate close and the restore for
+ // one as well; this check keeps the fence and the removal off it.
+ row, err := reg.Get(j.Host)
+ if err != nil {
+ log.Printf("provision: %s: import %s rollback deferred: the route could not be read: %v", j.Host, j.SeedID, err)
+ return j, false
+ }
+ if row != nil && !importRowOf(cfg, *row, j) {
+ log.Printf("provision: %s: import %s is not rolled back: the route at the host is not the import's, so nothing there is touched", j.Host, j.SeedID)
+ return j, false
+ }
+
+ // Step 1. The generation an earlier close stored is the one the fence
+ // is known by, so the journal is read back rather than trusted.
+ if j.State == registry.JournalSwapped {
+ if err := reg.CloseImportGate(j.Host, j.SeedID, h.NextFenceGen()); err != nil {
+ log.Printf("provision: %s: import %s: close the import gate: %v", j.Host, j.SeedID, err)
+ return j, false
+ }
+ cur, err := reg.ImportJournal(j.Host)
+ if err != nil || cur == nil || cur.SeedID != j.SeedID || cur.State != registry.JournalRollingBack {
+ log.Printf("provision: %s: import %s: the journal after the gate closed is %+v (%v)", j.Host, j.SeedID, cur, err)
+ return j, false
+ }
+ j = *cur
+ }
+
+ // Step 2. The host is fenced and stopped before anything is removed,
+ // so no wake can write the database or the replica afterwards. The
+ // hook lifts any fence it installs when it does not proceed.
+ proceed, err := h.FenceForRollback(ctx, j.Host, j.FenceGen)
+ if err != nil || !proceed {
+ log.Printf("provision: %s: import %s rollback deferred: the host is not fenced and stopped yet (%v)", j.Host, j.SeedID, err)
+ return j, false
+ }
+
+ // Step 3, under the fence. A failure from here on keeps the fence: the
+ // next pass finds it its own by the journal's generation and resumes.
+ row, err = reg.Get(j.Host)
+ if err != nil {
+ log.Printf("provision: %s: import %s: read the route under the fence: %v", j.Host, j.SeedID, err)
+ return j, false
+ }
+ switch {
+ case row == nil:
+ log.Printf("provision: %s: import %s has no route on this box: nothing local is removed and no route is put back", j.Host, j.SeedID)
+ case !importRowOf(cfg, *row, j) || row.ImportGate != registry.ImportGateClosed:
+ h.LiftFence(j.Host, j.FenceGen)
+ log.Printf("provision: %s: import %s is not rolled back: the route changed under the fence, so nothing there is touched", j.Host, j.SeedID)
+ return j, false
+ default:
+ if err := h.RemoveLocal(*row); err != nil {
+ log.Printf("provision: %s: import %s: remove its local data: %v", j.Host, j.SeedID, err)
+ return j, false
+ }
+ }
+
+ // Step 4: the original route back, and the journal at cleanup-pending,
+ // in one transaction. Step 5: the fence goes.
+ if err := reg.RestoreImportRoute(j.Host, j.SeedID); err != nil {
+ log.Printf("provision: %s: import %s: restore the original route: %v", j.Host, j.SeedID, err)
+ return j, false
+ }
+ h.LiftFence(j.Host, j.FenceGen)
+ j.State = registry.JournalCleanupPending
+ if row != nil {
+ log.Printf("provision: %s: import %s rolled back to its original route", j.Host, j.SeedID)
+ }
+ return j, true
+}
+
+// cleanUpImport is steps 6 to 8, run from cleanup-pending. It never fences
+// or stops anything: the route is the original row by now (or none), and
+// its process is the app's own.
+func cleanUpImport(ctx context.Context, s release.Store, reg *registry.Registry, cfg ReconcileConfig, j registry.ImportJournal) {
+ h := cfg.Seeds
+ valid := seeds.Check(j.Host, j.SeedID) == nil
+ if !valid {
+ log.Printf("provision: %s: import %q names no snapshot and no replica; only its journal row is cleaned up", j.Host, j.SeedID)
+ } else if err := h.Data.Delete(ctx, seeds.SnapshotKey(j.Host, j.SeedID)); err != nil && !errors.Is(err, os.ErrNotExist) {
+ log.Printf("provision: %s: import %s: delete its snapshot: %v", j.Host, j.SeedID, err)
+ return
+ }
+
+ // The replica is keyed by host, not by seed, so the record is read
+ // again right before it: the pass's copy may be stale.
+ rec, err := journalRecordByKey(ctx, s, j)
+ if err != nil {
+ log.Printf("provision: %s: import %s cleanup deferred: its record cannot be read: %v", j.Host, j.SeedID, err)
+ return
+ }
+ if valid {
+ ours, why, err := replicaIsTheImports(ctx, s, j, rec)
+ switch {
+ case err != nil:
+ log.Printf("provision: %s: import %s cleanup deferred: %v", j.Host, j.SeedID, err)
+ return
+ case !ours:
+ log.Printf("provision: %s: import %s: the host's replica is left alone: %s", j.Host, j.SeedID, why)
+ default:
+ if err := h.DeleteReplica(ctx, j.Host); err != nil {
+ log.Printf("provision: %s: import %s: delete the host's replica: %v", j.Host, j.SeedID, err)
+ return
+ }
+ }
+ }
+
+ if err := reg.DeleteImportJournal(j.Host, j.SeedID); err != nil {
+ log.Printf("provision: %s: import %s: retire its journal row: %v", j.Host, j.SeedID, err)
+ return
+ }
+ log.Printf("provision: %s: import %s is cleaned up and its journal row retired", j.Host, j.SeedID)
+
+ // Step 8. Only from aborted (SeedRolledBack): a record already
+ // refused or rolled back, or gone, is done. An aborted record whose
+ // CAS fails is finished by the no-journal path on a later pass.
+ if rec == nil || rec.Seed == nil || rec.Seed.ID != j.SeedID {
+ return
+ }
+ if _, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedRolledBack(j.SeedID)); err != nil &&
+ !errors.Is(err, ErrSeedConflict) && !errors.Is(err, os.ErrNotExist) {
+ log.Printf("provision: %s: mark import %s rolled back: %v", j.Host, j.SeedID, err)
+ }
+}
+
+// replicaIsTheImports decides step 6's replica delete. The host's replica
+// is the import's to delete only while the journal's record, if there is
+// one, still names this seed in an abandoned state, and no other record
+// holds the host. A record gone with no other record for the host counts.
+// A record naming another seed, or none, or another record for the host,
+// means the replica is someone else's: ours is false, and why says so.
+// Anything this pass cannot tell is an error, and the cleanup waits.
+func replicaIsTheImports(ctx context.Context, s release.Store, j registry.ImportJournal, rec *Record) (ours bool, why string, err error) {
+ if rec != nil {
+ switch {
+ case rec.Seed == nil || rec.Seed.ID != j.SeedID:
+ return false, "its record no longer names this seed", nil
+ case rec.Seed.State != seeds.StateAborted && rec.Seed.State != seeds.StateRefused && rec.Seed.State != seeds.StateRolledBack:
+ return false, "", fmt.Errorf("its record names this seed as %s, not abandoned", rec.Seed.State)
+ }
+ }
+ known, failed, err := knownAccounts(ctx, s)
+ if err != nil {
+ return false, "", err
+ }
+ if failed {
+ return false, "", errors.New("an account could not be read, so another record for the host cannot be ruled out")
+ }
+ keys, partial, err := listAppSubtree(ctx, s, known, "instances/")
+ if err != nil {
+ return false, "", err
+ }
+ if partial {
+ return false, "", errors.New("the records could not all be listed, so another record for the host cannot be ruled out")
+ }
+ for _, key := range keys {
+ if _, _, host, ok := ParseRecordKey(key, known); ok && host == j.Host && key != j.RecordKey {
+ return false, "the record " + key + " holds the host", nil
+ }
+ }
+ return true, "", nil
+}
+
+// journalRecordByKey reads the record under the journal's own key: nil when
+// it is gone, an error when it cannot be read, does not decode, or is not
+// the journal's (another host or key in its body).
+func journalRecordByKey(ctx context.Context, s release.Store, j registry.ImportJournal) (*Record, error) {
+ raw, _, err := s.Get(ctx, j.RecordKey)
+ if errors.Is(err, os.ErrNotExist) {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ r, err := DecodeRecord(raw)
+ if err != nil {
+ return nil, err
+ }
+ if r.Host != j.Host || RecordKey(r.Account, r.App, r.Host) != j.RecordKey {
+ return nil, fmt.Errorf("the record under %s is %s's", j.RecordKey, RecordKey(r.Account, r.App, r.Host))
+ }
+ return &r, nil
+}
diff --git a/internal/provision/seedrollback_test.go b/internal/provision/seedrollback_test.go
new file mode 100644
index 000000000..5cc2ac9b3
--- /dev/null
+++ b/internal/provision/seedrollback_test.go
@@ -0,0 +1,931 @@
+package provision
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "errors"
+ "os"
+ "reflect"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/seeds"
+)
+
+// rollbackRecorder is the brief's fake: it records the box effects in the
+// order they happen.
+type rollbackRecorder struct {
+ steps []string
+ proceed bool
+ fenceErr error
+ gen uint64
+}
+
+func (r *rollbackRecorder) hooks(h *SeedHooks) {
+ h.NextFenceGen = func() uint64 { r.gen++; return r.gen }
+ h.FenceForRollback = func(_ context.Context, host string, gen uint64) (bool, error) {
+ r.steps = append(r.steps, "fence")
+ return r.proceed, r.fenceErr
+ }
+ h.LiftFence = func(host string, gen uint64) { r.steps = append(r.steps, "lift") }
+ h.RemoveLocal = func(rt registry.Route) error { r.steps = append(r.steps, "remove-local:"+rt.Kind); return nil }
+ h.DeleteReplica = func(_ context.Context, host string) error { r.steps = append(r.steps, "delete-replica"); return nil }
+}
+
+func TestRollbackAfterAbortRestoresTheRowExactly(t *testing.T) {
+ s, sqid := shopSetup(t)
+ rec := importRecord(t, s, sqid)
+ reg := regWith(t, addrRow(rec, sqid))
+ original, _ := reg.Get(rec.Host)
+ cfg := importCfg(t, nil)
+ rr := &rollbackRecorder{proceed: true}
+ rr.hooks(cfg.Seeds)
+ cfg.Seeds.Data.Put(ctx, seeds.SnapshotKey(rec.Host, rec.Seed.ID), []byte("db"), "")
+ other, _ := seeds.NewID()
+ cfg.Seeds.Data.Put(ctx, seeds.SnapshotKey(rec.Host, other), []byte("db"), "")
+ Reconcile(ctx, s, reg, cfg) // swap
+ if _, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedAbort(rec.Seed.ID)); err != nil {
+ t.Fatal(err)
+ }
+ if err := Reconcile(ctx, s, reg, cfg); err != nil {
+ t.Fatal(err)
+ }
+ if got := mustGet(t, reg, rec.Host); !reflect.DeepEqual(*got, *original) {
+ t.Fatalf("restored %+v, want %+v", got, original)
+ }
+ if j, _ := reg.ImportJournal(rec.Host); j != nil {
+ t.Fatalf("journal = %+v", j)
+ }
+ want := []string{"fence", "remove-local:instance", "lift", "delete-replica"}
+ if !reflect.DeepEqual(rr.steps, want) {
+ t.Fatalf("steps = %q, want %q", rr.steps, want)
+ }
+ if _, _, err := cfg.Seeds.Data.Get(ctx, seeds.SnapshotKey(rec.Host, rec.Seed.ID)); !errors.Is(err, os.ErrNotExist) {
+ t.Fatal("this seed's snapshot survived")
+ }
+ if _, _, err := cfg.Seeds.Data.Get(ctx, seeds.SnapshotKey(rec.Host, other)); err != nil {
+ t.Fatal("another seed's snapshot was deleted")
+ }
+ r, _ := GetRecord(ctx, s, rec.Account, rec.App, rec.Host)
+ if r.Seed.State != seeds.StateRolledBack {
+ t.Fatalf("record = %s", r.Seed.State)
+ }
+}
+
+func TestRollbackForAGoneOrReplacedRecord(t *testing.T) {
+ for _, mutate := range []string{"deleted", "another seed"} {
+ t.Run(mutate, func(t *testing.T) {
+ s, sqid := shopSetup(t)
+ rec := importRecord(t, s, sqid)
+ reg := regWith(t, addrRow(rec, sqid))
+ original, _ := reg.Get(rec.Host)
+ cfg := importCfg(t, nil)
+ rr := &rollbackRecorder{proceed: true}
+ rr.hooks(cfg.Seeds)
+ Reconcile(ctx, s, reg, cfg)
+ if err := DeleteRecord(ctx, s, rec.Account, rec.App, rec.Host); err != nil {
+ t.Fatal(err)
+ }
+ var next Record
+ if mutate == "another seed" {
+ next = importRecord(t, s, sqid) // the same host, a new seed id, state ready
+ }
+ if err := Reconcile(ctx, s, reg, cfg); err != nil {
+ t.Fatal(err)
+ }
+ if got := mustGet(t, reg, rec.Host); !reflect.DeepEqual(*got, *original) {
+ t.Fatalf("restored %+v", got)
+ }
+ if j, _ := reg.ImportJournal(rec.Host); j != nil {
+ t.Fatalf("journal = %+v", j)
+ }
+ // A record naming another seed holds the host: the host's
+ // replica is not this rollback's to delete, and that record
+ // keeps its own state.
+ wantSteps := []string{"fence", "remove-local:instance", "lift", "delete-replica"}
+ if mutate == "another seed" {
+ wantSteps = wantSteps[:3]
+ if st := seedState(t, s, next); st.ID != next.Seed.ID || st.State != seeds.StateReady {
+ t.Fatalf("the new record's seed = %+v", st)
+ }
+ }
+ if !reflect.DeepEqual(rr.steps, wantSteps) {
+ t.Fatalf("steps = %q, want %q", rr.steps, wantSteps)
+ }
+ })
+ }
+}
+
+func TestRollbackDefersWhenTheFenceIsNotOurs(t *testing.T) {
+ s, sqid := shopSetup(t)
+ rec := importRecord(t, s, sqid)
+ reg := regWith(t, addrRow(rec, sqid))
+ cfg := importCfg(t, nil)
+ rr := &rollbackRecorder{proceed: false}
+ rr.hooks(cfg.Seeds)
+ Reconcile(ctx, s, reg, cfg)
+ SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedAbort(rec.Seed.ID))
+ Reconcile(ctx, s, reg, cfg)
+ j, _ := reg.ImportJournal(rec.Host)
+ got := mustGet(t, reg, rec.Host)
+ if j == nil || j.State != registry.JournalRollingBack || got.ImportGate != registry.ImportGateClosed || got.Kind != "instance" {
+ t.Fatalf("after a deferred fence: journal %+v row %+v; want rolling-back, gate closed, row kept", j, got)
+ }
+ if !reflect.DeepEqual(rr.steps, []string{"fence"}) {
+ t.Fatalf("steps = %q: a deferred fence went on to remove or delete", rr.steps)
+ }
+ // Next pass the fence proceeds, with the SAME generation.
+ gen := j.FenceGen
+ rr.proceed = true
+ Reconcile(ctx, s, reg, cfg)
+ if rr.gen != gen {
+ t.Fatalf("a resumed rollback minted a new generation: %d, journal had %d", rr.gen, gen)
+ }
+ if j, _ := reg.ImportJournal(rec.Host); j != nil {
+ t.Fatalf("journal = %+v", j)
+ }
+}
+
+// A fence hook that answers with an error defers, whatever proceed says.
+func TestRollbackDefersOnAFenceError(t *testing.T) {
+ x := abortedImport(t)
+ rr := &rollbackRecorder{proceed: true, fenceErr: errors.New("stop unproven")}
+ rr.hooks(x.cfg.Seeds)
+ x.pass(t)
+ if j, _ := x.reg.ImportJournal(x.rec.Host); j == nil || j.State != registry.JournalRollingBack {
+ t.Fatalf("journal = %+v", j)
+ }
+ if !reflect.DeepEqual(rr.steps, []string{"fence"}) {
+ t.Fatalf("steps = %q", rr.steps)
+ }
+}
+
+func TestCleanupPendingNeverFences(t *testing.T) {
+ s, sqid := shopSetup(t)
+ rec := importRecord(t, s, sqid)
+ reg := regWith(t, addrRow(rec, sqid))
+ cfg := importCfg(t, nil)
+ rr := &rollbackRecorder{proceed: true}
+ rr.hooks(cfg.Seeds)
+ Reconcile(ctx, s, reg, cfg)
+ SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedAbort(rec.Seed.ID))
+ // Crash after the route restore, before the bucket cleanup.
+ if err := reg.CloseImportGate(rec.Host, rec.Seed.ID, 9); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.RestoreImportRoute(rec.Host, rec.Seed.ID); err != nil {
+ t.Fatal(err)
+ }
+ rr.steps = nil
+ if err := Reconcile(ctx, s, reg, cfg); err != nil {
+ t.Fatal(err)
+ }
+ if !reflect.DeepEqual(rr.steps, []string{"delete-replica"}) {
+ t.Fatalf("steps = %q; a cleanup-pending resume fenced, stopped or removed something", rr.steps)
+ }
+ if rr.gen != 0 {
+ t.Fatalf("a cleanup-pending resume minted a fence generation")
+ }
+}
+
+func TestJournalDoesNotRollBackOnAReadFailure(t *testing.T) {
+ for _, how := range []string{"unparseable body", "read error"} {
+ t.Run(how, func(t *testing.T) {
+ s, sqid := shopSetup(t)
+ rec := importRecord(t, s, sqid)
+ reg := regWith(t, addrRow(rec, sqid))
+ cfg := importCfg(t, nil)
+ rr := &rollbackRecorder{proceed: true}
+ rr.hooks(cfg.Seeds)
+ Reconcile(ctx, s, reg, cfg)
+ var store release.Store = s
+ key := RecordKey(rec.Account, rec.App, rec.Host)
+ if how == "unparseable body" {
+ // The record body becomes unreadable: protected, never "gone".
+ putReplace(t, s, key, []byte("{not json"))
+ } else {
+ // preflight m7: a Get that fails (readFailed), not a bad body.
+ store = getFailsFor{s, key}
+ }
+ if err := Reconcile(ctx, store, reg, cfg); err != nil {
+ t.Fatal(err)
+ }
+ if len(rr.steps) != 0 || rr.gen != 0 {
+ t.Fatalf("an unreadable record started a rollback: steps %q", rr.steps)
+ }
+ if j, _ := reg.ImportJournal(rec.Host); j == nil || j.State != registry.JournalSwapped {
+ t.Fatalf("journal = %+v", j)
+ }
+ if got := mustGet(t, reg, rec.Host); got.Kind != "instance" {
+ t.Fatal("the removal phase touched a journaled host")
+ }
+ })
+ }
+}
+
+// getFailsFor fails every read of one key with something other than "not
+// found".
+type getFailsFor struct {
+ release.Store
+ key string
+}
+
+func (g getFailsFor) Get(ctx context.Context, key string) ([]byte, string, error) {
+ if key == g.key {
+ return nil, "", errors.New("connection reset")
+ }
+ return g.Store.Get(ctx, key)
+}
+
+// ---- the box side, with the step order checked as it happens -------------
+
+// crashed is the panic a fault raises to kill the "process" mid-pass.
+type crashed struct{}
+
+// rollbackBox is the box's side of the rollback: an in-memory fence (lost
+// when the process dies), the import's local data, and the host's replica.
+// Every hook checks, when it is called, the order the spec requires: the
+// fence comes after the gate is closed and under the journal's own
+// generation; nothing local is removed unless the host is fenced and the
+// journal is still rolling back; nothing in the bucket is deleted before
+// the original route is back.
+type rollbackBox struct {
+ t *testing.T
+ reg *registry.Registry
+ original registry.Route
+ steps []string
+ proceed bool
+ gen uint64
+ fenced map[string]uint64
+ local bool
+ replica bool
+ fault string // the step that fails, once
+ crash bool // the fault kills the process (a panic) rather than returning an error
+ noRoute bool // the import's row was removed: no route comes back
+}
+
+func newRollbackBox(t *testing.T, reg *registry.Registry, original registry.Route) *rollbackBox {
+ return &rollbackBox{t: t, reg: reg, original: original, proceed: true, fenced: map[string]uint64{}, local: true, replica: true}
+}
+
+func (b *rollbackBox) trip(step string) error {
+ if b.fault != step {
+ return nil
+ }
+ b.fault = ""
+ if b.crash {
+ panic(crashed{})
+ }
+ return errors.New("injected failure at " + step)
+}
+
+// restart is the process dying: the activator's fences are in memory.
+func (b *rollbackBox) restart() { b.fenced = map[string]uint64{} }
+
+func (b *rollbackBox) journal() *registry.ImportJournal {
+ j, err := b.reg.ImportJournal(b.original.Host)
+ if err != nil {
+ b.t.Fatal(err)
+ }
+ return j
+}
+
+func (b *rollbackBox) row() *registry.Route {
+ rt, err := b.reg.Get(b.original.Host)
+ if err != nil {
+ b.t.Fatal(err)
+ }
+ return rt
+}
+
+// wantRestored: nothing is deleted from the bucket before the original
+// route is back and the journal says cleanup-pending (or is gone, for the
+// no-journal finish).
+func (b *rollbackBox) wantRestored(what string) {
+ switch rt := b.row(); {
+ case b.noRoute:
+ if rt != nil {
+ b.t.Errorf("%s with a route at the host: %+v", what, rt)
+ }
+ case rt == nil || !reflect.DeepEqual(*rt, b.original):
+ b.t.Errorf("%s before the original route was back: row %+v", what, rt)
+ }
+ if j := b.journal(); j != nil && j.State != registry.JournalCleanupPending {
+ b.t.Errorf("%s with the journal at %s", what, j.State)
+ }
+ if len(b.fenced) != 0 {
+ b.t.Errorf("%s with a fence held: the cleanup never fences", what)
+ }
+}
+
+func (b *rollbackBox) hooks(h *SeedHooks) {
+ h.NextFenceGen = func() uint64 {
+ b.steps = append(b.steps, "gen")
+ b.gen++
+ return b.gen
+ }
+ h.FenceForRollback = func(_ context.Context, host string, gen uint64) (bool, error) {
+ b.steps = append(b.steps, "fence")
+ if j := b.journal(); j == nil || j.State != registry.JournalRollingBack || j.FenceGen != gen {
+ b.t.Errorf("fenced with generation %d under journal %+v", gen, j)
+ }
+ if rt := b.row(); rt != nil && rt.ImportGate != registry.ImportGateClosed {
+ b.t.Errorf("fenced before the gate was closed: %+v", rt)
+ }
+ if err := b.trip("fence"); err != nil {
+ return false, err
+ }
+ if held, ok := b.fenced[host]; ok {
+ return held == gen, nil // ours from an earlier pass, or another operation's
+ }
+ if !b.proceed {
+ return false, nil
+ }
+ b.fenced[host] = gen
+ return true, nil
+ }
+ h.LiftFence = func(host string, gen uint64) {
+ b.steps = append(b.steps, "lift")
+ _ = b.trip("lift") // a lift cannot fail; only a crash stops it
+ if b.fenced[host] == gen {
+ delete(b.fenced, host)
+ }
+ }
+ h.RemoveLocal = func(rt registry.Route) error {
+ b.steps = append(b.steps, "remove-local")
+ if _, ok := b.fenced[rt.Host]; !ok {
+ b.t.Errorf("removed local data of a host that is not fenced and stopped")
+ }
+ if j := b.journal(); j == nil || j.State != registry.JournalRollingBack {
+ b.t.Errorf("removed local data with the journal at %+v", j)
+ }
+ if rt.ImportGate != registry.ImportGateClosed || rt.DBPath != NestedDBPath("/data", rt.Host) {
+ b.t.Errorf("removed local data for a row that is not the closed import row: %+v", rt)
+ }
+ if err := b.trip("remove-local"); err != nil {
+ return err
+ }
+ b.local = false
+ return b.trip("remove-local-done")
+ }
+ h.DeleteReplica = func(_ context.Context, host string) error {
+ b.steps = append(b.steps, "delete-replica")
+ b.wantRestored("deleted the replica")
+ if err := b.trip("delete-replica"); err != nil {
+ return err
+ }
+ b.replica = false
+ return nil
+ }
+}
+
+// faultData is the region data bucket, with the snapshot delete checked and
+// faultable.
+type faultData struct {
+ release.Store
+ box *rollbackBox
+}
+
+func (d faultData) Delete(ctx context.Context, key string) error {
+ if strings.HasPrefix(key, seeds.Prefix) {
+ d.box.steps = append(d.box.steps, "delete-snapshot")
+ d.box.wantRestored("deleted the snapshot")
+ if err := d.box.trip("delete-snapshot"); err != nil {
+ return err
+ }
+ }
+ return d.Store.Delete(ctx, key)
+}
+
+// faultRecords is the release bucket, with the record CAS faultable.
+type faultRecords struct {
+ release.Store
+ box *rollbackBox
+ key string
+}
+
+func (f faultRecords) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
+ if key == f.key {
+ if err := f.box.trip("cas"); err != nil {
+ return "", err
+ }
+ }
+ return f.Store.Put(ctx, key, body, ifMatch)
+}
+
+// rollbackScene is an import this box swapped, verified once (its local
+// database placed and replicated) and then aborted.
+type rollbackScene struct {
+ raw *release.DirStore
+ store release.Store
+ reg *registry.Registry
+ path string
+ rec Record
+ other string // another seed of the host, with its own snapshot
+ cfg ReconcileConfig
+ box *rollbackBox
+}
+
+func abortedImport(t *testing.T) *rollbackScene {
+ t.Helper()
+ s, sqid := shopSetup(t)
+ rec := importRecord(t, s, sqid)
+ reg, path := regAt(t, addrRow(rec, sqid))
+ box := newRollbackBox(t, reg, *mustGet(t, reg, rec.Host))
+ cfg := importCfg(t, nil)
+ box.hooks(cfg.Seeds)
+ data := cfg.Seeds.Data
+ cfg.Seeds.Data = faultData{data, box}
+ other, _ := seeds.NewID()
+ for _, id := range []string{rec.Seed.ID, other} {
+ if _, err := data.Put(ctx, seeds.SnapshotKey(rec.Host, id), []byte("db"), ""); err != nil {
+ t.Fatal(err)
+ }
+ }
+ x := &rollbackScene{raw: s, store: faultRecords{s, box, RecordKey(rec.Account, rec.App, rec.Host)},
+ reg: reg, path: path, rec: rec, other: other, cfg: cfg, box: box}
+ x.pass(t)
+ if got := mustGet(t, reg, rec.Host); !registry.IsImportRow(*got, registry.ImportJournal{Host: rec.Host, SeedID: rec.Seed.ID}) {
+ t.Fatalf("the swap did not happen: %+v", got)
+ }
+ if _, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedAbort(rec.Seed.ID)); err != nil {
+ t.Fatal(err)
+ }
+ box.steps = nil
+ return x
+}
+
+// pass is one Reconcile. A fault that crashes the process ends the pass
+// where it stands, and the box restarts.
+func (x *rollbackScene) pass(t *testing.T) {
+ t.Helper()
+ defer func() {
+ if r := recover(); r != nil {
+ if _, ok := r.(crashed); !ok {
+ panic(r)
+ }
+ x.box.restart()
+ }
+ }()
+ if err := Reconcile(ctx, x.store, x.reg, x.cfg); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func (x *rollbackScene) journalState(t *testing.T) string {
+ t.Helper()
+ j, err := x.reg.ImportJournal(x.rec.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if j == nil {
+ return ""
+ }
+ return j.State
+}
+
+// wantRolledBack is the clean end state: the original route exactly, no
+// journal, this seed's snapshot gone and the other kept, no local data, no
+// replica, no fence, and the record rolled back.
+func (x *rollbackScene) wantRolledBack(t *testing.T) {
+ t.Helper()
+ if got := mustGet(t, x.reg, x.rec.Host); !reflect.DeepEqual(*got, x.box.original) {
+ t.Fatalf("row = %+v, want the original %+v", got, x.box.original)
+ }
+ if st := x.journalState(t); st != "" {
+ t.Fatalf("journal still at %s", st)
+ }
+ data := x.cfg.Seeds.Data
+ if _, _, err := data.Get(ctx, seeds.SnapshotKey(x.rec.Host, x.rec.Seed.ID)); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("this seed's snapshot: %v", err)
+ }
+ if _, _, err := data.Get(ctx, seeds.SnapshotKey(x.rec.Host, x.other)); err != nil {
+ t.Fatalf("another seed's snapshot: %v", err)
+ }
+ if x.box.local || x.box.replica || len(x.box.fenced) != 0 {
+ t.Fatalf("local %v replica %v fenced %v", x.box.local, x.box.replica, x.box.fenced)
+ }
+ if st := seedState(t, x.raw, x.rec); st.ID != x.rec.Seed.ID || st.State != seeds.StateRolledBack {
+ t.Fatalf("record seed = %+v, want rolled-back", st)
+ }
+}
+
+func (x *rollbackScene) trigger(t *testing.T, ddl string) {
+ t.Helper()
+ addTrigger(t, x.path, ddl)
+}
+
+func count(steps []string, step string) int {
+ n := 0
+ for _, s := range steps {
+ if s == step {
+ n++
+ }
+ }
+ return n
+}
+
+// Spec §9 and preflight M13: a failure between any two of the rollback's
+// steps, as an error the process survives or as a crash that loses the
+// in-memory fence, is resumed by the next passes to the same clean end,
+// with nothing lost and nothing done twice that must not be: one fence
+// generation per close, no local removal or fence once the route is back,
+// and no bucket delete before it is.
+func TestRollbackResumesAfterAFailureBetweenAnyTwoSteps(t *testing.T) {
+ const raise = ` BEGIN SELECT RAISE(ABORT, 'injected'); END`
+ for _, f := range []struct {
+ name string
+ hook string // a box hook or a bucket write that fails once
+ trigger string // or a registry write that fails, until dropped
+ after string // the journal's state after the failed pass
+ crashOnly bool
+ }{
+ {name: "closing the gate", trigger: `CREATE TRIGGER fault BEFORE UPDATE OF state ON import_journal WHEN NEW.state = 'rolling-back'` + raise, after: registry.JournalSwapped},
+ {name: "fencing", hook: "fence", after: registry.JournalRollingBack},
+ {name: "removing the local data", hook: "remove-local", after: registry.JournalRollingBack},
+ {name: "after removing the local data", hook: "remove-local-done", after: registry.JournalRollingBack},
+ {name: "restoring the route", trigger: `CREATE TRIGGER fault BEFORE UPDATE OF state ON import_journal WHEN NEW.state = 'cleanup-pending'` + raise, after: registry.JournalRollingBack},
+ {name: "lifting the fence", hook: "lift", after: registry.JournalCleanupPending, crashOnly: true},
+ {name: "deleting the snapshot", hook: "delete-snapshot", after: registry.JournalCleanupPending},
+ {name: "deleting the replica", hook: "delete-replica", after: registry.JournalCleanupPending},
+ {name: "deleting the journal row", trigger: `CREATE TRIGGER fault BEFORE DELETE ON import_journal` + raise, after: registry.JournalCleanupPending},
+ {name: "marking the record rolled back", hook: "cas", after: ""},
+ } {
+ for _, crash := range []bool{false, true} {
+ if f.crashOnly && !crash {
+ continue
+ }
+ name := f.name + "/error"
+ if crash {
+ name = f.name + "/crash"
+ }
+ t.Run(name, func(t *testing.T) {
+ x := abortedImport(t)
+ x.box.fault, x.box.crash = f.hook, crash
+ if f.trigger != "" {
+ x.trigger(t, f.trigger)
+ }
+ x.pass(t)
+ if x.box.fault != "" {
+ t.Fatalf("the fault at %s was never reached: steps %q", f.hook, x.box.steps)
+ }
+ if st := x.journalState(t); st != f.after {
+ t.Fatalf("after the failed pass the journal is %q, want %q", st, f.after)
+ }
+ if f.trigger != "" {
+ x.trigger(t, `DROP TRIGGER fault`)
+ if crash {
+ x.box.restart()
+ }
+ }
+ if f.after == "" {
+ if st := seedState(t, x.raw, x.rec); st.State != seeds.StateAborted {
+ t.Fatalf("record = %s, want aborted with no journal row", st.State)
+ }
+ }
+ restoredAt := -1
+ for i := 0; i < 3; i++ {
+ if restoredAt < 0 && x.journalState(t) != registry.JournalSwapped && x.journalState(t) != registry.JournalRollingBack {
+ restoredAt = len(x.box.steps)
+ }
+ x.pass(t)
+ }
+ x.wantRolledBack(t)
+ gens := 1
+ if f.after == registry.JournalSwapped {
+ gens = 2 // the failed close's generation was never stored
+ }
+ if n := count(x.box.steps, "gen"); n != gens {
+ t.Fatalf("minted %d fence generations, want %d: %q", n, gens, x.box.steps)
+ }
+ if restoredAt >= 0 {
+ for _, s := range x.box.steps[restoredAt:] {
+ if s == "fence" || s == "remove-local" || s == "gen" {
+ t.Fatalf("%s after the route was restored: %q", s, x.box.steps)
+ }
+ }
+ }
+ })
+ }
+ }
+}
+
+// Preflight M13: the record is deleted and the fence defers. The gated row
+// is Provisioned and its record gone, which is what the removal phase
+// deletes, but the journal owns it: it survives, closed, every pass.
+func TestRollbackRecordDeletedFenceDeferredRowSurvivesRemoval(t *testing.T) {
+ x := abortedImport(t)
+ if err := DeleteRecord(ctx, x.raw, x.rec.Account, x.rec.App, x.rec.Host); err != nil {
+ t.Fatal(err)
+ }
+ x.box.proceed = false
+ for i := 0; i < 2; i++ {
+ x.pass(t)
+ got := mustGet(t, x.reg, x.rec.Host)
+ if got.Kind != "instance" || got.ImportGate != registry.ImportGateClosed || !got.Provisioned {
+ t.Fatalf("pass %d: row = %+v, want the closed import row", i, got)
+ }
+ if st := x.journalState(t); st != registry.JournalRollingBack {
+ t.Fatalf("pass %d: journal = %s", i, st)
+ }
+ }
+ if !x.box.local || !x.box.replica || count(x.box.steps, "remove-local") != 0 {
+ t.Fatalf("a deferred rollback removed or deleted something: %q", x.box.steps)
+ }
+ x.box.proceed = true
+ x.pass(t)
+ if got := mustGet(t, x.reg, x.rec.Host); !reflect.DeepEqual(*got, x.box.original) {
+ t.Fatalf("row = %+v", got)
+ }
+ if x.journalState(t) != "" || x.box.local || x.box.replica {
+ t.Fatalf("not finished: journal %q local %v replica %v", x.journalState(t), x.box.local, x.box.replica)
+ }
+}
+
+// The rollback stops the instance before it removes or deletes anything,
+// restores the route before the bucket is touched, and the removal phase,
+// which runs on the route list read before the rollback, leaves the
+// restored legacy row alone (the journaled-host skip): here the record is
+// gone, so without the skip the stale Provisioned import row would have
+// its host removed, taking the legacy row with it.
+func TestRollbackOrderAndTheRemovalPhaseKeepsTheRestoredRow(t *testing.T) {
+ x := abortedImport(t)
+ if err := DeleteRecord(ctx, x.raw, x.rec.Account, x.rec.App, x.rec.Host); err != nil {
+ t.Fatal(err)
+ }
+ x.pass(t)
+ want := []string{"gen", "fence", "remove-local", "lift", "delete-snapshot", "delete-replica"}
+ if !reflect.DeepEqual(x.box.steps, want) {
+ t.Fatalf("steps = %q, want %q", x.box.steps, want)
+ }
+ if got := mustGet(t, x.reg, x.rec.Host); !reflect.DeepEqual(*got, x.box.original) {
+ t.Fatalf("row after the pass = %+v, want the original", got)
+ }
+ if x.journalState(t) != "" {
+ t.Fatalf("journal = %s", x.journalState(t))
+ }
+}
+
+// insertJournal writes a journal row straight into the registry file, as
+// a cold restore of an older snapshot would bring it back.
+func insertJournal(t *testing.T, path string, j registry.ImportJournal) {
+ t.Helper()
+ orig, err := json.Marshal(j.Original)
+ if err != nil {
+ t.Fatal(err)
+ }
+ db, err := sql.Open("sqlite", path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer db.Close()
+ if _, err := db.Exec(`INSERT INTO import_journal (host, seed_id, original, record_key, state, fence_gen, at) VALUES (?,?,?,?,?,?,?)`,
+ j.Host, j.SeedID, string(orig), j.RecordKey, j.State, j.FenceGen, j.At); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// The Task 4 review's stale journal row: the import was rolled back and
+// its record finished, the host was created again, and then a journal row
+// for the old import comes back (a cold restore of an older snapshot).
+// Whatever state the row comes back in, nothing the new instance owns is
+// touched: not its row, its record, its process, its local data or its
+// replica.
+func TestRollbackOfAStaleJournalTouchesNothingTheNewInstanceOwns(t *testing.T) {
+ for _, owner := range []string{"the same app", "another app"} {
+ for _, state := range []string{registry.JournalSwapped, registry.JournalRollingBack, registry.JournalCleanupPending} {
+ t.Run(owner+"/"+state, func(t *testing.T) {
+ x := abortedImport(t)
+ stale, _ := x.reg.ImportJournal(x.rec.Host)
+ x.pass(t)
+ x.wantRolledBack(t)
+ // The console finishes A and the host is created again.
+ if err := DeleteRecord(ctx, x.raw, x.rec.Account, x.rec.App, x.rec.Host); err != nil {
+ t.Fatal(err)
+ }
+ next := shopRecord(x.rec.Account)
+ if owner == "another app" {
+ next.App = "other"
+ if err := PutManifest(ctx, x.raw, next.Account, "other", Manifest{Schema: 1, App: "other", Domain: "other.example.com"}); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := CreateRecord(ctx, x.raw, next); err != nil {
+ t.Fatal(err)
+ }
+ // The new instance's row, as the box serves it.
+ if err := x.reg.Delete(next.Host); err != nil {
+ t.Fatal(err)
+ }
+ x.pass(t)
+ newRow := mustGet(t, x.reg, next.Host)
+ if newRow.App != next.App || !newRow.Provisioned || newRow.ImportGate != "" {
+ t.Fatalf("the new instance was not minted: %+v", newRow)
+ }
+ x.box.local, x.box.replica = true, true // its own data now
+ x.box.original = *newRow // what any bucket delete must find at the host
+ newRec, _ := GetRecord(ctx, x.raw, next.Account, next.App, next.Host)
+
+ j := *stale
+ j.State = state
+ if state != registry.JournalSwapped {
+ j.FenceGen = 7
+ }
+ insertJournal(t, x.path, j)
+ x.box.steps = nil
+ logged := captureLog(t)
+ x.pass(t)
+ x.pass(t)
+
+ if got := mustGet(t, x.reg, next.Host); !reflect.DeepEqual(*got, *newRow) {
+ t.Fatalf("the new instance's row changed:\n got %+v\nwant %+v", got, newRow)
+ }
+ if got, _ := GetRecord(ctx, x.raw, next.Account, next.App, next.Host); !reflect.DeepEqual(got, newRec) {
+ t.Fatalf("the new instance's record changed: %+v", got)
+ }
+ for _, s := range x.box.steps {
+ if s != "delete-snapshot" {
+ t.Fatalf("the stale journal reached the box: %q\n%s", x.box.steps, logged)
+ }
+ }
+ if !x.box.local || !x.box.replica {
+ t.Fatalf("the new instance's data went: local %v replica %v", x.box.local, x.box.replica)
+ }
+ // A stale row that would act on the route is held; one at
+ // cleanup-pending finishes its own seed's cleanup and goes.
+ if st := x.journalState(t); (state == registry.JournalCleanupPending) != (st == "") {
+ t.Fatalf("journal = %q after a stale %s row", st, state)
+ }
+ why := "the route at the host is not the import's"
+ if state == registry.JournalCleanupPending {
+ why = "the host's replica is left alone"
+ }
+ if !strings.Contains(logged.String(), why) {
+ t.Fatalf("not held for the reason %q:\n%s", why, logged)
+ }
+ })
+ }
+ }
+}
+
+// The rollback tolerates a missing import row (the Task 4 review's carry):
+// something removed it, so no route is put back, nothing local is removed
+// for a row that is not there, and the cleanup carries on.
+func TestRollbackOfAMissingRowSkipsTheRestore(t *testing.T) {
+ x := abortedImport(t)
+ if err := x.reg.Delete(x.rec.Host); err != nil {
+ t.Fatal(err)
+ }
+ x.box.noRoute = true
+ x.pass(t)
+ if rt, _ := x.reg.Get(x.rec.Host); rt != nil {
+ t.Fatalf("a route came back: %+v", rt)
+ }
+ want := []string{"gen", "fence", "lift", "delete-snapshot", "delete-replica"}
+ if !reflect.DeepEqual(x.box.steps, want) {
+ t.Fatalf("steps = %q, want %q", x.box.steps, want)
+ }
+ if x.journalState(t) != "" || x.box.replica {
+ t.Fatalf("journal %q replica %v", x.journalState(t), x.box.replica)
+ }
+ if st := seedState(t, x.raw, x.rec); st.State != seeds.StateRolledBack {
+ t.Fatalf("record = %s", st.State)
+ }
+}
+
+// Step 6 deletes the host's replica only when it can tell the replica is
+// this import's: a record it cannot read, a host listing it cannot finish,
+// or a record naming this seed in a state that is not abandoned all defer
+// the cleanup; the journal stays at cleanup-pending.
+func TestRollbackDefersTheReplicaDeleteWhenItCannotTell(t *testing.T) {
+ for _, c := range []string{"the record read fails", "the record names this seed, placed"} {
+ t.Run(c, func(t *testing.T) {
+ x := abortedImport(t)
+ if err := x.reg.CloseImportGate(x.rec.Host, x.rec.Seed.ID, 9); err != nil {
+ t.Fatal(err)
+ }
+ if err := x.reg.RestoreImportRoute(x.rec.Host, x.rec.Seed.ID); err != nil {
+ t.Fatal(err)
+ }
+ key := RecordKey(x.rec.Account, x.rec.App, x.rec.Host)
+ switch c {
+ case "the record read fails":
+ // Only the step-6 re-read fails: the pass's own read works.
+ x.store = &failNthGet{Store: x.store, key: key, n: 2}
+ default:
+ placed := x.rec
+ seed := *x.rec.Seed
+ seed.State = seeds.StatePlaced
+ placed.Seed = &seed
+ putReplace(t, x.raw, key, mustJSON(t, placed))
+ }
+ logged := captureLog(t)
+ x.pass(t)
+ if st := x.journalState(t); st != registry.JournalCleanupPending {
+ t.Fatalf("journal = %q", st)
+ }
+ if !strings.Contains(logged.String(), "cleanup deferred") {
+ t.Fatalf("not deferred:\n%s", logged)
+ }
+ if !x.box.replica || count(x.box.steps, "delete-replica") != 0 {
+ t.Fatalf("the replica was deleted: %q", x.box.steps)
+ }
+ })
+ }
+}
+
+// failNthGet fails every read of one key from the nth on.
+type failNthGet struct {
+ release.Store
+ key string
+ n int
+ seen int
+}
+
+func (f *failNthGet) Get(ctx context.Context, key string) ([]byte, string, error) {
+ if key == f.key {
+ f.seen++
+ if f.seen >= f.n {
+ return nil, "", errors.New("connection reset")
+ }
+ }
+ return f.Store.Get(ctx, key)
+}
+
+// Without every box hook the rollback does nothing at all: the import
+// stays journaled, gated and untouched.
+func TestRollbackNeedsEveryHook(t *testing.T) {
+ x := abortedImport(t)
+ x.cfg.Seeds.DeleteReplica = nil
+ row := *mustGet(t, x.reg, x.rec.Host)
+ x.pass(t)
+ if got := mustGet(t, x.reg, x.rec.Host); !reflect.DeepEqual(*got, row) {
+ t.Fatalf("row changed: %+v", got)
+ }
+ if st := x.journalState(t); st != registry.JournalSwapped || len(x.box.steps) != 0 {
+ t.Fatalf("journal %q steps %q", st, x.box.steps)
+ }
+}
+
+// The route is read again under the fence: a row that stopped being the
+// import's while the host was being fenced (removed and replaced by
+// another, here) is not removed, the fence is lifted, and the rollback
+// holds with the journal at rolling-back.
+func TestRollbackRereadsTheRouteUnderTheFence(t *testing.T) {
+ x := abortedImport(t)
+ other := addrRow(x.rec, x.rec.Account)
+ other.Addr = "127.0.0.1:9999"
+ fence := x.cfg.Seeds.FenceForRollback
+ x.cfg.Seeds.FenceForRollback = func(ctx context.Context, host string, gen uint64) (bool, error) {
+ proceed, err := fence(ctx, host, gen)
+ if err := x.reg.Delete(host); err != nil {
+ t.Fatal(err)
+ }
+ if err := x.reg.Upsert(other); err != nil {
+ t.Fatal(err)
+ }
+ return proceed, err
+ }
+ x.pass(t)
+ placed := mustGet(t, x.reg, x.rec.Host)
+ if placed.Addr != other.Addr || placed.ImportGate != "" {
+ t.Fatalf("the replacing row changed: %+v", placed)
+ }
+ if count(x.box.steps, "remove-local") != 0 || !x.box.local || len(x.box.fenced) != 0 {
+ t.Fatalf("steps %q local %v fenced %v", x.box.steps, x.box.local, x.box.fenced)
+ }
+ if st := x.journalState(t); st != registry.JournalRollingBack {
+ t.Fatalf("journal = %q", st)
+ }
+}
+
+// Preflight M3: a journal row whose seed id fails seeds.Check names no
+// snapshot and no replica of this import: the cleanup deletes nothing in
+// the bucket and only retires the journal row.
+func TestRollbackCleanupOfAnInvalidSeedIDDeletesNothing(t *testing.T) {
+ s, sqid := shopSetup(t)
+ rec := importRecord(t, s, sqid)
+ reg, path := regAt(t, addrRow(rec, sqid))
+ original := *mustGet(t, reg, rec.Host)
+ insertJournal(t, path, registry.ImportJournal{Host: rec.Host, SeedID: "../not-a-seed", Original: original,
+ RecordKey: RecordKey(rec.Account, rec.App, rec.Host), State: registry.JournalCleanupPending, FenceGen: 3, At: 1})
+ cfg := importCfg(t, nil)
+ b := newRollbackBox(t, reg, original)
+ b.hooks(cfg.Seeds)
+ cfg.Seeds.Data = faultData{cfg.Seeds.Data, b}
+ if err := Reconcile(ctx, s, reg, cfg); err != nil {
+ t.Fatal(err)
+ }
+ if len(b.steps) != 0 || !b.replica {
+ t.Fatalf("steps %q replica %v", b.steps, b.replica)
+ }
+ if j, _ := reg.ImportJournal(rec.Host); j != nil {
+ t.Fatalf("journal = %+v", j)
+ }
+ if got := mustGet(t, reg, rec.Host); !reflect.DeepEqual(*got, original) {
+ t.Fatalf("row = %+v", got)
+ }
+}
diff --git a/internal/provision/seedswap_test.go b/internal/provision/seedswap_test.go
index 40016bed7..ca815c4a6 100644
--- a/internal/provision/seedswap_test.go
+++ b/internal/provision/seedswap_test.go
@@ -522,7 +522,10 @@ func TestSwapResumesTheRecordCASAfterACrash(t *testing.T) {
}
// A journal row naming this seed ID under another record key is not this
-// record's swap: left alone, no CAS.
+// record's swap: no CAS for this record. The journal's own record (under
+// the other key) is gone, so the journal rolls its import back: the
+// original route returns exactly, and this record, which holds the host,
+// keeps the host's replica and its own state.
func TestSwapResumeNeedsTheRecordKey(t *testing.T) {
s, sqid := shopSetup(t)
rec := importRecord(t, s, sqid)
@@ -533,12 +536,24 @@ func TestSwapResumeNeedsTheRecordKey(t *testing.T) {
RecordKey: RecordKey(sqid, "other", rec.Host), At: 1}, next); err != nil {
t.Fatal(err)
}
- if err := Reconcile(ctx, s, reg, importCfg(t, nil)); err != nil {
+ cfg := importCfg(t, nil)
+ b := newRollbackBox(t, reg, *original)
+ b.hooks(cfg.Seeds)
+ if err := Reconcile(ctx, s, reg, cfg); err != nil {
t.Fatal(err)
}
if st := seedState(t, s, rec); st.State != seeds.StateReady || st.Reason != "" {
t.Fatalf("record = %s %q, want ready", st.State, st.Reason)
}
+ if got := mustGet(t, reg, rec.Host); !reflect.DeepEqual(*got, *original) {
+ t.Fatalf("row = %+v, want the original", got)
+ }
+ if j, _ := reg.ImportJournal(rec.Host); j != nil {
+ t.Fatalf("journal = %+v", j)
+ }
+ if b.local || !b.replica {
+ t.Fatalf("local %v replica %v: steps %q", b.local, b.replica, b.steps)
+ }
}
// A journal row this record's seed does not own (another seed's, or one