carlos / platform Public

layout: DeferAwake leaves an awake host for a pass that finds it asleep

The agent's serial-move leg converges while tenants are serving. With
DeferAwake set, convergeHost defers a host the activator does not report
quiesced, before any fence is asked for, so a row that becomes eligible
later is moved when it is asleep rather than stopped to be moved.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 20e825da1231e7d7523a5704ad18467c4c7115f7 parent 91a73ba
2 files changed, +50 −0
  • internal/layout/pass.go +9 −0
  • internal/layout/pass_linux_test.go +41 −0
diff --git a/internal/layout/pass.go b/internal/layout/pass.go
index c8ec0fbe4..349a00c8c 100644
--- a/internal/layout/pass.go
+++ b/internal/layout/pass.go
@@ -64,6 +64,12 @@ type Pass struct {
// 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.
Serialize func(func())
+ // DeferAwake leaves a host that is awake for a later pass, before any
+ // fence is asked for, rather than stopping it to move it. The design
+ // migrates a row that becomes eligible "when it is asleep", and the
+ // agent's serial-move leg runs while tenants are serving; recovery is
+ // not affected, because a host with a journal cannot be woken.
+ DeferAwake bool
// abortAt is a test seam: the step name after which a move returns as
// if the agent had died there, leaving its journal for Recover.
@@ -209,6 +215,9 @@ func (p *Pass) convergeHost(ctx context.Context, rt registry.Route, routes []reg
return Result{rt.Host, "unchanged", ""}
}
}
+ if p.DeferAwake && p.Fence != nil && !p.Fence.Quiesced(rt.Host) {
+ return Result{rt.Host, "deferred", "awake"}
+ }
return p.migrateHost(ctx, rt.Host, routes, mode)
}
diff --git a/internal/layout/pass_linux_test.go b/internal/layout/pass_linux_test.go
index 1978b138c..6c94909c9 100644
--- a/internal/layout/pass_linux_test.go
+++ b/internal/layout/pass_linux_test.go
@@ -428,6 +428,47 @@ func TestConvergeDefersAHostSomeoneElseFenced(t *testing.T) {
}
}
+// On the serial-move leg a row that becomes eligible while its host is
+// awake is left for a pass that finds it asleep: DeferAwake defers the
+// host before any fence is asked for, so the move never stops a running
+// tenant. A host already at the intent is unchanged, awake or not, and the
+// same awake host is moved when DeferAwake is off.
+func TestConvergeDefersAnAwakeHostBeforeFencingIt(t *testing.T) {
+ p, f := box(t)
+ p.DeferAwake = true
+ f.awake = map[string]bool{host: true}
+ res := byHost(t, p.Converge(context.Background(), ModeNested))
+ if r := res[host]; r.Outcome != "deferred" || r.Reason != "awake" {
+ t.Fatalf("awake host: %+v", r)
+ }
+ if res[replica].Outcome != "migrated" {
+ t.Fatalf("the asleep host: %+v", res[replica])
+ }
+ for _, e := range f.events {
+ if e == "fence "+host || e == "held "+host {
+ t.Fatalf("the awake host was offered a fence: %v", f.events)
+ }
+ }
+ assertFlatUntouched(t, p, host)
+
+ // The next pass finds the host asleep and moves it; the replica is awake
+ // now, but it is already nested.
+ f.awake = map[string]bool{replica: true}
+ res = byHost(t, p.Converge(context.Background(), ModeNested))
+ if res[host].Outcome != "migrated" {
+ t.Fatalf("the host once asleep: %+v", res[host])
+ }
+ if res[replica].Outcome != "unchanged" {
+ t.Fatalf("an awake host already at the intent: %+v", res[replica])
+ }
+
+ p.DeferAwake = false
+ f.awake = map[string]bool{host: true}
+ if r := byHost(t, p.Converge(context.Background(), ModeFlat))[host]; r.Outcome != "reverted" {
+ t.Fatalf("without DeferAwake the awake host: %+v", r)
+ }
+}
+
func TestConvergeDefersARowThatIsNotReady(t *testing.T) {
p, f := box(t)
if err := p.Registry.SetStatus(host, "moving"); err != nil {