carlos / platform Public

layout: a revert completed while the isolation gate refuses is held, not released flat (plan 4 final review)

There is no degraded mode (design §8.5). The journal stays at done and
fences the host, the in-memory fence is lifted, and the result is deferred
with the gate's error. The first recovery after the probe passes converges
the host to nested.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 41467752d6879d58770fa62650e4af9f20a435ef parent 006de0e
2 files changed, +46 −16
  • internal/layout/pass.go +9 −9
  • internal/layout/pass_linux_test.go +37 −7
diff --git a/internal/layout/pass.go b/internal/layout/pass.go
index 9eaab8621..21d519eee 100644
--- a/internal/layout/pass.go
+++ b/internal/layout/pass.go
@@ -668,8 +668,7 @@ func notActing(err error) string {
//
// Completing a journal is never gated on the isolation probe; the leg
// after it toward a nested intent is, exactly as Converge(ModeNested) is.
-// That is the one refused leg that releases the host: its completed
-// endpoint is then flat, which is safe to serve.
+// A gate that refuses the leg holds the host like any other refused leg.
//
// When another operation's fence already holds the host (the residence
// move's cut), the pass completes the journal under that fence, installing
@@ -765,13 +764,14 @@ func (p *Pass) recoverHost(ctx context.Context, host string, byHost map[string]r
}
if intent == ModeNested {
if gerr := gate(); gerr != nil {
- var rerr error
- p.serialize(func() { rerr = RemoveJournal(p.DataDir, host) })
- if rerr != nil {
- return Result{host, "refused", rerr.Error()}
- }
- p.logf("layout: %s: released -> %s; not converging to nested: isolation not ready: %v", host, j.Mode, gerr)
- return Result{host, "recovered", fmt.Sprintf("completed to %s; not converging to nested: isolation not ready: %v", j.Mode, gerr)}
+ // Held, not released flat: there is no degraded mode (design
+ // §8.5), and a host released at a forged revert's endpoint is
+ // a nested tenant running flat. The journal at done keeps it
+ // fenced, so the in-memory fence is lifted as usual, and the
+ // first recovery after the probe passes converges it.
+ reason := fmt.Sprintf("completed to %s; journal kept at done: not converging to nested: isolation not ready: %v", j.Mode, gerr)
+ p.logf("layout: %s: held -> %s", host, reason)
+ return Result{host, "deferred", reason}
}
}
next, h := p.shift(host, intent, j)
diff --git a/internal/layout/pass_linux_test.go b/internal/layout/pass_linux_test.go
index 43b8e5692..a19528225 100644
--- a/internal/layout/pass_linux_test.go
+++ b/internal/layout/pass_linux_test.go
@@ -928,8 +928,11 @@ func TestRecoverUnderAForeignFenceDefersAnAwakeHost(t *testing.T) {
// Completing a journal is never gated, but taking the host on toward a
// nested intent is, as Converge(ModeNested) is. With the probe failing, a
-// revert completes and the host is released flat, which is safe.
-func TestRecoverGatesTheLegTowardNestedAndReleasesFlat(t *testing.T) {
+// revert completes and the host is held flat, not released: there is no
+// degraded mode (design §8.5), and a released flat host is exactly what a
+// forged revert wants. The journal at done is the fence, so the pass
+// lifts its in-memory one.
+func TestRecoverGatesTheLegTowardNestedAndHoldsFlat(t *testing.T) {
p, f := box(t)
if res := byHost(t, p.Converge(context.Background(), ModeNested)); res[host].Outcome != "migrated" {
t.Fatalf("setup: %+v", res)
@@ -942,10 +945,10 @@ func TestRecoverGatesTheLegTowardNestedAndReleasesFlat(t *testing.T) {
f.events = nil
res := byHost(t, p.Recover(context.Background(), ModeNested))
for _, h := range []string{host, replica} {
- if r := res[h]; r.Outcome != "recovered" || !strings.Contains(r.Reason, "isolation not ready: probe: not ready") {
+ if r := res[h]; r.Outcome != "deferred" || !strings.Contains(r.Reason, "isolation not ready: probe: not ready") {
t.Errorf("%s: %+v", h, r)
}
- assertFlatUntouched(t, p, h)
+ assertHeldFlat(t, p, h)
}
if calls != 1 {
t.Errorf("NestedOK called %d times, want once per recovery", calls)
@@ -954,6 +957,29 @@ func TestRecoverGatesTheLegTowardNestedAndReleasesFlat(t *testing.T) {
if !reflect.DeepEqual(f.events, want) {
t.Fatalf("events = %v\nwant %v", f.events, want)
}
+ // Once the probe passes, the next recovery takes the held host on to
+ // nested.
+ p.NestedOK = nil
+ for h, r := range byHost(t, p.Recover(context.Background(), ModeNested)) {
+ if r.Outcome != "recovered" || JournalPresent(p.DataDir, h) || rowAt(t, p, h) != endpoints(t, p, h, ModeNested) {
+ t.Errorf("%s after the probe passed: %+v", h, r)
+ }
+ }
+}
+
+// assertHeldFlat is a host whose revert completed and is held: the row
+// and files at flat, and the journal still there at done.
+func assertHeldFlat(t *testing.T, p *Pass, h string) {
+ t.Helper()
+ if got := rowAt(t, p, h); got != endpoints(t, p, h, ModeFlat) {
+ t.Errorf("%s row not at flat: %+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 j, found, err := ReadJournal(p.DataDir, h); err != nil || !found || j.Mode != ModeFlat || j.Phase != PhaseDone {
+ t.Errorf("%s journal: %+v %v %v, want a flat journal held at done", h, j, found, err)
+ }
}
// addFlatRow adds a fresh flat managed row with its files, after any setup
@@ -1130,13 +1156,17 @@ func TestConvergeAsksTheIsolationGateOncePerPass(t *testing.T) {
if calls != 1 {
t.Fatalf("NestedOK called %d times in one pass", calls)
}
+ want := "recovered"
+ if tc.gate != nil {
+ want = "deferred"
+ }
for _, h := range []string{host, replica} {
- if res[h].Outcome != "recovered" {
- t.Errorf("%s: %+v", h, res[h])
+ if res[h].Outcome != want {
+ t.Errorf("%s: %+v, want %s", h, res[h], want)
}
}
if tc.gate != nil {
- assertFlatUntouched(t, p, host)
+ assertHeldFlat(t, p, host)
assertFlatUntouched(t, p, "f.example.com")
} else if res["f.example.com"].Outcome != "migrated" || rowAt(t, p, host) != endpoints(t, p, host, ModeNested) {
t.Errorf("ready probe: %+v", res)