carlos / platform Public

host: reconcile, sidecars, moves and steering read through the record view; index-only changes run a scoped pass (#336)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 6b236966deb73067d3a6316862079a2abf1e6404 parent cf9c78b
7 files changed, +1187 −58
  • internal/appconfig/syncexec.go +22 −1
  • internal/appconfig/syncexec_test.go +89 −0
  • internal/host/agent.go +131 −55
  • internal/host/changeindex.go +295 −0
  • internal/host/changeindex_test.go +626 −0
  • internal/host/movespass.go +20 −1
  • internal/host/steering_loop.go +4 −1
diff --git a/internal/appconfig/syncexec.go b/internal/appconfig/syncexec.go
index 493627a7a..1caa1b368 100644
--- a/internal/appconfig/syncexec.go
+++ b/internal/appconfig/syncexec.go
@@ -124,6 +124,9 @@ func SyncExecWithCallbacks(ctx context.Context, reg *registry.Registry, s releas
if !execSyncable(rt) && !execSidecarCandidate(rt) && !execAddrCandidate(rt) {
continue
}
+ if !o.kept(rt) {
+ continue
+ }
if o.fenced != nil && o.fenced(rt.Host) {
continue
}
@@ -321,6 +324,11 @@ func SyncExecWithCallbacks(ctx context.Context, reg *registry.Registry, s releas
// `carlos restart -app X` (host/restart.go), which pairs the stop
// with an explicit wake; this pass only ever writes the file.
inScope := func(rt registry.Route) (ok, addrShape, sidecarShape bool) {
+ // A route WithRouteFilter leaves out is out of scope before
+ // anything is read for it: no bundle, no opt-in, no overlay.
+ if !o.kept(rt) {
+ return false, false, false
+ }
if execSyncable(rt) {
return true, false, false
}
@@ -697,7 +705,7 @@ func SyncExecWithCallbacks(ctx context.Context, reg *registry.Registry, s releas
// the app's own supervisor has stopped reading it) because carlos has
// no way to know when that is.
for _, rt := range routes {
- if !execAddrCandidate(rt) {
+ if !execAddrCandidate(rt) || !o.kept(rt) {
continue
}
c := checkOptedIn(release.RouteAccount(rt), rt.App, rt.Environment)
@@ -752,12 +760,25 @@ type SyncOption func(*syncOptions)
type syncOptions struct {
fenced func(host string) bool
+ keep func(rt registry.Route) bool
dirAbsent func(host, dir string)
outcome func(host string, done bool)
hostWrites hostwrite.Config
hostWriteCount int
}
+// WithRouteFilter narrows one SyncExec pass to the routes keep accepts. A
+// route it leaves out is out of scope for the pass: nothing is read for it,
+// and its file is neither written nor removed (reap keys on route existence,
+// not scope). The agent's scoped pass uses it to converge only the apps the
+// change index says moved (S3 change-index design §4.5). Nil keeps every
+// route.
+func WithRouteFilter(keep func(registry.Route) bool) SyncOption {
+ return func(o *syncOptions) { o.keep = keep }
+}
+
+func (o *syncOptions) kept(rt registry.Route) bool { return o.keep == nil || o.keep(rt) }
+
// WhenInstanceDirAbsent hands SyncExec a callback for every nested row whose
// bundle it could not write because the row's instance directory does not
// exist yet: the activator makes that directory at the instance's first
diff --git a/internal/appconfig/syncexec_test.go b/internal/appconfig/syncexec_test.go
index 37440129e..ef8cbc533 100644
--- a/internal/appconfig/syncexec_test.go
+++ b/internal/appconfig/syncexec_test.go
@@ -10,6 +10,7 @@ import (
"reflect"
"slices"
"strings"
+ "sync"
"testing"
"time"
@@ -2646,3 +2647,91 @@ func TestSyncExecAcksDeliveredOnWake(t *testing.T) {
t.Fatalf("ack = %+v, want delivered on wake: Hibernating, not restarted, no error", a)
}
}
+
+// keyReadStore records every key and prefix a pass reads through it.
+type keyReadStore struct {
+ release.Store
+ mu sync.Mutex
+ read []string
+}
+
+func (s *keyReadStore) note(k string) { s.mu.Lock(); s.read = append(s.read, k); s.mu.Unlock() }
+
+func (s *keyReadStore) Get(ctx context.Context, key string) ([]byte, string, error) {
+ s.note(key)
+ return s.Store.Get(ctx, key)
+}
+
+func (s *keyReadStore) List(ctx context.Context, prefix string) ([]string, error) {
+ s.note(prefix)
+ return s.Store.List(ctx, prefix)
+}
+
+func (s *keyReadStore) ListDirs(ctx context.Context, prefix string) ([]string, error) {
+ s.note(prefix)
+ return s.Store.ListDirs(ctx, prefix)
+}
+
+// TestSyncExecWithRouteFilter pins the scoped config pass the agent runs
+// when the change index says one app moved (S3 change-index design §4.5):
+// a route the filter leaves out is not in scope, so nothing is read for it,
+// its file is left exactly as it is (neither rewritten nor reaped), and the
+// routes the filter keeps converge as on a full pass.
+func TestSyncExecWithRouteFilter(t *testing.T) {
+ t.Parallel()
+ ctx, base, reg, dd, priv, _ := execKit(t)
+ pk, err := ParsePrivateKey(priv, "test")
+ if err != nil {
+ t.Fatalf("ParsePrivateKey: %v", err)
+ }
+ for _, app := range []string{"shop", "cafe"} {
+ if err := SetEnv(ctx, base, "bab", app, map[string]string{"APP": app}); err != nil {
+ t.Fatalf("SetEnv %s: %v", app, err)
+ }
+ }
+ if err := SetExecDelivery(ctx, base, "bab", "cafe", ExecDeliveryFile); err != nil {
+ t.Fatalf("SetExecDelivery: %v", err)
+ }
+ for _, rt := range []registry.Route{
+ {Host: "shop.example.com", Kind: "instance", App: "shop", Account: "bab", Status: "ready", Socket: "/run/carlos/shop.sock"},
+ {Host: "cafe.example.com", Kind: "instance", App: "cafe", Account: "bab", Status: "ready", Socket: "/run/carlos/cafe.sock"},
+ // The addr shape: its opt-in is read before the resolution loop.
+ {Host: "cafe-addr.example.com", Kind: "instance", App: "cafe", Account: "bab", Status: "ready", Addr: "127.0.0.1:8444"},
+ } {
+ if err := reg.Upsert(rt); err != nil {
+ t.Fatalf("Upsert %s: %v", rt.Host, err)
+ }
+ }
+ stale := filepath.Join(dd, "cafe.example.com.config.env")
+ if err := os.WriteFile(stale, []byte("OLD=1\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ s := &keyReadStore{Store: base}
+ keep := WithRouteFilter(func(rt registry.Route) bool { return rt.App != "cafe" })
+ var bounced []string
+ if err := SyncExec(ctx, reg, s, pk, dd, func(h string) error { bounced = append(bounced, h); return nil }, "", nil, keep); err != nil {
+ t.Fatalf("SyncExec: %v", err)
+ }
+ var shopRead bool
+ for _, k := range s.read {
+ if strings.HasPrefix(k, "apps/bab/cafe/") {
+ t.Errorf("the filtered pass read %s, under an app the filter left out", k)
+ }
+ shopRead = shopRead || strings.HasPrefix(k, "apps/bab/shop/")
+ }
+ if !shopRead {
+ t.Fatalf("the pass read nothing for shop (%v): the witness pinned nothing", s.read)
+ }
+ if got, err := os.ReadFile(filepath.Join(dd, "shop.example.com.config.env")); err != nil || string(got) != "APP=shop\n" {
+ t.Fatalf("shop's file = %q, %v; want APP=shop", got, err)
+ }
+ if got, err := os.ReadFile(stale); err != nil || string(got) != "OLD=1\n" {
+ t.Fatalf("cafe's file = %q, %v; a route the filter leaves out keeps its file as it is", got, err)
+ }
+ if _, err := os.Stat(filepath.Join(dd, "cafe-addr.example.com.config.env")); !os.IsNotExist(err) {
+ t.Fatalf("cafe's addr route got a file from a pass that left it out: %v", err)
+ }
+ if !slices.Equal(bounced, []string{"shop.example.com"}) {
+ t.Fatalf("bounced %v, want only shop.example.com", bounced)
+ }
+}
diff --git a/internal/host/agent.go b/internal/host/agent.go
index 4526673f4..1d266f687 100644
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -788,6 +788,12 @@ func runAgent(args []string, buildClaims ClaimFactory) {
// The relocation pass (stage B Task 10): it says itself what it started.
startRelocationPass(agentCtx, claimCtx, claims, reg, svc)
+ // The change index (S3 change-index design §4): armed before the
+ // loops that read through it, with its backstop running from here.
+ // Unset CARLOS_CHANGE_INDEX, or a fleet box: nothing.
+ if v := armChangeIndex(agentCtx, os.Getenv, openDeployStore); v != nil {
+ go recordViewBackstopLoop(agentCtx, v)
+ }
go adoptLoop(reg, svc, sd, claims.Writes)
go fastConvergeLoop(reg, svc, sd, claims.Writes, serialClaims, bs, claims.Providers.Provision)
if logSink != nil {
@@ -1373,6 +1379,14 @@ var configSyncMu sync.Mutex
// mutex lives in a shared wrapper, not at the call sites, so a future third
// caller cannot forget it.
func syncExecSerialized(ctx context.Context, reg *registry.Registry, store release.Store, priv *ecdh.PrivateKey, dd string, svc *activator.Service, writes ...hostwrite.Config) error {
+ return syncExecSerializedFiltered(ctx, nil, reg, store, priv, dd, svc, writes...)
+}
+
+// syncExecSerializedFiltered is syncExecSerialized over the routes keep
+// accepts (nil: every route): the scoped pass's config leg (S3 change-index
+// design §4.5). The filter is applied where SyncExec chooses its routes
+// (appconfig.WithRouteFilter), so a route it leaves out costs no read.
+func syncExecSerializedFiltered(ctx context.Context, keep func(registry.Route) bool, reg *registry.Registry, store release.Store, priv *ecdh.PrivateKey, dd string, svc *activator.Service, writes ...hostwrite.Config) error {
configSyncMu.Lock()
defer configSyncMu.Unlock()
pending := map[string]string{}
@@ -1382,6 +1396,9 @@ func syncExecSerialized(ctx context.Context, reg *registry.Registry, store relea
appconfig.WhenInstanceDirAbsent(func(host, dir string) { pending[host] = dir }),
appconfig.WithHostOutcome(func(host string, done bool) { outcomes[host] = done }),
}
+ if keep != nil {
+ opts = append(opts, appconfig.WithRouteFilter(keep))
+ }
for _, cfg := range writes {
opts = append(opts, appconfig.WithHostWrite(cfg))
}
@@ -1394,7 +1411,9 @@ func syncExecSerialized(ctx context.Context, reg *registry.Registry, store relea
} else {
err = appconfig.SyncExec(ctx, reg, store, priv, dd, bounce(svc), os.Getenv("PLATFORM_APPS_DOMAIN"), configAckFunc(ctx, store, "agent"), opts...)
}
- configDirsPending.record(pending, outcomes, err == nil)
+ // A filtered pass names only the routes it kept, so it is never
+ // complete: a host it left out keeps its wait (pendingDirs.record).
+ configDirsPending.record(pending, outcomes, err == nil && keep == nil)
return err
}
@@ -1569,6 +1588,13 @@ func reconcileSerialized(ctx context.Context, s release.Store, reg *registry.Reg
func reconcileSerializedWith(ctx context.Context, s release.Store, reg *registry.Registry, cfg provision.ReconcileConfig, w *movesWorker) error {
reconcileMu.Lock()
defer reconcileMu.Unlock()
+ // With the change index armed, the record walks below read the record
+ // view's snapshot, refreshed here under reconcileMu so a pass's snapshot
+ // is never older than the previous pass's (S3 change-index design §4.3).
+ // Writes still go to s, and a key written through the snapshot store is
+ // read from s afterwards. Disarmed, or when the view cannot vouch, rs is
+ // s and the pass walks the bucket as before.
+ rs, _, _ := viewStore(ctx, s)
// A fresh row is minted at the layout this box's intent names
// (ReconcileConfig.Layout): nested on a nested box, so a new tenant
// never runs flat there. Set here, the one place both callers pass
@@ -1594,7 +1620,7 @@ func reconcileSerializedWith(ctx context.Context, s release.Store, reg *registry
// Phase 1's hooks are set.
if w != nil && w.cfg.OfflineCapable && w.cfg.Offline != nil {
if !w.cfg.Offline.built() || w.cfg.Offline.failed() {
- refreshOfflineView(ctx, s, w.cfg.Offline)
+ refreshOfflineView(ctx, rs, w.cfg.Offline)
}
cfg = withOfflineHolds(cfg, offlineHold(w.cfg), w.cfg.Offline)
}
@@ -1602,8 +1628,8 @@ func reconcileSerializedWith(ctx context.Context, s release.Store, reg *registry
// every in-scope record with a run, and acts after the pass.
cfg = withRunsHook(cfg)
return errors.Join(
- provision.Reconcile(ctx, s, reg, cfg),
- provision.ReconcileSidecars(ctx, s, reg, cfg),
+ provision.Reconcile(ctx, rs, reg, cfg),
+ provision.ReconcileSidecars(ctx, rs, reg, cfg),
)
}
@@ -2102,6 +2128,17 @@ type fastDeps struct {
restartHold map[string]*restartFailure // hosts held out of the sweep after a failed restart; see noteRestartFailed
serialBroken bool // last ReadSerial failed and was logged; reset on success
serialNotify chan<- struct{}
+
+ // The change index (CARLOS_CHANGE_INDEX=read, S3 change-index design
+ // §4.5); watch nil leaves the tick exactly as before. The watch is shared
+ // with the record view, so Poll's moved list is advisory: the tick diffs
+ // Observed against indexActed, the generations it last acted on, and
+ // moves that map on only once a pass has covered them.
+ watch *release.ChangeWatch
+ scopedPass func(ctx context.Context, moved []string) // passLegs.scoped, or a test hook
+ fullDue *atomic.Bool // the backstop's request for a full pass; nil = none
+ indexActed map[string]uint64 // nil until the first good observation seeds it
+ indexBroken bool // last Poll failed and was logged; reset on success
}
// restartFailure is one host's cross-pass memo of a version its restart
@@ -2534,6 +2571,10 @@ func logSlow(what string, start time.Time, detail string) {
// proof wait the sweep left standing; other staged members clear their
// backoff state.
func fastTick(ctx context.Context, d *fastDeps, now time.Time) {
+ // The index is observed BEFORE any pass this tick runs, so a pass covers
+ // at least what was observed and indexActed can take it afterwards.
+ obs, indexOK := d.observeIndex(ctx)
+ fullRan := false
doc, _, err := release.ReadSerial(ctx, d.store)
switch {
case err != nil:
@@ -2555,7 +2596,8 @@ func fastTick(ctx context.Context, d *fastDeps, now time.Time) {
default:
d.serialBroken = false
if doc.Serial != d.lastSerial && now.Sub(d.lastFull) >= fastFullClamp {
- d.fullPass(ctx)
+ d.runFull(ctx)
+ fullRan = true
if d.restartLeg != nil {
d.restartLeg(ctx)
}
@@ -2601,6 +2643,16 @@ func fastTick(ctx context.Context, d *fastDeps, now time.Time) {
}
}
+ // The backstop's full pass (spec §4.4), on this goroutine: requested
+ // after each rebuild, run once, under the same clamp.
+ if !fullRan && d.fullDue != nil && d.fullDue.Load() && now.Sub(d.lastFull) >= fastFullClamp {
+ d.runFull(ctx)
+ fullRan = true
+ d.lastFull = now
+ }
+ // An index-only change runs the scoped pass (spec §4.5).
+ d.indexTick(ctx, now, obs, indexOK, fullRan)
+
// The self-update tick, outside the serial switch and before the sweep's
// early return: it is what runs a spread deadline down and re-touches an
// unanswered request, and neither has anything to do with routes. It
@@ -2655,6 +2707,67 @@ func fastTick(ctx context.Context, d *fastDeps, now time.Time) {
}
}
+// runFull runs the full pass set's fullPass, taking any backstop request
+// first: a full pass that starts after the backstop's rebuild satisfies it.
+func (d *fastDeps) runFull(ctx context.Context) {
+ if d.fullDue != nil {
+ d.fullDue.Store(false)
+ }
+ d.fullPass(ctx)
+}
+
+// observeIndex polls the change index and returns its observed generations;
+// ok is false when no watch is armed or the index is unusable (then the
+// serial and the floors carry the tick, as before the index). A failure is
+// logged once per state change, never per tick.
+func (d *fastDeps) observeIndex(ctx context.Context) (map[string]uint64, bool) {
+ if d.watch == nil {
+ return nil, false
+ }
+ // Poll's moved list is advisory (shared watch): only Observed counts.
+ if _, err := d.watch.Poll(ctx); err != nil {
+ if !d.indexBroken {
+ log.Printf("agent: fast converge change index: %v (scoped passes wait; the serial and the floors carry on)", err)
+ d.indexBroken = true
+ }
+ return nil, false
+ }
+ obs, ok := d.watch.Observed()
+ if ok && d.indexBroken {
+ log.Printf("agent: fast converge change index readable again")
+ d.indexBroken = false
+ }
+ return obs, ok
+}
+
+// indexTick acts on an index observation: the first one seeds indexActed
+// without a pass (boot must not replay history; the backstop's first full
+// pass and the loops' first passes cover boot), a full pass this tick
+// covers everything observed, and otherwise app and account scopes that
+// moved since indexActed run one scoped pass, clamped like the full pass:
+// a change inside the clamp leaves indexActed as it was, so the next tick
+// past the window acts on it. indexActed moves on only after a pass ran.
+func (d *fastDeps) indexTick(ctx context.Context, now time.Time, obs map[string]uint64, ok, fullRan bool) {
+ if !ok {
+ return
+ }
+ if d.indexActed == nil || fullRan {
+ d.indexActed = obs
+ return
+ }
+ moved := indexScopes(d.indexActed, obs)
+ if len(moved) == 0 {
+ d.indexActed = obs // control/ scopes only: not this loop's business
+ return
+ }
+ if now.Sub(d.lastFull) < fastFullClamp || d.scopedPass == nil {
+ return
+ }
+ d.scopedPass(ctx, moved)
+ d.indexActed = obs
+ d.lastFull = now
+}
+
// fastConvergeLoop replaces the canary-only fast adopt loop with the
// carlos-deploy fast set (spec §3-§4): a 2s serial poll that turns a console
// bump (promote, instance create, config write) into the ordinary verified
@@ -2784,56 +2897,19 @@ func fastConvergeLoop(reg *registry.Registry, svc *activator.Service, sd *schedu
// a serial bump is an explicit console action, bounded by whoever keeps
// making it — and it is what lets a recovery promote through the console
// converge immediately even mid-hold.
- d.fullPass = func(ctx context.Context) {
- // Each stage's time, logged together when the set is slow, so the
- // journal says WHICH stage held a promote back.
- var took []string
- t := time.Now()
- mark := func(name string) {
- took = append(took, fmt.Sprintf("%s %s", name, time.Since(t).Round(100*time.Millisecond)))
- t = time.Now()
- }
- defer func(start time.Time) { logSlow("serial pass stages", start, strings.Join(took, ", ")) }(time.Now())
-
- if err := reconcileSerialized(ctx, store, reg, configuredClaimProvision(provisionTickConfig(os.Getenv), configure)); err != nil {
- log.Printf("agent: fast converge reconcile: %v", err)
- }
- mark("reconcile")
- // The registry's build-state copies, refreshed at every reconcile
- // (settings spec §4.2), before adopt reads them; refreshAll keeps
- // to its own budget, and the stage is timed apart from reconcile.
- if bs != nil {
- bs.refreshAll(ctx)
- mark("build states")
- }
- telemetryPass(ctx, store, reg)
- mark("telemetry")
- runMovesPass(ctx, store)
- mark("moves")
- runRunsPass(ctx)
- mark("runs")
- // Folded once per pass and handed in, never re-read inside the
- // loop: this leg runs on every serial bump (a seconds cadence),
- // and the fold is a LoadBoxes plus a List plus one Get per edges
- // file. On a fleet of under a dozen boxes that is a handful of
- // Gets per bump, which is accepted here rather than cached — a
- // cache would need its own staleness rule, and a STALE route set
- // is the one thing a security guard must not have.
- deploymentRoutes := edgesfile.DeploymentRoutes(ctx, store, time.Now())
- if err := domains.ReconcileAliases(ctx, store, reg, time.Now, deploymentRoutes, &aliasClaimCache); err != nil {
- log.Printf("agent: fast converge alias pass: %v", err)
- }
- mark("aliases")
- if err := release.Pass(ctx, reg, store, pub, optDir, restart, opts...); err != nil {
- log.Printf("agent: fast converge adopt pass: %v", err)
- }
- mark("adopt")
- if priv != nil {
- if err := syncExecSerialized(ctx, reg, store, priv, dd, svc, writes); err != nil {
- log.Printf("agent: fast converge config sync: %v", err)
- }
- mark("config")
- }
+ legs := &passLegs{store: store, reg: reg, pub: pub, optDir: optDir, restart: restart, opts: opts,
+ priv: priv, dd: dd, svc: svc, writes: writes, bs: bs,
+ cfg: func() provision.ReconcileConfig {
+ return configuredClaimProvision(provisionTickConfig(os.Getenv), configure)
+ }}
+ d.fullPass = legs.full
+ // The change index (S3 change-index design §4.5): an index-only change
+ // runs the scoped pass, and the backstop's request runs the full one,
+ // both on this loop's goroutine. Nil watch: the tick is as before.
+ if watch := armedChangeWatch.Load(); watch != nil {
+ d.watch = watch
+ d.scopedPass = legs.scoped
+ d.fullDue = &backstopFullDue
}
// The restart leg: this process's own children bounced here, everything
// unit-shaped relayed to a root pass. Deliberately NOT inside fullPass
diff --git a/internal/host/changeindex.go b/internal/host/changeindex.go
new file mode 100644
index 000000000..7e400f84a
--- /dev/null
+++ b/internal/host/changeindex.go
@@ -0,0 +1,295 @@
+package host
+
+// The box agent's reads through the S3 change index (S3 change-index design
+// §4.3-§4.5, discussion #336). Armed only by CARLOS_CHANGE_INDEX=read; unset,
+// every pointer below stays nil and every loop reads the bucket exactly as
+// before.
+
+import (
+ "context"
+ "crypto/ecdh"
+ "crypto/ed25519"
+ "fmt"
+ "log"
+ "math/rand/v2"
+ "sort"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/carlosframework/platform/internal/activator"
+ "github.com/carlosframework/platform/internal/domains"
+ "github.com/carlosframework/platform/internal/edgesfile"
+ "github.com/carlosframework/platform/internal/hostwrite"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// changeIndexArmed reports whether this process reads through the change
+// index. Only the exact value "read" arms it (spec §7).
+func changeIndexArmed(getenv func(string) string) bool {
+ return getenv("CARLOS_CHANGE_INDEX") == "read"
+}
+
+// armedChangeWatch is the process's one watch on the change index, and
+// armedRecordView the record view that follows it. RunAgent sets both once
+// at start-up when armed; nil means today's behaviour everywhere.
+var (
+ armedChangeWatch atomic.Pointer[release.ChangeWatch]
+ armedRecordView atomic.Pointer[provision.RecordView]
+)
+
+// backstopFullDue is the backstop's request for the unrestricted full pass:
+// set after each Rebuild, taken by fastTick on the fast converge loop's own
+// goroutine (fastDeps' maps are single-goroutine by construction).
+var backstopFullDue atomic.Bool
+
+// armChangeIndex arms the change index for this process: a watch on the
+// deployment store, and a record view over the same store. A fleet box
+// stays disarmed with the variable set: its store door has no conditional
+// read, so its watch would be unusable and every Refresh would walk anyway
+// (spec §3.4). The view's store must take no agent lock on any read path
+// (RecordView.mu is a leaf, spec §4.3): open hands back the plain deployment
+// store the other loops use.
+func armChangeIndex(ctx context.Context, getenv func(string) string, open func(context.Context) (release.Store, error)) *provision.RecordView {
+ if !changeIndexArmed(getenv) {
+ return nil
+ }
+ if shouldSkipPublisherElection(getenv) {
+ log.Printf("agent: change index: CARLOS_CHANGE_INDEX=read is ignored on a fleet box; record reads stay on the walk")
+ return nil
+ }
+ store, err := open(ctx)
+ if err != nil {
+ log.Printf("agent: change index: not armed: %v", err)
+ return nil
+ }
+ watch := release.NewChangeWatch(store)
+ v := provision.NewRecordView(store, watch)
+ armedChangeWatch.Store(watch)
+ armedRecordView.Store(v)
+ log.Printf("agent: change index armed: record walks read the record view (backstop %s)", release.ChangeIndexBackstop)
+ return v
+}
+
+// viewUse remembers whether the last viewStore call used the view, so the
+// switch is logged once per change of state, never per tick.
+var viewUse struct {
+ mu sync.Mutex
+ last int // 0 none yet, 1 using the view, 2 walking
+}
+
+func noteViewUse(ok bool, why string) {
+ state := 2
+ if ok {
+ state = 1
+ }
+ viewUse.mu.Lock()
+ changed := viewUse.last != state
+ viewUse.last = state
+ viewUse.mu.Unlock()
+ if !changed {
+ return
+ }
+ if ok {
+ log.Printf("agent: record view: record walks read the view")
+ return
+ }
+ log.Printf("agent: record view: %s; record walks read the bucket until it can vouch again", why)
+}
+
+// viewStore is the store a record-family walker reads: the armed view's
+// current snapshot over live, refreshed first, when the refresh returned nil
+// and the snapshot is Complete. Otherwise ok is false and s is live, so the
+// caller walks the bucket as before the index (spec §3.4).
+//
+// Only record-family walkers take it (Reconcile, ReconcileSidecars, the
+// offline keep view, the moves pass's listing, steering). Its ListDirs of
+// apps/<a>/ names only apps with record-family state, so app enumerators
+// (hostops.Apps, purge and activation sweeps, mail, economics) must never
+// get it.
+//
+// live must take no agent lock on any read path: RecordView.mu is a leaf
+// (lock order w.pass -> reconcileMu -> RecordView.mu).
+func viewStore(ctx context.Context, live release.Store) (s release.Store, epoch uint64, ok bool) {
+ v := armedRecordView.Load()
+ if v == nil || live == nil {
+ return live, 0, false
+ }
+ err := v.Refresh(ctx)
+ sn := v.Snapshot()
+ switch {
+ case err != nil:
+ noteViewUse(false, fmt.Sprintf("refresh failed (%v)", err))
+ return live, 0, false
+ case sn == nil || !sn.Complete:
+ noteViewUse(false, "the snapshot is incomplete")
+ return live, 0, false
+ }
+ noteViewUse(true, "")
+ return sn.Store(live), sn.Epoch, true
+}
+
+// recordViewBackstopLoop is the backstop (spec §4.4): Rebuild at start and
+// every release.ChangeIndexBackstop, jittered by +-10%, each followed by the
+// unrestricted full pass, so every leg is re-run over everything at least
+// that often. The full pass runs on the fast converge loop's goroutine
+// (backstopFullDue); on a box where that loop is not armed only the
+// rebuild runs.
+func recordViewBackstopLoop(ctx context.Context, v *provision.RecordView) {
+ for {
+ recordViewBackstop(ctx, v, &backstopFullDue)
+ select {
+ case <-ctx.Done():
+ return
+ case <-time.After(backstopEvery()):
+ }
+ }
+}
+
+// recordViewBackstop is one backstop round: the rebuild, then the request
+// for a full pass, whether or not the rebuild succeeded (a failed one
+// leaves the view unable to vouch, so that pass walks).
+func recordViewBackstop(ctx context.Context, v *provision.RecordView, due *atomic.Bool) {
+ if err := v.Rebuild(ctx); err != nil {
+ log.Printf("agent: record view backstop: %v", err)
+ }
+ due.Store(true)
+}
+
+// backstopEvery is release.ChangeIndexBackstop jittered by +-10%.
+func backstopEvery() time.Duration {
+ b := release.ChangeIndexBackstop
+ return b - b/10 + rand.N(b/5)
+}
+
+// indexScopes is the scopes of obs whose generation differs from acted's,
+// sorted, kept to the scopes a pass acts on (apps/<a>/<b> and accounts/<a>).
+// The control/ scopes belong to other readers.
+func indexScopes(acted, obs map[string]uint64) []string {
+ var moved []string
+ for scope, gen := range obs {
+ if have, ok := acted[scope]; ok && have == gen {
+ continue
+ }
+ if _, _, ok := provision.ScopeApp(scope); ok {
+ moved = append(moved, scope)
+ continue
+ }
+ if a, ok := strings.CutPrefix(scope, "accounts/"); ok && a != "" && !strings.Contains(a, "/") {
+ moved = append(moved, scope)
+ }
+ }
+ sort.Strings(moved)
+ return moved
+}
+
+// movedRoutes keeps the routes whose (account, app) has an app scope in
+// moved. Account scopes keep nothing here: their effect on a box (an
+// account soft-deleted) is Reconcile's, which runs whole.
+func movedRoutes(moved []string) func(registry.Route) bool {
+ apps := map[[2]string]bool{}
+ for _, scope := range moved {
+ if a, b, ok := provision.ScopeApp(scope); ok {
+ apps[[2]string{a, b}] = true
+ }
+ }
+ return func(rt registry.Route) bool { return apps[[2]string{release.RouteAccount(rt), rt.App}] }
+}
+
+// passLegs is the serial-triggered pass set's inputs (fastConvergeLoop),
+// shared by the full pass and the scoped one so the two run the same legs
+// in the same order and cannot drift.
+type passLegs struct {
+ store release.Store
+ reg *registry.Registry
+ pub ed25519.PublicKey
+ optDir string
+ restart func(string) error
+ opts []release.PassOption
+ priv *ecdh.PrivateKey // nil: no config leg (no CARLOS_SECRETS_KEY)
+ dd string
+ svc *activator.Service
+ writes hostwrite.Config
+ bs *buildStates
+ cfg func() provision.ReconcileConfig // read per pass, as provisionTickConfig always was
+}
+
+// passAfterReconcileHook, when set, runs in every pass right after its
+// reconcile leg. Tests only.
+var passAfterReconcileHook func()
+
+// full is the serial-triggered pass set, in spec order: mint routes
+// (reconcile), then reconcile aliases, then stage them (adopt), then deliver
+// their config, over every hosted route.
+func (l *passLegs) full(ctx context.Context) { l.run(ctx, "serial pass stages", nil) }
+
+// scoped is the pass an index-only change runs (spec §4.5, codex 10): the
+// same legs, with adopt and config sync restricted to the hosted routes
+// whose app scope moved, so changing one app re-reads that app only.
+// Reconcile and the moves pass run whole, over the record view.
+func (l *passLegs) scoped(ctx context.Context, moved []string) {
+ l.run(ctx, "scoped pass stages", movedRoutes(moved))
+}
+
+// run is one pass set; keep nil is the unrestricted full pass. Each error is
+// one log line, never fatal: the hourly floors stand underneath every leg.
+func (l *passLegs) run(ctx context.Context, what string, keep func(registry.Route) bool) {
+ // Each stage's time, logged together when the set is slow, so the
+ // journal says WHICH stage held a promote back.
+ var took []string
+ t := time.Now()
+ mark := func(name string) {
+ took = append(took, fmt.Sprintf("%s %s", name, time.Since(t).Round(100*time.Millisecond)))
+ t = time.Now()
+ }
+ defer func(start time.Time) { logSlow(what, start, strings.Join(took, ", ")) }(time.Now())
+
+ if err := reconcileSerialized(ctx, l.store, l.reg, l.cfg()); err != nil {
+ log.Printf("agent: fast converge reconcile: %v", err)
+ }
+ mark("reconcile")
+ if hook := passAfterReconcileHook; hook != nil {
+ hook()
+ }
+ // The registry's build-state copies, refreshed at every reconcile
+ // (settings spec §4.2), before adopt reads them; refreshAll keeps
+ // to its own budget, and the stage is timed apart from reconcile.
+ if l.bs != nil {
+ l.bs.refreshAll(ctx)
+ mark("build states")
+ }
+ telemetryPass(ctx, l.store, l.reg)
+ mark("telemetry")
+ // The moves pass takes its own snapshot inside w.pass (spec §4.3): it
+ // is handed the plain store, never this pass's reconcile snapshot.
+ runMovesPass(ctx, l.store)
+ mark("moves")
+ runRunsPass(ctx)
+ mark("runs")
+ // Folded once per pass and handed in, never re-read inside the
+ // loop: this leg runs on every serial bump (a seconds cadence),
+ // and the fold is a LoadBoxes plus a List plus one Get per edges
+ // file. On a fleet of under a dozen boxes that is a handful of
+ // Gets per bump, which is accepted here rather than cached — a
+ // cache would need its own staleness rule, and a STALE route set
+ // is the one thing a security guard must not have. Aliases read the
+ // domain claims, not the record families, so they read the bucket.
+ deploymentRoutes := edgesfile.DeploymentRoutes(ctx, l.store, time.Now())
+ if err := domains.ReconcileAliases(ctx, l.store, l.reg, time.Now, deploymentRoutes, &aliasClaimCache); err != nil {
+ log.Printf("agent: fast converge alias pass: %v", err)
+ }
+ mark("aliases")
+ if err := release.PassFilter(ctx, l.reg, l.store, l.pub, l.optDir, l.restart, keep, l.opts...); err != nil {
+ log.Printf("agent: fast converge adopt pass: %v", err)
+ }
+ mark("adopt")
+ if l.priv != nil {
+ if err := syncExecSerializedFiltered(ctx, keep, l.reg, l.store, l.priv, l.dd, l.svc, l.writes); err != nil {
+ log.Printf("agent: fast converge config sync: %v", err)
+ }
+ mark("config")
+ }
+}
diff --git a/internal/host/changeindex_test.go b/internal/host/changeindex_test.go
new file mode 100644
index 000000000..a37978144
--- /dev/null
+++ b/internal/host/changeindex_test.go
@@ -0,0 +1,626 @@
+package host
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "os"
+ "slices"
+ "sort"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/appconfig"
+ "github.com/carlosframework/platform/internal/domains"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// ciCountingStore is a DirStore with the change index on that records every
+// read by method and key. Embedding the concrete store forwards every
+// optional extension (ConditionalGetter and ETagLister included), so the
+// view and the watch see the store production gives them.
+type ciCountingStore struct {
+ *release.DirStore
+ mu sync.Mutex
+ calls []string
+ failList string // List and ListETags of exactly this prefix fail
+}
+
+func (s *ciCountingStore) setFailList(p string) { s.mu.Lock(); s.failList = p; s.mu.Unlock() }
+
+func (s *ciCountingStore) listFails(prefix string) bool {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.failList != "" && s.failList == prefix
+}
+
+func newCICountingStore(t *testing.T) *ciCountingStore {
+ t.Helper()
+ d := release.NewDirStore(t.TempDir())
+ d.EnableChangeIndex()
+ return &ciCountingStore{DirStore: d}
+}
+
+func (s *ciCountingStore) note(method, key string) {
+ s.mu.Lock()
+ s.calls = append(s.calls, method+" "+key)
+ s.mu.Unlock()
+}
+
+func (s *ciCountingStore) reset() { s.mu.Lock(); s.calls = nil; s.mu.Unlock() }
+
+func (s *ciCountingStore) got() []string {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ out := slices.Clone(s.calls)
+ sort.Strings(out)
+ return out
+}
+
+// count is how many recorded calls start with method+" " and name a key
+// under prefix.
+func (s *ciCountingStore) count(method, prefix string) int {
+ n := 0
+ for _, c := range s.got() {
+ if strings.HasPrefix(c, method+" "+prefix) {
+ n++
+ }
+ }
+ return n
+}
+
+func (s *ciCountingStore) Get(ctx context.Context, key string) ([]byte, string, error) {
+ s.note("Get", key)
+ return s.DirStore.Get(ctx, key)
+}
+
+func (s *ciCountingStore) GetIfChanged(ctx context.Context, key, etag string) ([]byte, string, bool, error) {
+ s.note("GetIfChanged", key)
+ return s.DirStore.GetIfChanged(ctx, key, etag)
+}
+
+func (s *ciCountingStore) List(ctx context.Context, prefix string) ([]string, error) {
+ s.note("List", prefix)
+ if s.listFails(prefix) {
+ return nil, errors.New("list refused")
+ }
+ return s.DirStore.List(ctx, prefix)
+}
+
+func (s *ciCountingStore) ListETags(ctx context.Context, prefix string) ([]release.KeyETag, error) {
+ s.note("ListETags", prefix)
+ if s.listFails(prefix) {
+ return nil, errors.New("list refused")
+ }
+ return s.DirStore.ListETags(ctx, prefix)
+}
+
+func (s *ciCountingStore) ListDirs(ctx context.Context, prefix string) ([]string, error) {
+ s.note("ListDirs", prefix)
+ return s.DirStore.ListDirs(ctx, prefix)
+}
+
+func (s *ciCountingStore) ListMod(ctx context.Context, prefix string) ([]release.KeyMod, error) {
+ s.note("ListMod", prefix)
+ return s.DirStore.ListMod(ctx, prefix)
+}
+
+// ciWorld is a deployment of one account with two apps, shop and cafe, each
+// with one instance record and a config bundle, written through the indexed
+// store.
+type ciWorld struct {
+ live *ciCountingStore
+ acct string
+ now time.Time
+}
+
+func newCIWorld(t *testing.T) *ciWorld {
+ t.Helper()
+ t.Setenv("CARLOS_HOST_CLAIM", "") // the appconfig and provision guards read the process env
+ ctx := context.Background()
+ w := &ciWorld{live: newCICountingStore(t), acct: domains.Sqid(2), now: time.Date(2026, 10, 21, 9, 0, 0, 0, time.UTC)}
+ manifest, err := json.Marshal(map[string]any{"id": 2, "sqid": w.acct, "name": "fixture", "created_at": w.now})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := w.live.Put(ctx, "accounts/"+w.acct+"/account.json", manifest, ""); err != nil {
+ t.Fatal(err)
+ }
+ for _, app := range []string{"shop", "cafe"} {
+ if err := provision.PutManifest(ctx, w.live, w.acct, app, provision.Manifest{Schema: 1, App: app, Domain: "example.com"}); err != nil {
+ t.Fatal(err)
+ }
+ w.record(t, app, app+".example.com")
+ if err := appconfig.SetEnv(ctx, w.live, w.acct, app, map[string]string{"APP": app}); err != nil {
+ t.Fatal(err)
+ }
+ }
+ w.live.reset()
+ return w
+}
+
+func (w *ciWorld) record(t *testing.T, app, host string) {
+ t.Helper()
+ rec := provision.Record{Schema: 1, Host: host, Slug: strings.SplitN(host, ".", 2)[0], Account: w.acct, App: app, Channel: "stable", CreatedAt: w.now}
+ if err := provision.CreateRecord(context.Background(), w.live, rec); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func (w *ciWorld) scope(app string) string { return "apps/" + w.acct + "/" + app }
+
+// armView arms the change index over live for one test, as RunAgent does,
+// and disarms it afterwards. Tests that call it stay serial.
+func armView(t *testing.T, live release.Store) *provision.RecordView {
+ t.Helper()
+ watch := release.NewChangeWatch(live)
+ v := provision.NewRecordView(live, watch)
+ armedChangeWatch.Store(watch)
+ armedRecordView.Store(v)
+ t.Cleanup(func() {
+ armedChangeWatch.Store(nil)
+ armedRecordView.Store(nil)
+ })
+ return v
+}
+
+// disarmView makes sure no earlier test left the view armed.
+func disarmView(t *testing.T) {
+ t.Helper()
+ armedChangeWatch.Store(nil)
+ armedRecordView.Store(nil)
+}
+
+func ciReconcileConfig(t *testing.T) provision.ReconcileConfig {
+ return provision.ReconcileConfig{DataDir: t.TempDir(), Layout: flatLayout}
+}
+
+// recordFamilyLists counts listings under the record families.
+func recordFamilyLists(s *ciCountingStore) int {
+ n := 0
+ for _, m := range []string{"List", "ListETags", "ListDirs"} {
+ n += s.count(m, "apps/") + s.count(m, "accounts/")
+ }
+ return n
+}
+
+func TestChangeIndexArmedOnlyForRead(t *testing.T) {
+ t.Parallel()
+ for v, want := range map[string]bool{"": false, "read": true, "READ": false, "write": false, "1": false, " read": false} {
+ if got := changeIndexArmed(func(k string) string {
+ if k == "CARLOS_CHANGE_INDEX" {
+ return v
+ }
+ return ""
+ }); got != want {
+ t.Errorf("CARLOS_CHANGE_INDEX=%q armed = %v, want %v", v, got, want)
+ }
+ }
+}
+
+// A fleet box stays disarmed with the variable set: its store door has no
+// conditional read, so every Refresh would walk anyway (spec §3.4).
+func TestChangeIndexStaysDisarmedOnFleetBox(t *testing.T) {
+ disarmView(t)
+ t.Cleanup(func() { disarmView(t) })
+ opened := 0
+ open := func(context.Context) (release.Store, error) { opened++; return newCICountingStore(t), nil }
+ env := func(m map[string]string) func(string) string { return func(k string) string { return m[k] } }
+
+ if v := armChangeIndex(context.Background(), env(map[string]string{"CARLOS_CHANGE_INDEX": "read", "CARLOS_FLEET": "pi"}), open); v != nil || armedRecordView.Load() != nil || armedChangeWatch.Load() != nil {
+ t.Fatal("a fleet box armed the change index")
+ }
+ if v := armChangeIndex(context.Background(), env(nil), open); v != nil || armedRecordView.Load() != nil {
+ t.Fatal("an unset CARLOS_CHANGE_INDEX armed the change index")
+ }
+ if opened != 0 {
+ t.Fatalf("a disarmed box opened the deployment store %d time(s)", opened)
+ }
+ v := armChangeIndex(context.Background(), env(map[string]string{"CARLOS_CHANGE_INDEX": "read"}), open)
+ if v == nil || armedRecordView.Load() != v || armedChangeWatch.Load() == nil {
+ t.Fatal("a platform box with CARLOS_CHANGE_INDEX=read did not arm")
+ }
+}
+
+// Armed, the record walks of a reconcile pass (the offline keep view's
+// listing, Reconcile and ReconcileSidecars) read the view: once it is built,
+// two passes with no writes list nothing on the bucket.
+func TestReconcileUsesViewWhenArmed(t *testing.T) {
+ w := newCIWorld(t)
+ v := armView(t, w.live)
+ if err := v.Rebuild(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ reg := restartTestRegistry(t)
+ // A capable worker whose keep view has read nothing yet, so the pass
+ // lists records for it too (refreshOfflineView).
+ mw := newMovesWorker(moveCfg{OfflineCapable: true})
+ mw.cfg.Offline = newOfflineView(mw.cfg, nil)
+ cfg := ciReconcileConfig(t)
+ w.live.reset()
+ for range 2 {
+ if err := reconcileSerializedWith(context.Background(), w.live, reg, cfg, mw); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if n := w.live.count("List", ""); n != 0 {
+ t.Fatalf("two armed passes with no writes listed the bucket %d time(s): %v", n, w.live.got())
+ }
+ if n := recordFamilyLists(w.live); n != 0 {
+ t.Fatalf("two armed passes listed the record families %d time(s): %v", n, w.live.got())
+ }
+ if !mw.cfg.Offline.built() || mw.cfg.Offline.failed() {
+ t.Fatal("the offline keep view was not read from the view")
+ }
+ for _, h := range []string{"shop.example.com", "cafe.example.com"} {
+ if rt, err := reg.Get(h); err != nil || rt == nil {
+ t.Fatalf("no route for %s from the view's records: %v", h, err)
+ }
+ }
+}
+
+// Disarmed, the same passes walk the bucket exactly as before the index.
+func TestReconcileWalksWhenDisarmed(t *testing.T) {
+ disarmView(t)
+ w := newCIWorld(t)
+ reg := restartTestRegistry(t)
+ cfg := ciReconcileConfig(t)
+ for range 2 {
+ if err := reconcileSerializedWith(context.Background(), w.live, reg, cfg, nil); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if n := w.live.count("List", "accounts/"); n < 2 {
+ t.Fatalf("two disarmed passes listed accounts/ %d time(s), want one walk each: %v", n, w.live.got())
+ }
+ if n := w.live.count("GetIfChanged", ""); n != 0 {
+ t.Fatalf("a disarmed pass read the change index: %v", w.live.got())
+ }
+}
+
+// instancesLoop's floor is reconcileSerialized over its own store: armed,
+// with nothing changed, it costs one conditional GET of the index and no
+// listing.
+func TestInstancesLoopFloorIsRefreshWhenArmed(t *testing.T) {
+ w := newCIWorld(t)
+ v := armView(t, w.live)
+ if err := v.Rebuild(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ reg := restartTestRegistry(t)
+ cfg := ciReconcileConfig(t)
+ if err := reconcileSerialized(context.Background(), w.live, reg, cfg); err != nil {
+ t.Fatal(err)
+ }
+ w.live.reset()
+ if err := reconcileSerialized(context.Background(), w.live, reg, cfg); err != nil {
+ t.Fatal(err)
+ }
+ if n := w.live.count("GetIfChanged", release.ChangeIndexKey); n != 1 {
+ t.Fatalf("a quiet floor read the index %d time(s), want 1: %v", n, w.live.got())
+ }
+ if n := w.live.count("List", "") + w.live.count("ListETags", "") + w.live.count("ListDirs", ""); n != 0 {
+ t.Fatalf("a quiet floor listed the bucket: %v", w.live.got())
+ }
+}
+
+// A view that cannot vouch (its Refresh failed, so there is no Complete
+// snapshot) is never used: the caller walks live, as before the index.
+func TestViewStoreFallsBackWhenTheViewCannotVouch(t *testing.T) {
+ disarmView(t)
+ w := newCIWorld(t)
+ if s, _, ok := viewStore(context.Background(), w.live); ok || s != release.Store(w.live) {
+ t.Fatal("a disarmed viewStore did not hand back live")
+ }
+ armView(t, w.live)
+ w.live.setFailList("accounts/")
+ if s, _, ok := viewStore(context.Background(), w.live); ok || s != release.Store(w.live) {
+ t.Fatal("viewStore used the view after a failed Refresh")
+ }
+ w.live.setFailList("")
+ s, epoch, ok := viewStore(context.Background(), w.live)
+ if !ok || s == release.Store(w.live) || epoch == 0 {
+ t.Fatalf("viewStore after a good Refresh: ok %v epoch %d", ok, epoch)
+ }
+}
+
+// ---- moves -----------------------------------------------------------------
+
+// The moves pass takes its own snapshot inside w.pass, never the one the
+// reconcile leg of its pass set read (spec §4.3, codex 1): pass A is paused
+// after its reconcile; pass B runs whole; a write lands; A's moves leg then
+// consumes the newest epoch, never A's own older one.
+func TestMovesPassTakesSnapshotUnderPass(t *testing.T) {
+ w := newCIWorld(t)
+ v := armView(t, w.live)
+ mw := newMovesWorker(moveCfg{})
+ armedMovesWorker.Store(mw)
+ t.Cleanup(func() { armedMovesWorker.Store(nil) })
+ reg := restartTestRegistry(t)
+ rcfg := ciReconcileConfig(t)
+ legs := &passLegs{store: w.live, reg: reg, optDir: t.TempDir(), dd: t.TempDir(),
+ cfg: func() provision.ReconcileConfig { return rcfg }}
+
+ paused, resume := make(chan uint64), make(chan struct{})
+ // Only the first pass (A) pauses; a sync.Once would block pass B on A.
+ var first atomic.Bool
+ passAfterReconcileHook = func() {
+ if first.CompareAndSwap(false, true) {
+ paused <- v.Snapshot().Epoch
+ <-resume
+ }
+ }
+ t.Cleanup(func() { passAfterReconcileHook = nil })
+
+ done := make(chan struct{})
+ go func() { defer close(done); legs.full(context.Background()) }() // pass A
+ epochA := <-paused
+
+ w.record(t, "shop", "two.shop.example.com")
+ legs.full(context.Background()) // pass B, whole
+ epochB := mw.lastEpoch
+ if epochB <= epochA {
+ t.Fatalf("pass B's moves consumed epoch %d, not past A's reconcile at %d", epochB, epochA)
+ }
+ w.record(t, "cafe", "two.cafe.example.com")
+ close(resume)
+ <-done
+ if got := mw.lastEpoch; got <= epochB {
+ t.Fatalf("pass A's moves consumed epoch %d after B consumed %d: it did not refresh under w.pass", got, epochB)
+ }
+ if got, want := mw.lastEpoch, v.Snapshot().Epoch; got != want {
+ t.Fatalf("pass A's moves consumed epoch %d, the newest is %d", got, want)
+ }
+}
+
+// A snapshot older than one the worker already consumed is refused: the
+// pass logs it and takes no step (here, the offline keep view stays unread).
+func TestMovesPassRefusesOlderEpoch(t *testing.T) {
+ w := newCIWorld(t)
+ v := armView(t, w.live)
+ if err := v.Rebuild(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ logs := captureLog(t)
+ mw := newMovesWorker(moveCfg{})
+ mw.cfg.Offline = newOfflineView(mw.cfg, nil)
+ newest := v.Snapshot().Epoch
+ mw.lastEpoch = newest + 5 // as if a newer snapshot had been consumed
+ if err := movesPass(context.Background(), w.live, mw); err != nil {
+ t.Fatal(err)
+ }
+ if mw.cfg.Offline.built() {
+ t.Fatal("a pass over an older snapshot read its records")
+ }
+ if mw.lastEpoch != newest+5 {
+ t.Fatalf("lastEpoch went from %d to %d", newest+5, mw.lastEpoch)
+ }
+ want := "agent: moves pass: snapshot epoch"
+ if !strings.Contains(logs(), want) || !strings.Contains(logs(), "older than") {
+ t.Fatalf("no refusal line in %q", logs())
+ }
+ // The same worker at the current epoch runs.
+ mw.lastEpoch = 0
+ if err := movesPass(context.Background(), w.live, mw); err != nil {
+ t.Fatal(err)
+ }
+ if !mw.cfg.Offline.built() || mw.lastEpoch != v.Snapshot().Epoch {
+ t.Fatalf("the pass at the current epoch did not run (built %v, epoch %d)", mw.cfg.Offline.built(), mw.lastEpoch)
+ }
+}
+
+// ---- fastTick --------------------------------------------------------------
+
+// ciFastDeps is a fastDeps over an indexed store with counting hooks for
+// both pass shapes.
+func ciFastDeps(t *testing.T) (*fastDeps, *release.DirStore, *[]string, *[][]string) {
+ t.Helper()
+ env := newFastFixture(t)
+ ds := release.NewDirStore(t.TempDir())
+ ds.EnableChangeIndex()
+ env.store = ds
+ var full []string
+ var scoped [][]string
+ d := env.deps(func() { full = append(full, "full") })
+ d.watch = release.NewChangeWatch(ds)
+ d.scopedPass = func(_ context.Context, moved []string) { scoped = append(scoped, moved) }
+ return d, ds, &full, &scoped
+}
+
+func TestFastTickIndexOnlyRunsScopedPass(t *testing.T) {
+ t.Parallel()
+ ctx := context.Background()
+ d, ds, full, scoped := ciFastDeps(t)
+ now := time.Unix(1754640000, 0)
+ fastTick(ctx, d, now) // seeds the serial and the index: no pass
+ if len(*full)+len(*scoped) != 0 {
+ t.Fatalf("the seeding tick ran a pass: full %v scoped %v", *full, *scoped)
+ }
+ if _, err := ds.Put(ctx, "apps/bab/shop/instances/one.shop.example.com.json", []byte(`{}`), ""); err != nil {
+ t.Fatal(err)
+ }
+ fastTick(ctx, d, now.Add(10*time.Second))
+ if len(*full) != 0 {
+ t.Fatalf("an index-only change ran the full pass: %v", *full)
+ }
+ if want := [][]string{{"apps/bab/shop"}}; !slices.EqualFunc(*scoped, want, slices.Equal[[]string]) {
+ t.Fatalf("scoped passes %v, want %v", *scoped, want)
+ }
+ // Inside the clamp the change waits, and is acted on at the next tick
+ // past it.
+ if _, err := ds.Put(ctx, "apps/bab/cafe/instances/one.cafe.example.com.json", []byte(`{}`), ""); err != nil {
+ t.Fatal(err)
+ }
+ fastTick(ctx, d, now.Add(12*time.Second))
+ if len(*scoped) != 1 {
+ t.Fatalf("a change inside the clamp ran at once: %v", *scoped)
+ }
+ fastTick(ctx, d, now.Add(16*time.Second))
+ if want := [][]string{{"apps/bab/shop"}, {"apps/bab/cafe"}}; !slices.EqualFunc(*scoped, want, slices.Equal[[]string]) {
+ t.Fatalf("scoped passes %v, want %v", *scoped, want)
+ }
+ fastTick(ctx, d, now.Add(30*time.Second))
+ if len(*scoped) != 2 {
+ t.Fatalf("an unchanged index ran the scoped pass again: %v", *scoped)
+ }
+ // Scopes outside the record view's (control/edges) run no pass.
+ if _, err := ds.Put(ctx, "control/edges/one.json", []byte(`{}`), ""); err != nil {
+ t.Fatal(err)
+ }
+ fastTick(ctx, d, now.Add(40*time.Second))
+ if len(*scoped) != 2 || len(*full) != 0 {
+ t.Fatalf("a control/ scope ran a pass: full %v scoped %v", *full, *scoped)
+ }
+}
+
+// A shared watch: another poller (the record view) taking Poll's moved list
+// first does not hide the movement from fastTick.
+func TestFastTickActsOnObservedNotPollReturn(t *testing.T) {
+ t.Parallel()
+ ctx := context.Background()
+ d, ds, _, scoped := ciFastDeps(t)
+ now := time.Unix(1754640000, 0)
+ fastTick(ctx, d, now)
+ if _, err := ds.Put(ctx, "apps/bab/shop/instances/one.shop.example.com.json", []byte(`{}`), ""); err != nil {
+ t.Fatal(err)
+ }
+ if moved, err := d.watch.Poll(ctx); err != nil || len(moved) == 0 {
+ t.Fatalf("the sibling poll saw %v, %v", moved, err)
+ }
+ fastTick(ctx, d, now.Add(10*time.Second))
+ if want := [][]string{{"apps/bab/shop"}}; !slices.EqualFunc(*scoped, want, slices.Equal[[]string]) {
+ t.Fatalf("scoped passes %v, want %v: a sibling's Poll swallowed the movement", *scoped, want)
+ }
+}
+
+func TestSerialChangeStillRunsFullPass(t *testing.T) {
+ t.Parallel()
+ ctx := context.Background()
+ d, ds, full, scoped := ciFastDeps(t)
+ now := time.Unix(1754640000, 0)
+ fastTick(ctx, d, now)
+ if _, err := ds.Put(ctx, "apps/bab/shop/instances/one.shop.example.com.json", []byte(`{}`), ""); err != nil {
+ t.Fatal(err)
+ }
+ if err := release.BumpSerial(ctx, ds, now); err != nil {
+ t.Fatal(err)
+ }
+ fastTick(ctx, d, now.Add(10*time.Second))
+ if len(*full) != 1 || len(*scoped) != 0 {
+ t.Fatalf("a serial change with the index moved: full %v scoped %v, want one full pass only", *full, *scoped)
+ }
+ // The full pass covered the movement: the next tick runs nothing.
+ fastTick(ctx, d, now.Add(20*time.Second))
+ if len(*full) != 1 || len(*scoped) != 0 {
+ t.Fatalf("the movement the full pass covered ran again: full %v scoped %v", *full, *scoped)
+ }
+}
+
+// The backstop asks for the unrestricted full pass after its rebuild; the
+// loop's own tick runs it, once.
+func TestFastTickRunsTheBackstopsFullPass(t *testing.T) {
+ t.Parallel()
+ ctx := context.Background()
+ d, ds, full, scoped := ciFastDeps(t)
+ var due atomic.Bool
+ d.fullDue = &due
+ now := time.Unix(1754640000, 0)
+ fastTick(ctx, d, now)
+ v := provision.NewRecordView(ds, d.watch)
+ recordViewBackstop(ctx, v, &due)
+ if v.Snapshot() == nil {
+ t.Fatal("the backstop did not rebuild the view")
+ }
+ fastTick(ctx, d, now.Add(10*time.Second))
+ if len(*full) != 1 || len(*scoped) != 0 || due.Load() {
+ t.Fatalf("after the backstop: full %v scoped %v due %v, want one full pass", *full, *scoped, due.Load())
+ }
+ fastTick(ctx, d, now.Add(20*time.Second))
+ if len(*full) != 1 {
+ t.Fatalf("the backstop's full pass ran again: %v", *full)
+ }
+}
+
+func TestBackstopEveryIsJitteredAroundTheBackstop(t *testing.T) {
+ t.Parallel()
+ lo, hi := release.ChangeIndexBackstop*9/10, release.ChangeIndexBackstop*11/10
+ for range 200 {
+ if d := backstopEvery(); d < lo || d >= hi {
+ t.Fatalf("backstopEvery() = %v, want in [%v, %v)", d, lo, hi)
+ }
+ }
+}
+
+// ---- the scoped pass -------------------------------------------------------
+
+// Codex 10's witness: on a box hosting shop and cafe, an index bump for shop
+// runs a scoped pass that reads nothing under cafe's prefix, while shop's
+// new config is delivered.
+func TestScopedPassTouchesOnlyMovedApp(t *testing.T) {
+ w := newCIWorld(t)
+ armView(t, w.live)
+ armedMovesWorker.Store(newMovesWorker(moveCfg{}))
+ t.Cleanup(func() { armedMovesWorker.Store(nil) })
+ raw, _, err := appconfig.GenerateKey()
+ if err != nil {
+ t.Fatal(err)
+ }
+ pk, err := appconfig.ParsePrivateKey(raw, "test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ reg := restartTestRegistry(t)
+ dd := t.TempDir()
+ legs := &passLegs{store: w.live, reg: reg, optDir: t.TempDir(), priv: pk, dd: dd,
+ cfg: func() provision.ReconcileConfig { return provision.ReconcileConfig{DataDir: dd, Layout: flatLayout} }}
+ legs.full(context.Background())
+ for _, h := range []string{"shop.example.com", "cafe.example.com"} {
+ rt, err := reg.Get(h)
+ if err != nil || rt == nil {
+ t.Fatalf("fixture: no route for %s: %v", h, err)
+ }
+ if !strings.Contains(readConfigEnv(t, dd, *rt), "APP=") {
+ t.Fatalf("fixture: %s got no config from the full pass", h)
+ }
+ }
+
+ if err := appconfig.SetEnv(context.Background(), w.live, w.acct, "shop", map[string]string{"APP": "shop2"}); err != nil {
+ t.Fatal(err)
+ }
+ w.live.reset()
+ legs.scoped(context.Background(), []string{w.scope("shop")})
+
+ cafe := "apps/" + w.acct + "/cafe/"
+ shop := "apps/" + w.acct + "/shop/"
+ var shopReads int
+ for _, c := range w.live.got() {
+ _, key, _ := strings.Cut(c, " ")
+ if strings.HasPrefix(key, cafe) {
+ t.Errorf("the scoped pass for shop read %s", c)
+ }
+ if strings.HasPrefix(key, shop) {
+ shopReads++
+ }
+ }
+ if shopReads == 0 {
+ t.Fatalf("the scoped pass read nothing under shop: %v", w.live.got())
+ }
+ rt, _ := reg.Get("shop.example.com")
+ if got := readConfigEnv(t, dd, *rt); !strings.Contains(got, "APP=shop2") {
+ t.Fatalf("shop's config after the scoped pass = %q, want APP=shop2", got)
+ }
+}
+
+func readConfigEnv(t *testing.T, dd string, rt registry.Route) string {
+ t.Helper()
+ b, err := os.ReadFile(appconfig.ConfigEnvPath(dd, rt))
+ if err != nil && !os.IsNotExist(err) {
+ t.Fatal(err)
+ }
+ return string(b)
+}
diff --git a/internal/host/movespass.go b/internal/host/movespass.go
index 16efd306e..b7d03b0e9 100644
--- a/internal/host/movespass.go
+++ b/internal/host/movespass.go
@@ -110,6 +110,10 @@ type movesWorker struct {
// lost names the hosts whose claim lists this box in Lost, as this pass
// read them (lostHere); rebuilt every pass under pass.
lost map[string]bool
+ // lastEpoch is the record view snapshot epoch the last pass consumed
+ // (S3 change-index design §4.3); guarded by pass. A pass refuses an
+ // older one: a guard, since publication is monotonic.
+ lastEpoch uint64
}
// staging is the memoized staging writer for a target bucket region.
@@ -1060,6 +1064,21 @@ func moveReadyDeltaFromEnv(getenv func(string) string) (delta int64, ok bool) {
func movesPass(ctx context.Context, s release.Store, w *movesWorker) error {
w.pass.Lock()
defer w.pass.Unlock()
+ // With the change index armed, the record listing below reads the record
+ // view, refreshed here inside w.pass, never a snapshot handed over from
+ // a reconcile leg (S3 change-index design §4.3). Only the listing does:
+ // every step after it reads and writes s, so a step's own re-read and
+ // CAS see the bucket as they always have. Lock order: w.pass ->
+ // RecordView.mu, which is a leaf.
+ records := s
+ if vs, epoch, ok := viewStore(ctx, s); ok {
+ if epoch < w.lastEpoch {
+ log.Printf("agent: moves pass: snapshot epoch %d older than %d consumed", epoch, w.lastEpoch)
+ return nil
+ }
+ w.lastEpoch = epoch
+ records = vs
+ }
cfg := w.cfg
if cfg.claimRecords {
ctx = provision.WithRecordSequence(ctx)
@@ -1076,7 +1095,7 @@ func movesPass(ctx context.Context, s release.Store, w *movesWorker) error {
}
// Strict returns exactly ListAllRecords' records and errors, plus the
// hosts whose record did not decode, which the offline keep view keeps.
- recs, unreadable, err := provision.ListAllRecordsStrict(ctx, s)
+ recs, unreadable, err := provision.ListAllRecordsStrict(ctx, records)
if err != nil {
return fmt.Errorf("moves: list records: %w", err)
}
diff --git a/internal/host/steering_loop.go b/internal/host/steering_loop.go
index 6b2379d99..a13a55399 100644
--- a/internal/host/steering_loop.go
+++ b/internal/host/steering_loop.go
@@ -126,7 +126,10 @@ func steeringPass(ctx context.Context, s release.Store, converge func(context.Co
if err != nil {
return err
}
- routes, err := steeredRoutesFromRecords(ctx, s)
+ // The records through the record view when the change index is armed
+ // and the view can vouch; the bucket otherwise (viewStore).
+ rs, _, _ := viewStore(ctx, s)
+ routes, err := steeredRoutesFromRecords(ctx, rs)
if err != nil {
return err
}