Skip to content

fix(sync-service): shed live shape subscribers that stop draining their mailbox#4672

Open
robacourt wants to merge 2 commits into
mainfrom
rob/fix-request-mailbox-flood
Open

fix(sync-service): shed live shape subscribers that stop draining their mailbox#4672
robacourt wants to merge 2 commits into
mainfrom
rob/fix-request-mailbox-flood

Conversation

@robacourt

@robacourt robacourt commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes an unbounded-memory / OOM failure mode in the live shape-streaming path. A live GET /v1/shape subscriber whose client can't keep up would grow its process mailbox without bound until the node ran out of memory.

Problem

A live request registers as a change subscriber. On every transaction the consumer notifies each subscriber with a fire-and-forget send/2 (Consumer.notify_clients_of_new_changes/2Registry.dispatch), with no backpressure to the producer and no cap on the subscriber's mailbox. The only backpressure is the socket write (Plug.Conn.chunk), which blocks the request process on a stalled/dead client — so the process stops draining its mailbox while the producer keeps enqueuing :new_changes. The message queue, and the reference-counted binary each queued message pins, grows without bound.

Observed in production (Honeycomb):

  • A single GET /v1/shape process with vm.monitor.long_message_queue.length200k (MAX == SUM for that process_type → one process).
Screenshot 2026-07-01 at 17 30 58 - `vm.memory.binary` climbing 0 → **3.9 GB** in lock-step, dragging `vm.memory.system`/`total` to ~5.2 GB → **OOM**. - Corroborating `Error while streaming response: :timeout` / `socket closed` warnings, and near-zero completed `shape_get.plug.stream_chunk` spans (the process was parked inside one blocked `chunk` write). - Node was otherwise near-idle on request volume (~78 shape requests in 5h), ruling out load; footprint fixes in 1.7.x don't address it because it's a missing-backpressure bug, not a memory-footprint one.

Solution

Producer-side mailbox watermark. Before notifying each subscriber, the consumer checks its message_queue_len; if it exceeds :slow_subscriber_max_queue_len, the subscriber is terminated instead of notified. The client reconnects and resumes from its last offset (the live protocol is resumable), so shedding is safe.

This is the only mechanism that catches a handler blocked inside Plug.Conn.chunk — it can't wake to check itself, but the producer inspects it externally. The watermark is always on (a positive integer, default 10,000; non-positive values are rejected at stack startup) and tunable via ELECTRIC_SLOW_SUBSCRIBER_MAX_QUEUE_LEN.

Changes

  • lib/electric/shapes/consumer.ex — watermark check + shed in the notify loop.
  • Config wiring for ELECTRIC_SLOW_SUBSCRIBER_MAX_QUEUE_LEN (runtime.exs, config.ex, application.ex, stack_supervisor.ex, stack_config.ex), mirroring the existing ELECTRIC_CONSUMER_GC_HEAP_THRESHOLD path.
  • Changeset (patch).

Test Plan

  • New reproduction test: a non-draining subscriber is shed once its mailbox crosses the watermark (fails before the fix, passes after).
  • New guard test: a healthy, draining subscriber whose backlog stays under the watermark is never shed.
  • consumer_test.exs, config_test.exs, stack_supervisor_test.exs, shape_cache_test.exs, shape_log_collector_test.exs, partitioned_tables_test.exs all pass (no regressions on the notify path).
  • mix compile --warnings-as-errors clean.

Note: this is producer-side shedding (mechanism A) only. Notification coalescing and a tighter socket send-timeout were discussed as defense-in-depth and deferred; A alone bounds the mailbox and stops the OOM.


Generated with Claude Code

…ir mailbox

A live `GET /v1/shape` request whose client can't keep up — a stalled or
dead socket while changes keep streaming — registered as a change
subscriber and was notified via a fire-and-forget `send/2` per transaction,
with no backpressure to the producer and no cap on the subscriber's mailbox.
The request process, blocked writing to the socket, stopped draining its
mailbox while the consumer kept enqueuing `:new_changes`, so the message
queue (and the reference-counted binary each message pins) grew without
bound until the node OOMed. Observed in production as a single
`GET /v1/shape` process with a ~200k message queue and ~3.9 GB of
`vm.memory.binary`.

Fix (producer-side mailbox watermark): before notifying each subscriber,
`Consumer.notify_clients_of_new_changes/2` checks the subscriber's
`message_queue_len`; if it exceeds `:slow_subscriber_max_queue_len` the
subscriber is terminated instead of notified. The client reconnects and
resumes from its last offset (the live protocol is resumable), so shedding
is safe. This is the only mechanism that catches a handler blocked inside
`Plug.Conn.chunk` — it can't wake to check itself, but the producer inspects
it externally.

The watermark is always on (a positive integer, default 10,000; non-positive
values are rejected at stack startup) and tunable via the
`ELECTRIC_SLOW_SUBSCRIBER_MAX_QUEUE_LEN` env var.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@codecov

codecov Bot commented Jul 1, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 60.01%. Comparing base (ed04ee4) to head (a3f8e0b).
⚠️ Report is 2 commits behind head on main.
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #4672      +/-   ##
==========================================
- Coverage   60.15%   60.01%   -0.15%     
==========================================
  Files         410      395      -15     
  Lines       44348    43747     -601     
  Branches    12582    12583       +1     
==========================================
- Hits        26677    26254     -423     
+ Misses      17593    17414     -179     
- Partials       78       79       +1     
Flag Coverage Δ
electric-telemetry ?
elixir ?
packages/agents 72.64% <ø> (ø)
packages/agents-mcp 77.70% <ø> (ø)
packages/agents-mobile 80.67% <ø> (ø)
packages/agents-runtime 83.75% <ø> (+0.02%) ⬆️
packages/agents-server 75.47% <ø> (+0.02%) ⬆️
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.71% <ø> (-0.12%) ⬇️
packages/y-electric 56.05% <ø> (ø)
typescript 60.01% <ø> (+0.01%) ⬆️
unit-tests 60.01% <ø> (-0.15%) ⬇️

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.

@robacourt robacourt requested a review from alco July 1, 2026 18:13
@claude

claude Bot commented Jul 1, 2026

Copy link
Copy Markdown

Claude Code Review

Summary

This PR adds producer-side mailbox-watermark shedding to Electric.Shapes.Consumer: before notifying each live :new_changes subscriber, the consumer inspects the subscriber's message_queue_len and Process.exit(pid, :kill)s it if it exceeds :slow_subscriber_max_queue_len (default 10,000, env-tunable via ELECTRIC_SLOW_SUBSCRIBER_MAX_QUEUE_LEN). It targets a real, well-documented production OOM (a GET /v1/shape handler stuck inside Plug.Conn.chunk on a dead socket, accumulating one binary-pinning message per transaction). The single change since my last review is a strengthened reproduction test — and it's a good one. I'm in favor of merging.

Previous Review Status

The one Important issue from iteration 1 — "the reproduction test does not exercise the actual production failure state" — is resolved by a3f8e0ba9 (the only commit since my last review; the implementation is byte-for-byte unchanged).

The rewritten consumer_test.exs:226 ("sheds a live subscriber blocked writing to a stalled socket") now:

  • Blocks the subscriber in a real :gen_tcp.send to a peer that accepts but never reads (sndbuf: 1024, 1 MB chunks, send_timeout: :infinity), parking it in the genuine production scheduler state (:waiting in the inet_reply receive) rather than idle-in-receive via Process.sleep(:infinity). This is exactly the busy-port state I was worried the old test didn't cover.
  • Directly proves the consumer's own Process.info(_, :message_queue_len) does not stall on the socket-blocked subscriber — the {:DOWN, ...} shed assertion can only fire if the mailbox check completed, and the follow-up healthy-subscriber assertion confirms the per-shape consumer is still alive and notifying afterwards. That was the specific failure mode ("worse than the one being fixed") I asked to have explicitly guarded, and it's now covered end-to-end.

Nicely done — this is a more convincing regression test than I suggested.

Issues Found

Critical (Must Fix)

None.

Important (Should Fix)

None. (The iteration-1 Important item is resolved above; no new ones introduced.)

Suggestions (Nice to Have)

New, on the added test (both very minor):

  • The stuck subscriber has no on_exit cleanup. Every other spawned process in the test (acceptor, healthy) gets an on_exit(fn -> Process.exit(pid, :kill) end), and in the passing path the subscriber is killed by the shed itself. But if an earlier assertion fails, the subscriber is left spawned. Once the accepted socket eventually closes, its Stream.cycle send loop stops blocking and spins on immediate {:error, :closed} returns (the return of :gen_tcp.send isn't matched in the Enum.each), busy-looping a scheduler until the test process tears down. Adding on_exit(fn -> Process.exit(subscriber, :kill) end) (harmless on an already-dead pid) makes failures clean. Purely test-hygiene.
  • Process.sleep(50) to let the subscriber park in the blocking send is a small timing assumption. It's not load-bearing for correctness (the subscriber has no receive loop, so it never drains :new_changes regardless of whether it's parked yet), so this is fine as-is — just flagging that it's the kind of line that can look flaky under CI contention even though it isn't.

Still open from iteration 1 (implementation unchanged, all nice-to-have — repeating briefly, not re-litigating):

  • Emit telemetry on shed (consumer.ex:1105), not just a Logger.warning, so ops can track shed frequency per shape.
  • Process.info per subscriber per txn in the notify hot path (consumer.ex:1073) — negligible at typical fan-out; worth keeping in mind against the documented single-process-bottleneck pitfall at high subscriber counts.
  • slow_subscriber naming slightly undersells "stopped draining"; stalled_subscriber_max_queue_len reads truer. Cosmetic.

Issue Conformance

No linked issue (flagged per convention), but the PR description thoroughly documents the production incident, Honeycomb evidence, and the rationale for producer-side shedding over the deferred alternatives (coalescing / send-timeout). The implementation matches the described solution, the changeset (@core/sync-service, patch) is present and accurate, and the config wiring end-to-end (runtime.exs -> config.ex -> application.ex -> stack_supervisor.ex -> stack_config.ex) is complete and consistent with the existing consumer_gc_heap_threshold path.


Review iteration: 2 | 2026-07-02

Address review: the reproduction simulated the stuck subscriber with
Process.sleep(:infinity) (idle in receive), which is a different scheduler
state from the production failure — a handler blocked inside Plug.Conn.chunk.
It proved the watermark comparison, not that the mechanism fires against a
genuinely socket-blocked handler, and didn't cover the risk that
Process.info(_, :message_queue_len) could itself stall on the blocked target
and freeze the per-shape consumer.

The subscriber is now blocked in a real :gen_tcp.send to a peer that never
reads — the actual production state (`:waiting` in the inet_reply receive,
verified on OTP 28). The shed still firing proves process_info did not block
the consumer; an added assertion registers a healthy subscriber afterwards
and confirms it is still notified, explicitly guarding consumer liveness.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@robacourt robacourt self-assigned this Jul 2, 2026
Comment on lines +1053 to +1058
# A live subscriber (e.g. a `GET /v1/shape` request) that can't drain its
# mailbox — a stalled or dead client socket while changes keep streaming —
# would otherwise accumulate one `:new_changes` message per transaction
# without bound, pinning reference-counted binary until the node runs out of
# memory. Once its mailbox crosses the watermark we stop notifying it and
# terminate it; the client reconnects and resumes from its last offset.

@alco alco Jul 2, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Here's what looks suspicios to me in this approach:

When processing a shape request, the request handler process enters the receive state until it receive a single :new_changes message. At that point it serves the shape log from storage until the end and closes the HTTP response. There's no looping and there's no expectation that the request handler process can do anything with the 2nd or any subsequent :new_changes message.

So how does a handler process end up with 10k messages in its inbox? To me the more likely explanation is that this is a bug, partly caused by a leaky abstraction: the subscription for changes is initiated by a call to Registry.register() which subscribes the current process. A single request's lifetime is usually smaller than that of a process because the same Bandit process may handle multiple requests. So the process ends up receiving messages from a consumer after it has already finished processing the request and has returned to Bandit's pool of request handlers.

This also highlights another problem: once the current request finishes and the same process picks up a new request, it will get a new subscription ref and will use it for pattern matching in its receive expression. So any messages from the previous subscription will remain in the mailbox and will cause repeated compute tax on the request handler process while it is doing selective receive using a different ref value.

@robacourt robacourt Jul 2, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks @alco , I should have spotted that! As for your proposed alternative cause, I'm looking into it.

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.

2 participants