Batching pipelines: back-pressure sees their depth, faulted receivers rebuild, OOM can't fault the block (CritterWatch#942) - #3864
Merged
Conversation
… rebuild, OOM can't kill the block (CritterWatch#942) RC.7 of CritterWatch moved its telemetry ingest to BatchMessagesOf and a production fleet (10.8k agents, 117 msg/s steady) went from 2.7 GiB to its 6 GiB GC hard limit in two minutes, then split between two endings: a hard restart, or a zombie listener polling forever against a terminally faulted receive block while deleting nothing (934 identical receive-loop errors). Three composable defects, each fixed here: 1. BatchMessagesOf severed the back-pressure chain. The transport receive block (bounded, the only stage BackPressureAgent watches) drains instantly into the batching channel and onward to the batch handler's local queue, which is deliberately unbounded (GH-3287) and excluded from back-pressure. QueueCount reads ~0 while the real backlog — every member pinned via Envelope.Batch with its payload and deserialized graph — grows without bound. Fix: BatchingPendingCounts tracks members in flight per originating listener address (incremented in BatchingProcessor.HandleAsync, settled exactly once per grouped envelope at both receivers' CompleteAsync batch terminals, guarded by Envelope.BatchPendingSettled), and ListeningAgent.QueueCount adds it to the receive block's own depth. The existing latch now oscillates exactly as it did under per-message handling. Nothing blocks and local publishers (null Listener) are never counted, so the GH-3287 no-deadlock rule for self-cascading handlers is preserved. 2. Per-envelope retention amplification. The raw SQS Message rode the envelope for its whole life, including the base64 Body — a UTF-16 string ~2.7x the wire payload — long after mapping copied it into Envelope.Data. SqsListener now releases Body once mapping succeeds; the mapping-failure DLQ path still has it. (Deliberately NOT done: nulling Envelope.Data after deserialization — dead-letter persistence and durable retry re-upserts store Data, and would be corrupted.) 3. An OOM anywhere in a receiver's recovery path faulted the block terminally and permanently. The catch rungs in executeAsync, both onBlockErrors, and DurableReceiver's requeue-on-failure all allocate (logging), so the very memory pressure that fails a message could throw again inside recovery, hit Block's error rung, and fault the block — which has no un-fault, freezes QueueCount (so a latched listener never resumes), and rejects every post (so an Accepting listener receives and drops forever; ListeningAgent.StartAsync's `_receiver ??=` re-attached every new transport listener to the same corpse). Fix: every recovery rung is now exception-safe; receivers record the #506 terminal fault (null-item OnError) as IFaultTrackingReceiver.HasFaulted; IListeningAgent.ReceiverHasFaulted exposes it for health checks (default-interface false); StartAsync disposes and rebuilds a faulted receiver instead of reusing it; and BackPressureAgent's periodic check force-restarts the listener when it sees the fault — covering both the frozen-latched and the Accepting-but-dropping zombie. Known gaps, on purpose: durable-mode endpoints with back-pressure disabled have no automatic fault watchdog (BackPressureAgent hosts the timer) — RestartAsync(force: true) remains the manual remediation; the receive-loop health reporting (GH-3236) does not yet read ReceiverHasFaulted. CoreTests: 2292 passed / 0 failed (net9.0), including new BatchingPendingCountsTests and three BackPressureAgent watchdog tests. Full mechanical analysis in JasperFx/CritterWatch#942. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016v2Aijyo8MX2AdPUZL5VtG
This was referenced Aug 7, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Context
CritterWatch RC.7 moved telemetry ingest to
BatchMessagesOfand a production fleet (10.8k agents, 117 msg/s steady inflow) went from 2.7 GiB to its 6 GiB GC hard limit in two minutes, then split non-deterministically between a hard restart and a zombie: the SQS receive loop polling forever against a terminally faulted receive block, deleting nothing, 934 identicalThis Block<Envelope> has faultederrors while status reported green. Full mechanical analysis in JasperFx/CritterWatch#942.Three composable defects, all fixed here. Verified first that neither 6.24.8 nor current JasperFx (#506 closed via #509 — loud-only) addresses any of them.
1.
BatchMessagesOfsevers the back-pressure chainThe transport receive block (bounded, the only stage
BackPressureAgentwatches) drains instantly into the batching channel and onward to the batch handler's local queue — deliberately unbounded (GH-3287) and excluded from back-pressure.QueueCountreads ~0 while the real backlog (every member pinned viaEnvelope.Batchwith payload + deserialized graph, ~20–30× wire size each) grows without bound.Fix — accounting, not bounding:
BatchingPendingCountstracks members in flight per originating listener address;ListeningAgent.QueueCountadds it to the receive block's depth, so the existing latch oscillates exactly as it did under per-message handling. Increment inBatchingProcessor.HandleAsync; settle exactly once per grouped envelope at both receivers'CompleteAsyncbatch terminals (Envelope.BatchPendingSettledguard). Nothing blocks, and local publishers (nullListener) are never counted — the GH-3287 no-deadlock rule for self-cascading handlers is preserved.2. Retention amplification
SqsListenernow releasesMessage.Body(UTF-16 base64, ~2.7× wire size, previously pinned for the envelope's whole life) once mapping into the envelope succeeds; the mapping-failure DLQ path keeps it. Deliberately not done: nullingEnvelope.Datapost-deserialization — dead-letter persistence and durable retry re-upserts storeDataand would be corrupted. (The complementary consumer-side fix — CritterWatch's brotli serializer no longer overwritingDatawith the decompressed payload — ships in CritterWatch.)3. An OOM in a recovery path faults the block terminally, forever
Every catch rung in
BufferedReceiver.executeAsync, bothonBlockErrors, andDurableReceiver's requeue-on-failure allocate (logging), so the same memory pressure that fails a message can throw again inside recovery, hit the block's error rung, and fault it — no un-fault exists,QueueCountfreezes (a latched listener never resumes), every post throws (an Accepting listener receives and drops forever), andStartAsync's_receiver ??=re-attached each new transport listener to the same corpse.Fix: all recovery rungs are exception-safe; receivers record the #506 terminal fault (null-item
OnError) asIFaultTrackingReceiver.HasFaulted;IListeningAgent.ReceiverHasFaultedexposes it for health checks (default-interfacefalse, non-breaking);StartAsyncdisposes and rebuilds a faulted receiver; andBackPressureAgent's 2s check force-restarts on fault — covering both the frozen-latched and the Accepting-but-dropping zombie.Known gaps (on purpose)
BackPressureAgenthosts the timer);RestartAsync(force: true)remains the manual remediation.ReceiverHasFaulted.FailureontoIBlockand an un-fault primitive.Tests
CoreTests 2292 passed / 0 failed (net9.0), including 6 new
BatchingPendingCountsTestsand 3 newBackPressureAgentwatchdog tests (forces_a_full_rebuild_when_the_receiver_has_terminally_faulted,faulted_receiver_recovery_fires_even_while_accepting,no_rebuild_when_the_receiver_is_healthy).Wolverine.AmazonSqsbuilds; its LocalStack integration suite was not run.🤖 Generated with Claude Code
https://claude.ai/code/session_016v2Aijyo8MX2AdPUZL5VtG