Skip to content

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

Open
alco wants to merge 7 commits into
mainfrom
alco/flush-tracker-writer-monitors
Open

fix(sync-service): prevent permanent flush-boundary stalls from unexpected consumer lifecycle transitions#4730
alco wants to merge 7 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 finds shapes whose entries make no flush progress for ELECTRIC_FLUSH_STALL_GRACE_PERIOD (default 60s) and invalidates them 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.
  • Stall challenge-response: a stalled entry whose writer is still alive is challenged (:verify_flush_progress) before being acted on. A consumer answers only while legitimately deferring flush notifications (buffering ahead of PG snapshot info, subquery move-in awaiting splice); the answer re-arms the entry's grace period so healthy deferral phases are not misread as stalls. Only a suspect still stalled and silent at the next check — or a shape whose writer is already dead and demonitored, which skips the challenge — is invalidated. Consumers stay silent in steady state: no periodic messaging, and a wedged consumer can't answer, so the backstop stays armed.
  • 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.18%. Comparing base (4051e0f) to head (54ffe11).
⚠️ Report is 5 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.18%   +0.13%     
==========================================
  Files         397      412      +15     
  Lines       43766    44396     +630     
  Branches    12586    12587       +1     
==========================================
+ Hits        26281    26720     +439     
- Misses      17403    17594     +191     
  Partials       82       82              
Flag Coverage Δ
electric-telemetry 71.26% <ø> (?)
elixir 71.26% <ø> (?)
packages/agents 72.64% <ø> (ø)
packages/agents-mcp 77.70% <ø> (ø)
packages/agents-mobile 80.67% <ø> (ø)
packages/agents-runtime 83.72% <ø> (ø)
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.78% <ø> (ø)
packages/y-electric 56.05% <ø> (ø)
typescript 60.02% <ø> (-0.03%) ⬇️
unit-tests 60.18% <ø> (+0.13%) ⬆️

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>

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2828324e3a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/sync-service/lib/electric/replication/shape_log_collector.ex Outdated
alco and others added 2 commits July 20, 2026 12:34
…allenge-response

The deferral heartbeat had every consumer in a deferral phase cast
notify_flush_deferred to the ShapeLogCollector every third of the grace
period — a per-shape periodic message stream converging on the singleton
SLC, deviating from the single-stall-timer design.

Invert the initiative: consumers are silent, and the stall check
challenges suspects instead. The first time a shape shows up stalled
with a live monitored writer, the SLC sends the writer a
:verify_flush_progress message and holds off. A consumer answers only
while deliberately deferring its flushes (buffering ahead of PG snapshot
info, or a subquery move-in awaiting splice) with the existing
notify_flush_deferred cast, which touches the entry and clears the
suspicion. Only a suspect still stalled at the next check — or a shape
whose writer is already dead and demonitored (the self-heal path, which
keeps its single-check latency) — is invalidated.

Any flush progress also clears suspect status, so a shape that answered
is re-challenged rather than invalidated if it stalls again (relevant
when the grace period is shorter than the 1s check-interval floor).

Net effect: zero steady-state messaging (challenges only fire on the
rare stall-suspect path), the backstop stays fully armed — a wedged
consumer cannot answer — at the cost of one extra check interval of
latency before a genuinely wedged-alive shape is culled.

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

The initial timer armed in init/1 used the fixed 10s interval while every
re-arm clamps to the configured grace period, so a sub-10s
ELECTRIC_FLUSH_STALL_GRACE_PERIOD was not enforced at its own granularity
for the first window after startup. Extract the clamp into
schedule_stall_check/1 and use it from init/1 too.

Addresses #4730 (comment)

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