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>
2 files changed,
+50
−0
internal/layout/pass.go+9 −0internal/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 { |