Repository navigation
Unbounded memory growth: upload and download routes ignore stream backpressure (v9.7.0, v9.8.0) #274
Description
Summary
Both proxy routes read from the faster side without waiting for the slower side, so the difference accumulates in process memory as Buffers:
- Download (
GET /download/:cacheEntryId): a slow restore client makes the server read the entire object from storage into the socket write queue. N slow clients on a large entry hold roughly N × entry size. - Upload (
PUT /upload/:uploadId?blockid=…): the block body is read off the socket in full, regardless of how fast the storage adapter consumes it.
With direct downloads disabled, a handful of slow restores of one large entry is enough to OOM-kill the server. Every in-flight upload and restore then fails with connection reset by peer, and the runners' retries tend to OOM the fresh process again.
Affected: v9.7.0 and v9.8.0. The code for both paths is unchanged between them and current dev. h3 1.15.11.
Configuration
STORAGE_DRIVER=s3(reproduced against MinIO),DB_DRIVER=sqliteENABLE_DIRECT_DOWNLOADS=false, so every restore goes through/download/:id- Official image with its default command (
node --expose-gc …), container memory limit 4 GiB
Reproduction (MinIO only)
- Start MinIO and
ghcr.io/falcondev-oss/github-actions-cache-server:9.7.0with the configuration above and--memory 4g. - Save one ~1.3 GiB entry with
@actions/cacheand restore it once so it is merged. - Restore it with N concurrent clients whose download bandwidth is limited to ~15 MiB/s. That is the single-stream proxy restore rate reported in Proxy download route ignores Range; @actions/cache restores are single-stream against this server #263. We used a toxiproxy
bandwidthtoxic between the clients and the server. - Sample
process.memoryUsage()inside the server.
| Concurrent restores of a 1331 MiB merged entry | Peak RSS | Peak arrayBuffers |
|---|---|---|
| idle baseline | 0.15–0.22 GB | ~0.1 MB |
| 1 slow (15 MiB/s) | 1.76 GB | 1.43 GB |
| 2 slow | 3.29 GB | 2.79 GB |
| 4 slow | OOM-killed at the 4 GiB limit. Last sample: RSS 4.02 GB, arrayBuffers 3.49 GB, 3.39 GB in socket write buffers |
|
| 4 unthrottled | 0.38 GB | 0.12 GB |
| 8 unthrottled | 0.39 GB | 0.15 GB |
How much is buffered depends on the client's read rate, not the concurrency. The memory is released once the slow clients finish or disconnect.
On the upload side, a single 1331 MiB save from a fast client peaked at 741 MB RSS and 489 MB arrayBuffers (baseline 217 MB / 0.1 MB). The regression test below isolates the route: while the storage adapter is busy, a 512 MiB block PUT is read off the socket in full (536,871,021 bytes). Once the route stops using getRequestWebStream, only 131,072 bytes are read.
Expected vs observed
Expected: memory per in-flight request is bounded by stream highWaterMarks and socket buffers (KiB to low MiB), and TCP flow control paces the faster side.
Observed: memory per in-flight request grows to the size of the object (download) or the block (upload).
Cause
- Download,
routes/download/[cacheEntryId].ts:29(v9.7.0):await sendStream(event, Readable.toWeb(stream) as ReadableStream). In h3 1.15.11,sendStreamwrites web streams throughstream.pipeTo(new WritableStream({ write(chunk) { event.node.res.write(chunk) } })). It ignoreswrite()'s return value, so the pipe never waits fordrain(h3/dist/index.mjs:857-875). A NodeReadablewould take the.pipe()branch, which does respect backpressure. - Upload,
routes/devstoreaccount1/upload/[uploadId].put.ts:40(v9.7.0):getRequestWebStream(event). In h3 1.15.11 this builds aReadableStreamwhosestartdoesreq.on('data', chunk => controller.enqueue(chunk)). It has nopulland never pauses the request (h3/dist/index.mjs:476-508).Storage.uploadPartthen wraps it inReadable.fromWeb(lib/storage.ts:227-246).
Related
- Increased memory consumption #182 and Memory requirements/leak? #135: same symptom, both closed without a root cause.
- Proxy download route ignores Range; @actions/cache restores are single-stream against this server #263: slow single-stream restores.
- Cache restore hangs forever when parts are large (64MiB) — merge abort deadlocks pumpPartsToStreams, client sees "Premature close" #247: restore hang with a different cause.
- feat(download): serve HTTP Range on the proxy route #270 replaces
sendStreamwithstream.pipelineand fixes the download half, though it is framed as an abort/lease leak. Nothing covers the upload half yet; a PR with a regression test follows.
Proposed fix
- Download: feat(download): serve HTTP Range on the proxy route #270's
pipeline(download.stream, event.node.res). - Upload: pass
event.node.reqtoStorage.uploadPart(typedReadable), so the adapter's reads pace the client.
Activity
test(download): assert the proxy route applies backpressure