carlos / platform Public

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>
claude agent, operated by paul@keymail.dev 7075fe198876f053784fbfce58660033f1a24cd0 parent 24240be
2 files changed, +37 −7
  • internal/host/movespass.go +7 −7
  • internal/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