carlos / platform Public

provision: a snapshot pass decides removals from the bucket's own walk (#336)

Final review findings 2 and 4. The per-row confirmations (record key,
region and pool only) are gone. A Reconcile or ReconcileSidecars pass over a
record view snapshot that reaches a removal candidate runs the record walk
again over the live bucket (scanRecords / scanSidecars over freshStore) and
decides every removal that pass from it, by the walk's own rules: invalid
shapes and empty channels protected before scope, hosts matched across all
keys, full sidecar placement (record, live sidecars manifest, inbound), the
live trash marker. A walk that cannot read cleanly keeps every row. Passes
with no candidate read nothing more; non-snapshot stores walk once as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev c1d293789110ecb6155c15c846e71b821aaba858 parent cd418eb
5 files changed, +399 −122
  • docs/superpowers/specs/2026-10-21-s3-change-index-design.md +17 −13
  • internal/provision/reconcile.go +86 −35
  • internal/provision/recordview_store.go +14 −62
  • internal/provision/recordview_test.go +216 −0
  • internal/provision/sidecar_reconcile.go +66 −12
diff --git a/docs/superpowers/specs/2026-10-21-s3-change-index-design.md b/docs/superpowers/specs/2026-10-21-s3-change-index-design.md
index 78430f753..7246dd8db 100644
--- a/docs/superpowers/specs/2026-10-21-s3-change-index-design.md
+++ b/docs/superpowers/specs/2026-10-21-s3-change-index-design.md
@@ -295,19 +295,23 @@ those writers (box run/seed/move writes, direct-bucket CLI writes) rely on
the 5-minute floor; with the index they converge in seconds, and only
failures and old binaries wait for the backstop.
-**Removals are confirmed in the bucket.** The same writers mean absence in
-a snapshot is not the bucket's word: a record written without its bump is
-missing from a Complete snapshot until the next rebuild. So a removal that
-a snapshot proves by absence is confirmed against the live bucket before a
-row goes. That covers "record gone" in `Reconcile` and in
-`ReconcileSidecars`, and the trash-marker removal in `Reconcile`, which
-checks that the marker the snapshot holds is still in the bucket
-(`confirmRecordGone`, `confirmSidecarGone` and `confirmTrashed` in
-`internal/provision/recordview_store.go`). A record that is there and in
-the pass's scope keeps the row, and so does a live read that fails: the
-row stays until a pass can read the bucket cleanly. Over any store that is
-not a snapshot the checks answer at once without a read, because there the
-walk's absence already is the bucket's.
+**Removals are decided by the bucket's walk.** The same writers mean
+absence in a snapshot is not the bucket's word: a record written without
+its bump is missing from a Complete snapshot until the next rebuild. So a
+`Reconcile` or `ReconcileSidecars` pass over a snapshot that reaches a
+removal candidate proved by absence (record gone, a sidecar gone or no
+longer placeable, the trash marker) runs the record walk once more over the
+live bucket (`scanRecords`, `scanSidecars` over `freshStore`) and decides
+every removal in that pass from what that walk read, by the walk's own
+rules: invalid shapes and empty channels protected before scope, hosts
+matched across every key (a host re-homed to another key is present), full
+sidecar eligibility (region, pool, placement from the record and the live
+sidecars manifest, against this box's inbound), and the live trash marker.
+A walk that cannot read cleanly keeps every row that pass, as the walk
+always has. A snapshot pass with no removal candidate reads nothing more.
+Over any store that is not a snapshot the pass's own walk already is the
+bucket's, and nothing is read twice (final review R1, findings 2 and 4,
+amadan #336).
### 4.5 What triggers a pass, and what it reads
diff --git a/internal/provision/reconcile.go b/internal/provision/reconcile.go
index a2f08183c..cd21e7e54 100644
--- a/internal/provision/reconcile.go
+++ b/internal/provision/reconcile.go
@@ -819,16 +819,37 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg
if cfg.Now == nil {
cfg.Now = time.Now
}
+ sc, err := scanRecords(ctx, s, cfg)
+ if err != nil {
+ return err
+ }
+ return reconcileScanned(ctx, s, reg, cfg, sc)
+}
+// recordScan is the record-collection phase of a Reconcile pass: the
+// in-scope records by host, the hosts whose record is there but cannot be
+// used (protect), and whether anything failed to read (readFailed, which
+// disables removal for the whole pass).
+type recordScan struct {
+ recs map[string]Record
+ protect map[string]bool
+ readFailed bool
+}
+
+// scanRecords is Reconcile's record walk over s. A pass over a record view
+// snapshot that has a removal to decide runs it again over the bucket
+// (freshStore), so both judge every record by exactly these rules (S3
+// change-index design §4.4).
+func scanRecords(ctx context.Context, s release.Store, cfg ReconcileConfig) (recordScan, error) {
known, readFailed, err := knownAccounts(ctx, s)
if err != nil {
- return err
+ return recordScan{}, err
}
// --- collect in-scope records --------------------------------------------
keys, partial, err := listAppSubtree(ctx, s, known, "instances/")
if err != nil {
- return fmt.Errorf("provision: list apps: %w", err)
+ return recordScan{}, fmt.Errorf("provision: list apps: %w", err)
}
if partial {
readFailed = true // unseen records must not read as retired
@@ -941,6 +962,13 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg
recs[rec.Host] = rec
}
}
+ return recordScan{recs: recs, protect: protect, readFailed: readFailed}, nil
+}
+
+// reconcileScanned is the rest of a Reconcile pass over s, from its record
+// walk.
+func reconcileScanned(ctx context.Context, s release.Store, reg *registry.Registry, cfg ReconcileConfig, sc recordScan) error {
+ recs, protect, readFailed := sc.recs, sc.protect, sc.readFailed
// --- manifest seed cache, per (acct, app), lazily -------------------------
// known=false (a genuine read failure, not "no manifest") means the seed
@@ -1093,31 +1121,38 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg
// removable — true ONLY for a marker positively read and present,
// whose app is not flagged production. Removal is not reversible, so
// an unreadable marker (or an unreadable flag) must never reach it.
- trashCache := map[acctApp]trashState{}
- trashSeen := map[acctApp]bool{}
- trashFor := func(acct, app string) trashState {
- k := acctApp{acct, app}
- if trashSeen[k] {
+ //
+ // trashOver is that lookup over st, answering from pre where pre holds
+ // the app: the pass's own over s and its prefetch, and the removal
+ // phase's over the bucket when this pass reads a snapshot (below).
+ trashOver := func(st release.Store, pre map[acctApp]tombRead) func(acct, app string) trashState {
+ trashCache := map[acctApp]trashState{}
+ trashSeen := map[acctApp]bool{}
+ return func(acct, app string) trashState {
+ k := acctApp{acct, app}
+ if trashSeen[k] {
+ return trashCache[k]
+ }
+ trashSeen[k] = true
+ var tomb *release.AppTombstone
+ var err error
+ if r, ok := pre[k]; ok {
+ tomb, err = r.tomb, r.err
+ } else {
+ tomb, err = release.ReadAppTombstone(ctx, st, acct, app)
+ }
+ switch {
+ case err != nil:
+ log.Printf("provision: trash marker for %s/%s unreadable: %v — not minting for this app", acct, app, err)
+ trashCache[k] = trashState{declineMint: true}
+ case tomb != nil:
+ log.Printf("provision: %s/%s is in the trash — not minting instance routes for it", acct, app)
+ trashCache[k] = trashState{declineMint: true, removable: productionClear(ctx, st, cfg.ReleasePub, acct, app)}
+ }
return trashCache[k]
}
- trashSeen[k] = true
- var tomb *release.AppTombstone
- var err error
- if r, ok := tombs[k]; ok {
- tomb, err = r.tomb, r.err
- } else {
- tomb, err = release.ReadAppTombstone(ctx, s, acct, app)
- }
- switch {
- case err != nil:
- log.Printf("provision: trash marker for %s/%s unreadable: %v — not minting for this app", acct, app, err)
- trashCache[k] = trashState{declineMint: true}
- case tomb != nil:
- log.Printf("provision: %s/%s is in the trash — not minting instance routes for it", acct, app)
- trashCache[k] = trashState{declineMint: true, removable: productionClear(ctx, s, cfg.ReleasePub, acct, app)}
- }
- return trashCache[k]
}
+ trashFor := trashOver(s, tombs)
trashedFor := func(acct, app string) bool { return trashFor(acct, app).declineMint }
// --- mint / converge / conflict -------------------------------------------
@@ -1504,7 +1539,15 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg
// trade from the other three: it would strand precisely the hibernating
// tenants the ladder was built for, in exchange for guarding an authority
// this column does not carry.
- if !readFailed {
+ //
+ // retire runs that phase over one set of facts: the records, the
+ // protected hosts and the trash lookup. With stopAtFirst it removes
+ // nothing and reports whether any row would go. A record view
+ // snapshot's absence is not the bucket's (a write that missed its bump
+ // is missing from a Complete snapshot until the backstop rebuild), so a
+ // pass over a snapshot that would remove anything decides every removal
+ // again from the bucket's own walk, below (S3 change-index design §4.4).
+ retire := func(recs map[string]Record, protect map[string]bool, trashFor func(acct, app string) trashState, stopAtFirst bool) (wouldRemove bool) {
for _, rt := range routes {
// Removal authority is scoped to this pass's OWN kind. A sidecar
// row is Provisioned too — that flag is what grants a pass removal
@@ -1552,16 +1595,8 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg
log.Printf("provision: not removing %s (%s): participant of a running operation", rt.Host, why)
continue
}
- // Over a record view snapshot, the absence (or the marker) is
- // confirmed in the bucket first: a write that missed its bump
- // is not in the snapshot (S3 change-index design §4.4).
- if why == "record gone" && !confirmRecordGone(ctx, s, rt, func(r Record) bool {
- return cfg.inScope(r.Region, r.Pool, everywhereClass(r))
- }) {
- continue
- }
- if why == "app in the trash" && !confirmTrashed(ctx, s, rt.Account, rt.App) {
- continue
+ if stopAtFirst {
+ return true
}
if cfg.DryRun {
log.Printf("provision: dry-run: would remove retired %s route %s (%s)", rt.Kind, rt.Host, why)
@@ -1573,7 +1608,23 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg
}
log.Printf("provision: removed retired %s route %s (%s)", rt.Kind, rt.Host, why)
}
+ return false
+ }
+ if readFailed || !retire(recs, protect, trashFor, onSnapshot(s)) {
+ return nil
+ }
+ // A snapshot pass with a removal to make: the record walk again, over
+ // the bucket, and the removal phase over what it read, by the walk's
+ // own rules (invalid shapes and empty channels protected before scope,
+ // hosts matched across every key, the bucket's trash marker). Anything
+ // it cannot read keeps every row this pass, as in the walk.
+ live := freshStore(s)
+ lsc, err := scanRecords(ctx, live, cfg)
+ if err != nil || lsc.readFailed {
+ log.Printf("provision: not removing any route this pass: the record view lacks a record or holds a trash marker, and the bucket's own walk could not be read cleanly%s", walkErrSuffix(err))
+ return nil
}
+ retire(lsc.recs, lsc.protect, trashOver(live, nil), false)
return nil
}
diff --git a/internal/provision/recordview_store.go b/internal/provision/recordview_store.go
index 00fa81881..8c1f74789 100644
--- a/internal/provision/recordview_store.go
+++ b/internal/provision/recordview_store.go
@@ -5,7 +5,6 @@ import (
"errors"
"fmt"
"io"
- "log"
"maps"
"os"
"slices"
@@ -13,7 +12,6 @@ import (
"sync"
"github.com/carlosframework/platform/internal/domains"
- "github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
)
@@ -323,72 +321,26 @@ func freshStore(s release.Store) release.Store {
return s
}
-// onSnapshot reports whether s is a record view snapshot store.
+// onSnapshot reports whether s is a record view snapshot store. A snapshot's
+// absence is not the bucket's decision: a record written without its bump (a
+// failed bump, an old binary, a direct-bucket writer) is missing from a
+// Complete snapshot until the backstop rebuild. So Reconcile and
+// ReconcileSidecars over a snapshot decide any removal from the bucket's own
+// walk (scanRecords, scanSidecars over freshStore), and over any other store
+// from the walk they already made.
func onSnapshot(s release.Store) bool {
_, ok := s.(*snapStore)
return ok
}
-// Absence in a snapshot is not the bucket's decision: a record written
-// without its bump (a failed bump, an old binary, a direct-bucket writer) is
-// missing from a Complete snapshot until the backstop rebuild. A removal
-// pass over a snapshot therefore confirms in the bucket before it retires a
-// row. Over any other store these answer true without reading: the walk's
-// absence is the bucket's.
-
-// confirmRecordGone confirms that the instance record behind rt is gone from
-// the bucket, or out of this pass's scope there (inScope). A record that is
-// there and in scope, or that cannot be read cleanly, keeps the row.
-func confirmRecordGone(ctx context.Context, s release.Store, rt registry.Route, inScope func(Record) bool) bool {
- if !onSnapshot(s) {
- return true
- }
- rec, err := GetRecord(ctx, freshStore(s), rt.Account, rt.App, rt.Host)
- switch {
- case errors.Is(err, os.ErrNotExist):
- return true
- case err != nil:
- log.Printf("provision: not removing %s: the record view lacks its record and the bucket's could not be read cleanly: %v", rt.Host, err)
- return false
- case !inScope(*rec):
- return true
+// walkErrSuffix renders the error of a bucket walk that could not be read
+// cleanly for a log line: nothing when the walk returned but read partially
+// (its own lines name what failed).
+func walkErrSuffix(err error) string {
+ if err == nil {
+ return ""
}
- log.Printf("provision: not removing %s: the record view lacks its record but the bucket has it; the view catches up at its next refresh or rebuild", rt.Host)
- return false
-}
-
-// confirmSidecarGone is confirmRecordGone for a sidecar row, whose Slug is
-// its record's name.
-func confirmSidecarGone(ctx context.Context, s release.Store, rt registry.Route, inScope func(SidecarRecord) bool) bool {
- if !onSnapshot(s) {
- return true
- }
- rec, err := GetSidecarRecord(ctx, freshStore(s), rt.Account, rt.App, rt.Slug)
- switch {
- case errors.Is(err, os.ErrNotExist):
- return true
- case err != nil:
- log.Printf("provision: not removing sidecar %s: the record view lacks its record and the bucket's could not be read cleanly: %v", rt.Host, err)
- return false
- case rec.Host != rt.Host || !inScope(*rec):
- return true
- }
- log.Printf("provision: not removing sidecar %s: the record view lacks its record but the bucket has it; the view catches up at its next refresh or rebuild", rt.Host)
- return false
-}
-
-// confirmTrashed confirms that the app's trash marker, which the snapshot
-// has, is still in the bucket. Absent or unreadable keeps the row.
-func confirmTrashed(ctx context.Context, s release.Store, acct, app string) bool {
- if !onSnapshot(s) {
- return true
- }
- tomb, err := release.ReadAppTombstone(ctx, freshStore(s), acct, app)
- if err != nil || tomb == nil {
- log.Printf("provision: not removing %s/%s's rows: the record view has its trash marker but the bucket does not (%v)", acct, app, err)
- return false
- }
- return true
+ return ": " + err.Error()
}
type snapStore struct {
diff --git a/internal/provision/recordview_test.go b/internal/provision/recordview_test.go
index 3d9685bdf..f0dd76f71 100644
--- a/internal/provision/recordview_test.go
+++ b/internal/provision/recordview_test.go
@@ -1134,3 +1134,219 @@ func TestSnapshotAbsenceDoesNotRetireALiveSidecar(t *testing.T) {
t.Fatalf("a sidecar record really gone did not retire its row over the snapshot: %+v %v", rt, err)
}
}
+
+// removalWorld is one account with app shop (instances and sidecars
+// manifests) over a bucket whose writes bump the change index (live), with a
+// second handle on the same directory whose writes do not (bypass: a bump
+// that failed, an old binary, a direct-bucket writer). v follows live.
+type removalWorld struct {
+ live *viewCountingStore
+ bypass *release.DirStore
+ acct string
+ v *RecordView
+}
+
+func newRemovalWorld(t *testing.T) *removalWorld {
+ t.Helper()
+ root := t.TempDir()
+ d := release.NewDirStore(root)
+ d.EnableChangeIndex()
+ w := &removalWorld{live: &viewCountingStore{DirStore: d}, bypass: release.NewDirStore(root)}
+ w.acct = writeAccountManifest(t, w.live, 401, "Shop")
+ if err := PutManifest(ctx, w.live, w.acct, "shop", Manifest{Schema: 1, App: "shop", Domain: "example.com"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := PutSidecarManifest(ctx, w.live, w.acct, "shop", SidecarManifest{Schema: 1, App: "shop", Args: []string{"run"}, Channel: "stable"}); err != nil {
+ t.Fatal(err)
+ }
+ w.v = NewRecordView(w.live, release.NewChangeWatch(w.live))
+ return w
+}
+
+// snap is the view's current Complete snapshot as a store over live.
+func (w *removalWorld) snap(t *testing.T) release.Store {
+ t.Helper()
+ return completeSnapshot(t, w.v).Store(w.live)
+}
+
+func (w *removalWorld) record(app, slug, region, channel string) Record {
+ return Record{Schema: 1, Host: slug + ".example.com", Slug: slug, App: app, Account: w.acct,
+ Region: region, Channel: channel, CreatedAt: time.Now()}
+}
+
+// listings counts the record-family listings live answered since the last
+// reset: what a walk costs and a snapshot pass must not.
+func (w *removalWorld) listings() int {
+ n := 0
+ for _, c := range w.live.got() {
+ for _, m := range []string{"List ", "ListDirs ", "ListETags "} {
+ if strings.HasPrefix(c, m+"accounts/") || strings.HasPrefix(c, m+"apps/") {
+ n++
+ }
+ }
+ }
+ return n
+}
+
+// Final review findings 2 and 4 (amadan #336): a removal a snapshot proves
+// by absence is decided by the walk's own rules over the live bucket. A
+// sidecar whose placement becomes no-inbound, in its record or in the app's
+// sidecars manifest, with the bump landing, leaves an inbound box: the live
+// walk does not place it either. Before, the confirmation asked only region
+// and pool, and kept the row for ever.
+func TestSnapshotSidecarPlacementChangeEvacuates(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ apply func(t *testing.T, w *removalWorld, rec SidecarRecord)
+ }{
+ {"record", func(t *testing.T, w *removalWorld, rec SidecarRecord) {
+ rec.Placement = PlacementNoInbound
+ putReplace(t, w.live, SidecarRecordKey(w.acct, "shop", "bot"), mustJSON(t, rec))
+ }},
+ {"manifest", func(t *testing.T, w *removalWorld, _ SidecarRecord) {
+ putReplace(t, w.live, SidecarManifestKey(w.acct, "shop"), mustJSON(t,
+ SidecarManifest{Schema: 1, App: "shop", Args: []string{"run"}, Channel: "stable", Placement: PlacementNoInbound}))
+ }},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+ w := newRemovalWorld(t)
+ host := SidecarHost("bot", "shop", w.acct)
+ rec := SidecarRecord{Schema: 1, Name: "bot", Host: host, App: "shop", Account: w.acct, Channel: "stable"}
+ if err := CreateSidecarRecord(ctx, w.live, rec); err != nil {
+ t.Fatal(err)
+ }
+ reg := regWith(t)
+ cfg := ReconcileConfig{DataDir: t.TempDir()} // an inbound box
+ if err := ReconcileSidecars(ctx, w.snap(t), reg, cfg); err != nil {
+ t.Fatal(err)
+ }
+ mustGet(t, reg, host)
+ sn := w.snap(t)
+ w.live.reset()
+ if err := ReconcileSidecars(ctx, sn, reg, cfg); err != nil {
+ t.Fatal(err)
+ }
+ if n := w.listings(); n != 0 {
+ t.Fatalf("a snapshot pass with nothing to remove listed the bucket: %v", w.live.got())
+ }
+
+ tc.apply(t, w, rec)
+ if err := ReconcileSidecars(ctx, w.snap(t), reg, cfg); err != nil {
+ t.Fatal(err)
+ }
+ if rt, err := reg.Get(host); err != nil || rt != nil {
+ t.Fatalf("a no-inbound sidecar stayed on an inbound box over the snapshot: %+v %v", rt, err)
+ }
+ })
+ }
+}
+
+// A record the snapshot lacks, whose live copy has an empty channel and a
+// region out of this pass's scope: the walk protects an unusable record
+// before it asks about scope, so the row stays. Before, the confirmation
+// decoded the record, found it out of scope, and removed the row.
+func TestSnapshotRemovalProtectsAnInvalidOutOfScopeRecord(t *testing.T) {
+ t.Parallel()
+ w := newRemovalWorld(t)
+ sn := w.snap(t) // before the record: it never reaches the view
+ rec := w.record("shop", "one", "ie", "stable")
+ if err := CreateRecord(ctx, w.bypass, rec); err != nil {
+ t.Fatal(err)
+ }
+ reg := regWith(t)
+ cfg := ReconcileConfig{Region: "ie", DataDir: "/data"}
+ if err := Reconcile(ctx, w.live, reg, cfg); err != nil { // a walking pass mints
+ t.Fatal(err)
+ }
+ mustGet(t, reg, rec.Host)
+ bad := rec
+ bad.Region, bad.Channel = "uk", ""
+ putReplace(t, w.bypass, RecordKey(w.acct, "shop", rec.Host), mustJSON(t, bad))
+ if err := Reconcile(ctx, sn, reg, cfg); err != nil {
+ t.Fatal(err)
+ }
+ mustGet(t, reg, rec.Host) // protected, as the walk protects it
+ if err := Reconcile(ctx, w.live, reg, cfg); err != nil {
+ t.Fatal(err)
+ }
+ mustGet(t, reg, rec.Host) // and the walk agrees
+}
+
+// A host whose record moved to another key (another app here) without a
+// bump: the walk matches hosts across the whole deployment, so the row
+// stays. Before, the confirmation read only the row's old key, found it
+// gone, and removed the row. Once the record is gone everywhere, the row
+// goes.
+func TestSnapshotRemovalKeepsARehomedHost(t *testing.T) {
+ t.Parallel()
+ w := newRemovalWorld(t)
+ w.snap(t) // built before the record: no bypass write below reaches the view
+ rec := w.record("shop", "one", "ie", "stable")
+ if err := CreateRecord(ctx, w.bypass, rec); err != nil {
+ t.Fatal(err)
+ }
+ reg := regWith(t)
+ cfg := ReconcileConfig{Region: "ie", DataDir: "/data"}
+ if err := Reconcile(ctx, w.live, reg, cfg); err != nil {
+ t.Fatal(err)
+ }
+ mustGet(t, reg, rec.Host)
+ if err := DeleteRecord(ctx, w.bypass, w.acct, "shop", rec.Host); err != nil {
+ t.Fatal(err)
+ }
+ moved := w.record("cafe", "one", "ie", "stable")
+ if err := CreateRecord(ctx, w.bypass, moved); err != nil {
+ t.Fatal(err)
+ }
+ if err := Reconcile(ctx, w.snap(t), reg, cfg); err != nil {
+ t.Fatal(err)
+ }
+ mustGet(t, reg, rec.Host) // kept: the bucket still has the host
+
+ if err := DeleteRecord(ctx, w.bypass, w.acct, "cafe", rec.Host); err != nil {
+ t.Fatal(err)
+ }
+ if err := Reconcile(ctx, w.snap(t), reg, cfg); err != nil {
+ t.Fatal(err)
+ }
+ if rt, err := reg.Get(rec.Host); err != nil || rt != nil {
+ t.Fatalf("a host really gone did not retire its row over the snapshot: %+v %v", rt, err)
+ }
+}
+
+// Disarmed (any store that is not a snapshot), removal is the walk's, from
+// the one walk the pass already made: no second walk.
+func TestRemovalOverTheBucketWalksOnce(t *testing.T) {
+ t.Parallel()
+ w := newRemovalWorld(t)
+ rec := w.record("shop", "one", "ie", "stable")
+ if err := CreateRecord(ctx, w.live, rec); err != nil {
+ t.Fatal(err)
+ }
+ reg := regWith(t)
+ cfg := ReconcileConfig{Region: "ie", DataDir: "/data"}
+ if err := Reconcile(ctx, w.live, reg, cfg); err != nil {
+ t.Fatal(err)
+ }
+ mustGet(t, reg, rec.Host)
+ if err := DeleteRecord(ctx, w.live, w.acct, "shop", rec.Host); err != nil {
+ t.Fatal(err)
+ }
+ w.live.reset()
+ if err := Reconcile(ctx, w.live, reg, cfg); err != nil {
+ t.Fatal(err)
+ }
+ if rt, err := reg.Get(rec.Host); err != nil || rt != nil {
+ t.Fatalf("a gone record's row survived a walking pass: %+v %v", rt, err)
+ }
+ n := 0
+ for _, c := range w.live.got() {
+ if c == "List accounts/" {
+ n++
+ }
+ }
+ if n != 1 {
+ t.Fatalf("a walking pass listed accounts/ %d time(s), want 1: %v", n, w.live.got())
+ }
+}
diff --git a/internal/provision/sidecar_reconcile.go b/internal/provision/sidecar_reconcile.go
index 10bc2c53e..45cfec6b2 100644
--- a/internal/provision/sidecar_reconcile.go
+++ b/internal/provision/sidecar_reconcile.go
@@ -192,10 +192,38 @@ func ReconcileSidecars(ctx context.Context, s release.Store, reg *registry.Regis
cfg.Now = time.Now
}
- known, readFailed, err := knownAccounts(ctx, s)
+ sc, err := scanSidecars(ctx, s, cfg)
if err != nil {
return err
}
+ return reconcileSidecarsScanned(ctx, s, reg, cfg, sc)
+}
+
+// sidecarScan is the record-collection phase of a ReconcileSidecars pass: the
+// placeable in-scope records by host, the hosts whose record is there but
+// cannot be used (protect), the per-app sidecars manifest lookup the pass
+// goes on using, and whether anything failed to read (readFailed, which
+// disables removal for the whole pass; a manifest read that fails later in
+// the pass sets it too).
+type sidecarScan struct {
+ recs map[string]SidecarRecord
+ protect map[string]bool
+ readFailed bool
+ manifestFor func(acct, app string) (SidecarManifest, bool)
+}
+
+// scanSidecars is ReconcileSidecars' record walk over s. A pass over a record
+// view snapshot that has a removal to decide runs it again over the bucket
+// (freshStore), so both judge every record by exactly these rules,
+// placement against the record and the app's sidecars manifest included
+// (S3 change-index design §4.4).
+func scanSidecars(ctx context.Context, s release.Store, cfg ReconcileConfig) (*sidecarScan, error) {
+ sc := &sidecarScan{}
+ known, readFailed, err := knownAccounts(ctx, s)
+ if err != nil {
+ return nil, err
+ }
+ sc.readFailed = readFailed
// --- manifest cache, per (acct, app), lazily ------------------------------
// known=false covers BOTH "no manifest" and "the read failed", and in both
@@ -231,7 +259,7 @@ func ReconcileSidecars(ctx context.Context, s release.Store, reg *registry.Regis
return SidecarManifest{}, false
}
log.Printf("provision: sidecar manifest %s/%s: read failed: %v", acct, app, err)
- readFailed = true
+ sc.readFailed = true
return SidecarManifest{}, false
}
manCache[k], manKnown[k] = *m, true
@@ -241,10 +269,10 @@ func ReconcileSidecars(ctx context.Context, s release.Store, reg *registry.Regis
// --- collect in-scope records --------------------------------------------
keys, partial, err := listAppSubtree(ctx, s, known, "sidecars/")
if err != nil {
- return fmt.Errorf("provision: list apps: %w", err)
+ return nil, fmt.Errorf("provision: list apps: %w", err)
}
if partial {
- readFailed = true // unseen records must not read as retired
+ sc.readFailed = true // unseen records must not read as retired
}
recs := map[string]SidecarRecord{} // host -> record, in-scope only
// protect holds hosts whose record KEY parsed but whose body could not be
@@ -274,7 +302,7 @@ func ReconcileSidecars(ctx context.Context, s release.Store, reg *registry.Regis
// Its absence from recs below must not read as "the bucket retired
// it" — a transient Get failure disables removal for the whole
// pass, same as an unreadable account manifest.
- readFailed = true
+ sc.readFailed = true
continue
}
var rec SidecarRecord
@@ -351,6 +379,14 @@ func ReconcileSidecars(ctx context.Context, s release.Store, reg *registry.Regis
}
}
+ sc.recs, sc.protect, sc.manifestFor = recs, protect, manifestFor
+ return sc, nil
+}
+
+// reconcileSidecarsScanned is the rest of a ReconcileSidecars pass over s,
+// from its record walk.
+func reconcileSidecarsScanned(ctx context.Context, s release.Store, reg *registry.Registry, cfg ReconcileConfig, sc *sidecarScan) error {
+ recs, protect, manifestFor := sc.recs, sc.protect, sc.manifestFor
trashed := trashGate(ctx, s, "sidecar")
// --- mint / converge -------------------------------------------------------
@@ -464,7 +500,13 @@ func ReconcileSidecars(ctx context.Context, s release.Store, reg *registry.Regis
// authority; a hand-added row never has it), scope (this box's own
// region/pool jurisdiction), and absence from the record set this pass just
// read (the bucket's own decision to retire the sidecar, not a guess).
- if !readFailed {
+ //
+ // retire runs that phase over one set of records and protected hosts.
+ // With stopAtFirst it removes nothing and reports whether any row would
+ // go: a record view snapshot's absence is not the bucket's, so a pass
+ // over a snapshot that would remove anything decides every removal again
+ // from the bucket's own walk, below (S3 change-index design §4.4).
+ retire := func(recs map[string]SidecarRecord, protect map[string]bool, stopAtFirst bool) (wouldRemove bool) {
for _, rt := range routes {
if rt.Kind != SidecarKind || !rt.Provisioned {
continue
@@ -479,12 +521,8 @@ func ReconcileSidecars(ctx context.Context, s release.Store, reg *registry.Regis
log.Printf("provision: not removing sidecar %s: its record exists but could not be read cleanly this pass", rt.Host)
continue
}
- // Over a record view snapshot, the absence is confirmed in the
- // bucket first (confirmRecordGone's reason).
- if !confirmSidecarGone(ctx, s, rt, func(r SidecarRecord) bool {
- return r.Region == cfg.Region && poolServed(cfg.Pools, r.Pool)
- }) {
- continue
+ if stopAtFirst {
+ return true
}
if cfg.DryRun {
log.Printf("provision: dry-run: would remove retired sidecar route %s (record gone)", rt.Host)
@@ -496,7 +534,23 @@ func ReconcileSidecars(ctx context.Context, s release.Store, reg *registry.Regis
}
log.Printf("provision: removed retired sidecar route %s (record gone)", rt.Host)
}
+ return false
+ }
+ if sc.readFailed || !retire(recs, protect, onSnapshot(s)) {
+ return nil
+ }
+ // A snapshot pass with a removal to make: the record walk again, over
+ // the bucket, and the removal phase over what it read, by the walk's own
+ // rules (unusable records protected before scope, hosts matched across
+ // every key, placement from the bucket's record and sidecars manifest
+ // against this box's inbound). Anything it cannot read keeps every row
+ // this pass, as in the walk.
+ lsc, err := scanSidecars(ctx, freshStore(s), cfg)
+ if err != nil || lsc.readFailed {
+ log.Printf("provision: not removing any sidecar route this pass: the record view lacks a sidecar this box would run, and the bucket's own walk could not be read cleanly%s", walkErrSuffix(err))
+ return nil
}
+ retire(lsc.recs, lsc.protect, false)
return nil
}