carlos / platform Public

host: two-phase move paging, never suppressed by a journal failure

moves-phase1-6 Tasks 4+5 fix round 1, ruling 4. pageOnce journals
pending:<rule> before the page and paged:<rule> only once the pager
took it; a call that finds pending without paged sends it again. A
journal that cannot record pending pages anyway, and one that cannot
record paged leaves the rule to be sent again, so no journal failure,
a directory sync failing after the replace included, suppresses a rule
(at least once).

moves.SetJournalSyncForTest injects a failure of the directory sync
after the replace (journal.go), for the test. The bad/host subtest,
which failed name validation before any write, is replaced by an
injected sync failure, a refused delivery retried, and an unreadable
journal that still pages.

No censused unit changes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 9445cce1bf8bf4b03d06280ccdb34043e4c8081b parent 4002bd1
3 files changed, +125 −23
  • internal/host/movespaging.go +36 −17
  • internal/host/movespaging_test.go +74 −5
  • internal/moves/journal.go +15 −1
diff --git a/internal/host/movespaging.go b/internal/host/movespaging.go
index c3c3b11c8..cdfdcdf9f 100644
--- a/internal/host/movespaging.go
+++ b/internal/host/movespaging.go
@@ -39,14 +39,17 @@ import (
// an overdue close and an overdue abort. A move converted to relocating with
// no live box in its region (M-C22) is Plan 5 Task 7a's page.
//
-// A rule's rate state is the op journal on this box (moves.Journal, step
-// paged:<rule>, the time it was marked), so a restarted agent does not page
-// it again. It is read and written under the journal's one lock
-// (moves.UpdateJournal), which every other writer of the journal shares. The step is written before the page is sent: a crash between the
-// two, or a send that fails (logged), pages nothing more, as the console's
-// move_pages records an attempt whether or not delivery worked (pages are
-// best effort). A journal that cannot be read or written sends nothing, so a
-// rule that fires every pass can never flood.
+// A rule's rate state is the op journal on this box (moves.Journal), read
+// and written under the journal's one lock (moves.UpdateJournal), which every
+// other writer of the journal shares, so a restarted agent does not page it
+// again. Paging is two-phase (fix round 1, ruling 4): pending:<rule> is
+// journalled before the page, and paged:<rule> only once the pager took it.
+// A call that finds paged sends nothing; one that finds pending without paged
+// sends it again. No journal failure suppresses a rule, a directory sync that
+// fails after the replace included: a call that cannot record pending pages
+// anyway, and one that cannot record paged leaves the rule to be sent again.
+// At least once, then: a rare second page beats a lost one. A page the pager
+// refuses stays pending, and is sent again when its rule fires again.
//
// Unwired (nil Page, as production is until Plan 5 Task 9b wires it), a rule
// is never silent: the agent's log says so loudly, once per rule per move
@@ -62,6 +65,9 @@ var pagingLogf = log.Printf
const (
pagedStep = "paged:"
unwiredStep = "unwired:"
+ // pendingStep is journalled before a rule's page is sent (fix round 1,
+ // ruling 4); paged:<rule> follows once the pager took it.
+ pendingStep = "pending:"
)
// pageOutcome is what became of one rule's page.
@@ -99,32 +105,45 @@ func pageRuleStuck(m *provision.MoveState) string {
return "stuck-" + strconv.FormatUint(m.RecoveryRetryEpoch, 10)
}
-// pageOnce sends one page for rule unless the journal of (host, op) under
-// dataDir says it was sent: it marks the journal, then pages. Unwired, it
-// warns instead (unwiredOnce).
+// pageOnce sends rule's page unless the journal of (host, op) under dataDir
+// says it was sent (paged:<rule>), in two phases: pending:<rule> before the
+// page, paged:<rule> after it. A journal that cannot be read or written never
+// stops the page, so the rule is never suppressed; it may come again.
+// Unwired, it warns instead (unwiredOnce).
func pageOnce(ctx context.Context, page func(ctx context.Context, level, subject, body string) error, dataDir, host, op, rule string, now time.Time, level, subject, body string) pageOutcome {
if page == nil {
unwiredOnce(dataDir, host, op, rule, now)
return pageUnwired
}
+ stamp := now.UTC().Format(time.RFC3339)
+ paged := false
err := moves.UpdateJournal(dataDir, host, op, func(j *moves.Journal) error {
if j.Done(pagedStep + rule) {
+ paged = true
return errJournalUnchanged
}
- j.Mark(pagedStep+rule, now.UTC().Format(time.RFC3339))
+ if j.Done(pendingStep + rule) {
+ return errJournalUnchanged // pending and never sent: send it again
+ }
+ j.Mark(pendingStep+rule, stamp)
return nil
})
- if errors.Is(err, errJournalUnchanged) {
+ if paged {
return pagePagedBefore
}
- if err != nil {
- pagingLogf("moves: %s: the %s page of %s is not sent: its journal: %v", host, rule, op, err)
- return pageUndelivered
+ if err != nil && !errors.Is(err, errJournalUnchanged) {
+ pagingLogf("moves: %s: the %s page of %s is sent without its pending mark: its journal: %v", host, rule, op, err)
}
if err := page(ctx, level, subject, body); err != nil {
- pagingLogf("moves: %s: the %s page of %s: %v", host, rule, op, err)
+ pagingLogf("moves: %s: the %s page of %s was not delivered, and is sent again when the rule fires again: %v", host, rule, op, err)
return pageUndelivered
}
+ if err := moves.UpdateJournal(dataDir, host, op, func(j *moves.Journal) error {
+ j.Mark(pagedStep+rule, stamp)
+ return nil
+ }); err != nil {
+ pagingLogf("moves: %s: the %s page of %s was sent, and its journal may not say so, so it may be sent again: %v", host, rule, op, err)
+ }
return pageSent
}
diff --git a/internal/host/movespaging_test.go b/internal/host/movespaging_test.go
index 67ab4b747..74872e5c5 100644
--- a/internal/host/movespaging_test.go
+++ b/internal/host/movespaging_test.go
@@ -4,6 +4,8 @@ import (
"context"
"errors"
"fmt"
+ "os"
+ "path/filepath"
"strings"
"testing"
"time"
@@ -311,13 +313,80 @@ func TestPagingIsRateLimited(t *testing.T) {
}
})
- t.Run("a journal that cannot be written sends nothing", func(t *testing.T) {
+ // Fix round 1, ruling 4: paging is two-phase. pending:<rule> is
+ // journalled before the page and paged:<rule> only once the pager took
+ // it; a later call that finds pending without paged sends it again. No
+ // journal failure suppresses a rule, a directory sync that fails after
+ // the replace included: a page may come twice, never not at all.
+ t.Run("two-phase: a sync failure after the replace never suppresses a page", func(t *testing.T) {
+ syncErr := errors.New("injected directory sync failure")
+ restore := moves.SetJournalSyncForTest(func(string) error { return syncErr })
+ defer restore()
+ dd := t.TempDir()
+ var pages pagesSeen
+ now := time.Date(2026, 10, 11, 9, 0, 0, 0, time.UTC)
+ // Every journal write replaces the file and then fails its sync.
+ if o := pageOnce(ctx, pages.hook(), dd, p1Host, p1ID, pageRuleStray, now, "incident", "s", "b"); o != pageSent {
+ t.Fatalf("the first call: %s, want the page sent", o)
+ }
+ if o := pageOnce(ctx, pages.hook(), dd, p1Host, p1ID, pageRuleStray, now, "incident", "s", "b"); o != pagePagedBefore {
+ t.Fatalf("the second call: %s, want paged before (the replace landed)", o)
+ }
+ if len(pages) != 1 {
+ t.Fatalf("pages %+v, want one", pages)
+ }
+ j, err := moves.ReadJournal(dd, p1Host, p1ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !j.Done(pendingStep+pageRuleStray) || !j.Done(pagedStep+pageRuleStray) {
+ t.Fatalf("the journal: %+v, want pending and paged", j.Steps)
+ }
+
+ // A page the pager refuses stays pending, and the next call sends it.
+ failing := true
+ flaky := func(_ context.Context, level, subject, body string) error {
+ if failing {
+ return errors.New("the webhook is down")
+ }
+ pages = append(pages, struct{ level, subject, body string }{level, subject, body})
+ return nil
+ }
+ if o := pageOnce(ctx, flaky, dd, p1Host, p1ID, pageRuleStartCheck, now, "incident", "s2", "b2"); o != pageUndelivered {
+ t.Fatalf("a refused delivery: %s", o)
+ }
+ if j, _ := moves.ReadJournal(dd, p1Host, p1ID); !j.Done(pendingStep+pageRuleStartCheck) || j.Done(pagedStep+pageRuleStartCheck) {
+ t.Fatalf("after a refused delivery: %+v, want pending without paged", j.Steps)
+ }
+ failing = false
+ if o := pageOnce(ctx, flaky, dd, p1Host, p1ID, pageRuleStartCheck, now, "incident", "s2", "b2"); o != pageSent {
+ t.Fatalf("the retry: %s, want sent", o)
+ }
+ if o := pageOnce(ctx, flaky, dd, p1Host, p1ID, pageRuleStartCheck, now, "incident", "s2", "b2"); o != pagePagedBefore {
+ t.Fatalf("after the retry: %s, want paged before", o)
+ }
+ if n, _ := pagesWith(pages, "s2"); n != 1 {
+ t.Fatalf("the retried page delivered %d times, want once: %+v", n, pages)
+ }
+ })
+
+ t.Run("two-phase: a journal that cannot be read still pages", func(t *testing.T) {
+ dd := t.TempDir()
+ dir := moves.JournalDir(dd, p1Host, p1ID)
+ if err := os.MkdirAll(dir, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "journal.json"), []byte("not json"), 0o600); err != nil {
+ t.Fatal(err)
+ }
var pages pagesSeen
- if pageOnce(ctx, pages.hook(), t.TempDir(), "bad/host", p1ID, pageRuleStray, time.Now(), "incident", "s", "b") == pageSent || len(pages) != 0 {
- t.Fatalf("paged without a journal: %+v", pages)
+ for i := 0; i < 2; i++ {
+ if o := pageOnce(ctx, pages.hook(), dd, p1Host, p1ID, pageRuleStray, time.Now(), "incident", "s", "b"); o != pageSent {
+ t.Fatalf("call %d: %s, want sent (at least once)", i, o)
+ }
}
- if pageOnce(ctx, nil, t.TempDir(), p1Host, p1ID, pageRuleStray, time.Now(), "incident", "s", "b") != pageUnwired {
- t.Fatal("an unwired pager reported a page")
+ if len(pages) != 2 {
+ t.Fatalf("pages %+v, want one per call while the journal cannot remember", pages)
}
})
}
diff --git a/internal/moves/journal.go b/internal/moves/journal.go
index d0a3410a3..1d24ae40b 100644
--- a/internal/moves/journal.go
+++ b/internal/moves/journal.go
@@ -225,7 +225,21 @@ func (j *Journal) write(dataDir string) error {
return err
}
j.read = j.readSteps()
- return syncDir(dir)
+ return journalReplacedSync(dir)
+}
+
+// journalReplacedSync is the directory sync after the replace: the journal
+// on disk is already the new one when it fails. A variable only so a test can
+// fail it (SetJournalSyncForTest).
+var journalReplacedSync = syncDir
+
+// SetJournalSyncForTest makes f the directory sync after a journal's
+// replace, for a test of what a sync failure after the rename leaves (Plan 6
+// fix round 1, ruling 4), and returns the restore. Tests only.
+func SetJournalSyncForTest(f func(dir string) error) (restore func()) {
+ saved := journalReplacedSync
+ journalReplacedSync = f
+ return func() { journalReplacedSync = saved }
}
// missingDirs lists the directories at and above dir that do not exist yet,