host: moves worker uses the shared JournalPresent predicate
os.Stat on the journal path follows symlinks, so a dangling symlink there read as "no journal" and let the cut proceed mid-migration. The layout pass, the activator, SyncExec and litestream all already share layout.JournalPresent (an Lstat), which sees the link itself. Switch movespass to the same predicate so a dangling symlink fences the cut exactly as a regular journal file does.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2 files changed,
+37
−7
internal/host/movespass.go+7 −7internal/host/movespass_test.go+30 −0
diff --git a/internal/host/movespass.go b/internal/host/movespass.go| index 6ec19b166..6f1891d6a 100644 |
| --- a/internal/host/movespass.go |
| +++ b/internal/host/movespass.go |
| @@ -5,7 +5,6 @@ import ( |
| "encoding/json" |
| "errors" |
| "fmt" |
| - "io/fs" |
| "log" |
| "os" |
| "path/filepath" |
| @@ -15,6 +14,7 @@ import ( |
| "time" |
| "github.com/carlosframework/platform/internal/activator" |
| + "github.com/carlosframework/platform/internal/layout" |
| "github.com/carlosframework/platform/internal/lease" |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/registry" |
| @@ -380,12 +380,12 @@ func runMoveRound(ctx context.Context, s release.Store, w *movesWorker, rec prov |
| // §8.2) is fenced here exactly as Classify fences it for a wake: its |
| // DB may be mid-copy between the flat and nested paths, and neither |
| // location is safe to checkpoint or purge until the journal clears. |
| - // Retried next pass, like every other not-yet-ready state above. A |
| - // journal that cannot be read is not proof there isn't one, so only |
| - // a definite ErrNotExist counts as "no journal": anything else |
| - // fences rather than risks the move against a migration this box |
| - // simply failed to see. |
| - if _, err := os.Stat(provision.LayoutJournalPath(cfg.DataDir, rec.Host)); !errors.Is(err, fs.ErrNotExist) { |
| + // Retried next pass, like every other not-yet-ready state above. |
| + // JournalPresent Lstats rather than Stats, so a dangling symlink at |
| + // the journal path still fences: the pass, the activator, SyncExec |
| + // and litestream all share this one predicate for "held", and a |
| + // journal this box cannot even read is not proof there isn't one. |
| + if layout.JournalPresent(cfg.DataDir, rec.Host) { |
| log.Printf("moves: %s: layout migration in flight; leaving the move for the next pass", rec.Host) |
| return nil |
| } |
diff --git a/internal/host/movespass_test.go b/internal/host/movespass_test.go| index 9fca637a1..84fd98a1c 100644 |
| --- a/internal/host/movespass_test.go |
| +++ b/internal/host/movespass_test.go |
| @@ -300,6 +300,36 @@ func TestMovesPassCutFencedWhenTheJournalCannotBeRead(t *testing.T) { |
| } |
| } |
| +// TestMovesPassCutFencedByDanglingJournalSymlink pins the same fence for a |
| +// dangling symlink at the journal path: os.Stat follows the link and reports |
| +// ErrNotExist for a target that is gone, which would read as "no journal" |
| +// even though something still sits at the path. Lstat sees the link itself |
| +// and does not resolve it, so the cut must fence here exactly as it does for |
| +// a regular journal file. |
| +func TestMovesPassCutFencedByDanglingJournalSymlink(t *testing.T) { |
| + h := newMoveHarness(t) |
| + host := "a.go.example.com" |
| + h.record(t, host, provision.MoveRequest("uk", time.Now()), provision.MovePromoteReady(time.Now()), provision.MoveMarkCutting()) |
| + h.f.put("data-ie", "instances/"+host+"/generations/g/snapshot", "e1") |
| + journal := provision.LayoutJournalPath(h.cfg.DataDir, host) |
| + if err := os.MkdirAll(filepath.Dir(journal), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Symlink(filepath.Join(filepath.Dir(journal), "gone.json"), journal); err != nil { |
| + t.Fatal(err) |
| + } |
| + h.quiet = true |
| + if err := movesPass(context.Background(), h.s, h.worker()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(h.f.ops) != 0 { |
| + t.Fatalf("cut proceeded past a dangling journal symlink: %v", h.f.ops) |
| + } |
| + if got := h.read(t, host); got.Region != "ie" || got.Move.State != provision.MoveCutting { |
| + t.Fatalf("cut state advanced past a dangling journal symlink: %+v", got) |
| + } |
| +} |
| + |
| // A cutting move for a nested row checkpoints the row's OWN DBPath, not the |
| // legacy <DataDir>/<host>.db guess: a nested row's DB lives under |
| // provision.InstanceDir and the flat guess names a file that was never |