carlos / platform Public

Sidecar isolation Task 8 (1/4): the layout pass moves worker rows (D8, D9, D11)

The pass's eligibility widens from ActivatorManaged to ActivatorManaged ||
WorkerManaged, the latter only while the agent-start workers readiness
holds (Pass.WorkersReady, nil = false), at all three sites: Converge,
RestoreHolds and the re-read under the fence. A worker journal is still
recovered without it: completing a journal is exact.

PlanFor learns the worker shape from the host's suffix
(provision.SidecarSuffix), never the row's Kind: both endpoints carry
Socket "" and the socket artifact is recorded absent on both sides. A row
whose Kind disagrees with the suffix (WorkerRowAgrees) is refused before
any stop, in Converge and in recovery, like any journal mismatch.

D8 rev 2's missing-database rule (DatabaseAbsent): the db stops being
Required when it and every companion (db-wal, db-shm, litestream-shadow,
corrupt, <db>-litestream) are absent at both endpoints; the journal then
records it absent and the rest moves. A companion without its database
refuses with the names. Validate no longer refuses a journal whose db is
recorded absent; recovery's existing refusal of anything recorded absent
that exists at either end is what keeps a present database from being
stranded, now pinned.

D9's running-replicator check: boxReplicatorHolds (replicator.go) decides
from /etc/litestream.yml and carlos-litestream's ActiveState, SubState,
ExecMainStartTimestamp, MainPID and cgroup through injected observations;
BoxReplicatorHolds is the production pair (systemctl show
--timestamp=unix, read-only, and cgroup.events under the unit's
ControlGroup). The pass asks it for EVERY worker row (R4-1) before the
stop and again under the fence inside the serialized section, right
before the journal and the renames; a hold defers the move with the
spec's reason (a placeholder constant in layout_copy.go for Task 12).
Nil holds.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 3d8ce335cc97baee7daeda3f41bd9c3edd5ef53b parent 7490ed6
8 files changed, +1574 −36
  • internal/layout/journal.go +9 −7
  • internal/layout/journal_test.go +13 −11
  • internal/layout/layout_copy.go +9 −0
  • internal/layout/pass.go +105 −17
  • internal/layout/pass_worker_test.go +815 −0
  • internal/layout/plan.go +121 −1
  • internal/layout/replicator.go +254 −0
  • internal/layout/replicator_test.go +248 −0
diff --git a/internal/layout/journal.go b/internal/layout/journal.go
index a8a66255f..ae8457fcc 100644
--- a/internal/layout/journal.go
+++ b/internal/layout/journal.go
@@ -337,13 +337,15 @@ func Validate(j *Journal, dataDir, socketDir string, row registry.Route) (atFrom
return false, fmt.Errorf("layout: %s: journal artifact %d is %q, expected %q", j.Host, i, a.Name, artifactNames[i])
}
}
- // The db is the one artifact recovery cannot proceed without: every
- // other artifact missing is a smaller, optional file the pass would
- // simply skip, but a journal claiming its db was never even there
- // describes a move that cannot have happened.
- if !j.Artifacts[0].Present {
- return false, fmt.Errorf("layout: %s: journal's db artifact is not marked present; not acting", j.Host)
- }
+ // A journal may record its db absent (sidecar isolation D8 rev 2): a
+ // row whose database was never on this box moves its config and extras
+ // without one, and the next wake restores from the bucket. The pass
+ // only writes that when the database and every companion were absent
+ // at both endpoints (DatabaseAbsent), and recovery holds a journal to
+ // it: complete refuses, not acting, when anything recorded absent
+ // exists at either end, so a forged journal that claims a present
+ // database was never there cannot strand it.
+ //
// The extra names pass the same rule a scan applies, and an extra is
// never absent: the plan carries one because it was seen.
if err := p.AddExtras(j.extraNames()); err != nil {
diff --git a/internal/layout/journal_test.go b/internal/layout/journal_test.go
index 00e5f72cc..fd712ba71 100644
--- a/internal/layout/journal_test.go
+++ b/internal/layout/journal_test.go
@@ -198,20 +198,22 @@ func TestValidateRefusesAWrongArtifactCount(t *testing.T) {
}
}
-// TestValidateRequiresTheDBArtifactPresent pins that a forged journal
-// cannot claim its db was never there and still be accepted: everything
-// else in an optional artifact list can be absent and the pass simply
-// skips it, but a journal with no db describes a move that cannot have
-// happened, whatever its endpoints say.
-func TestValidateRequiresTheDBArtifactPresent(t *testing.T) {
+// TestValidateAcceptsADBRecordedAbsent pins sidecar isolation D8 rev 2: a
+// journal may record its db absent (a standby row that never held the
+// database moves its config and extras without one), so Validate no longer
+// refuses it on shape. The data safety moved to recovery: complete refuses
+// any artifact recorded absent that exists at either end (pinned by
+// TestRecoverRefusesAForgedJournalClaimingThePresentDBAbsent), so a forged
+// journal cannot strand a database that is there.
+func TestValidateAcceptsADBRecordedAbsent(t *testing.T) {
dd := t.TempDir()
j := journalFor(t, dd, Mode("nested"))
row := registry.Route{Host: "h.example.com", DBPath: j.From.DB, Socket: j.From.Socket}
- forged := *j
- forged.Artifacts = append([]JournalArtifact(nil), j.Artifacts...)
- forged.Artifacts[0].Present = false
- if _, err := Validate(&forged, dd, "/run/carlos", row); err == nil {
- t.Error("a journal whose db artifact is not present was accepted")
+ standby := *j
+ standby.Artifacts = append([]JournalArtifact(nil), j.Artifacts...)
+ standby.Artifacts[0].Present = false
+ if atFrom, err := Validate(&standby, dd, "/run/carlos", row); err != nil || !atFrom {
+ t.Errorf("a journal recording its db absent: atFrom %v, err %v", atFrom, err)
}
}
diff --git a/internal/layout/layout_copy.go b/internal/layout/layout_copy.go
new file mode 100644
index 000000000..72293670b
--- /dev/null
+++ b/internal/layout/layout_copy.go
@@ -0,0 +1,9 @@
+package layout
+
+// User-facing strings of the layout pass that the sidecar isolation spec
+// marks "copy to review". Placeholders until Task 12 collects, reviews and
+// fills them; the words here are the spec's own.
+
+// replicatorHoldsCopy is the reason a worker's move is deferred while the
+// box-wide replicator may still hold its database (spec §3.9, D9).
+const replicatorHoldsCopy = "the box-wide replicator may still hold this database" // copy: placeholder, Task 12
diff --git a/internal/layout/pass.go b/internal/layout/pass.go
index 26a3870cd..2630b8ed0 100644
--- a/internal/layout/pass.go
+++ b/internal/layout/pass.go
@@ -62,6 +62,25 @@ type Pass struct {
// cannot establish the namespace does not start nested tenants, so it
// does not make any either. Nil means ungated.
NestedOK func() error
+ // WorkersReady is the box's workers readiness as the agent found it at
+ // its start (sidecar isolation D11: CARLOS_NESTED_WORKERS=on and both
+ // the running build and carlos.prev at or above the worker layout
+ // floor). While it does not hold, worker rows are not the pass's to
+ // move in either direction; they are still recovered from a journal,
+ // since completing a journal is exact. Nil means not ready. It is not
+ // the whole gate: a move to nested still needs the fleet's intent
+ // (Converge's mode) and NestedOK, as for instance rows.
+ WorkersReady func() bool
+ // Replicator reports whether the box-wide litestream may still hold a
+ // worker's database open (sidecar isolation D9, R3-3): its config
+ // names the file, or the daemon is in a state the pass cannot place
+ // against that config. Asked for EVERY worker row's move (R4-1),
+ // before the host is stopped and again under the fence immediately
+ // before the first rename; a hold defers the move with the reason,
+ // retried on every pass. Nil holds every worker move: a box with no
+ // observation wired cannot vouch for the daemon. Never asked for an
+ // instance row. Production wires BoxReplicatorHolds.
+ Replicator func(dbPath string) (holds bool, reason string)
// Serialize, when set, runs the span from journal write to journal
// removal under the lock config sync holds, so sync never renders a
// host's bundle into the directory the pass is moving it out of.
@@ -229,6 +248,36 @@ func deadline(ctx context.Context) (context.Context, context.CancelFunc) {
// worth moving.
func ready(rt registry.Route) bool { return rt.Status == "" || rt.Status == "ready" }
+// workersReady is WorkersReady with nil read as not ready.
+func (p *Pass) workersReady() bool { return p.WorkersReady != nil && p.WorkersReady() }
+
+// managed is the pass's eligibility (design §8.2 step 1, widened by
+// sidecar isolation D8): an activator-managed instance row always, a
+// native worker row only while the box's workers readiness holds.
+func (p *Pass) managed(rt registry.Route) bool {
+ return activator.ActivatorManaged(rt) || (p.workersReady() && activator.WorkerManaged(rt))
+}
+
+// replicatorHolds asks Replicator about a worker plan's source database.
+// An instance plan never holds: the box-wide replicator never names an
+// activator-managed instance's database (ops.ActivatorReplicated).
+func (p *Pass) replicatorHolds(plan *Plan) (bool, string) {
+ if !plan.Worker {
+ return false, ""
+ }
+ if p.Replicator == nil {
+ return true, "no replicator observation is wired on this box"
+ }
+ return p.Replicator(plan.From.DB)
+}
+
+// replicatorDeferred is the result for a worker move the box-wide
+// replicator's state defers (D9): not a refusal and not a hold, since no
+// tenant brought it about; the next pass asks again.
+func replicatorDeferred(host, why string) Result {
+ return Result{host, "deferred", replicatorHoldsCopy + ": " + why}
+}
+
// Converge runs Recover, then moves every managed row that is not already
// in mode (design §8.2 steps 1 to 9). Rows are visited in host order so a
// log reads the same way twice. A host Recover reported is not visited
@@ -261,7 +310,7 @@ func (p *Pass) Converge(ctx context.Context, mode Mode) []Result {
sort.Slice(routes, func(i, j int) bool { return routes[i].Host < routes[j].Host })
migrated := false
for _, rt := range routes {
- if !activator.ActivatorManaged(rt) || seen[rt.Host] {
+ if !p.managed(rt) || seen[rt.Host] {
continue
}
if ctx.Err() != nil {
@@ -287,6 +336,9 @@ func (p *Pass) convergeHost(ctx context.Context, rt registry.Route, routes []reg
if !ready(rt) {
return Result{rt.Host, "deferred", "status " + rt.Status}
}
+ if err := WorkerRowAgrees(rt.Host, rt); err != nil {
+ return Result{rt.Host, "refused", err.Error()}
+ }
layout, reason := p.Classify(rt, routes)
switch layout {
case activator.LayoutFenced, activator.LayoutConflict:
@@ -345,14 +397,17 @@ func (p *Pass) migrateHost(ctx context.Context, host string, routes []registry.R
if err := Preflight(plan, routes); err != nil {
return Result{host, "refused", err.Error()}
}
- // A host whose database is not on this box cannot move. Finding that
- // out must not cost it a stop, and the pass directories and a
- // destination check, on every pass. The same check under the fence
- // stays the authority.
- if db := plan.Artifacts[0]; db.Required {
- if ok, err := lstatPresent(db.Src); err != nil || !ok {
- return Result{host, "refused", fmt.Sprintf("db is missing at %s", db.Src)}
- }
+ // A host whose database's history is here without the database cannot
+ // move (DatabaseAbsent). Finding that out must not cost it a stop, and
+ // the pass directories and a destination check, on every pass. The same
+ // check under the fence (presence) stays the authority.
+ if _, err := DatabaseAbsent(plan); err != nil {
+ return Result{host, "refused", err.Error()}
+ }
+ // Nor must a worker be stopped for a move the box-wide replicator's
+ // state defers (D9). The read under the fence is the one that counts.
+ if holds, why := p.replicatorHolds(plan); holds {
+ return replicatorDeferred(host, why)
}
r, fence, gen := p.quiesce(ctx, host, p.generation())
if fence != fenceOurs {
@@ -526,10 +581,14 @@ func (p *Pass) restoreHold(ctx context.Context, host string) Result {
if err != nil {
return Result{host, "refused", "list routes: " + err.Error()}
}
- if rt == nil || !activator.ActivatorManaged(*rt) {
+ if rt == nil || !p.managed(*rt) {
removeHoldMarker(p.DataDir, host)
return Result{host, "released", "hold marker dropped: the row is gone or no longer managed"}
}
+ if err := WorkerRowAgrees(host, *rt); err != nil {
+ removeHoldMarker(p.DataDir, host)
+ return Result{host, "released", "hold marker dropped: " + err.Error()}
+ }
if layout, _ := p.Classify(*rt, routes); layout != activator.LayoutFlat {
removeHoldMarker(p.DataDir, host)
return Result{host, "released", "hold marker dropped: the row is " + string(layout)}
@@ -744,8 +803,11 @@ func (p *Pass) shift(host string, mode Mode, prior *Journal) (res Result, h hand
if !ready(*fresh) {
return Result{host, "refused", "status " + fresh.Status + " under the fence"}, handoverNone, false
}
- if !activator.ActivatorManaged(*fresh) {
- return Result{host, "refused", "no longer activator-managed under the fence"}, handoverNone, false
+ if !p.managed(*fresh) {
+ return Result{host, "refused", "no longer managed under the fence"}, handoverNone, false
+ }
+ if err := WorkerRowAgrees(host, *fresh); err != nil {
+ return Result{host, "refused", err.Error()}, handoverNone, false
}
routes, err := p.Registry.List()
if err != nil {
@@ -794,6 +856,14 @@ func (p *Pass) shift(host string, mode Mode, prior *Journal) (res Result, h hand
res, h, hold = Result{host, "refused", err.Error()}, handoverNone, true
return
}
+ // The running replicator, not the file, decides a worker's move
+ // (D9): read here, under the fence and inside the serialized
+ // section, so nothing of the host's is renamed between this
+ // observation and the renames the journal below commits to.
+ if holds, why := p.replicatorHolds(plan); holds {
+ res, h, hold = replicatorDeferred(host, why), handoverNone, false
+ return
+ }
res, h = p.commit(plan, plan.Journal(present), aside, prior)
})
if res.Outcome == "migrated" || res.Outcome == "reverted" {
@@ -803,11 +873,20 @@ func (p *Pass) shift(host string, mode Mode, prior *Journal) (res Result, h hand
}
// presence records which artifacts are at their sources, in artifactNames
-// order, and refuses a plan whose database is not. It then adds to the
-// plan every entry the app made for the host (ScanExtras; hosts is the
-// box's rows, which decide ownership), so the journal written from the
-// plan names them and the move carries them.
+// order, and refuses a plan whose database is not, unless the database and
+// every companion are absent at both endpoints (DatabaseAbsent, D8 rev 2),
+// in which case the database is recorded absent and the rest moves. It
+// then adds to the plan every entry the app made for the host
+// (ScanExtras; hosts is the box's rows, which decide ownership), so the
+// journal written from the plan names them and the move carries them.
func presence(plan *Plan, hosts []string) ([]bool, error) {
+ absent, err := DatabaseAbsent(plan)
+ if err != nil {
+ return nil, err
+ }
+ if absent {
+ plan.Artifacts[0].Required = false
+ }
present := make([]bool, len(plan.Artifacts))
for i, a := range plan.Artifacts {
// Nothing is ever renamed for the socket: the listener is gone
@@ -1102,7 +1181,13 @@ func (p *Pass) recoverHost(ctx context.Context, host string, byHost map[string]r
return Result{host, "refused", "journal names a host with no row; not acting"}
}
// Checked before the stop as well as after it, so a journal that
- // cannot be honoured never costs its host a stop.
+ // cannot be honoured never costs its host a stop. The row's kind must
+ // agree with the host's shape first: the plan a worker journal is
+ // checked against is derived from the host's suffix (D8), and a row
+ // whose Kind says otherwise is a row that was edited.
+ if err := WorkerRowAgrees(host, rt); err != nil {
+ return Result{host, "refused", err.Error()}
+ }
if _, err := Validate(j, p.DataDir, p.socketDir(), rt); err != nil {
return Result{host, "refused", notActing(err)}
}
@@ -1144,6 +1229,9 @@ func (p *Pass) recoverHost(ctx context.Context, host string, byHost map[string]r
if fresh == nil {
return Result{host, "refused", "row vanished under the fence; not acting"}
}
+ if err := WorkerRowAgrees(host, *fresh); err != nil {
+ return Result{host, "refused", err.Error()}
+ }
atFrom, err := Validate(j, p.DataDir, p.socketDir(), *fresh)
if err != nil {
return Result{host, "refused", notActing(err)}
diff --git a/internal/layout/pass_worker_test.go b/internal/layout/pass_worker_test.go
new file mode 100644
index 000000000..0d689228d
--- /dev/null
+++ b/internal/layout/pass_worker_test.go
@@ -0,0 +1,815 @@
+//go:build linux
+
+package layout
+
+// The layout pass over native worker rows (sidecar isolation spec §3.8 D8,
+// §3.9 D9's running-replicator check, §3.11 D11's readiness gate).
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/activator"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+const (
+ // hibWorker hibernates: it moves asleep. onWorker is always-on: it is
+ // awake whenever the agent is, so it is deferred and then stopped once.
+ hibWorker = "bot.shop.abc123" + provision.SidecarSuffix
+ onWorker = "cron.shop.abc123" + provision.SidecarSuffix
+)
+
+// fakeReplicator is the pass's Replicator seam: it records every database
+// it was asked about and answers from answers in order, the last answer
+// repeating. No answers means "does not hold".
+type fakeReplicator struct {
+ mu sync.Mutex
+ asked []string
+ answers []bool
+}
+
+func (r *fakeReplicator) holds(db string) (bool, string) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ n := len(r.asked)
+ r.asked = append(r.asked, db)
+ if len(r.answers) == 0 {
+ return false, ""
+ }
+ if n >= len(r.answers) {
+ n = len(r.answers) - 1
+ }
+ if r.answers[n] {
+ return true, "fixture: carlos-litestream.service started before the config"
+ }
+ return false, ""
+}
+
+func workerRow(dd, h string, hibernate bool) registry.Route {
+ return registry.Route{Host: h, Kind: "sidecar", Hibernate: hibernate, Status: "ready",
+ Args: `["work"]`, App: "shop", Account: "acct", DBPath: provision.DefaultDBPath(dd, h)}
+}
+
+// seedWorkerFlat is seedFlat plus the rendered bundle config delivery
+// writes for every sidecar on every box.
+func seedWorkerFlat(t *testing.T, dd, h string) {
+ t.Helper()
+ seedFlat(t, dd, h)
+ if err := os.WriteFile(filepath.Join(dd, h+".config.env"), []byte("LOG_LEVEL=debug\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// workerBox is box with two flat worker rows whose files exist, a ready
+// box (WorkersReady true) and a replicator that does not hold.
+func workerBox(t *testing.T) (*Pass, *fakeFence, *fakeReplicator) {
+ t.Helper()
+ p, f := box(t)
+ for h, hib := range map[string]bool{hibWorker: true, onWorker: false} {
+ if err := p.Registry.Upsert(workerRow(p.DataDir, h, hib)); err != nil {
+ t.Fatal(err)
+ }
+ seedWorkerFlat(t, p.DataDir, h)
+ }
+ p.WorkersReady = func() bool { return true }
+ rep := &fakeReplicator{}
+ p.Replicator = rep.holds
+ return p, f, rep
+}
+
+// workerEndpoint is a worker's row at a layout: the database path alone,
+// no socket in either layout (D2).
+func workerEndpoint(dd, h string, mode Mode) Endpoint {
+ if mode == ModeNested {
+ return Endpoint{DB: provision.NestedDBPath(dd, h)}
+ }
+ return Endpoint{DB: provision.DefaultDBPath(dd, h)}
+}
+
+func fenceEvents(f *fakeFence, h string) (fences, lifts int) {
+ for _, e := range f.events {
+ switch e {
+ case "fence " + h:
+ fences++
+ case "lift " + h:
+ lifts++
+ }
+ }
+ return fences, lifts
+}
+
+func assertWorkerFlatUntouched(t *testing.T, p *Pass, h string) {
+ t.Helper()
+ if got := rowAt(t, p, h); got != workerEndpoint(p.DataDir, h, ModeFlat) {
+ t.Errorf("%s row moved: %+v", h, got)
+ }
+ if b, err := os.ReadFile(provision.DefaultDBPath(p.DataDir, h)); err != nil || string(b) != "db "+h {
+ t.Errorf("%s flat db: %q %v", h, b, err)
+ }
+ if JournalPresent(p.DataDir, h) {
+ t.Errorf("%s has a journal", h)
+ }
+ // EnsureDirs may have made the raw directory before a check under the
+ // fence refused; nothing of the host's may be in it.
+ if entries, err := os.ReadDir(rawInstanceDir(p.DataDir, h)); err == nil && len(entries) != 0 {
+ names := make([]string, 0, len(entries))
+ for _, e := range entries {
+ names = append(names, e.Name())
+ }
+ t.Errorf("%s has files in its instance directory: %v", h, names)
+ }
+}
+
+func TestPassMovesHibernatingWorkerAsleep(t *testing.T) {
+ p, f, rep := workerBox(t)
+ res := byHost(t, p.Converge(context.Background(), ModeNested))
+ if r := res[hibWorker]; r.Outcome != "migrated" {
+ t.Fatalf("result = %+v", r)
+ }
+ if got, want := rowAt(t, p, hibWorker), workerEndpoint(p.DataDir, hibWorker, ModeNested); got != want {
+ t.Fatalf("row = %+v, want %+v (no socket in either layout)", got, want)
+ }
+ raw := rawInstanceDir(p.DataDir, hibWorker)
+ for _, name := range []string{hibWorker + ".db", hibWorker + ".db-wal", hibWorker + ".env", hibWorker + ".config.env", shadowName(hibWorker) + "/generation"} {
+ if _, err := os.Lstat(filepath.Join(raw, name)); err != nil {
+ t.Errorf("%s not at the nested path: %v", name, err)
+ }
+ }
+ for _, name := range []string{hibWorker + ".db", hibWorker + ".db-wal", hibWorker + ".env", hibWorker + ".config.env"} {
+ if _, err := os.Lstat(filepath.Join(p.DataDir, name)); !errors.Is(err, os.ErrNotExist) {
+ t.Errorf("%s still at the flat path: %v", name, err)
+ }
+ }
+ if JournalPresent(p.DataDir, hibWorker) {
+ t.Error("journal left behind")
+ }
+ // A nested worker row classifies nested with the same Classify a wake
+ // would use, and the socket artifact was never a file to move.
+ rt, _ := p.Registry.Get(hibWorker)
+ routes, _ := p.Registry.List()
+ if l, why := p.Classify(*rt, routes); l != activator.LayoutNested {
+ t.Errorf("moved row classifies %s (%s)", l, why)
+ }
+ if fences, lifts := fenceEvents(f, hibWorker); fences != 1 || lifts != 1 {
+ t.Errorf("fence events for the worker: %d fences, %d lifts; all: %v", fences, lifts, f.events)
+ }
+ // The replicator was asked about the worker's flat database (twice:
+ // before the stop and under the fence) and never about an instance.
+ for _, db := range rep.asked {
+ if !WorkerHost(strings.TrimSuffix(filepath.Base(db), ".db")) {
+ t.Errorf("replicator asked about a non-worker database %s", db)
+ }
+ }
+ n := 0
+ for _, db := range rep.asked {
+ if db == provision.DefaultDBPath(p.DataDir, hibWorker) {
+ n++
+ }
+ }
+ if n != 2 {
+ t.Errorf("replicator asked %d times about %s, want 2 (before the stop, under the fence): %v", n, hibWorker, rep.asked)
+ }
+}
+
+// Review Focus 5 and D11: without the agent-start readiness, worker rows
+// are not the pass's. Nil and false alike; instance rows move as before.
+func TestWorkersNotEligibleWithoutReadiness(t *testing.T) {
+ for _, c := range []struct {
+ name string
+ ready func() bool
+ }{
+ {"nil", nil},
+ {"false", func() bool { return false }},
+ } {
+ t.Run(c.name, func(t *testing.T) {
+ p, f, rep := workerBox(t)
+ p.WorkersReady = c.ready
+ res := byHost(t, p.Converge(context.Background(), ModeNested))
+ for _, h := range []string{hibWorker, onWorker} {
+ if r, listed := res[h]; listed {
+ t.Errorf("%s was considered: %+v", h, r)
+ }
+ assertWorkerFlatUntouched(t, p, h)
+ if fences, _ := fenceEvents(f, h); fences != 0 {
+ t.Errorf("%s was fenced: %v", h, f.events)
+ }
+ }
+ if res[host].Outcome != "migrated" || res[replica].Outcome != "migrated" {
+ t.Errorf("instance rows: %+v %+v", res[host], res[replica])
+ }
+ if len(rep.asked) != 0 {
+ t.Errorf("replicator asked while workers were not eligible: %v", rep.asked)
+ }
+ // The revert direction is not the pass's either: the box's
+ // workers are off, so the pass leaves worker rows where they
+ // stand.
+ p.WorkersReady = func() bool { return true }
+ p.Converge(context.Background(), ModeNested)
+ p.WorkersReady = c.ready
+ out := byHost(t, p.Converge(context.Background(), ModeFlat))
+ if r, listed := out[hibWorker]; listed {
+ t.Errorf("a nested worker was considered for revert without readiness: %+v", r)
+ }
+ if got := rowAt(t, p, hibWorker); got != workerEndpoint(p.DataDir, hibWorker, ModeNested) {
+ t.Errorf("row = %+v, want still nested", got)
+ }
+ })
+ }
+}
+
+// D8: an always-on worker is awake whenever the agent is. It is deferred
+// for the awake grace like any awake host and then stopped once for the
+// move; it wakes nested on the sweep's next always-on start.
+func TestPassDefersAwakeAlwaysOnWorkerThenStopsOnce(t *testing.T) {
+ p, f, _ := workerBox(t)
+ p.DeferAwake = true
+ p.AwakeGrace = time.Hour
+ clock := time.Unix(1700000000, 0)
+ p.Now = func() time.Time { return clock }
+ f.awake = map[string]bool{onWorker: true}
+
+ res := byHost(t, p.Converge(context.Background(), ModeNested))
+ if r := res[onWorker]; r.Outcome != "deferred" || !strings.Contains(r.Reason, "awake") {
+ t.Fatalf("first pass: %+v", r)
+ }
+ if r := res[hibWorker]; r.Outcome != "migrated" {
+ t.Fatalf("the asleep worker on the same pass: %+v", r)
+ }
+ clock = clock.Add(30 * time.Minute)
+ if r := byHost(t, p.Converge(context.Background(), ModeNested))[onWorker]; r.Outcome != "deferred" {
+ t.Fatalf("half way: %+v", r)
+ }
+ if fences, _ := fenceEvents(f, onWorker); fences != 0 {
+ t.Fatalf("the awake worker was fenced inside its grace: %v", f.events)
+ }
+ clock = clock.Add(30 * time.Minute)
+ if r := byHost(t, p.Converge(context.Background(), ModeNested))[onWorker]; r.Outcome != "migrated" {
+ t.Fatalf("grace spent: %+v", r)
+ }
+ if got := rowAt(t, p, onWorker); got != workerEndpoint(p.DataDir, onWorker, ModeNested) {
+ t.Fatalf("row = %+v", got)
+ }
+ if fences, lifts := fenceEvents(f, onWorker); fences != 1 || lifts != 1 {
+ t.Fatalf("stopped %d times, released %d times, want once each: %v", fences, lifts, f.events)
+ }
+ if len(p.awakeSince) != 0 {
+ t.Errorf("awake clocks still held: %v", p.awakeSince)
+ }
+}
+
+// R4-1: the running-replicator check applies to every worker row, a
+// hibernating one included. A worker that was always-on when the daemon
+// started and has since been switched to hibernating may still be open in
+// it, so the row's flag proves nothing. Held before the stop, the worker
+// is not stopped; held under the fence, it is released and nothing moves.
+func TestFormerlyAlwaysOnWorkerMoveHeldWhileReplicatorNamesFile(t *testing.T) {
+ t.Run("before the stop", func(t *testing.T) {
+ p, f, rep := workerBox(t)
+ rep.answers = []bool{true}
+ res := byHost(t, p.Converge(context.Background(), ModeNested))
+ r := res[hibWorker]
+ if r.Outcome != "deferred" || !strings.Contains(r.Reason, replicatorHoldsCopy) || !strings.Contains(r.Reason, "fixture") {
+ t.Fatalf("result = %+v", r)
+ }
+ if fences, _ := fenceEvents(f, hibWorker); fences != 0 {
+ t.Errorf("a worker the replicator holds was stopped for nothing: %v", f.events)
+ }
+ assertWorkerFlatUntouched(t, p, hibWorker)
+ if res[host].Outcome != "migrated" {
+ t.Errorf("the instance row on the same pass: %+v", res[host])
+ }
+ if _, held := p.Held(hibWorker); held {
+ t.Error("a replicator hold is not a tenant's doing and must not hold the host down")
+ }
+ })
+ t.Run("under the fence", func(t *testing.T) {
+ p, f, rep := workerBox(t)
+ // The pre-stop read says clear; the daemon restarts on an old
+ // config between it and the read under the fence.
+ rep.answers = []bool{false, true}
+ res := byHost(t, p.Converge(context.Background(), ModeNested))
+ r := res[hibWorker]
+ if r.Outcome != "deferred" || !strings.Contains(r.Reason, replicatorHoldsCopy) {
+ t.Fatalf("result = %+v", r)
+ }
+ if fences, lifts := fenceEvents(f, hibWorker); fences != 1 || lifts != 1 {
+ t.Errorf("fence events: %d fences, %d lifts; want the stop and its release: %v", fences, lifts, f.events)
+ }
+ assertWorkerFlatUntouched(t, p, hibWorker)
+ if _, held := p.Held(hibWorker); held {
+ t.Error("held after a replicator deferral")
+ }
+ // The next pass, with the daemon placed, moves it.
+ rep.answers = []bool{false}
+ if r := byHost(t, p.Converge(context.Background(), ModeNested))[hibWorker]; r.Outcome != "migrated" {
+ t.Fatalf("next pass: %+v", r)
+ }
+ })
+ t.Run("no replicator wired holds", func(t *testing.T) {
+ p, f, _ := workerBox(t)
+ p.Replicator = nil
+ r := byHost(t, p.Converge(context.Background(), ModeNested))[hibWorker]
+ if r.Outcome != "deferred" || !strings.Contains(r.Reason, replicatorHoldsCopy) {
+ t.Fatalf("result = %+v", r)
+ }
+ if fences, _ := fenceEvents(f, hibWorker); fences != 0 {
+ t.Errorf("fenced: %v", f.events)
+ }
+ assertWorkerFlatUntouched(t, p, hibWorker)
+ })
+}
+
+// writeWorkerJournal writes a nested journal for h recording each
+// artifact present exactly when it is at its flat source, except the names
+// in absent.
+func writeWorkerJournal(t *testing.T, p *Pass, h string, mode Mode, absent ...string) *Journal {
+ t.Helper()
+ plan, err := PlanFor(p.DataDir, p.SocketDir, h, mode)
+ if err != nil {
+ t.Fatal(err)
+ }
+ present := make([]bool, len(plan.Artifacts))
+ for i, a := range plan.Artifacts {
+ if a.Name == "socket" || slicesContains(absent, a.Name) {
+ continue
+ }
+ present[i], _ = lstatPresent(a.Src)
+ }
+ j := plan.Journal(present)
+ if err := WriteJournal(p.DataDir, j); err != nil {
+ t.Fatal(err)
+ }
+ return j
+}
+
+// D8: the worker test is the host's suffix, never the row's Kind alone. A
+// journal for a worker host whose row says instance, and a sidecar row on
+// a host without the suffix, are both refused without a stop.
+func TestWorkerJournalKindSuffixMismatchRefused(t *testing.T) {
+ t.Run("worker host, instance row, journal", func(t *testing.T) {
+ p, f, _ := workerBox(t)
+ writeWorkerJournal(t, p, hibWorker, ModeNested)
+ edited := workerRow(p.DataDir, hibWorker, true)
+ edited.Kind = "instance"
+ if err := p.Registry.Upsert(edited); err != nil {
+ t.Fatal(err)
+ }
+ r := byHost(t, p.Recover(context.Background(), ModeNested))[hibWorker]
+ if r.Outcome != "refused" || !strings.Contains(r.Reason, `row kind "instance"`) || !strings.Contains(r.Reason, "not acting") {
+ t.Fatalf("result = %+v", r)
+ }
+ if !JournalPresent(p.DataDir, hibWorker) {
+ t.Error("the journal was removed; the host must stay fenced by it")
+ }
+ if fences, _ := fenceEvents(f, hibWorker); fences != 0 {
+ t.Errorf("stopped for a journal that cannot be honoured: %v", f.events)
+ }
+ if b, err := os.ReadFile(provision.DefaultDBPath(p.DataDir, hibWorker)); err != nil || string(b) != "db "+hibWorker {
+ t.Errorf("flat db: %q %v", b, err)
+ }
+ // Converge refuses the same row before any fence, and nothing
+ // moves.
+ res := byHost(t, p.Converge(context.Background(), ModeNested))
+ if r := res[hibWorker]; r.Outcome != "refused" {
+ t.Errorf("converge: %+v", r)
+ }
+ })
+ t.Run("sidecar row on a host without the suffix", func(t *testing.T) {
+ p, f, _ := workerBox(t)
+ odd := "worker.example.com"
+ rt := workerRow(p.DataDir, odd, true)
+ if err := p.Registry.Upsert(rt); err != nil {
+ t.Fatal(err)
+ }
+ seedWorkerFlat(t, p.DataDir, odd)
+ r := byHost(t, p.Converge(context.Background(), ModeNested))[odd]
+ if r.Outcome != "refused" || !strings.Contains(r.Reason, provision.SidecarSuffix) {
+ t.Fatalf("result = %+v", r)
+ }
+ if fences, _ := fenceEvents(f, odd); fences != 0 {
+ t.Errorf("fenced: %v", f.events)
+ }
+ assertWorkerFlatUntouched(t, p, odd)
+ })
+}
+
+// A forged journal that records a present database absent would, if
+// honoured, move the rest and strand the database flat. Recovery refuses
+// it, not acting, and the journal keeps the host fenced for an operator.
+func TestRecoverRefusesAForgedJournalClaimingThePresentDBAbsent(t *testing.T) {
+ p, _, _ := workerBox(t)
+ writeWorkerJournal(t, p, hibWorker, ModeNested, "db")
+ db := provision.DefaultDBPath(p.DataDir, hibWorker)
+ r := byHost(t, p.Recover(context.Background(), ModeNested))[hibWorker]
+ if r.Outcome != "refused" || !strings.Contains(r.Reason, "records db absent") || !strings.Contains(r.Reason, db) {
+ t.Fatalf("result = %+v", r)
+ }
+ if !JournalPresent(p.DataDir, hibWorker) {
+ t.Error("the journal was removed")
+ }
+ if b, err := os.ReadFile(db); err != nil || string(b) != "db "+hibWorker {
+ t.Errorf("flat db: %q %v", b, err)
+ }
+ if _, err := os.Lstat(filepath.Join(p.DataDir, hibWorker+".env")); err != nil {
+ t.Errorf("the env was moved without the database: %v", err)
+ }
+}
+
+// R1-4: a worker's move takes the full extras inventory, exactly as an
+// instance's does. The box-wide replicator's meta directory beside a
+// present database is one of them.
+func TestWorkerExtrasMove(t *testing.T) {
+ p, _, _ := workerBox(t)
+ blobs := filepath.Join(p.DataDir, hibWorker+"-blobs")
+ if err := os.MkdirAll(blobs, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(blobs, "x"), []byte("blob"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ meta := provision.DefaultDBPath(p.DataDir, hibWorker) + "-litestream"
+ if err := os.MkdirAll(meta, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(meta, "generation"), []byte("1"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if r := byHost(t, p.Converge(context.Background(), ModeNested))[hibWorker]; r.Outcome != "migrated" {
+ t.Fatalf("result = %+v", r)
+ }
+ raw := rawInstanceDir(p.DataDir, hibWorker)
+ for _, rel := range []string{hibWorker + "-blobs/x", hibWorker + ".db-litestream/generation"} {
+ if _, err := os.Lstat(filepath.Join(raw, rel)); err != nil {
+ t.Errorf("%s not at the nested path: %v", rel, err)
+ }
+ }
+ for _, path := range []string{blobs, meta} {
+ if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) {
+ t.Errorf("%s still flat: %v", path, err)
+ }
+ }
+ if note := p.Note(hibWorker); !strings.Contains(note, hibWorker+"-blobs") || !strings.Contains(note, hibWorker+".db-litestream") {
+ t.Errorf("note = %q, want the extras named", note)
+ }
+}
+
+// D8 rev 2: a standby row, with config and no database (nor any companion)
+// at either endpoint, moves with the database journaled absent. The next
+// wake restores from the bucket into the nested path.
+func TestStandbyRowMovesConfigOnly(t *testing.T) {
+ p, f, _ := workerBox(t)
+ standby := "idle.shop.abc123" + provision.SidecarSuffix
+ if err := p.Registry.Upsert(workerRow(p.DataDir, standby, true)); err != nil {
+ t.Fatal(err)
+ }
+ for name, body := range map[string]string{standby + ".config.env": "LOG_LEVEL=debug\n", standby + ".env": "CARLOS_ADMIN_TOKEN=t\n"} {
+ if err := os.WriteFile(filepath.Join(p.DataDir, name), []byte(body), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ // Stop after the journal, so the test reads what the pass recorded.
+ p.abortAt = "after-journal"
+ p.Converge(context.Background(), ModeNested)
+ p.abortAt = ""
+ j, found, err := ReadJournal(p.DataDir, standby)
+ if err != nil || !found {
+ t.Fatalf("journal: %v %v", found, err)
+ }
+ byName := map[string]bool{}
+ for _, a := range j.Artifacts {
+ byName[a.Name] = a.Present
+ }
+ if byName["db"] || byName["db-wal"] || byName["litestream-shadow"] || byName["socket"] {
+ t.Errorf("journal records db %v wal %v shadow %v socket %v present, want all absent", byName["db"], byName["db-wal"], byName["litestream-shadow"], byName["socket"])
+ }
+ if !byName["config-env"] || !byName["env"] {
+ t.Errorf("journal records config-env %v env %v, want both present", byName["config-env"], byName["env"])
+ }
+ if j.From.Socket != "" || j.To.Socket != "" {
+ t.Errorf("journal endpoints carry sockets: %+v -> %+v", j.From, j.To)
+ }
+ // Recovery honours it, and the row ends nested with no database
+ // anywhere.
+ r := byHost(t, p.Recover(context.Background(), ModeNested))[standby]
+ if r.Outcome != "recovered" {
+ t.Fatalf("recover: %+v", r)
+ }
+ if got := rowAt(t, p, standby); got != workerEndpoint(p.DataDir, standby, ModeNested) {
+ t.Fatalf("row = %+v", got)
+ }
+ raw := rawInstanceDir(p.DataDir, standby)
+ for _, name := range []string{standby + ".config.env", standby + ".env"} {
+ if _, err := os.Lstat(filepath.Join(raw, name)); err != nil {
+ t.Errorf("%s not at the nested path: %v", name, err)
+ }
+ if _, err := os.Lstat(filepath.Join(p.DataDir, name)); !errors.Is(err, os.ErrNotExist) {
+ t.Errorf("%s still flat: %v", name, err)
+ }
+ }
+ for _, path := range []string{provision.DefaultDBPath(p.DataDir, standby), filepath.Join(raw, standby+".db")} {
+ if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) {
+ t.Errorf("a database appeared at %s: %v", path, err)
+ }
+ }
+ if JournalPresent(p.DataDir, standby) {
+ t.Error("journal left behind")
+ }
+ if fences, lifts := fenceEvents(f, standby); fences == 0 || fences != lifts {
+ t.Errorf("fence events: %d fences, %d lifts: %v", fences, lifts, f.events)
+ }
+ // A plain pass does the same in one step for a second standby.
+ second := "idle2.shop.abc123" + provision.SidecarSuffix
+ if err := p.Registry.Upsert(workerRow(p.DataDir, second, true)); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(p.DataDir, second+".config.env"), []byte("A=1\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if r := byHost(t, p.Converge(context.Background(), ModeNested))[second]; r.Outcome != "migrated" {
+ t.Fatalf("second standby: %+v", r)
+ }
+ if _, err := os.Lstat(filepath.Join(rawInstanceDir(p.DataDir, second), second+".config.env")); err != nil {
+ t.Errorf("second standby's config: %v", err)
+ }
+}
+
+// D8 rev 2's other half: a companion without its database is local history
+// that went missing. The move is refused with the names, before any stop,
+// at either endpoint. Instance rows get the same rule.
+func TestCompanionWithoutDatabaseRefused(t *testing.T) {
+ cases := []struct {
+ name string
+ plant func(t *testing.T, p *Pass, h string) string
+ }{
+ {"wal at the source", func(t *testing.T, p *Pass, h string) string {
+ return provision.DefaultDBPath(p.DataDir, h) + "-wal"
+ }},
+ {"shadow at the source", func(t *testing.T, p *Pass, h string) string {
+ return filepath.Join(p.DataDir, shadowName(h), "generation")
+ }},
+ {"meta directory at the source", func(t *testing.T, p *Pass, h string) string {
+ return provision.DefaultDBPath(p.DataDir, h) + "-litestream/generation"
+ }},
+ {"shm at the destination", func(t *testing.T, p *Pass, h string) string {
+ return filepath.Join(rawInstanceDir(p.DataDir, h), h+".db-shm")
+ }},
+ {"database at the destination only", func(t *testing.T, p *Pass, h string) string {
+ return filepath.Join(rawInstanceDir(p.DataDir, h), h+".db")
+ }},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ p, f, _ := workerBox(t)
+ h := "lone.shop.abc123" + provision.SidecarSuffix
+ if err := p.Registry.Upsert(workerRow(p.DataDir, h, true)); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(p.DataDir, h+".config.env"), []byte("A=1\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ path := c.plant(t, p, h)
+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, []byte("history"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ r := byHost(t, p.Converge(context.Background(), ModeNested))[h]
+ named := path
+ if strings.HasSuffix(path, "/generation") {
+ named = filepath.Dir(path)
+ }
+ if r.Outcome != "refused" || !strings.Contains(r.Reason, "db is missing at") || !strings.Contains(r.Reason, named) {
+ t.Fatalf("result = %+v, want a refusal naming %s", r, named)
+ }
+ if fences, _ := fenceEvents(f, h); fences != 0 {
+ t.Errorf("stopped before the refusal: %v", f.events)
+ }
+ if JournalPresent(p.DataDir, h) {
+ t.Error("a journal was written")
+ }
+ if _, err := os.Lstat(filepath.Join(p.DataDir, h+".config.env")); err != nil {
+ t.Errorf("the config moved without the database: %v", err)
+ }
+ if _, err := os.Lstat(path); err != nil {
+ t.Errorf("the planted companion moved: %v", err)
+ }
+ })
+ }
+ t.Run("instance row too", func(t *testing.T) {
+ p, f := box(t)
+ if err := os.Remove(provision.DefaultDBPath(p.DataDir, host)); err != nil {
+ t.Fatal(err)
+ }
+ r := byHost(t, p.Converge(context.Background(), ModeNested))[host]
+ if r.Outcome != "refused" || !strings.Contains(r.Reason, provision.DefaultDBPath(p.DataDir, host)+"-wal") {
+ t.Fatalf("result = %+v", r)
+ }
+ if fences, _ := fenceEvents(f, host); fences != 0 {
+ t.Errorf("fenced: %v", f.events)
+ }
+ })
+}
+
+// workerWakeBackend is wakeBackend with a worker start and a restore that
+// writes a database at the row's path, the way restorePlain fills an empty
+// path from the bucket on a cold box.
+type workerWakeBackend struct {
+ wakeBackend
+ svc *activator.Service
+ restored []string
+}
+
+func (b *workerWakeBackend) Restore(_ context.Context, rt registry.Route) (bool, error) {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ if err := os.MkdirAll(filepath.Dir(rt.DBPath), 0o700); err != nil {
+ return false, err
+ }
+ if err := os.WriteFile(rt.DBPath, []byte("db from the bucket"), 0o600); err != nil {
+ return false, err
+ }
+ b.restored = append(b.restored, rt.DBPath)
+ return false, nil
+}
+
+func (b *workerWakeBackend) StartWorker(_ context.Context, rt registry.Route, _ map[string]string) (activator.Proc, string, error) {
+ b.mu.Lock()
+ b.started = append(b.started, rt)
+ b.mu.Unlock()
+ // The child reports ready over the agent socket, which ends the
+ // settle window; off this goroutine, as the real call arrives.
+ go b.svc.NoteWorkerReady(rt.Host, "")
+ return nopProc{}, "", nil
+}
+
+// R1-5: two boxes, where only one has ever held the database. The other's
+// row moves with config only, and the host then fails over to it nested,
+// restoring into the nested path.
+func TestTwoBoxFailoverAfterConfigOnlyMove(t *testing.T) {
+ h := hibWorker
+ pA, _, _ := workerBox(t) // holds the database
+ pB, _, _ := workerBox(t)
+ // Box B never woke the worker: config only.
+ for _, name := range []string{h + ".db", h + ".db-wal", h + ".env"} {
+ if err := os.Remove(filepath.Join(pB.DataDir, name)); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := os.RemoveAll(filepath.Join(pB.DataDir, shadowName(h))); err != nil {
+ t.Fatal(err)
+ }
+ for name, p := range map[string]*Pass{"A": pA, "B": pB} {
+ if r := byHost(t, p.Converge(context.Background(), ModeNested))[h]; r.Outcome != "migrated" {
+ t.Fatalf("box %s: %+v", name, r)
+ }
+ if got := rowAt(t, p, h); got != workerEndpoint(p.DataDir, h, ModeNested) {
+ t.Fatalf("box %s row = %+v", name, got)
+ }
+ }
+ if _, err := os.Lstat(filepath.Join(rawInstanceDir(pA.DataDir, h), h+".db")); err != nil {
+ t.Fatalf("box A's database: %v", err)
+ }
+ if _, err := os.Lstat(filepath.Join(rawInstanceDir(pB.DataDir, h), h+".db")); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("box B has a database it never held: %v", err)
+ }
+ if _, err := os.Lstat(filepath.Join(rawInstanceDir(pB.DataDir, h), h+".config.env")); err != nil {
+ t.Fatalf("box B's config did not move: %v", err)
+ }
+
+ // Failover: the host wakes on B through a real activator, against the
+ // row the pass flipped, and restores into the nested path.
+ b := &workerWakeBackend{}
+ svc := activator.New(activator.Config{Bucket: "test-bucket", Idle: time.Hour}, b, pB.Registry)
+ svc.Routes = pB.Registry
+ b.svc = svc
+ t.Cleanup(func() {
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ svc.Shutdown(ctx)
+ })
+ rt, err := pB.Registry.Get(h)
+ if err != nil || rt == nil {
+ t.Fatal(err)
+ }
+ if err := svc.WakeWorker(*rt); err != nil {
+ t.Fatalf("WakeWorker on B after the move: %v", err)
+ }
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ want := provision.NestedDBPath(pB.DataDir, h)
+ if len(b.restored) != 1 || b.restored[0] != want {
+ t.Fatalf("restored = %v, want [%s]", b.restored, want)
+ }
+ if len(b.started) != 1 || b.started[0].DBPath != want || b.started[0].Socket != "" {
+ t.Fatalf("started = %+v, want the nested row", b.started)
+ }
+ if _, err := os.Lstat(provision.DefaultDBPath(pB.DataDir, h)); !errors.Is(err, os.ErrNotExist) {
+ t.Errorf("a flat database appeared on B: %v", err)
+ }
+}
+
+// snapshotFlat is every entry of the data directory named for h, with the
+// contents of every regular file under it.
+func snapshotFlat(t *testing.T, dd, h string) map[string]string {
+ t.Helper()
+ out := map[string]string{}
+ entries, err := os.ReadDir(dd)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, e := range entries {
+ if !belongsTo(e.Name(), h) {
+ continue
+ }
+ root := filepath.Join(dd, e.Name())
+ err := filepath.Walk(root, func(path string, fi os.FileInfo, err error) error {
+ if err != nil {
+ return err
+ }
+ rel, _ := filepath.Rel(dd, path)
+ if fi.IsDir() {
+ out[rel+"/"] = ""
+ return nil
+ }
+ b, err := os.ReadFile(path)
+ if err != nil {
+ return err
+ }
+ out[rel] = string(b)
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+ return out
+}
+
+func sortedKeys(m map[string]string) []string {
+ keys := make([]string, 0, len(m))
+ for k := range m {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ return keys
+}
+
+// D11: carlos system layout revert brings a worker back byte for byte,
+// extras included, with the row at the flat endpoints and no socket.
+func TestRevertWorkerByteForByte(t *testing.T) {
+ p, _, _ := workerBox(t)
+ blobs := filepath.Join(p.DataDir, hibWorker+"-blobs")
+ if err := os.MkdirAll(blobs, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(blobs, "x"), []byte("blob"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ before := snapshotFlat(t, p.DataDir, hibWorker)
+ if len(before) < 6 {
+ t.Fatalf("snapshot too small to prove anything: %v", sortedKeys(before))
+ }
+ if r := byHost(t, p.Converge(context.Background(), ModeNested))[hibWorker]; r.Outcome != "migrated" {
+ t.Fatalf("to nested: %+v", r)
+ }
+ if left := snapshotFlat(t, p.DataDir, hibWorker); len(left) != 0 {
+ t.Fatalf("left flat after the move: %v", sortedKeys(left))
+ }
+ if r := byHost(t, p.Converge(context.Background(), ModeFlat))[hibWorker]; r.Outcome != "reverted" {
+ t.Fatalf("to flat: %+v", r)
+ }
+ after := snapshotFlat(t, p.DataDir, hibWorker)
+ if len(after) != len(before) {
+ t.Fatalf("entries before %v, after %v", sortedKeys(before), sortedKeys(after))
+ }
+ for k, v := range before {
+ if after[k] != v {
+ t.Errorf("%s: before %q, after %q", k, v, after[k])
+ }
+ }
+ if got := rowAt(t, p, hibWorker); got != workerEndpoint(p.DataDir, hibWorker, ModeFlat) {
+ t.Fatalf("row = %+v", got)
+ }
+ if JournalPresent(p.DataDir, hibWorker) {
+ t.Error("journal left behind")
+ }
+ // The raw instance directory may remain (empty); nothing of the
+ // host's is in it.
+ if entries, err := os.ReadDir(rawInstanceDir(p.DataDir, hibWorker)); err == nil && len(entries) != 0 {
+ names := make([]string, 0, len(entries))
+ for _, e := range entries {
+ names = append(names, e.Name())
+ }
+ t.Errorf("left nested after the revert: %v", names)
+ }
+}
diff --git a/internal/layout/plan.go b/internal/layout/plan.go
index 2fc6a9120..c89388fc6 100644
--- a/internal/layout/plan.go
+++ b/internal/layout/plan.go
@@ -10,6 +10,7 @@ package layout
import (
"fmt"
"path/filepath"
+ "slices"
"strconv"
"strings"
@@ -18,6 +19,7 @@ import (
"github.com/carlosframework/platform/internal/instancectl"
"github.com/carlosframework/platform/internal/instancekey"
"github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
)
@@ -74,6 +76,12 @@ type Plan struct {
Mode Mode
From, To Endpoint
Artifacts []Artifact
+ // Worker marks a plan for a native worker host (sidecar isolation D8):
+ // decided by the host's suffix alone (WorkerHost), never by the row's
+ // Kind, because recovery recomputes the plan from the host name and a
+ // same-uid flat tenant can edit the row. Both endpoints carry Socket ""
+ // and the socket artifact is recorded absent on both sides.
+ Worker bool
// DataDir is the box's data directory, kept so the checks that run on
// the plan can find the pass's own directory under it (the aside
// directory, outside any instance tree) without being handed it again.
@@ -231,6 +239,30 @@ func IsNestedPlatformName(host, name string) bool {
return err == nil && instancectl.GenLaunchFileName(gen) == name
}
+// WorkerHost reports whether host is a native worker's (sidecar isolation
+// D2): every sidecar host the platform mints ends in
+// provision.SidecarSuffix, which never resolves. This is the pass's one
+// worker test; the row's Kind must agree with it (WorkerRowAgrees) or the
+// row is refused like any journal mismatch.
+func WorkerHost(host string) bool {
+ return strings.HasSuffix(host, provision.SidecarSuffix)
+}
+
+// WorkerRowAgrees checks that a row's Kind says what its host's shape says:
+// a sidecar row on a worker host, or a non-sidecar row on any other. A
+// disagreement is a row someone edited, or a journal forged for the wrong
+// kind of host, and the pass refuses to act on it rather than derive a
+// plan the row cannot have come from.
+func WorkerRowAgrees(host string, rt registry.Route) error {
+ if WorkerHost(host) == rt.IsSidecar() {
+ return nil
+ }
+ if rt.IsSidecar() {
+ return fmt.Errorf("layout: %s: row kind %q on a host without the %s suffix; not acting", host, rt.Kind, provision.SidecarSuffix)
+ }
+ return fmt.Errorf("layout: %s: row kind %q on a worker host (%s suffix); not acting", host, rt.Kind, provision.SidecarSuffix)
+}
+
// PlanFor derives the plan for host in the given direction from the host
// name alone (design §8.2 step 3). The endpoints are the row values the
// pass will compare against and swap; the artifacts are the files (and one
@@ -241,6 +273,11 @@ func IsNestedPlatformName(host, name string) bool {
// any directory a caller passes, which is invisible in production
// (socketDir is always provision.SocketDir there) but would silently break
// every test that wants an isolated socket tree.
+//
+// A worker host (WorkerHost) has no socket in either layout (sidecar
+// isolation D2, D8): both endpoints carry Socket "", and the socket
+// artifact, which the pass never renames anyway, is recorded absent on
+// both sides. The eight other artifacts keep their order and their paths.
func PlanFor(dataDir, socketDir, host string, mode Mode) (*Plan, error) {
if mode != ModeNested && mode != ModeFlat {
return nil, fmt.Errorf("layout: mode %q is not nested or flat", mode)
@@ -249,6 +286,7 @@ func PlanFor(dataDir, socketDir, host string, mode Mode) (*Plan, error) {
return nil, fmt.Errorf("layout: %q is not a hostname", host)
}
iid := provision.InstanceID(host)
+ worker := WorkerHost(host)
flat := Endpoint{
DB: provision.DefaultDBPath(dataDir, host),
Socket: filepath.Join(socketDir, host+".sock"),
@@ -258,7 +296,15 @@ func PlanFor(dataDir, socketDir, host string, mode Mode) (*Plan, error) {
Socket: filepath.Join(socketDir, "instances", iid, "app.sock"),
}
src, dst := flatFiles(dataDir, socketDir, host), nestedFiles(dataDir, socketDir, host)
- p := &Plan{Host: host, IID: iid, Mode: mode, From: flat, To: nested, DataDir: dataDir}
+ if worker {
+ flat.Socket, nested.Socket = "", ""
+ for i, name := range artifactNames {
+ if name == "socket" {
+ src[i], dst[i] = "", ""
+ }
+ }
+ }
+ p := &Plan{Host: host, IID: iid, Mode: mode, From: flat, To: nested, DataDir: dataDir, Worker: worker}
if mode == ModeFlat {
p.From, p.To = nested, flat
src, dst = dst, src
@@ -275,6 +321,80 @@ func PlanFor(dataDir, socketDir, host string, mode Mode) (*Plan, error) {
return p, nil
}
+// databaseCompanions are the artifacts that are evidence of a database's
+// local history: files that exist only beside a database that was here.
+var databaseCompanions = []string{"db-wal", "db-shm", "litestream-shadow", "corrupt"}
+
+// DatabaseAbsent decides the database's Required flag for one move
+// (sidecar isolation D8, revision 2). The pass used to refuse any host
+// whose database was not at its source, which kept a row minted on a box
+// that never woke it flat for good: config delivery writes a sidecar's
+// .config.env on every box whether or not it has run, so a standby has
+// config and no database.
+//
+// It reports absent (and the caller drops Required, so the journal records
+// the database absent and the rest moves) only when the database and every
+// one of its companions are absent at BOTH endpoints: db, db-wal, db-shm,
+// the activator's litestream shadow, the quarantine, and the box-wide
+// replicator's <db>-litestream directory. The next wake restores from the
+// bucket into whichever path the row then names, which is what every cold
+// box does already. A companion without its database (a WAL with no
+// database, a shadow directory, a meta directory) is local history that
+// went missing, and the move is refused with the names, as before; an
+// operator decides. A database that is at its source is simply present,
+// and the answer is false with no error.
+//
+// Filesystem absence is enough here (spec §3.8, the part of R1-4 declined):
+// the platform's own rule is already that an absent database means
+// "restore from the bucket", and a flat tenant that can rename a stopped
+// sibling's database can delete it outright with no pass involved.
+func DatabaseAbsent(plan *Plan) (absent bool, err error) {
+ db := plan.Artifacts[0]
+ if db.Name != "db" {
+ return false, fmt.Errorf("layout: %s: plan's first artifact is %q, not db", plan.Host, db.Name)
+ }
+ atSrc, err := lstatPresent(db.Src)
+ if err != nil {
+ return false, err
+ }
+ if atSrc {
+ return false, nil
+ }
+ var found []string
+ note := func(path string) error {
+ ok, err := lstatPresent(path)
+ if err != nil {
+ return err
+ }
+ if ok {
+ found = append(found, path)
+ }
+ return nil
+ }
+ if err := note(db.Dst); err != nil {
+ return false, err
+ }
+ for _, a := range plan.Artifacts {
+ if !slices.Contains(databaseCompanions, a.Name) {
+ continue
+ }
+ for _, path := range []string{a.Src, a.Dst} {
+ if err := note(path); err != nil {
+ return false, err
+ }
+ }
+ }
+ for _, path := range []string{db.Src + "-litestream", db.Dst + "-litestream"} {
+ if err := note(path); err != nil {
+ return false, err
+ }
+ }
+ if len(found) > 0 {
+ return false, fmt.Errorf("db is missing at %s, but its history is not: %s exist(s); refusing to move a database's files without it", db.Src, strings.Join(found, ", "))
+ }
+ return true, nil
+}
+
// Journal builds the journal for this plan with the given presence
// vector (one bool per fixed artifact, artifactNames order), plus the
// plan's extra entries by name.
diff --git a/internal/layout/replicator.go b/internal/layout/replicator.go
new file mode 100644
index 000000000..81f417d97
--- /dev/null
+++ b/internal/layout/replicator.go
@@ -0,0 +1,254 @@
+package layout
+
+// The box-wide replicator's hold on a worker's database (sidecar isolation
+// spec §3.9, D9; R2-3, R3-3, R4-1).
+//
+// Every worker's lease replication is its only replication, and the
+// box-wide litestream's generated config leaves every worker out
+// (ops.ActivatorReplicated). But /etc/litestream.yml is regenerated by an
+// operator (carlos ops litestream) and litestream reads it only at start,
+// so a daemon that started on an older config may still hold a worker's
+// database open, checkpointing its WAL, long after the file stopped naming
+// it. Renaming the database under such a daemon is the two-writer case
+// the file itself warns about. The running daemon, not the file, decides:
+// the pass moves a worker row only when all of these hold, read under the
+// fence right before the first rename, and not only for always-on workers
+// (a worker switched from always-on to hibernating since the daemon
+// started may still be open in it, so the row's flag proves nothing):
+//
+// - the config does not exist, or exists and does not name the database,
+// so any start from now on cannot open it;
+// - carlos-litestream is exactly active/running and its main process
+// started AFTER the config's mtime (so it read the config that excludes
+// the file), or exactly inactive or failed with no main PID and an
+// empty cgroup (so nothing of it runs);
+// - no observation failed.
+//
+// Anything else (activating, deactivating, reloading, an unreadable
+// property, a config naming the file, a running daemon with no config to
+// place it against) holds, with the reason, and the pass defers the move
+// and asks again on its next pass. Because the config already excludes the
+// file, a restart or a pending start after the check opens a config
+// without it.
+
+import (
+ "bufio"
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+)
+
+const (
+ // litestreamUnit is the box-wide replicator's unit.
+ litestreamUnit = "carlos-litestream.service"
+ // litestreamConfigPath is the config carlos ops litestream generates
+ // and the unit's ExecStart reads (internal/host/units).
+ litestreamConfigPath = "/etc/litestream.yml"
+ // systemctlTimeout bounds each systemctl show the production
+ // observation runs; an answer that does not come is a failed
+ // observation, which holds.
+ systemctlTimeout = 5 * time.Second
+ // cgroupRoot is where the unit's cgroup.events is read from, under the
+ // ControlGroup path systemd reports.
+ cgroupRoot = "/sys/fs/cgroup"
+)
+
+// boxReplicatorHolds is the decision above with its two observations
+// injected: show answers one systemctl show property of a unit (the string
+// the --value form prints), cgroupEmpty reports whether the unit's cgroup
+// has no process in it (an absent cgroup is empty). It returns true when
+// the move must wait, with the reason an operator reads.
+func boxReplicatorHolds(dbPath string, show func(unit, prop string) (string, error), cgroupEmpty func(unit string) (bool, error), configPath string) (bool, string) {
+ if show == nil || cgroupEmpty == nil {
+ return true, "no observation of " + litestreamUnit + " is wired"
+ }
+ configExists, configMtime, err := configNames(configPath, dbPath)
+ if err != nil {
+ return true, err.Error()
+ }
+ props := map[string]string{}
+ for _, prop := range []string{"ActiveState", "SubState", "ExecMainStartTimestamp", "MainPID"} {
+ v, err := show(litestreamUnit, prop)
+ if err != nil {
+ return true, fmt.Sprintf("%s %s unreadable: %v", litestreamUnit, prop, err)
+ }
+ props[prop] = strings.TrimSpace(v)
+ }
+ state, sub := props["ActiveState"], props["SubState"]
+ switch state {
+ case "active":
+ if sub != "running" {
+ return true, fmt.Sprintf("%s is active/%s, not running", litestreamUnit, sub)
+ }
+ if !configExists {
+ return true, fmt.Sprintf("%s is running but %s does not exist, so what it read at start cannot be placed", litestreamUnit, configPath)
+ }
+ started, err := parseSystemdTimestamp(props["ExecMainStartTimestamp"])
+ if err != nil {
+ return true, fmt.Sprintf("%s ExecMainStartTimestamp %q: %v", litestreamUnit, props["ExecMainStartTimestamp"], err)
+ }
+ if !started.After(configMtime) {
+ return true, fmt.Sprintf("%s started at %s, before %s was written at %s, so it may have read a config naming this database", litestreamUnit, started.UTC().Format(time.RFC3339), configPath, configMtime.UTC().Format(time.RFC3339))
+ }
+ return false, ""
+ case "inactive", "failed":
+ if props["MainPID"] != "0" {
+ return true, fmt.Sprintf("%s is %s but reports MainPID %s", litestreamUnit, state, props["MainPID"])
+ }
+ empty, err := cgroupEmpty(litestreamUnit)
+ if err != nil {
+ return true, fmt.Sprintf("%s cgroup unreadable: %v", litestreamUnit, err)
+ }
+ if !empty {
+ return true, fmt.Sprintf("%s is %s but its cgroup is not empty", litestreamUnit, state)
+ }
+ return false, ""
+ }
+ return true, fmt.Sprintf("%s is %s/%s, neither running nor stopped", litestreamUnit, state, sub)
+}
+
+// configNames reads the replicator's config and reports whether it exists,
+// its mtime, and an error when it cannot be read or when it names dbPath.
+// The generated file writes each database as " - path: <path>"; a
+// hand-written one may quote the value or omit the space, so every
+// whitespace-separated token is compared, quotes and a "path:" prefix
+// stripped, as a cleaned path. A match anywhere is a hold: the generous
+// reading is the safe one.
+func configNames(configPath, dbPath string) (exists bool, mtime time.Time, err error) {
+ fi, err := os.Stat(configPath)
+ if errors.Is(err, os.ErrNotExist) {
+ return false, time.Time{}, nil
+ }
+ if err != nil {
+ return false, time.Time{}, fmt.Errorf("%s unreadable: %w", configPath, err)
+ }
+ f, err := os.Open(configPath)
+ if err != nil {
+ return true, fi.ModTime(), fmt.Errorf("%s unreadable: %w", configPath, err)
+ }
+ defer f.Close()
+ want := filepath.Clean(dbPath)
+ sc := bufio.NewScanner(f)
+ sc.Buffer(make([]byte, 0, 64*1024), 1<<20)
+ for sc.Scan() {
+ line := sc.Text()
+ if i := strings.IndexByte(line, '#'); i >= 0 {
+ line = line[:i]
+ }
+ for _, tok := range strings.Fields(line) {
+ tok = strings.TrimPrefix(tok, "path:")
+ tok = strings.Trim(tok, `"'`)
+ if tok != "" && filepath.Clean(tok) == want {
+ return true, fi.ModTime(), fmt.Errorf("%s names %s", configPath, dbPath)
+ }
+ }
+ }
+ if err := sc.Err(); err != nil {
+ return true, fi.ModTime(), fmt.Errorf("%s unreadable: %w", configPath, err)
+ }
+ return true, fi.ModTime(), nil
+}
+
+// parseSystemdTimestamp reads a timestamp as systemctl show prints it:
+// "@<seconds>" under --timestamp=unix (what the production observation
+// asks for), or the default "Fri 2026-10-24 10:00:00 UTC". The textual
+// form is accepted only in UTC or GMT: Go reads an unknown zone
+// abbreviation as offset zero, which would place a daemon started in
+// summer time an hour wrong, and an hour is exactly the kind of margin
+// this comparison must not be wrong by. Anything else is an error, and an
+// error holds.
+func parseSystemdTimestamp(s string) (time.Time, error) {
+ s = strings.TrimSpace(s)
+ if s == "" || s == "n/a" {
+ return time.Time{}, errors.New("no timestamp")
+ }
+ if rest, ok := strings.CutPrefix(s, "@"); ok {
+ secs, frac, _ := strings.Cut(rest, ".")
+ n, err := strconv.ParseInt(secs, 10, 64)
+ if err != nil {
+ return time.Time{}, fmt.Errorf("not a unix timestamp: %w", err)
+ }
+ var nanos int64
+ if frac != "" {
+ // Up to microseconds, as systemd prints them.
+ for len(frac) < 9 {
+ frac += "0"
+ }
+ nanos, err = strconv.ParseInt(frac[:9], 10, 64)
+ if err != nil {
+ return time.Time{}, fmt.Errorf("not a unix timestamp: %w", err)
+ }
+ }
+ return time.Unix(n, nanos), nil
+ }
+ t, err := time.Parse("Mon 2006-01-02 15:04:05 MST", s)
+ if err != nil {
+ return time.Time{}, err
+ }
+ if name, _ := t.Zone(); name != "UTC" && name != "GMT" {
+ return time.Time{}, fmt.Errorf("zone %q is not UTC; ask systemctl for --timestamp=unix", name)
+ }
+ return t, nil
+}
+
+// BoxReplicatorHolds is the production observation of the box-wide
+// replicator for the pass's Replicator seam: systemctl show, run as the
+// agent user and read-only, and the unit's cgroup.events under the
+// ControlGroup systemd reports for it. An inactive unit has no control
+// group, which is an empty one.
+func BoxReplicatorHolds(dbPath string) (bool, string) {
+ return boxReplicatorHolds(dbPath, systemctlShow, unitCgroupEmpty, litestreamConfigPath)
+}
+
+// systemctlShow prints one property of a unit. --timestamp=unix makes
+// every timestamp property "@<seconds>", which parseSystemdTimestamp reads
+// without a zone.
+func systemctlShow(unit, prop string) (string, error) {
+ ctx, cancel := context.WithTimeout(context.Background(), systemctlTimeout)
+ defer cancel()
+ out, err := exec.CommandContext(ctx, "systemctl", "show", "--timestamp=unix", "-p", prop, "--value", unit).Output()
+ if err != nil {
+ var ee *exec.ExitError
+ if errors.As(err, &ee) && len(ee.Stderr) > 0 {
+ return "", fmt.Errorf("systemctl show %s: %w: %s", unit, err, strings.TrimSpace(string(ee.Stderr)))
+ }
+ return "", fmt.Errorf("systemctl show %s: %w", unit, err)
+ }
+ return strings.TrimSpace(string(out)), nil
+}
+
+// unitCgroupEmpty reads the unit's cgroup.events. A unit systemd reports
+// no ControlGroup for has no cgroup, so nothing runs in it; a cgroup
+// directory that is gone is empty the same way; otherwise cgroup.events
+// must say "populated 0".
+func unitCgroupEmpty(unit string) (bool, error) {
+ group, err := systemctlShow(unit, "ControlGroup")
+ if err != nil {
+ return false, err
+ }
+ if group == "" {
+ return true, nil
+ }
+ if !strings.HasPrefix(group, "/") || strings.Contains(group, "..") {
+ return false, fmt.Errorf("ControlGroup %q is not a cgroup path", group)
+ }
+ b, err := os.ReadFile(filepath.Join(cgroupRoot, group, "cgroup.events"))
+ if errors.Is(err, os.ErrNotExist) {
+ return true, nil
+ }
+ if err != nil {
+ return false, err
+ }
+ for _, line := range strings.Split(string(b), "\n") {
+ if f := strings.Fields(line); len(f) == 2 && f[0] == "populated" {
+ return f[1] == "0", nil
+ }
+ }
+ return false, fmt.Errorf("cgroup.events of %s has no populated line", group)
+}
diff --git a/internal/layout/replicator_test.go b/internal/layout/replicator_test.go
new file mode 100644
index 000000000..5a1fa7584
--- /dev/null
+++ b/internal/layout/replicator_test.go
@@ -0,0 +1,248 @@
+package layout
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+// replicatorFixture is one observation of carlos-litestream: the config
+// file (written, or absent when config is ""), its mtime, the four
+// properties systemctl show would print, and the cgroup's answer.
+type replicatorFixture struct {
+ config string
+ configMtime time.Time
+ props map[string]string
+ propErr map[string]error
+ cgroupEmpty bool
+ cgroupErr error
+}
+
+const fixtureDB = "/data/bot.shop.abc123.sidecar.internal.db"
+
+var (
+ configAt = time.Date(2026, 10, 24, 10, 0, 0, 0, time.UTC)
+ before = configAt.Add(-time.Minute)
+ after = configAt.Add(time.Minute)
+)
+
+func unixStamp(t time.Time) string { return "@" + itoa(t.Unix()) }
+
+func itoa(n int64) string {
+ b, neg := []byte{}, n < 0
+ if neg {
+ n = -n
+ }
+ for n >= 10 {
+ b = append([]byte{byte('0' + n%10)}, b...)
+ n /= 10
+ }
+ b = append([]byte{byte('0' + n)}, b...)
+ if neg {
+ b = append([]byte{'-'}, b...)
+ }
+ return string(b)
+}
+
+// configWithout is a generated config naming another database only.
+const configWithout = "snapshot:\n interval: 1h\ndbs:\n - path: /data/registry.db\n replicas:\n - url: s3://b/p/data/registry.db\n"
+
+// configWith names the worker's database.
+const configWith = configWithout + " - path: " + fixtureDB + "\n replicas:\n - url: s3://b/p" + fixtureDB + "\n"
+
+func running(started time.Time) map[string]string {
+ return map[string]string{"ActiveState": "active", "SubState": "running", "ExecMainStartTimestamp": unixStamp(started), "MainPID": "4242"}
+}
+
+func stopped(state string) map[string]string {
+ return map[string]string{"ActiveState": state, "SubState": "dead", "ExecMainStartTimestamp": "n/a", "MainPID": "0"}
+}
+
+func (f replicatorFixture) holds(t *testing.T) (bool, string) {
+ t.Helper()
+ dir := t.TempDir()
+ path := filepath.Join(dir, "litestream.yml")
+ if f.config != "" {
+ if err := os.WriteFile(path, []byte(f.config), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chtimes(path, f.configMtime, f.configMtime); err != nil {
+ t.Fatal(err)
+ }
+ }
+ show := func(unit, prop string) (string, error) {
+ if unit != litestreamUnit {
+ t.Errorf("asked about %q, want %s", unit, litestreamUnit)
+ }
+ if err := f.propErr[prop]; err != nil {
+ return "", err
+ }
+ v, ok := f.props[prop]
+ if !ok {
+ t.Errorf("asked for %q, which the fixture does not define", prop)
+ }
+ return v, nil
+ }
+ cgroup := func(unit string) (bool, error) { return f.cgroupEmpty, f.cgroupErr }
+ return boxReplicatorHolds(fixtureDB, show, cgroup, path)
+}
+
+func wantHold(t *testing.T, f replicatorFixture, reasonHas string) {
+ t.Helper()
+ holds, why := f.holds(t)
+ if !holds {
+ t.Fatalf("holds = false, want a hold mentioning %q", reasonHas)
+ }
+ if !strings.Contains(why, reasonHas) {
+ t.Fatalf("reason = %q, want it to mention %q", why, reasonHas)
+ }
+}
+
+func wantProceed(t *testing.T, f replicatorFixture) {
+ t.Helper()
+ if holds, why := f.holds(t); holds {
+ t.Fatalf("holds = true (%s), want the move to proceed", why)
+ }
+}
+
+// The config names the file: a hold whatever the daemon is doing, running
+// or stopped, because the next start would open it.
+func TestAlwaysOnMoveHeldWhileReplicatorNamesFile(t *testing.T) {
+ t.Run("running after the config", func(t *testing.T) {
+ wantHold(t, replicatorFixture{config: configWith, configMtime: configAt, props: running(after)}, "names "+fixtureDB)
+ })
+ t.Run("inactive and empty", func(t *testing.T) {
+ wantHold(t, replicatorFixture{config: configWith, configMtime: configAt, props: stopped("inactive"), cgroupEmpty: true}, "names "+fixtureDB)
+ })
+ t.Run("quoted hand-written path", func(t *testing.T) {
+ cfg := "dbs:\n - path: \"" + fixtureDB + "\"\n"
+ wantHold(t, replicatorFixture{config: cfg, configMtime: configAt, props: running(after)}, "names "+fixtureDB)
+ })
+ t.Run("a longer path is not this file", func(t *testing.T) {
+ cfg := "dbs:\n - path: " + fixtureDB + "-litestream/x.db\n"
+ wantProceed(t, replicatorFixture{config: cfg, configMtime: configAt, props: running(after)})
+ })
+}
+
+// A running daemon that started before the config was written may have
+// read a config that named the file.
+func TestAlwaysOnMoveHeldWhileReplicatorStartedBeforeConfig(t *testing.T) {
+ wantHold(t, replicatorFixture{config: configWithout, configMtime: configAt, props: running(before)}, "before")
+ t.Run("started at the same instant", func(t *testing.T) {
+ wantHold(t, replicatorFixture{config: configWithout, configMtime: configAt, props: running(configAt)}, "before")
+ })
+ t.Run("running with no config at all", func(t *testing.T) {
+ wantHold(t, replicatorFixture{props: running(after)}, "does not exist")
+ })
+}
+
+func TestAlwaysOnMoveHeldWhileDeactivating(t *testing.T) {
+ props := map[string]string{"ActiveState": "deactivating", "SubState": "stop-sigterm", "ExecMainStartTimestamp": unixStamp(after), "MainPID": "4242"}
+ wantHold(t, replicatorFixture{config: configWithout, configMtime: configAt, props: props, cgroupEmpty: true}, "deactivating")
+}
+
+func TestAlwaysOnMoveHeldWhileActivating(t *testing.T) {
+ props := map[string]string{"ActiveState": "activating", "SubState": "start", "ExecMainStartTimestamp": unixStamp(after), "MainPID": "4242"}
+ wantHold(t, replicatorFixture{config: configWithout, configMtime: configAt, props: props}, "activating")
+ t.Run("reloading", func(t *testing.T) {
+ props := map[string]string{"ActiveState": "reloading", "SubState": "reload", "ExecMainStartTimestamp": unixStamp(after), "MainPID": "4242"}
+ wantHold(t, replicatorFixture{config: configWithout, configMtime: configAt, props: props}, "reloading")
+ })
+ t.Run("active but not running", func(t *testing.T) {
+ props := running(after)
+ props["SubState"] = "exited"
+ wantHold(t, replicatorFixture{config: configWithout, configMtime: configAt, props: props}, "not running")
+ })
+}
+
+func TestAlwaysOnMoveHeldOnUnreadableProperty(t *testing.T) {
+ boom := errors.New("systemctl: connection refused")
+ for _, prop := range []string{"ActiveState", "SubState", "ExecMainStartTimestamp", "MainPID"} {
+ t.Run(prop, func(t *testing.T) {
+ wantHold(t, replicatorFixture{config: configWithout, configMtime: configAt, props: running(after), propErr: map[string]error{prop: boom}}, prop+" unreadable")
+ })
+ }
+ t.Run("timestamp in an unknown zone", func(t *testing.T) {
+ props := running(after)
+ props["ExecMainStartTimestamp"] = "Fri 2026-10-24 12:00:00 CEST"
+ wantHold(t, replicatorFixture{config: configWithout, configMtime: configAt, props: props}, "not UTC")
+ })
+ t.Run("timestamp unparseable", func(t *testing.T) {
+ props := running(after)
+ props["ExecMainStartTimestamp"] = "n/a"
+ wantHold(t, replicatorFixture{config: configWithout, configMtime: configAt, props: props}, "ExecMainStartTimestamp")
+ })
+ t.Run("cgroup unreadable", func(t *testing.T) {
+ wantHold(t, replicatorFixture{config: configWithout, configMtime: configAt, props: stopped("inactive"), cgroupErr: errors.New("permission denied")}, "cgroup unreadable")
+ })
+ t.Run("inactive with a main pid", func(t *testing.T) {
+ props := stopped("inactive")
+ props["MainPID"] = "77"
+ wantHold(t, replicatorFixture{config: configWithout, configMtime: configAt, props: props, cgroupEmpty: true}, "MainPID 77")
+ })
+ t.Run("inactive with a populated cgroup", func(t *testing.T) {
+ wantHold(t, replicatorFixture{config: configWithout, configMtime: configAt, props: stopped("failed"), cgroupEmpty: false}, "cgroup is not empty")
+ })
+ t.Run("config unreadable", func(t *testing.T) {
+ dir := t.TempDir()
+ // A directory where the file should be: Stat succeeds, Open
+ // succeeds, the read fails.
+ path := filepath.Join(dir, "litestream.yml")
+ if err := os.Mkdir(path, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ show := func(unit, prop string) (string, error) { return running(after)[prop], nil }
+ holds, why := boxReplicatorHolds(fixtureDB, show, func(string) (bool, error) { return true, nil }, path)
+ if !holds || !strings.Contains(why, "unreadable") {
+ t.Fatalf("holds = %v (%s), want a hold for an unreadable config", holds, why)
+ }
+ })
+ t.Run("no observation wired", func(t *testing.T) {
+ if holds, _ := boxReplicatorHolds(fixtureDB, nil, nil, "/nonexistent"); !holds {
+ t.Fatal("nil observations must hold")
+ }
+ })
+}
+
+func TestAlwaysOnMoveProceedsWhenInactiveEmptyAndConfigExcludes(t *testing.T) {
+ wantProceed(t, replicatorFixture{config: configWithout, configMtime: configAt, props: stopped("inactive"), cgroupEmpty: true})
+ t.Run("failed and empty", func(t *testing.T) {
+ wantProceed(t, replicatorFixture{config: configWithout, configMtime: configAt, props: stopped("failed"), cgroupEmpty: true})
+ })
+ t.Run("no config at all", func(t *testing.T) {
+ wantProceed(t, replicatorFixture{props: stopped("inactive"), cgroupEmpty: true})
+ })
+}
+
+func TestAlwaysOnMoveProceedsWhenRunningAfterConfigWithout(t *testing.T) {
+ wantProceed(t, replicatorFixture{config: configWithout, configMtime: configAt, props: running(after)})
+ t.Run("textual UTC timestamp", func(t *testing.T) {
+ props := running(after)
+ props["ExecMainStartTimestamp"] = after.Format("Mon 2006-01-02 15:04:05 MST")
+ wantProceed(t, replicatorFixture{config: configWithout, configMtime: configAt, props: props})
+ })
+ t.Run("unix timestamp with microseconds", func(t *testing.T) {
+ props := running(after)
+ props["ExecMainStartTimestamp"] = unixStamp(after) + ".250000"
+ wantProceed(t, replicatorFixture{config: configWithout, configMtime: configAt, props: props})
+ })
+}
+
+func TestParseSystemdTimestamp(t *testing.T) {
+ got, err := parseSystemdTimestamp("@1761300000")
+ if err != nil || !got.Equal(time.Unix(1761300000, 0)) {
+ t.Fatalf("unix form: %v %v", got, err)
+ }
+ got, err = parseSystemdTimestamp("Fri 2026-10-24 10:00:00 UTC")
+ if err != nil || !got.Equal(configAt) {
+ t.Fatalf("textual UTC form: %v %v", got, err)
+ }
+ for _, bad := range []string{"", "n/a", "@x", "Fri 2026-10-24 10:00:00 BST", "2026-10-24"} {
+ if _, err := parseSystemdTimestamp(bad); err == nil {
+ t.Errorf("%q parsed, want an error", bad)
+ }
+ }
+}