Skip to content

fix(sync-service): prevent permanent flush-boundary stalls from unexpected consumer lifecycle transitions#4730

Draft
alco wants to merge 5 commits into
mainfrom
alco/flush-tracker-writer-monitors
Draft

fix(sync-service): prevent permanent flush-boundary stalls from unexpected consumer lifecycle transitions#4730
alco wants to merge 5 commits into
mainfrom
alco/flush-tracker-writer-monitors

Conversation

@alco

@alco alco commented Jul 17, 2026

Copy link
Copy Markdown
Member

Problem

The FlushTracker pins confirmed_flush_lsn for the whole stack on the minimum incomplete flush entry, and cleanup of entries is purely event-driven: either new traffic routed to the same shape (publish-time DOWN detection) or an explicit ShapeLogCollector.remove_shape/2 arriving via the ShapeCleaner chain. Several real-world paths break both — a storage error that crashes the consumer and aborts its terminate/2 before handle_writer_termination runs, a removal chain that dies between the ShapeStatus removal and the SLC removal (unretryable: the retry short-circuits at {:error, :data_removed}), untagged {:shutdown, x} exit reasons classified as benign, and a consumer that stays alive but stops flushing. On a subsequently-quiet table any of these pins the WAL ack forever, causing unbounded WAL growth on Postgres. PR #4713 has a minimal reproduction of the stall.

Solution

The SLC becomes the authority on consumer-death handling for flush accounting. Invariant: an incomplete FlushTracker entry always has a live monitor watching the pid responsible for completing it, and a timestamp bounding how long it may sit without flush progress.

  • Writer monitors: the SLC monitors every consumer with an incomplete FlushTracker entry (created when a commit-fragment publish tracks the shape, released when the entry completes or the shape is removed). DOWNs are classified by exit reason: crashes unpin the flush boundary immediately and trigger shape invalidation; deliberate-cleanup DOWNs ({:shutdown, :cleanup}) unpin only; bare :shutdown/:killed are assumed to be supervisor teardown and left alone.
  • Stall detection: incomplete entries carry a last_progress_at timestamp refreshed only by flush notifications. A periodic tick invalidates shapes whose entries make no flush progress for ELECTRIC_FLUSH_STALL_GRACE_PERIOD (default 60s) via the normal ShapeCleaner path, re-arming on each fire so a lost cleanup chain retries instead of pinning forever. Misclassified DOWNs self-heal through the same mechanism. Consumers that legitimately defer flush notifications (buffering ahead of PG snapshot info, subquery move-in awaiting splice) periodically notify the SLC so healthy deferral phases are not misread as stalls.
  • Reason normalization: the benign clause of handle_writer_termination shrinks to exactly :normal/:killed/:shutdown, so untagged {:shutdown, x} reasons now trigger removal; tagged dependency-materializer shutdowns map to stop_and_clean; suspend additionally requires all txn state flushed and notified (guaranteeing a suspended consumer never leaves an incomplete entry behind); Consumer.stop escalates a wedged consumer to a kill after the stop call times out.
  • ShapeCleaner re-issues the idempotent SLC removal even when the shape is already gone from ShapeStatus, healing pins left by removal chains that died partway through.

Telemetry: new [:electric, :flush_tracker, :writer_down] and [:electric, :flush_tracker, :stall_detected] events.

🤖 Generated with Claude Code

alco and others added 4 commits July 16, 2026 22:16
Adds last_progress_at (caller-injected monotonic ms) to incomplete
flush entries, refreshed only by flush notifications; new
stalled_shapes/3 and touch/3 API; handle_txn_fragment reports
newly-tracked shapes. Groundwork for SLC writer monitors and stall
detection (spec electric-2026-07-16-flush-tracker-writer-monitors).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… suspend contract

Shrinks the benign clause of handle_writer_termination to exactly
:normal/:killed/:shutdown so untagged {:shutdown, x} reasons trigger
shape removal; maps tagged dependency-materializer shutdowns to
stop_and_clean instead of propagating the reason verbatim; suspend now
additionally requires all txn state flushed AND notified; Consumer.stop
escalates a wedged consumer to a kill after the stop call times out.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lled shapes

The SLC now holds a monitor on every consumer with an incomplete
FlushTracker entry: crash DOWNs unpin the flush boundary immediately and
trigger shape invalidation; deliberate-cleanup DOWNs unpin only; bare
:shutdown/:killed DOWNs are left to the grace-period stall tick, which
invalidates shapes whose entries make no flush progress for
ELECTRIC_FLUSH_STALL_GRACE_PERIOD (default 60s). Fixes the permanent
confirmed_flush_lsn stall when a consumer dies without cleanup on a
quiet table. Includes changeset and telemetry events.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…acking

- Re-issue the ShapeLogCollector removal from the ShapeCleaner when the
  shape is already gone from ShapeStatus: an earlier removal chain that
  died between the ShapeStatus removal and the SLC removal used to leave
  the shape's flush entry pinned forever, because every stall-check
  re-fire short-circuited at {:error, :data_removed}. The SLC removal is
  idempotent, so the retry now issues it unconditionally.

- Stop the stall check from invalidating healthy shapes that deliberately
  defer flush notifications. A consumer buffering transactions ahead of
  PG snapshot info, or sitting in a subquery move-in awaiting splice, can
  legitimately produce no flush notifications for longer than the grace
  period. While in either deferral phase the consumer now periodically
  casts notify_flush_deferred to the SLC (at a third of the grace
  period), which touches its FlushTracker entry and re-arms the grace
  window. The tick stops itself once the deferral phase ends, and a
  wedged consumer can't send it, so the stall backstop stays intact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@alco alco added the claude label Jul 17, 2026
@codecov

codecov Bot commented Jul 17, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 60.21%. Comparing base (4051e0f) to head (2828324).
⚠️ Report is 3 commits behind head on main.
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #4730      +/-   ##
==========================================
+ Coverage   60.04%   60.21%   +0.17%     
==========================================
  Files         397      412      +15     
  Lines       43766    44396     +630     
  Branches    12586    12590       +4     
==========================================
+ Hits        26281    26735     +454     
- Misses      17403    17580     +177     
+ Partials       82       81       -1     
Flag Coverage Δ
electric-telemetry 73.17% <ø> (?)
elixir 73.17% <ø> (?)
packages/agents 72.64% <ø> (ø)
packages/agents-mcp 77.70% <ø> (ø)
packages/agents-mobile 80.67% <ø> (ø)
packages/agents-runtime 83.73% <ø> (+0.01%) ⬆️
packages/agents-server 75.54% <ø> (-0.14%) ⬇️
packages/agents-server-ui 8.32% <ø> (ø)
packages/electric-ax 51.06% <ø> (ø)
packages/experimental 87.73% <ø> (ø)
packages/react-hooks 86.48% <ø> (ø)
packages/start 82.83% <ø> (ø)
packages/typescript-client 91.89% <ø> (+0.11%) ⬆️
packages/y-electric 56.05% <ø> (ø)
typescript 60.03% <ø> (-0.02%) ⬇️
unit-tests 60.21% <ø> (+0.17%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

…t is delivered to

Monitors were only established for shapes newly tracked by a commit, so
when the suspend-retry path delivered a commit for an already-tracked
shape to a fresh consumer pid, the monitor stayed on the suspended
predecessor. Its queued {:shutdown, :suspend} DOWN was then classified
as a contract-violating crash, spuriously invalidating a healthy shape
and unpinning its boundary while the fresh consumer was still writing.
Monitor every delivered shape that is still tracked after the commit
instead: the swap clause hands the monitor over and flushes the old
pid's queued DOWN. This also makes FlushTracker.handle_txn_fragment's
newly-tracked return value redundant, so it returns just the tracker
again.

Also derive the stall-check re-arm interval from the configured grace
period (clamped to [1s, 10s]) so a short grace period is enforced at
matching granularity, and add test coverage for the monitor hand-over
and for crash classification of exits observed at publish time.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant