Skip to content

test(sync-service): reproduce FlushTracker stall when a tracked consumer dies without cleanup#4713

Open
balegas wants to merge 1 commit into
mainfrom
test/flush-tracker-stall-repro
Open

test(sync-service): reproduce FlushTracker stall when a tracked consumer dies without cleanup#4713
balegas wants to merge 1 commit into
mainfrom
test/flush-tracker-stall-repro

Conversation

@balegas

@balegas balegas commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Summary

Deterministic reproduction of the long-standing "system stops flushing the WAL" stall (replication slot confirmed_flush_lsn stops advancing → unbounded WAL growth on Postgres). This PR adds a test only — no fix. The test passes because it asserts the current (buggy) behavior, documenting the stall.

The stall

ShapeLogCollector's FlushTracker tracks each delivered shape at commit until the consumer reports its storage flush. The global flush boundary is the minimum across incomplete entries, so a single entry that never completes pins confirmed_flush_lsn for the entire stack.

Existing cleanup paths for a dead consumer are:

  1. DOWN detection in ConsumerRegistry.publish/2 — but only when a later event is routed to that same shape.
  2. ShapeCleaner.handle_writer_termination/3 from the consumer's terminate/2 — but terminate never runs on an untrappable :kill (and is a no-op for :normal / :shutdown / {:shutdown, other} reasons).

A consumer killed after its shape is tracked but before it flushes, on a shape whose table then goes quiet, escapes both paths: the FlushTracker entry is permanent and the WAL flush boundary never advances again — even though every live consumer is fully flushed.

The test

FlushTracker stall when tracked consumer dies without cleanup on a quiet shape in shape_log_collector_test.exs:

  • txn 1 (lsn 42) touches two tables; both consumers process it and both shapes are tracked
  • the consumer for other_table is killed with :kill before flushing
  • the surviving consumer flushes everything; boundary advances only to 40 (just below the dead shape's tracked position)
  • txn 2 (lsn 50) touches only test_table, so publish-time DOWN detection never fires; after the surviving consumer flushes it, no boundary update is emitted — the stall
  • root-cause proof: explicitly calling ShapeLogCollector.remove_shape/2 for the dead shape immediately unblocks the boundary to 50

Fix direction (follow-up)

Reconcile FlushTracker entries against consumer liveness in the SLC (monitor consumer pids, or periodically sweep entries whose consumer is dead) instead of relying on publish-time detection and consumer self-reporting. A stall watchdog logging the blocking shape handles + consumer liveness would also make future occurrences diagnosable in production.

Test plan

  • mix test test/electric/replication/shape_log_collector_test.exs — 38 tests, 0 failures

🤖 Generated with Claude Code

…mer dies without cleanup

Reproduces the long-standing "system stops flushing the WAL" stall: a
consumer that dies without running terminate (untrappable :kill) after
its shape is tracked in the FlushTracker leaves a permanently
incomplete entry. Publish-time DOWN detection only fires for events
routed to that same shape, and the ShapeCleaner path requires terminate
to run — so if the shape's table goes quiet, the global flush boundary
is pinned forever and confirmed_flush_lsn never advances for the whole
stack, causing unbounded WAL growth on Postgres.

The test proves the boundary stays pinned while every live consumer is
fully flushed, and that explicitly removing the dead shape from the
ShapeLogCollector immediately unblocks it.

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

codecov Bot commented Jul 14, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 60.05%. Comparing base (5a298c6) to head (47a6dd2).
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@           Coverage Diff           @@
##             main    #4713   +/-   ##
=======================================
  Coverage   60.04%   60.05%           
=======================================
  Files         397      397           
  Lines       43766    43766           
  Branches    12587    12585    -2     
=======================================
+ Hits        26280    26283    +3     
+ Misses      17405    17402    -3     
  Partials       81       81           
Flag Coverage Δ
packages/agents 72.64% <ø> (ø)
packages/agents-mcp 77.70% <ø> (ø)
packages/agents-mobile 80.67% <ø> (ø)
packages/agents-runtime 83.72% <ø> (-0.02%) ⬇️
packages/agents-server 75.69% <ø> (+0.06%) ⬆️
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% <ø> (ø)
packages/y-electric 56.05% <ø> (ø)
typescript 60.05% <ø> (+<0.01%) ⬆️
unit-tests 60.05% <ø> (+<0.01%) ⬆️

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.

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.

1 participant