8000 fix(reconciler): retry failed batcher flushes with bounded backoff by VLukiantsev · Pull Request #1200 · google/xmc · GitHub
[go: up one dir, main page]

Skip to content

fix(reconciler): retry failed batcher flushes with bounded backoff - #1200

Merged
VLukiantsev merged 6 commits into
fix/jobset-persist-stopped-before-finalizerfrom
fix/batcher-requeue-failed-flushes
Oct 13, 2026
Merged

VLukiantsev merged 6 commits into
fix/jobset-persist-stopped-before-finalizerfrom
fix/batcher-requeue-failed-flushes

Conversation

@VLukiantsev
8000
@VLukiantsev VLukiantsev commented Oct 12, 2026 •
Copy link
Copy Markdown
Collaborator

Summary

Retry UpdateWorkUnitExecutions failures in the reconciler batcher instead of dropping them. Transient failures are re-queued for each (xid, wid, source) slot. The wait between retries starts at the tick interval (at least 250ms), doubles each time, caps at 30s, and an update is dropped after a 5-minute retry budget. Permanent failures still drop the batch, now counted in a metric. Layer 2 of the b/569896494 stack.

Motivation & Context

Before this change, processBatches swapped out the pending map and then only logged and counted storage errors. Any failure, including the 30s timeout, silently lost every update in the batch (b/569896494 point 2).

Design:

  • Classification: shared.IsPermanentStorageError (NotFound, InvalidArgument, FailedPrecondition, PermissionDenied) moves from jobset to shared, so the JobSet reconciler and the batcher use one classifier.
  • Merge & hot-path lock rules:
    • takeSnapshots keeps the happy path (len(b.retries) == 0) to an $O(1)$ map pointer swap under UpdatesMu with zero extra allocations, and iterates only b.retries ($O(|\text{retries}|)$) when retries are queued.
    • buildExperimentBatch constructs and sorts each experiment's []batchEntry outside UpdatesMu in its per-experiment goroutine.
    • A pending update with an equal or higher EID supersedes a queued retry; a higher-EID retry replaces a lower-EID pending update; Remove also evicts matching b.retries entries with EID <= maxEID and records an in-flight tombstone while a flush is active so a failing in-flight RPC cannot re-queue a removed or superseded update (or falsely count retry_budget_exhausted).
    • Retries that aren't due yet are sent along with an experiment's flush when it's happening that tick anyway, which adds no Spanner calls.
  • Load: due retries join the experiment's batch, so it's still at most one call per experiment per tick.

Key Changes

  • cmd/reconciler/shared/batcher_retry.go (new): retry queue, backoff, budget, takeSnapshots, buildExperimentBatch, and in-flight Remove/Add supersede handling (removeRetryLocked, finishFlush, isSupersededLocked).
  • cmd/reconciler/shared/storage_errors.go (new): IsPermanentStorageError.
  • cmd/reconciler/shared/batcher.go: processBatches uses takeSnapshots + buildExperimentBatch + finishFlush; Remove also evicts queued and in-flight retries; flushes keep each update's (wid, source) key.
  • New metrics: batcher_updates_dropped_total{xid,reason}, batcher_updates_retried_total{xid}, gauge batcher_retry_queue_size.
  • docs/flows/reconciliation.md §6 is updated.

Assumptions

  • Restarts: retries live in memory, so a pod restart loses them. The terminal STOPPED update doesn't depend on this, because fix(reconciler): persist STOPPED status before releasing JobSet finalizer #1189 writes it synchronously.
  • Metric meaning changes: xmc_actuator_batcher_update_calls_total now includes retries. batcher_updates_processed_total counts a retried update again. No dashboards or alerts in the repo reference either metric.
  • Fields outside the state: fields that storage doesn't guard by time take whichever source writes last. The window for that is now up to 5 minutes instead of one tick. The state itself is protected by the storage transition guard (terminal states have no allowed transitions out).

Verification & Testing

  • Commands Executed:
    • bazel test //cmd/reconciler/...: 7/7 pass.
    • bazel test --@io_bazel_rules_go//go/config:race //cmd/reconciler/shared:shared_test: passes.
    • Pre-commit hook: passes.
  • Tests: batcher_retry_test.go uses a scripted fault-injecting StorageUpdater and a fake clock:
    • a transient failure followed by success persists the update on retry;
    • a retry isn't sent before its scheduled time;
    • backoff doubles and caps at 30s;
    • the merge and supersede rules, including retries sent along with an experiment's flush;
    • TestRetry_RemoveEvictsQueuedRetries: Remove evicts queued retries with EID <= eid, preserves retries with EID > eid, and updates batcher_retry_queue_size;
    • TestRetry_InFlightRemoveAndAddSupersede: in-flight Remove (EID >= entry.EID vs EID < entry.EID) and in-flight Add (EID >= entry.EID suppressing retry and retry_budget_exhausted drop, and EID < entry.EID evicted on failure);
    • the budget boundary: kept at exactly 5m, dropped at 5m+1ns;
    • a permanent failure drops the update without retrying;
    • a newer queued retry survives a failure of an older update;
    • a concurrent Add/flush/requeue test, run with the race detector.

Related Issues / Bugs

Part of b/569896494. Stacked on #1189.

TAG=agy
CONV=cbf91f29-980f-4054-9fd9-4a0bf6574811

@VLukiantsev
Copy link
Copy Markdown
Collaborator Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a retry mechanism with exponential backoff and a retry budget for transient storage failures in the UpdateBatcher component. It also refactors permanent storage error detection into a shared package, updates documentation, and adds comprehensive unit tests. The review feedback focuses on performance optimizations: specifically, avoiding unnecessary heap allocations in updatePointers by directly referencing slice elements, and replacing the allocating groupByWorkUnit helper with a zero-allocation countUniqueWorkUnits function since it is only used for metrics observation.

Comment thread cmd/reconciler/shared/batcher_retry.go Resolved
Comment thread cmd/reconciler/shared/batcher_retry.go Resolved
Comment thread cmd/reconciler/shared/batcher.go Resolved
@gemini-code-assist gemini-code-assist Bot left a comment
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a retry mechanism for storage updates in the JobSet reconciler. It adds a retry queue with exponential backoff for transient storage errors, while permanently failing updates are dropped. The implementation includes logic to merge pending updates with retries, handle concurrency, and provide observability via new metrics. My feedback focuses on optimizing the critical section by reducing lock contention in takeBatches and mergeRetriesLocked, suggesting the use of strconv for efficient metric formatting, and improving performance by avoiding unnecessary struct copies in updatePointers.

Comment thread cmd/reconciler/shared/batcher_retry.go Resolved
Comment thread cmd/reconciler/shared/batcher_retry.go Resolved
Comment thread cmd/reconciler/shared/batcher_retry.go Resolved
Comment thread cmd/reconciler/shared/batcher_retry.go Resolved
@VLukiantsev
VLukiantsev force-pushed the fix/batcher-requeue-failed-flushes branch 2 times, most recently from 9668977 to d43207e Compare October 12, 2026 11:32
@VLukiantsev
VLukiantsev marked this pull request as ready for review October 12, 2026 12:53
@github-actions
github-actions Bot requested a review from pachopepe October 12, 2026 12:53
Previously a failed UpdateWorkUnitExecutions call dropped every update in
the batch. Transient failures are now requeued per (xid, wid, source) slot
with exponential backoff (tick, min 250ms, doubling, 30s cap) and a 5m
retry budget. Newer pending updates supersede queued retries (EID-aware),
and not-yet-due retries piggyback on experiments already flushing.

A permanent failure still drops the whole batch, now counted with
reason=permanent.

- Move IsPermanentStorageError to shared for reuse by jobset and batcher.
- New metrics: batcher_updates_dropped_total{xid,reason},
  batcher_updates_retried_total{xid}, batcher_retry_queue_size.

Part of b/569896494

TAG=agy
CONV=cbf91f29-980f-4054-9fd9-4a0bf6574811
…k unit counting

Address review on #1200: point directly into batch entries and count unique
work units in one pass (groupByWorkUnit belongs in the isolation layer).

Part of b/569896494

TAG=agy
CONV=cbf91f29-980f-4054-9fd9-4a0bf6574811
Share workUnitState, workUnitExecution, and testUpdate from batcher_test.go
so batcher_retry_test.go does not add a duplicate legacy proto import.

TAG=agy
CONV=cbf91f29-980f-4054-9fd9-4a0bf6574811
@VLukiantsev
VLukiantsev force-pushed the fix/batcher-requeue-failed-flushes branch from 45ca2e1 to 394427c Compare October 12, 2026 16:43
Comment thread cmd/reconciler/shared/batcher_retry.go Resolved
Comment thread cmd/reconciler/shared/batcher.go Resolved
Comment thread cmd/reconciler/shared/batcher.go Resolved
@VLukiantsev
VLukiantsev requested a review from pachopepe October 13, 2026 07:28
@VLukiantsev
VLukiantsev added this pull request to the merge queue Oct 13, 2026
Merged via the queue into main with commit eedfbb0 Oct 13, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

0