carlos / platform Public

provision: an aborted --from seed removes its local database before it rolls back (final fix F2)

A --from seed aborted after its publish landed (a lost answer) could
have been minted and woken, leaving its database at the host's default
nested path. The row was removed and the seed rolled back with the file
still there; the console then deleted the replica and the record, and a
plain instance created at the host later woke on the seed's data.

On the pass that finds no row, finishAbortedSeed now applies Plan 7's
missing-row rule first: when the <db>.seed marker names this seed, the
host is fenced and its stop proven, the route and the marker are checked
again, and the database goes through RemoveLocal (PurgeSeedLocal) before
the snapshot delete and the CAS to rolled-back. A marker that is absent
or names another seed rolls back with the file left, logged; a marker
read error, a fence that does not proceed, a row that appears or a
failed removal keeps the seed aborted. The marker is on disk, so this
holds after a restart.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 9592c22adbc2b4d01d31e541c5b4b9402dd24cbd parent ce23521
3 files changed, +339 −6
  • internal/provision/reconcile.go +1 −1
  • internal/provision/seedreconcile.go +92 −5
  • internal/provision/seedreconcile_test.go +246 −0
diff --git a/internal/provision/reconcile.go b/internal/provision/reconcile.go
index 118cad568..8124aa588 100644
--- a/internal/provision/reconcile.go
+++ b/internal/provision/reconcile.go
@@ -1129,7 +1129,7 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg
retireAbortedSeedRow(ctx, reg, cfg, rec, existing)
continue
}
- finishAbortedSeed(ctx, s, cfg, rec)
+ finishAbortedSeed(ctx, s, reg, cfg, rec)
continue
}
seed, seedIsKnown = "", true
diff --git a/internal/provision/seedreconcile.go b/internal/provision/seedreconcile.go
index deef939cc..d5b46b3aa 100644
--- a/internal/provision/seedreconcile.go
+++ b/internal/provision/seedreconcile.go
@@ -177,19 +177,23 @@ func retireAbortedSeedRow(ctx context.Context, reg *registry.Registry, cfg Recon
log.Printf("provision: removed %s route %s (its seed %s was aborted); the next pass rolls it back", rt.Kind, rt.Host, rec.Seed.ID)
}
-// finishAbortedSeed is §3.2's no-journal path for an aborted seed: delete
-// this seed's snapshot (only this seed's id, codex R4-1), then CAS the
-// record to rolled-back. Idempotent: an absent snapshot and a lost CAS are
-// both done. The console finishes from rolled-back (Plan 8).
+// finishAbortedSeed is §3.2's no-journal path for an aborted seed: remove
+// a --from seed's local database (purgeAbortedFromLocal), delete this
+// seed's snapshot (only this seed's id, codex R4-1), then CAS the record to
+// rolled-back. Idempotent: an absent snapshot and a lost CAS are both done.
+// The console finishes from rolled-back (Plan 8).
//
// A host or id that fails seeds.Check names no snapshot (no valid key
// could hold one), so nothing is deleted and the record is still rolled
// back.
-func finishAbortedSeed(ctx context.Context, s release.Store, cfg ReconcileConfig, rec Record) {
+func finishAbortedSeed(ctx context.Context, s release.Store, reg *registry.Registry, cfg ReconcileConfig, rec Record) {
if cfg.DryRun {
log.Printf("provision: dry-run: would finish aborted seed %s of %s", rec.Seed.ID, rec.Host)
return
}
+ if rec.Seed.Kind == seeds.KindReplica && !purgeAbortedFromLocal(ctx, reg, cfg, rec) {
+ return
+ }
if err := seeds.Check(rec.Host, rec.Seed.ID); err != nil {
log.Printf("provision: %s: aborted seed %q names no snapshot: %v", rec.Host, rec.Seed.ID, err)
} else {
@@ -206,6 +210,89 @@ func finishAbortedSeed(ctx context.Context, s release.Store, cfg ReconcileConfig
log.Printf("provision: %s: aborted seed %s rolled back (no box had swapped it)", rec.Host, rec.Seed.ID)
}
+// rollbackArmed reports whether h has the rollback's six box hooks.
+func rollbackArmed(h *SeedHooks) bool {
+ return h != nil && h.FenceForRollback != nil && h.LiftFence != nil && h.RemoveLocal != nil && h.DeleteReplica != nil &&
+ h.NextFenceGen != nil && h.LocalSeedID != nil
+}
+
+// purgeAbortedFromLocal removes an aborted --from seed's local database
+// before the seed is rolled back (final review F2), on a pass that found
+// no row for the host. The seed may have been aborted after its publish
+// landed (a lost publish answer), and a wake then placed its database at
+// the host's default nested path, where a later plain instance at the
+// host would open it as a warm cache. The console's finish deletes the
+// replica and the record, so nothing else would ever remove the file.
+//
+// It is Plan 7's rule for an import whose row is gone (removeOrphanedLocal),
+// decided from the file: the database goes only when its <db>.seed marker
+// names this seed and the host still has no row, with the host fenced and
+// its stop proven. The marker is on disk, so this holds after a restart
+// too. A marker that is absent or names another seed does not prove the
+// data is this seed's: logged, and the seed rolls back with the file left.
+// A marker that cannot be read, a fence that does not proceed, a row that
+// appears or a removal that fails defers the rollback (ok false).
+//
+// A box without the rollback's hooks places no seed (they are armed with
+// the seed reader, and only claim control leaves them off, which refuses
+// every seed), so it has nothing of this seed's to remove: ok.
+func purgeAbortedFromLocal(ctx context.Context, reg *registry.Registry, cfg ReconcileConfig, rec Record) (ok bool) {
+ h := cfg.Seeds
+ if !rollbackArmed(h) {
+ log.Printf("provision: %s: aborted seed %s: this box cannot read seed markers, and places no seed; nothing local is removed", rec.Host, rec.Seed.ID)
+ return true
+ }
+ db := NestedDBPath(cfg.DataDir, rec.Host)
+ ours := func() (bool, bool) {
+ id, err := h.LocalSeedID(db)
+ if err != nil {
+ log.Printf("provision: %s: aborted seed %s stays aborted: the seed marker beside %s cannot be read: %v", rec.Host, rec.Seed.ID, db, err)
+ return false, false
+ }
+ if id != rec.Seed.ID {
+ log.Printf("provision: %s: aborted seed %s rolls back with the database at %s left: its seed marker names %q, not this seed", rec.Host, rec.Seed.ID, db, id)
+ return false, true
+ }
+ return true, true
+ }
+ if mine, ok := ours(); !mine {
+ return ok
+ }
+ gen, err := h.NextFenceGen()
+ if err == nil && gen == 0 {
+ err = errors.New("the generation source answered 0")
+ }
+ if err != nil {
+ log.Printf("provision: %s: aborted seed %s stays aborted: no fence generation: %v", rec.Host, rec.Seed.ID, err)
+ return false
+ }
+ proceed, err := h.FenceForRollback(ctx, rec.Host, gen)
+ defer h.LiftFence(rec.Host, gen) // a hook that failed may have fenced first
+ if err != nil || !proceed {
+ log.Printf("provision: %s: aborted seed %s stays aborted: the host is not fenced and stopped yet (%v)", rec.Host, rec.Seed.ID, err)
+ return false
+ }
+ // Under the fence: still no row, and the marker still names this seed.
+ row, err := reg.Get(rec.Host)
+ if err != nil {
+ log.Printf("provision: %s: aborted seed %s stays aborted: the route could not be read: %v", rec.Host, rec.Seed.ID, err)
+ return false
+ }
+ if row != nil {
+ log.Printf("provision: %s: aborted seed %s stays aborted: a route appeared at the host, so its local database is left", rec.Host, rec.Seed.ID)
+ return false
+ }
+ if mine, ok := ours(); !mine {
+ return ok
+ }
+ if err := h.RemoveLocal(registry.Route{Host: rec.Host, Kind: "instance", DBPath: db}); err != nil {
+ log.Printf("provision: %s: aborted seed %s stays aborted: remove its local data: %v", rec.Host, rec.Seed.ID, err)
+ return false
+ }
+ log.Printf("provision: %s: aborted seed %s has no route on this box: its local data is removed", rec.Host, rec.Seed.ID)
+ return true
+}
+
// journalPass is spec §5.7: the box's import journal is read before the
// records are acted on, every pass, and each row drives its host (§5.4
// "Crashes", §5.6). handled hosts are skipped by the record loop;
diff --git a/internal/provision/seedreconcile_test.go b/internal/provision/seedreconcile_test.go
index a95173f35..6751eb2d8 100644
--- a/internal/provision/seedreconcile_test.go
+++ b/internal/provision/seedreconcile_test.go
@@ -4,6 +4,9 @@ import (
"context"
"errors"
"os"
+ "path/filepath"
+ "reflect"
+ "strings"
"testing"
"time"
@@ -240,3 +243,246 @@ func TestReconcileNeverRemovesAnAbortedImportsRow(t *testing.T) {
t.Fatalf("record = %+v, %v; want rolled-back", got.Seed, err)
}
}
+
+// diskBox is the rollback's six box hooks over a real data directory: the
+// marker and the database are files, read and removed as the activator's
+// ReadSeedMarker and PurgeSeedLocal do (their own tests are the activator's
+// seedpurge_linux_test.go). It records the steps in order.
+type diskBox struct {
+ t *testing.T
+ s release.Store
+ rec Record
+ steps []string
+ proceed bool
+ gen uint64
+ fenced map[string]uint64
+ markErr error
+}
+
+func newDiskBox(t *testing.T, s release.Store, rec Record) *diskBox {
+ return &diskBox{t: t, s: s, rec: rec, proceed: true, fenced: map[string]uint64{}}
+}
+
+func (b *diskBox) hooks(h *SeedHooks) {
+ h.NextFenceGen = func() (uint64, error) { b.gen++; return b.gen, nil }
+ h.FenceForRollback = func(_ context.Context, host string, gen uint64) (bool, error) {
+ b.steps = append(b.steps, "fence")
+ 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")
+ if b.fenced[host] == gen {
+ delete(b.fenced, host)
+ }
+ }
+ h.LocalSeedID = func(db string) (string, error) {
+ b.steps = append(b.steps, "read-marker")
+ if b.markErr != nil {
+ return "", b.markErr
+ }
+ m, err := os.ReadFile(db + ".seed")
+ if errors.Is(err, os.ErrNotExist) {
+ return "", nil
+ }
+ return string(m), err
+ }
+ 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 the local data of %s while it was not fenced and stopped", rt.Host)
+ }
+ // Before the rollback: the record is still aborted.
+ if got, err := GetRecord(ctx, b.s, b.rec.Account, b.rec.App, b.rec.Host); err != nil || got.Seed.State != seeds.StateAborted {
+ b.t.Errorf("removed the local data with the record at %+v, %v; want aborted", got.Seed, err)
+ }
+ for _, p := range []string{rt.DBPath, rt.DBPath + "-wal", rt.DBPath + ".seed"} {
+ if err := os.Remove(p); err != nil && !errors.Is(err, os.ErrNotExist) {
+ return err
+ }
+ }
+ return nil
+ }
+ h.DeleteReplica = func(context.Context, string) error {
+ b.t.Error("the no-journal path deleted a replica")
+ return nil
+ }
+}
+
+// placeLocal writes a database and its <db>.seed marker naming id at db,
+// as a wake that placed the seed leaves them.
+func placeLocal(t *testing.T, db, id string) {
+ t.Helper()
+ if err := os.MkdirAll(filepath.Dir(db), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ for p, body := range map[string]string{db: "the seed's data", db + "-wal": "wal", db + ".seed": id} {
+ if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ }
+}
+
+func diskSeedCfg(t *testing.T, data release.Store, box *diskBox) ReconcileConfig {
+ cfg := nestedSeedCfg(data)
+ cfg.DataDir = t.TempDir()
+ box.hooks(cfg.Seeds)
+ return cfg
+}
+
+func requireSeedState(t *testing.T, s release.Store, rec Record, state string) {
+ t.Helper()
+ got, err := GetRecord(ctx, s, rec.Account, rec.App, rec.Host)
+ if err != nil || got.Seed == nil || got.Seed.State != state {
+ t.Fatalf("record = %+v, %v; want %s", got.Seed, err, state)
+ }
+}
+
+// Final review F2: a --from seed whose publish landed but whose answer was
+// lost. A box minted its row and a wake placed its database; then the abort
+// won. The row goes first; the pass that finds no row removes the database
+// (its marker names this seed) under a fence, before the record is rolled
+// back. That pass runs as after an agent restart: nothing in memory, only
+// the marker on disk. A plain instance created at the host afterwards is
+// minted at the same path and finds no file there.
+func TestReconcileAbortedFromSeedRemovesItsLocalDatabase(t *testing.T) {
+ s, sqid := setup(t)
+ data := dirStore(t)
+ rec := seededRecord(t, s, sqid, seeds.KindReplica, seeds.StateReady)
+ box := newDiskBox(t, s, rec)
+ cfg := diskSeedCfg(t, data, box)
+ reg := regWith(t)
+ if err := Reconcile(ctx, s, reg, cfg); err != nil {
+ t.Fatal(err)
+ }
+ db := mustGet(t, reg, rec.Host).DBPath
+ if db != NestedDBPath(cfg.DataDir, rec.Host) {
+ t.Fatalf("minted at %s", db)
+ }
+ placeLocal(t, db, rec.Seed.ID)
+ 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 rt, _ := reg.Get(rec.Host); rt != nil {
+ t.Fatalf("the aborted seed's row is still up: %+v", rt)
+ }
+ requireSeedState(t, s, rec, seeds.StateAborted)
+ if _, err := os.Stat(db); err != nil {
+ t.Fatalf("the database went with the row, before the no-row pass: %v", err)
+ }
+
+ restarted := newDiskBox(t, s, rec) // the agent restarted: no fences, no memory
+ restarted.hooks(cfg.Seeds)
+ if err := Reconcile(ctx, s, reg, cfg); err != nil {
+ t.Fatal(err)
+ }
+ requireSeedState(t, s, rec, seeds.StateRolledBack)
+ if want := []string{"read-marker", "fence", "read-marker", "remove-local", "lift"}; !reflect.DeepEqual(restarted.steps, want) {
+ t.Fatalf("steps = %q, want %q", restarted.steps, want)
+ }
+ if len(restarted.fenced) != 0 {
+ t.Fatalf("a fence was left: %v", restarted.fenced)
+ }
+ for _, p := range []string{db, db + "-wal", db + ".seed"} {
+ if _, err := os.Lstat(p); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("%s survived the rollback: %v", p, err)
+ }
+ }
+
+ // The console's finish deletes the record; a plain instance follows.
+ if err := DeleteRecord(ctx, s, rec.Account, rec.App, rec.Host); err != nil {
+ t.Fatal(err)
+ }
+ plain := acmeRecord(sqid)
+ if err := CreateRecord(ctx, s, plain); err != nil {
+ t.Fatal(err)
+ }
+ if err := Reconcile(ctx, s, reg, cfg); err != nil {
+ t.Fatal(err)
+ }
+ next := mustGet(t, reg, plain.Host)
+ if next.SeedSource != "" {
+ t.Fatalf("the plain instance's row = %+v", next)
+ }
+ if _, err := os.Lstat(next.DBPath); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("the plain instance at %s finds a database: %v", next.DBPath, err)
+ }
+}
+
+// The marker decides, as for an import whose row is gone (Plan 7): a
+// marker naming another seed, or none, rolls back with the file left; a
+// marker that cannot be read, or a fence that does not proceed, keeps the
+// seed aborted with everything in place.
+func TestReconcileAbortedFromSeedDecidesItsLocalDataFromTheMarker(t *testing.T) {
+ other, _ := seeds.NewID()
+ for _, c := range []struct {
+ name string
+ marker string // "" writes no marker; "this" names the seed
+ markErr error
+ noFence bool
+ state string
+ removed bool
+ steps []string
+ logged string
+ }{
+ {name: "this seed", marker: "this", state: seeds.StateRolledBack, removed: true,
+ steps: []string{"read-marker", "fence", "read-marker", "remove-local", "lift"}, logged: "its local data is removed"},
+ {name: "another seed", marker: other, state: seeds.StateRolledBack,
+ steps: []string{"read-marker"}, logged: "its seed marker names \"" + other + "\", not this seed"},
+ {name: "no marker", state: seeds.StateRolledBack,
+ steps: []string{"read-marker"}, logged: "its seed marker names \"\", not this seed"},
+ {name: "unreadable marker", marker: "this", markErr: errors.New("permission denied"), state: seeds.StateAborted,
+ steps: []string{"read-marker"}, logged: "the seed marker beside"},
+ {name: "not fenced", marker: "this", noFence: true, state: seeds.StateAborted,
+ steps: []string{"read-marker", "fence", "lift"}, logged: "is not fenced and stopped yet"},
+ } {
+ t.Run(c.name, func(t *testing.T) {
+ s, sqid := setup(t)
+ data := dirStore(t)
+ rec := seededRecord(t, s, sqid, seeds.KindReplica, seeds.StateAborted)
+ snap := seeds.SnapshotKey(rec.Host, rec.Seed.ID)
+ if _, err := data.Put(ctx, snap, []byte("db"), ""); err != nil {
+ t.Fatal(err)
+ }
+ box := newDiskBox(t, s, rec)
+ box.markErr, box.proceed = c.markErr, !c.noFence
+ cfg := diskSeedCfg(t, data, box)
+ db := NestedDBPath(cfg.DataDir, rec.Host)
+ switch c.marker {
+ case "":
+ placeLocal(t, db, "")
+ if err := os.Remove(db + ".seed"); err != nil {
+ t.Fatal(err)
+ }
+ case "this":
+ placeLocal(t, db, rec.Seed.ID)
+ default:
+ placeLocal(t, db, c.marker)
+ }
+ logged := captureLog(t)
+ if err := Reconcile(ctx, s, regWith(t), cfg); err != nil {
+ t.Fatal(err)
+ }
+ requireSeedState(t, s, rec, c.state)
+ if !reflect.DeepEqual(box.steps, c.steps) {
+ t.Fatalf("steps = %q, want %q", box.steps, c.steps)
+ }
+ if !strings.Contains(logged.String(), c.logged) {
+ t.Fatalf("not logged %q:\n%s", c.logged, logged)
+ }
+ if _, err := os.Lstat(db); errors.Is(err, os.ErrNotExist) != c.removed {
+ t.Fatalf("database at %s: %v; want removed %v", db, err, c.removed)
+ }
+ _, _, err := data.Get(ctx, snap)
+ if gone := errors.Is(err, os.ErrNotExist); gone != (c.state == seeds.StateRolledBack) {
+ t.Fatalf("snapshot: %v; want it deleted only with the rollback", err)
+ }
+ })
+ }
+}