fix(sfmc): stop async poll hanging on large /results payloads - #3952
fix(sfmc): stop async poll hanging on large /results payloads#3952rokatyal wants to merge 3 commits into
Conversation
performPoll fetches /results whenever SFMC reports a batch Complete with Has Errors. That response carries one item per failed record, so for a batch of any real size it exceeds the 16KB highWaterMark of the tee that response.clone() sets up in prepare-response. The middleware awaits the clone while the original body goes unread, the tee back-pressures, and clone.text() never resolves -- the poll request hangs until the caller's deadline expires and reports a synthetic 504. Reproduced against cross-fetch@3.2.0/node-fetch@2.7.0 with a /results-shaped body: 1 and 50 items resolve in single-digit ms, 1640 items (149KB decompressed, 1.59KB gzipped) never resolves. Setting skipResponseCloning takes the non-cloning path and the same 1640-item body parses in 4ms. Same root cause and same fix as the Iterable Lists hang (PR #2461). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The existing "Complete but Has Errors" test uses a 4-item fixture, which stays under the 16KB clone-tee threshold and so passes with or without skipResponseCloning. This adds a 1640-item case -- the batch size that surfaced the hang in stage -- so the regression is actually covered. Verified the test is load-bearing against cross-fetch@3.2.0/node-fetch@2.7.0 with nock: without skipResponseCloning the 1640-item case never settles and the test times out; with it, the same body parses in 4ms with errorCount=820/successCount=820. The 4-item fixture passes either way. Note this failure mode is a timeout, not an assertion failure -- matching how it manifests in production, as a poll that hangs until the caller's deadline expires. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Deep review surfaced that the poll's first call, /status, takes the same cloning path and carries an unbounded resultMessages array, so it can deadlock identically -- and it runs every cycle, before /results is reached. Sets skipResponseCloning there too. Also guards the results loop. jobStatus is set to SUCCEEDED before the /results fetch, and items was dereferenced unguarded, so a missing items field crashed into the catch as a bare FAILED/400, and an empty items array reported a batch SFMC had explicitly flagged Has Errors as fully succeeded with zero records accounted for. Both now return RETRYABLE_ERROR. Verified the guard across undefined data, data without items, empty items, and a non-JSON body that leaves data as a string; the normal path is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Deep code review — 3 independent passesRan three independent bug-hunting passes over this diff. Two findings were significant enough to fix in Fixed in this PR1. The original fix was incomplete —
This was only visible from the type declaration — in the incident logs 2. Missing/empty
Both now return Flagged, deliberately not fixed here
One unresolved disagreement worth a maintainer's eye: pass 1 flagged an index-space mismatch between the success path (indexes by CI statusThe regression test passes in CI: All SFMC suites green. Two job failures on that run appear unrelated to this diff:
Both look pre-existing on Note that run was on |
Summary
performPollfor SFMCasyncDataExtensionhangs indefinitely whenever a batch completes with errors, until the caller's deadline expires and reports a synthetic 504. No records are ever delivered, and the poll retries into the identical hang forever.The
/resultscall now setsskipResponseCloning: true.Root cause
performPollcalls/resultsonly on theComplete+Has Errorspath — theresultStatus === 'OK'path returns early and never touches it. That response carries one item per failed record, so for a batch of any real size it exceeds the 16KB highWaterMark of thePassThroughtee thatresponse.clone()sets up inprepare-response.ts:Both tee branches must drain. The middleware awaits the clone while the original
responsebody goes unread, so back-pressure stalls the pipe andclone.text()waits forever. It is not an error and produces no log line or span — the HTTP span closes normally because the network did finish; the hang is userland body consumption afterward.skipResponseCloning(declared atrequest-client.ts:67, honored atprepare-response.ts:10) takes theresponse.text()path instead, with no tee and no deadlock.Prior art
Same root cause, same file, same line, same fix as #2461 ("Iterable Lists bugfix: cloned response hangs operations between this Destination and Iterable", merged 2024-10-01), which reported it as "hangs operations whose response payload is large, creating timeouts during audience syncs". That PR set the flag at Iterable's own call sites; SFMC was never covered.
Reproduction
Against
cross-fetch@3.2.0→node-fetch@2.7.0(the pinned client), with a/results-shaped body served gzipped + chunked, replicatingprepare-response.tsexactly (clone, read the clone, never read the original):With
skipResponseCloning: true, the identical 1640-item body:The gzip ratio is what makes this easy to miss: a 1.59KB compressed body is ~145KB decompressed, and the tee buffers decompressed bytes.
Observed in stage
Every poll for a 1640-record SFMC batch, across 4 pods and 3 aggregation IDs:
/status→ 200 in 0.316s,/results→ 200 in 0.449sdestination call timed out/destination call aborted before starting, status 504,success_count: 0RETRYABLE_ERRORand hits the same hangRegression test
asyncDataExtension.async.test.tsalready covers theComplete but Has Errorspath, but with a4-item fixture that stays under the threshold and passes with or without the fix. This PR adds a
1640-item case — the batch size that surfaced the hang in stage.
Verified the test is load-bearing (same nock/
cross-fetch@3.2.0path the suite uses):Worth flagging for reviewers: without the fix this test times out rather than failing an
assertion, which is exactly how the bug manifests in production — a poll that hangs until the
caller's deadline expires. If the suite has a per-test timeout shorter than the hang, that is what
will trip.
Testing
Notes for reviewers
Targeting
stagingrather thanmainbecauseindex.async.tsand theasyncActionsregistration exist only onstaging.Two adjacent issues found while tracing, deliberately not fixed here to keep this reviewable:
agent.destroy()sits after the awaitedclone.text()inprepare-response.ts, so every hung request also leaks its socket. Fixed as a side effect here, but the ordering is still wrong for any other caller that hangs./resultsis paginated —page,pageSize, andcountare declared inAsyncUpsertRowsPollResultsResponseand never used, so only the first page is consumed. Separately, the error branch indexesMultiStatusResponseby position withinitemswhile the success branch indexes by position within the original batch; those index spaces do not correspond.