Production RCA

Recurring processor OOM and stream retry failure

The processors are not simply “using a lot of memory.” A repeatable workload amplification exhausts V8; the recovery path then has a demonstrated race that can delete active work.

Incident focus: 2026-09-09 05:36–06:19 UTC · Extended evidence: 2026-09-02 00:00–2026-09-09 17:38 UTC

Volunteers requested: help prove or reject retained heap growth

We need engineers to run the controlled heap-canary experiment and compare retained-object dominators after normal work, heavy work, cache expiry, idle time, and full GC. Focus on reference-cache values, RevenueArrangement graphs, promises/AsyncContext, Buffers, and logger payloads. Start with the profiling plan and report evidence before changing cache or memory limits.

This is a public, redacted engineering share. Do not add raw customer payloads, credentials, heap snapshots, or reversible identifiers.

Verdict

Two coupled production defects

The OOM and the data-integrity risk are related but distinct. A memory fix alone does not make retry exhaustion safe.

Primary availability defect

Unbounded concurrent live-object amplification

processMessageBatch sends every returned message through Promise.all. Production normal polling was 20. Reclaim scans up to 100 and submits all successfully claimed messages to the same fan-out.

The observed heavy orders each loaded many full Revenue Arrangement graphs—commonly 20–22 IDs—plus derived schedules, cloned organization reference data, Redis/warehouse results, and processing intermediates. These remain live together until their promises settle.

Primary integrity defect

Retry-limit deletion races active ownership

When Redis reports delivery count at least five, the auto-claim scanner immediately runs XACK and XDEL. It neither claims ownership first nor requires the message to remain idle.

On Sep 9, one processor claimed 20 messages. A second processor deleted them 325 ms later after seeing delivery count five and idle age about 324 ms. The active processor continued from its returned payload and then OOMed before completion accounting.

What this is not

It is not established as an EC2 shortage, a single old task leaking slowly, or a problem that can be solved by adding RAM. Processor service CPU averaged 5.25% over seven days; a fresh task still consumed the heap in minutes; and the container already allows 7,384 MiB.

Latest incident

Five OOMs and one deletion race

All five OOM task streams were last active on organization Org A, job Job A2.

  1. Heavy order processing begins.

    Job context loads modified orders with 8–22 arrangement IDs. One task records reference-cache heap around 1,039 MiB.

  2. First V8 fatal OOM.

    Mark-compact cannot recover enough old space near 6.8 GiB. Message finally blocks do not complete.

  3. Memory warning alarm enters ALARM.

    Symptom detection trails the first process death by about six minutes.

  4. Second processor OOM.

    The same org/job context is redelivered.

  5. Critical memory alarm enters ALARM.

    The alarm name says “5min-critical,” but its configuration is three consecutive five-minute periods: a 15-minute condition.

  6. A fresh task claims 20 and OOMs.

    Heap is only 214.5 MiB before reference loading and 215.5 MiB after it, then reaches the fatal limit in roughly three minutes. This is the strongest evidence against “slow leak alone.”

  7. Fourth claim and OOM.

    The replay loop remains deterministic.

  8. Processor consumer-alias claims 20 messages.

    The successful XCLAIM increments delivery count and resets ownership/idle age.

  9. Another processor begins deleting the same 20.

    Logs show [1 / 1] for both XDEL and XACK, retry count 5, owner consumer-alias, and idle age about 324 ms.

  10. The active processor OOMs after deletion.

    Its in-memory payload can still execute after the stream entry is gone, but it never reaches normal JobStatus completion.

Root-cause chain

Why one batch can consume gigabytes

The fatal logs say “ineffective mark-compacts,” which means the collector ran but the object graph remained substantially live. That is consistent with concurrent batch retention.

Large sync arrives
Many modified orders and arrangements
Batch of 20
Normal poll or reclaimed pending work
Promise.all
All orders become live concurrently
Graph expansion
SalesOrder + 20–22 RAs + schedules
Old-space pressure
Objects survive GC while work is active
Fatal OOM
No ACK or completion counter update
Redelivery
Same allocation pattern repeats
Factor Evidence Confidence Role
Promise.all(msgItems.map(...)) Direct source inspection; every message in the returned batch starts concurrently. Proven Primary allocation multiplier
Arrangement-heavy modified orders Pre-OOM logs show many orders loading 20–22 IDs. Proven Per-message cost
Fresh-task exhaustion 215.5 MiB before reclaimed work; fatal heap within ~3 minutes. Proven Rules out slow-retention-only theory
Reference cache retention Ten-minute in-process cache; no size budget or proactive sweep in the dependency. Contributor Raises baseline and duplicates state per task
Only 512 MiB outside V8 old-space Container 7,384 MiB; MAX_OLD_SPACE=6872. Contributor Small margin for native buffers and runtime
True long-lived leak No paired post-full-GC baselines or heap-dominator diff captured during incident. Unproven Requires targeted profiling

Complete evidence

All fatal events in the reviewed window

Every observed fatal processor event is included. Customer, job, task, account, and message identifiers use stable aliases.

27Processor OOM events, Sep 2–9
23Processor OOM events in rolling seven days
60Retry-limit stream entries removed
89%OOM events attributed to two org aliases

Daily service evidence

Date (2026 UTC)Processor memory peakFatal OOM eventsRetry-limit deletes
Sep 02Not retained in seven-day query4Not observed
Sep 0399.49%1140
Sep 0499.62%20
Sep 0599.19%30
Sep 0699.58%20
Sep 0799.47%00
Sep 0876.44%00
Sep 0999.00%520

Memory uses the AWS/ECS service-level Maximum statistic. OOM counts use fatal V8 signatures grouped by task stream. Delete counts use the retry-limit warning path; zero does not prove successful completion.

Fatal-event attribution ledger

#DateFatal timestampOrg aliasJob alias
01Sep 0208:56 UTCOrg AJob A3
02Sep 0209:06 UTCOrg AJob A3
03Sep 0209:17 UTCOrg AJob A3
04Sep 0209:27 UTCOrg CJob C1
05Sep 0305:27 UTCOrg BJob B1
06Sep 0305:27 UTCOrg BJob B1
07Sep 0305:37 UTCOrg BJob B1
08Sep 0308:53 UTCOrg AJob A1
09Sep 0308:53 UTCOrg AJob A1
10Sep 0309:03 UTCOrg DJob D1
11Sep 0309:04 UTCOrg AJob A1
12Sep 0309:13 UTCOrg AJob A1
13Sep 0309:13 UTCOrg AJob A1
14Sep 0309:23 UTCOrg AJob A1
15Sep 0309:24 UTCOrg CJob C2
16Sep 0408:57 UTCOrg AJob A4
17Sep 0409:07 UTCOrg AJob A4
18Sep 0505:32 UTCOrg BJob B2
19Sep 0505:32 UTCOrg BJob B2
20Sep 0505:32 UTCOrg BJob B2
21Sep 0605:27 UTCOrg BJob B3
22Sep 0605:30 UTCOrg BJob B3
23Sep 0905:37 UTCOrg AJob A2
24Sep 0905:48 UTCOrg AJob A2
25Sep 0905:59 UTCOrg AJob A2
26Sep 0906:08 UTCOrg AJob A2
27Sep 0906:19 UTCOrg AJob A2

Each fatal task stream was searched backward ten minutes for the last non-system logger context. The aliases cannot be reversed from this site.

Evidence sources and limits

Runtime evidence
  • AWS/ECS CPU and memory Average/Maximum datapoints
  • ECS services, task definitions, deployments, health, and stops
  • Fatal task log streams and alarm history/configuration
  • Scalable targets, policies, ASGs, and capacity providers
Code evidence
  • Stream poll, claim, concurrency, ACK, delete, and retry paths
  • Task memory and V8 old-space settings
  • Health checks, heap snapshots, metrics, and runner signals
  • Schedules, logger context, caches, and async context
Still unavailable
  • No paired heap snapshots or post-full-GC retained baseline
  • No authoritative Redis reconciliation of affected jobs
  • No warehouse row comparison during the review
  • No quantified customer-visible or financial impact

Frequency

Why the spikes recur

The evidence shows scheduled workload concentration plus replay amplification, not random host behavior.

Recurring arrival windows

  • Sep 3, 5, 6, and 9 incidents cluster near 05:20–06:20 UTC.
  • Sep 2–4 also show a cluster around 08:50–09:25 UTC.
  • Two organizations account for 89% of extended-window OOMs.
  • Top job contexts repeat across several processor deaths.

Strong inference: large customer sync schedules repeatedly inject a similar shape of modified orders. Job metadata should be checked before asserting the exact upstream schedule.

Why each burst becomes a spike

  1. Shared Redis stream distributes heavy messages to processors.
  2. Each processor accepts a multi-message batch without a memory budget.
  3. All messages allocate concurrently.
  4. Long GC pauses raise CPU and make health checks time out.
  5. Unfinished messages become reclaim candidates.
  6. Reclaim reproduces the same allocation pattern.
How to smooth rather than merely move the spike

Bound per-process concurrency, estimate work weight from payload bytes and arrangement count, stop claiming near a heap watermark, apply tenant fairness, split oversized batches, and scale on backlog age/throughput. Staggering customer schedules helps, but it is a secondary control—not a substitute for bounded execution.

Impact and outcome

Availability loss is proven; financial impact is not yet quantified

The distinction matters. The code path creates a credible completeness risk, but repository and CloudWatch evidence alone cannot prove which downstream financial rows are missing.

Established

Operational impact

  • 23 processor deaths in the rolling seven-day window.
  • At least one separate worker OOM; no jobsQ OOM in that window.
  • Capacity consumed by multi-second to multi-minute mark-compact cycles.
  • Repeated processing delay and task replacement.
  • Memory alarms entered ALARM eight times.
Credible risk

Data and job impact

  • 60 stream entries permanently removed at retry limit.
  • Deleted messages have no durable DLQ payload.
  • Deletion path does not decrement JobStatus message count.
  • Deletion path does not explicitly fail or isolate the job.
  • Orders, recognition, warehouse, or posting may remain incomplete.
Not established

What cannot yet be claimed

  • No quantified number of customer-visible missing records.
  • No proven journal or revenue misstatement.
  • No authoritative final Redis JobStatus snapshot.
  • No downstream table reconciliation for the deleted IDs.
  • No proof that cache retention is the dominant heap owner.
Outcome of the Sep 9 loop

ECS returned the service to 30/30 healthy tasks and the repeated OOM burst ended. However, the system stopped retrying because 20 entries reached the deletion path. “No longer failing” is not the same outcome as “processed successfully.” Reconcile Redis JobStatus, raw-order data, message counters, and downstream outputs before replay or closure.

Tagging audit

Correlation exists, but it is fragmented

“Tagging” means three different things here: AWS resource tags, application correlation fields, and metric dimensions. They have different cost and cardinality rules.

Surface Available today Observed state Gap / recommendation
ECS service tags Yes Each investigated service has only Name. Add owner, component, environment, criticality, cost-center, data-classification, and runbook.
ECS managed task tags Yes aws:ecs:clusterName and aws:ecs:serviceName. Keep enabled; use enhanced Container Insights for task-level telemetry.
Service tag propagation Capability unused propagateTags=NONE. Set propagation deliberately for new tasks after defining a controlled tag schema.
Application logs Yes Logger prefix: (clientId)[jobId][level][userId][component][timestamp]. Emit JSON consistently and add traceId, taskId, messageId, stage, and outcome.
Redis stream fields Yes Message ID, salesOrderId, clientId, jobId, and type are available. Carry one correlation envelope through DLQ and all stage handoffs.
Custom metric context Partial MetricTracker can add TenantId, TenantName, and JobId based on sysconfig; current revlock metrics did not show those dimensions. Do not turn JobId into a broad metric dimension. Keep high-cardinality IDs in logs/spans.
Reporting metrics High cardinality present OrgId, UserId, and TemplateId produce 395 of 561 observed revlock metric identities. Review cost/value; move unbounded IDs to EMF properties or logs where possible.
Distributed tracing No No OpenTelemetry, X-Ray, traceparent, or correlation ID dependency was found. Add trace propagation across API, Camunda, jobs, streams, processors, and warehouse calls.
Cardinality boundary

Metric dimensions should be bounded: environment, profile, service, operation, stage, job type, outcome, driver, dependency, and message type. Put organization, job, order, message, task, user, and trace IDs in structured event properties. Every unique CloudWatch dimension combination creates another metric identity.

Investigation method

How the diagnosis was built

The method moved from infrastructure inventory to time correlation, then from time correlation to code-level causal checks.

  1. Established the runtime topology. Described the cluster, services, task definitions, deployments, capacity providers, ASGs, desired/running counts, memory limits, health checks, and scaling state.
  2. Built seven-day resource baselines. Queried AWS/ECS hourly Average and Maximum CPU/memory for processor, worker, and jobsQ.
  3. Audited alarm behavior. Read thresholds, evaluation periods, dimensions, actions, and seven-day state transitions. This exposed the silent ProcessorCrash alarm and the misleading 5-minute name.
  4. Located fatal signatures. Queried the ECS log group for V8 “JavaScript heap out of memory” and “ineffective mark-compacts” messages by processor log stream.
  5. Attributed task streams. For each fatal timestamp, looked backward ten minutes in that exact log stream and parsed the last non-system (clientId)[jobId] context.
  6. Correlated recovery events. Compared claim count, cache/heap checkpoints, max-retry deletion, owner, retry count, idle age, and subsequent fatal timestamps at millisecond precision.
  7. Read the exact source paths. Verified processMessageBatch, pollForMessages, retry deletion, JobStatus.completeMessages, health monitor, runner signal handling, heap snapshot service, reference cache, and scheduled service refresh.
  8. Tested competing explanations. The fresh-task baseline rejected “only a long-lived leak”; the low average CPU rejected sustained compute shortage; the repeated heavy job contexts supported workload amplification.
Representative read-only Logs Insights patterns
fields @timestamp, @logStream, @message
| filter @logStream like /prod-processor-b/
  and @message like /JavaScript heap out of memory/
| sort @timestamp asc

filter @logStream like /prod-processor-b/
  and @message like /hit the max retry count/
| stats count(*) as DeletedMessages by bin(1d) as day
| sort day asc

Attribution then used filter-log-events against each exact task log stream with a bounded ten-minute lookback.

Known unknowns

What must be verified next

These are not reasons to delay the concurrency and retry-safety fixes. They determine customer remediation and later optimization.

Customer/job reconciliation

  • Authoritative Redis status and phase counters for the affected jobs.
  • Whether all 20 deleted raw-order payloads still exist.
  • Order and RevenueArrangement documents written before each crash.
  • Warehouse rows and staging status for the job.
  • Whether an idempotent replay can safely repair partial work.

Heap ownership

  • Retained-size dominators before and after a representative heavy batch.
  • Heap, RSS, external, and ArrayBuffer growth after forced/full GC.
  • Reference-cache entry count, estimated bytes, hit rate, and eviction behavior.
  • Duplicate arrangement IDs and graph cloning across concurrent orders.
  • Memory released after order completion and job finalization.

Continue with the memory and heap remediation playbook or the whole-system observability design.

Engineering playbook

Memory pressure, leak detection, and spike control

Contain the current failure safely, prove or reject retained growth, identify dominant heap owners, and prevent workload size from becoming process death.

Current answer: a memory leak is possible, but not proven as the incident’s direct cause.

A fresh processor with roughly 215 MiB heap claimed 20 messages and reached the 6.8 GiB V8 limit in about three minutes. A slow leak cannot explain that by itself. The strongest current diagnosis is peak live-object pressure from concurrent arrangement-heavy work.

Proven

Peak allocation failure

All messages in a batch execute concurrently. Fatal logs show repeated mark-compact cycles near the old-space limit and “ineffective mark-compacts,” meaning most of the graph remained live.

Plausible

Retained baseline contributor

The processor has a ten-minute reference-data cache with no explicit entry or byte cap. Thirty tasks duplicate that state. Expired entries are removed on access rather than by a proactive size policy.

Missing evidence

Leak proof

No post-full-GC baseline series or comparable heap snapshots were captured around the incident. A leak claim requires retained objects growing across completed work, not just a high peak.

Memory model

Measure more than heapUsed

Container memory and V8 heap are not interchangeable. A process can hit V8 old-space while the container still has a little room, or it can be killed by the container limit while V8 heap looks safe.

Signal Meaning Failure pattern Required collection
heapUsed Live and not-yet-collected JavaScript objects Peak pressure or retained-object leak Gauge every 15–30s plus post-GC baseline
heapTotal V8 heap pages currently committed Can remain high after objects are freed Gauge alongside heapUsed
V8 heap limit Maximum old-space and heap policy Fatal V8 OOM near 6,872 MiB here v8.getHeapStatistics()
rss Total resident process memory Container OOM, native leak, allocator fragmentation process.memoryUsage().rss
external Native memory attached to JS objects Buffers, compression, drivers, native libraries process.memoryUsage().external
arrayBuffers Buffer/ArrayBuffer backing memory MsgPack, CSV, Redis, file and network payload growth process.memoryUsage().arrayBuffers
GC pause / reclaimed bytes Collector cost and effectiveness Event-loop stalls, health-check timeout, low mutator utilization PerformanceObserver GC events + before/after heap
Event-loop delay Ability to answer health checks and make progress Long GC or synchronous processing monitorEventLoopDelay

Current processor budget

7,384 MiB container hard limit
6,872 MiB configured V8 old-space limit
512 MiB remaining margin for runtime, stacks, code, and native memory

The 512 MiB margin is tight, but increasing it is not the first fix: the application demonstrated the ability to create more than 6 GiB of live heap from one batch.

Fast exhaustion

Why the heap grows faster than GC can recover it

GC can reclaim unreachable objects. It cannot reclaim order, arrangement, promise, and context objects that are still reachable from active asynchronous work.

  1. Batch fan-out: processMessageBatch executes Promise.all for every returned stream item.
  2. Per-message graph size: a modified SalesOrder can load many Revenue Arrangements. The incident contains repeated orders with 20–22 IDs, and each arrangement may contain elements, schedules, allocations, and expense data.
  3. Per-order cloning: the worker intentionally deep clones organization reference data so mutations do not cross order boundaries. Correct isolation has a memory cost multiplied by concurrency.
  4. Promise retention: the batch array, closures, AsyncContext state, raw message fields, intermediate results, and unresolved promise chains remain reachable until work settles.
  5. Cache and entity reuse: task-local reference and entity caches may retain additional graphs after individual order work completes.
  6. GC death spiral: as headroom shrinks, V8 spends seconds or minutes compacting. Application progress slows, event loop health degrades, but the active set remains live.
  7. Replay: a crash skips ACK, cleanup, and completeMessages; reclaim reruns the same expensive allocation shape.
Back-of-envelope scale—not a measured allocation model

Twenty concurrent orders × roughly twenty arrangements means hundreds of full arrangement graphs can be live together. The observed fresh-task delta implies hundreds of MiB per in-flight order for this workload. Do not use that estimate for capacity planning; add per-order retained-size and heap-delta telemetry.

Immediate containment

Reduce blast radius without hiding the defect

These controls buy safety and evidence. They do not replace the bounded executor or retry-integrity fix.

Action Expected effect Limitation / risk Rollback signal
Reduce live messagesPerPoll from 20 to 2 for profile b Bounds new normal-poll fan-out immediately Does not cap the separate reclaim path; can increase queue age Oldest pending exceeds temporary budget without heap benefit
Pause/reconcile affected jobs before replay Prevents compounding partial writes Requires authoritative Redis and downstream checks Replay only after idempotency and impact are documented
Alert directly on fatal OOM and retry deletion strings Stops reliance on suppressed ProcessorCrash metric Log alarm is a bridge, not semantic instrumentation Replace after structured OOM/DLQ/task-state events ship
Route heavy tenant schedules apart where operationally possible Reduces simultaneous fleet-wide amplification Only shifts load if per-process fan-out remains unbounded Do not accept permanent schedule toil as the fix
Use one profiling canary Collects high-value heap evidence with controlled risk Large snapshots pause work, consume IO, and may contain customer data Stop on latency/IO impact or snapshot growth beyond budget
Do not mass-enable PROFILE_HEAP_ENABLED

The existing ProfileServices can write snapshots to mounted storage, and the processor requests a capture every three minutes when enabled. A near-7-GiB snapshot across 30 tasks would add severe pause, IO, storage, and data exposure risk. Use a controlled canary and threshold-triggered capture.

Permanent changes

Bound execution and make failure durable

Concurrency and retry semantics must share one design. A memory-safe worker that can silently lose exhausted messages is still unsafe.

1. One shared bounded executor

// Design sketch, not committed implementation.
async function processMessageBatch(connection, items) {
  await runWithConcurrency(items, maxInFlight, async (item) => {
    await waitForHeapHeadroom();
    await processOneMessage(connection, item);
  });
}

2. Add a memory admission watermark

3. Replace max-retry deletion with an explicit terminal path

Claim ownership
Require valid idle/owner transition
Persist failure
Durable DLQ payload + context
Update job
Isolate/fail and reconcile counter
ACK source
Only after prior writes succeed
Alert + replay
Operator has payload and reason

4. Reduce graph amplification after concurrency is safe

Load shaping

How to manage recurring spikes

The objective is stable throughput under large jobs, not maximum instantaneous throughput followed by GC collapse and replay.

Inside each task

Bound memory-generating work

  • Concurrency token pool shared by every intake path
  • Heap/RSS headroom before starting the next item
  • Per-message weight from payload bytes and arrangement count
  • Chunk oversized order work where semantics allow
  • Deadline and cancellation propagation for draining
Across the fleet

Shape tenant and queue load

  • Fair-share or weighted scheduling by organization
  • Separate heavy-job lane or dedicated task pool
  • Stagger known large source schedules
  • Backpressure publishers when pending age and heap pressure rise
  • Autoscale on pending age + weighted backlog + completion rate
Why memory-based autoscaling is insufficient

Scaling adds tasks but does not reduce the number of concurrently live objects inside one task. It can also increase the number of reclaimers racing over a poison batch. Scale only after intake and ownership are bounded, and use queue age/throughput to decide how many safe workers are needed.

Profiling plan

Prove leak, peak pressure, or native growth

Run this first in a production-like load environment with sanitized fixtures from the top OOM job shapes, then canary one production task if necessary.

Continuous low-overhead series

Every 15–30 seconds Per batch / order Per job
heapUsed, heapTotal, heapLimit, RSS, external, arrayBuffers before/after heap, peak, duration, in-flight, payload bytes post-idle/post-GC baseline and maximum
event-loop p50/p95/p99 delay, GC count/pause/reclaimed bytes order type, arrangement count, schedule/element count cache entries/bytes/hit/miss/eviction
active promises/handles if safely measurable Redis/DB latency and response bytes work completed, failed, retried, DLQed

Three-point heap comparison

  1. A — clean baseline: after deploy/startup, idle, reference data initialized, and a full GC where available.
  2. B — after representative normal work: complete enough ordinary jobs to warm caches, then idle and GC.
  3. C — after one heavy job: process the sanitized high-arrangement fixture with safe concurrency, then idle for at least the ten-minute cache TTL and GC.

Compare retained size and dominator growth between A, B, and C. Focus on SalesOrder, RevenueArrangement, schedule arrays, Buffers/ArrayBuffers, Maps, cache entries, promises, AsyncContext/cls-hooked objects, Redis results, and logger payloads.

Leak

Post-GC baseline climbs

The same dominator paths grow after completed jobs and do not fall after idle/TTL/GC. Fix the retaining owner and verify the slope returns to zero.

Peak pressure

Baseline returns, peak fails

Objects are legitimately live during work. Bound concurrency, reduce graph size, stream/chunk work, or raise memory only after the peak is measured and bounded.

Native / fragmentation

Heap stabilizes, RSS climbs

Inspect Buffers, compression, drivers, native allocations, and allocator fragmentation. Heap snapshots alone will not explain all RSS growth.

Snapshot data handling

Heap snapshots can contain organization configuration, order fields, identifiers, and financial data. Encrypt storage, restrict access, record the task/job/time, set short retention, and delete under the incident data-handling policy.

Conditional fixes

If profiling proves retained growth

Apply the fix to the retaining path shown by dominators. Do not perform a broad “clear everything” cleanup that hides ownership and destroys useful cache behavior.

Dominator / symptom Likely mechanism Targeted fix Proof after fix
Reference cache Map and full org/job reference data Time-only eviction, too many active keys, large values Byte/entry-bounded LRU, explicit job completion eviction, size telemetry Post-TTL cache size and heap baseline return to bound
RevenueArrangement graphs retained by SalesOrders Entity/cache/global reference survives operation Detach where contract permits; avoid duplicate hydration; narrow loaded fields Arrangement retained size falls after each item completes
Promises / AsyncContext namespaces Unsettled chains, timers, listeners, context references Bound async lifetime, remove listeners/timers, verify context cleanup Promise/context counts return after batch settlement
Buffers / ArrayBuffers Raw payloads, MsgPack, CSV, compression or driver buffers retained Stream/chunk, release buffers, avoid duplicate copies, cap payload external/arrayBuffers and RSS return after operation
Logger/error objects Large payload or exception graphs retained in queues/closures Log bounded summaries and IDs; avoid serializing full documents Logger dominator disappears without loss of correlation
RSS only Native library or allocator behavior Dependency-specific profile/upgrade; allocator and buffer review RSS slope stops while heap behavior remains unchanged
Scheduled six-hour service refresh

serviceRefresh force-deploys processors and workers every six hours. This can cap a leak’s lifetime and make graphs look healthy after restart, but it is not a leak fix. Keep deployment annotations in dashboards and remove refresh as a memory control only after post-GC baselines are demonstrably stable.

Release gates

Do not ship on “it no longer crashes locally”

The hotfix must prove bounded memory and message correctness under process death and competing consumers.

Load and memory gates

  • Fixture with 20 modified orders and at least 20 representative arrangements per order.
  • Run concurrency 1, 2, 4, and 5; record throughput, p95/p99, peak heap/RSS, GC pause.
  • Selected concurrency keeps peak heap below 70% and RSS below 80% of task limit.
  • Post-job/post-TTL/post-GC baseline returns to the measured bound.
  • Normal small-job throughput remains within the agreed regression budget.

Correctness and failure gates

  • Kill the process after claim at every major message boundary.
  • Run two reclaimers concurrently at retry counts 4 and 5.
  • No active message can be ACKed/deleted by another consumer.
  • Every exhausted message exists in a durable DLQ with original context.
  • Job counters equal completed + isolated/error + DLQed work exactly.
  • Replay is idempotent and produces one terminal outcome.

Canary rollout

  1. Deploy metrics/event schema before or with the behavior change.
  2. Canary one controlled task or profile with representative synthetic/sanitized work.
  3. Compare heap/RSS/GC, throughput, queue age, retries, and job completion against baseline.
  4. Increase task share in stages; stop on integrity delta, heap regression, or latency burn.
  5. After full rollout, observe at least two known heavy schedule windows.
Definition of resolved

Heavy jobs complete or durably fail without OOM; task memory is bounded; post-GC baseline is stable; no active stream message can be deleted; all published work is accounted for; and on-call receives a task/job-linked alert before customer discovery.

See the production evidence and impact and the whole-system observability design.