ITADN

Sync v2 stream has no backpressure — `send()` ignores `response.write()`'s return value, so a large `AssetOcrV1` backlog OOMs the api worker (V8 heap FATAL) and crash-loops it with exit code 0

#29925Openneoocean 创建于 2026-07-15
N
neooceancommented
### The bug `/api/sync/stream`'s send helper writes serialized rows to the HTTP response without checking `response.write()`'s return value. When the client drains the socket slower than Postgres produces rows, `write()` starts returning `false` — but the `for await` loop keeps running at cursor speed, so every untransmitted row accumulates in process memory until the api worker hits V8's default heap ceiling (~2240 MB without `NODE_OPTIONS`) and dies: ``` FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory ``` `start.sh` then exits **0**, so `restart: always` restarts the container in ~250 ms with `OOMKilled=false` — from the outside it looks like a healthy container that quietly loses every long sync session, and it defeats restart/OOM monitoring (no non-zero exit, no cgroup OOM kill, sub-second gap). The sync checkpoint makes each session resume, so the loop repeats every time a client with a large backlog connects. The trigger type in our case is `AssetOcrV1` (new in v3; not present in v2.7.5), simply because it is the biggest payload: our instance has 4,320,180 `asset_ocr` rows ≈ 1,137 MB of serialized JSON in a single stream. The missing-backpressure pattern itself is shared by the other sync types — `AssetOcrV1` is just the first one big enough to hit the ceiling. Your Sync v2 blog post says the design goal was "end-to-end streaming … avoiding serializing huge chunks of JSON in memory" — the design does stream end-to-end (the Kysely cursor genuinely paginates), but the last hop never asks the socket whether it is ready, which un-does the memory-boundedness whenever the client is slower than the database. ### Environment - immich server **v3.0.2** (also byte-identical in `main` as of 2026-07-14: the send helper still discards `write()`'s return value) - Docker Compose deployment, default `NODE_OPTIONS` (none) - Postgres on the same host → the producer is much faster than any real client ### Evidence (measured, not inferred) - Sampling the resident worker's memory during the ratchet shows the bytes are post-serialize/pre-transmit sync frames: resident arenas are full of `{"type":"AssetOcrV1",…,"ack":"AssetOcrV1|<uuid>"}` strings — and `ack` is not a DB column; it is synthesized in JS at send time, so the accumulation can only be the unflushed write backlog. - One 22-second client session ratcheted the worker 2.67 GB → 7.33 GB, which persisted after the client disconnected; the worker died at the heap ceiling ~an hour later. 4 crash-loops in one afternoon, 24 h of zero sync progress. - Isolated reproduction (same code, same driver, same table, throwaway container): pushing all 4.32 M rows through the unmodified send path with a slow reader grows the socket write queue to **1,571 MB and climbing**; the identical run with a drain-await added holds the write queue at **0.0 MB** (worker RSS 118 MB). - With a drain-await deployed on our instance, a real client streamed **+414,279 rows in a 38-minute session with zero restarts and zero V8 FATALs** (peak 2,088 MiB); before the fix the same workload produced the 7.33 GiB ratchet and worker death. - Raising the container memory cap does not help (we measured before understanding the mechanism): the accumulation lives largely outside the V8 heap (allocator arenas ~3× heap in our samples), and the V8 ceiling is independent of the cgroup limit anyway. ### The fix needs one subtlety: the disconnect path The obvious fix — `if (!response.write(x)) await once(response, 'drain')` — **hangs on client disconnect** (measured): no `'drain'` ever fires, the generator parks at its `yield`, so Kysely's `finally { release() }` never runs and the reserved Postgres connection leaks for the life of the process. That trades a self-healing crash for a permanent connection leak. The await must be abortable on `'close'`/`destroyed`, and the handler must **stop the whole handler** on disconnect (not just break one loop — the deletes loop otherwise falls through and streams upserts to the dead socket). ### Reference: the hotfix we run locally We bind-mount a patched compiled `dist/services/sync.service.js` over v3.0.2 (2 behavioral call sites in `syncAssetOcrV1` only; all other `send()` call sites untouched). Shape: ```js const sendWithBackpressure = async (response, item) => { if (response.write(serialize(item))) { return true; } if (response.destroyed || response.writableEnded) { return false; } const ac = new AbortController(); const onClose = () => ac.abort(); response.once('close', onClose); try { await once(response, 'drain', { signal: ac.signal }); return true; } catch { return false; // disconnected while waiting — caller must stop } finally { response.off('close', onClose); } }; // call sites (deletes + upserts loops): if (!(await sendWithBackpressure(response, { type, ids, data }))) { return; // return from the handler, not break — see above } ``` We verified `return` unwinds the generator and releases the Postgres connection (1 release / 0 hangs across break/throw/return variants; the bare drain-await variant is the only one that leaks). A proper upstream fix presumably wants this in `sync.service.ts` for **all** sync types, with the send helper itself made async. Happy to provide any of the measurements in more detail. *Disclosure: all pre-fix evidence above was collected on an unpatched v3.0.2; the instance has run the bind-mount hotfix since 2026-07-14.*
1 条评论