moves-phase1-6 Task 3 fix round 4 (2): unresolved log rows take backoff and the hour warning
An unconfirmed row the drainer cannot settle yet (its claim still holds the move, or is unreadable) is rescheduled like a failed delivery, with the same one-hour warning, and the batch picks rows by their next attempt; a confirmation makes a row due at once. TestUnresolvedMoveLogRowsDoNotStarveTheBatch.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3 files changed,
+78
−24
internal/console/movesrecovery.go+32 −20internal/console/movesrecovery_test.go+41 −0internal/console/store.go+5 −4
diff --git a/internal/console/movesrecovery.go b/internal/console/movesrecovery.go| index 4d5c605f9..7ef90d6c8 100644 |
| --- a/internal/console/movesrecovery.go |
| +++ b/internal/console/movesrecovery.go |
| @@ -634,8 +634,14 @@ func (s *Server) drainMoveLog(ctx context.Context, lw *hostops.MoveLogWriter) er |
| if err := ctx.Err(); err != nil { |
| return err |
| } |
| - if !r.Confirmed && !s.settleUnconfirmed(ctx, r) { |
| - continue |
| + if !r.Confirmed { |
| + deliver, wait := s.settleUnconfirmed(ctx, r) |
| + if wait != nil { |
| + s.retryMoveLog(r, now, wait) |
| + } |
| + if !deliver { |
| + continue |
| + } |
| } |
| s.deliverMoveLog(ctx, lw, r, now) |
| } |
| @@ -644,29 +650,42 @@ func (s *Server) drainMoveLog(ctx context.Context, lw *hostops.MoveLogWriter) er |
| // settleUnconfirmed settles a row queued for a release that was not yet |
| // confirmed, from the host's claim: released for the row's move, it is |
| -// confirmed (true: deliver it); still the move's, it is left for the |
| -// release; anything else, it is dropped with a journal line. |
| -func (s *Server) settleUnconfirmed(ctx context.Context, r MoveLogPendingEntry) bool { |
| +// confirmed (deliver); still the move's, or the claim unreadable, it waits |
| +// (wait says why, and it takes a failed delivery's backoff and warning, so |
| +// it rotates out of the batch); anything else, it is dropped with a journal |
| +// line. |
| +func (s *Server) settleUnconfirmed(ctx context.Context, r MoveLogPendingEntry) (deliver bool, wait error) { |
| o, err := hostclaim.Read(ctx, s.cfg.Releases, r.Host) |
| if err != nil { |
| - log.Printf("console: moves: %s: the unconfirmed log of move %s at %s waits: the claim: %v", r.Host, r.Op, r.Name, err) |
| - return false |
| + return false, fmt.Errorf("unconfirmed, and the claim cannot be read: %w", err) |
| } |
| switch { |
| case !o.Absent && o.Claim != nil && hostclaim.RequireReleasedMove(r.Op)(o.Claim) == nil: |
| if err := s.store.ConfirmMoveLog(r.Host, r.Op, r.Name); err != nil { |
| - log.Printf("console: moves: %s: the log outbox of move %s: %v", r.Host, r.Op, err) |
| - return false |
| + return false, fmt.Errorf("unconfirmed: %w", err) |
| } |
| - return true |
| + return true, nil |
| case !o.Absent && o.Claim != nil && o.Claim.Op.ID == r.Op: |
| - return false |
| + return false, errors.New("unconfirmed: the claim still holds the move") |
| } |
| log.Printf("console: moves: %s: the log of move %s at %s was never confirmed and the claim no longer holds the move; dropped", r.Host, r.Op, r.Name) |
| if err := s.store.SettleMoveLog(r.Host, r.Op, r.Name); err != nil { |
| log.Printf("console: moves: %s: the log outbox of move %s: %v", r.Host, r.Op, err) |
| } |
| - return false |
| + return false, nil |
| +} |
| + |
| +// retryMoveLog moves a row's next attempt on after one that did not settle |
| +// it (why), and warns of it once in the journal when it is an hour old. |
| +func (s *Server) retryMoveLog(r MoveLogPendingEntry, now time.Time, why error) { |
| + attempts, warned := r.Attempts+1, r.Warned |
| + if !warned && now.Sub(r.QueuedAt) >= moveLogWarnAfter { |
| + log.Printf("console: moves: warning: %s: the log of move %s at %s has been unwritten for over an hour: %v", r.Host, r.Op, r.Name, why) |
| + warned = true |
| + } |
| + if err := s.store.RetryMoveLog(r.Host, r.Op, r.Name, attempts, now.Add(moveLogBackoff(attempts)), warned); err != nil { |
| + log.Printf("console: moves: %s: the log outbox of move %s: %v", r.Host, r.Op, err) |
| + } |
| } |
| // deliverMoveLog writes one row, and settles it or schedules its retry. |
| @@ -693,14 +712,7 @@ func (s *Server) deliverMoveLog(ctx context.Context, lw *hostops.MoveLogWriter, |
| log.Printf("console: moves: %s: the log of move %s at %s can never be written, and is dropped: %v", r.Host, r.Op, r.Name, err) |
| settle() |
| default: |
| - attempts, warned := r.Attempts+1, r.Warned |
| - if !warned && now.Sub(r.QueuedAt) >= moveLogWarnAfter { |
| - log.Printf("console: moves: warning: %s: the log of move %s at %s has been unwritten for over an hour: %v", r.Host, r.Op, r.Name, err) |
| - warned = true |
| - } |
| - if err := s.store.RetryMoveLog(r.Host, r.Op, r.Name, attempts, now.Add(moveLogBackoff(attempts)), warned); err != nil { |
| - log.Printf("console: moves: %s: the log outbox of move %s: %v", r.Host, r.Op, err) |
| - } |
| + s.retryMoveLog(r, now, err) |
| } |
| } |
diff --git a/internal/console/movesrecovery_test.go b/internal/console/movesrecovery_test.go| index d4367ac27..b910b484d 100644 |
| --- a/internal/console/movesrecovery_test.go |
| +++ b/internal/console/movesrecovery_test.go |
| @@ -4,6 +4,7 @@ import ( |
| "context" |
| "crypto/rand" |
| "encoding/json" |
| + "fmt" |
| "os" |
| "path/filepath" |
| "strings" |
| @@ -957,3 +958,43 @@ func TestClaimReleasedIsPublishedOnlyAfterTheRelease(t *testing.T) { |
| } |
| }) |
| } |
| + |
| +// Fix round 4, ruling 2: a row the drainer cannot settle yet (unconfirmed, |
| +// its claim still the move's) takes the same backoff and one-hour warning as |
| +// a failed delivery, and the batch picks rows by their next attempt, so a |
| +// full batch of them never starves a confirmed row. |
| +func TestUnresolvedMoveLogRowsDoNotStarveTheBatch(t *testing.T) { |
| + journal := captureLog(t) |
| + w := landingMovePass(t, "code-mvpass-starve") |
| + body := func(name string) []byte { |
| + b, err := json.Marshal(moves.LogEvent{V: 1, Op: w.id, Host: "acme.go.tito.io", Event: "x", Identity: name, Actor: "console", At: w.clock}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return b |
| + } |
| + prefix := moves.ReportPrefix("bab", "titogo", "acme.go.tito.io", w.id) |
| + for i := 0; i < moveLogDrainBatch; i++ { |
| + name := fmt.Sprintf("held-%03d", i) |
| + if err := w.fx.st.QueueMoveLog("acme.go.tito.io", w.id, name, prefix, body(name), false, w.clock); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if err := w.fx.st.QueueMoveLog("acme.go.tito.io", w.id, "late", prefix, body("late"), true, w.clock); err != nil { |
| + t.Fatal(err) |
| + } |
| + for i := 0; i < 2 && moveLogOf(t, w)["late"].Identity == ""; i++ { |
| + w.pass(t) |
| + } |
| + if moveLogOf(t, w)["late"].Identity != "late" { |
| + t.Fatal("the confirmed row was starved by a full batch of unresolved rows") |
| + } |
| + // The unresolved rows are warned of once, an hour on. |
| + for i := 0; i < 10; i++ { |
| + w.advance(30 * time.Minute) |
| + w.pass(t) |
| + } |
| + if n := strings.Count(journal.String(), "unwritten for over an hour"); n != moveLogDrainBatch { |
| + t.Fatalf("%d hour-old warnings, want one per unresolved row (%d)", n, moveLogDrainBatch) |
| + } |
| +} |
diff --git a/internal/console/store.go b/internal/console/store.go| index 429483b26..6cfab03cf 100644 |
| --- a/internal/console/store.go |
| +++ b/internal/console/store.go |
| @@ -2251,9 +2251,9 @@ ON CONFLICT(host, op, name) DO NOTHING`, host, op, name, prefix, body, at.Unix() |
| return err |
| } |
| -// ConfirmMoveLog marks a queued entry deliverable. |
| +// ConfirmMoveLog marks a queued entry deliverable, due at once. |
| func (s *Store) ConfirmMoveLog(host, op, name string) error { |
| - _, err := s.db.Exec(`UPDATE move_log_outbox SET confirmed = 1 WHERE host = ? AND op = ? AND name = ?`, host, op, name) |
| + _, err := s.db.Exec(`UPDATE move_log_outbox SET confirmed = 1, next_at = 0 WHERE host = ? AND op = ? AND name = ?`, host, op, name) |
| return err |
| } |
| @@ -2284,9 +2284,10 @@ func (s *Store) MoveLogPending(host, op string) ([]MoveLogPendingEntry, error) { |
| } |
| // MoveLogDue is up to limit queued entries whose next attempt is due at now, |
| -// oldest first. |
| +// the longest due first: every attempt that does not settle a row moves its |
| +// next attempt on, so due rows rotate through the batch. |
| func (s *Store) MoveLogDue(now time.Time, limit int) ([]MoveLogPendingEntry, error) { |
| - rows, err := s.db.Query(`SELECT `+moveLogColumns+` FROM move_log_outbox WHERE next_at <= ? ORDER BY rowid LIMIT ?`, now.Unix(), limit) |
| + rows, err := s.db.Query(`SELECT `+moveLogColumns+` FROM move_log_outbox WHERE next_at <= ? ORDER BY next_at, rowid LIMIT ?`, now.Unix(), limit) |
| if err != nil { |
| return nil, err |
| } |