PR 2841: backpressure for CSV and NDJSON export
A review guide that explains the streaming logic first, then walks the diff with findings under each hunk
Verdict
The PR touches 2 files, src/export/stream.js (+15 -4) and test/export/stream.test.js (+21), about 40 changed lines.
Backpressure in one minute
You asked for focus on the streaming logic, so here is the model before the diff.
A fast producer (the database cursor) can emit rows faster than a slow consumer (the HTTP response to a client on a poor connection) can send them. Node does not stop the producer on its own when you use on('data'). Unsent rows pile up in the response's memory buffer. For a 2 GB export that buffer grows until the process is killed.
The protocol has three parts.
res.write(chunk)returnstruewhile the buffer has room andfalseonce it passes its high-water mark (16 KiB by default).- On
false, the producer must stop: callcursor.pause(). - When the buffer empties,
resemits'drain'. The producer resumes:cursor.resume().
The same loop as a timeline of one saturation cycle.
| term | meaning |
|---|---|
write() returns false | the buffer passed the high-water mark; stop sending |
highWaterMark | the buffer size that triggers false; 16 KiB for a response by default |
'drain' | the buffer is empty again; safe to send |
pause() and resume() | stop and restart 'data' events on the cursor |
Why the PR was needed
The benchmark in the PR description exports a 2 GB table to a client throttled to 5 Mbps. Memory before the fix grows without limit; after it stays flat.
Data
| minute | before | after |
|---|---|---|
| m0 | 120 MB | 118 MB |
| m1 | 210 MB | 126 MB |
| m2 | 340 MB | 131 MB |
| m3 | 480 MB | 129 MB |
| m4 | 610 MB | 133 MB |
| m5 | 760 MB | 130 MB |
| m6 | 890 MB | 132 MB |
| m7 | 1,010 MB | 131 MB |
| m8 | 1,130 MB | 134 MB |
The diff, annotated
Each hunk is followed by the findings that apply to it. Severity is marked by tag and callout level: BLOCKING is a red alert, NIT is a warning, NICE is a note.
src/export/stream.js
Before, every row was written and the return value ignored. After, the function pauses on false and resumes on 'drain'. Read the three hunks in order.
Hunk 1: the signature and the flag
@@ -1,10 +1,21 @@
'use strict';
-function streamExport(cursor, res) {
- cursor.on('data', (row) => {
- res.write(JSON.stringify(row) + '\n');
- });
+function streamExport(cursor, res, { highWaterMark = 16 * 1024 } = {}) {
+ let paused = false;
+
Hunk 2: the pause and resume
@@ -5,3 +6,10 @@ function streamExport(cursor, res, { highWaterMark = 16 * 1024 } = {}) {
+ cursor.on('data', (row) => {
+ const ok = res.write(JSON.stringify(row) + '\n');
+ if (ok || paused) return;
+ paused = true;
+ cursor.pause();
+ res.once('drain', () => {
+ paused = false;
+ cursor.resume();
+ });
+ });
Hunk 3: end and error
@@ -7,2 +17,4 @@ function streamExport(cursor, res, { highWaterMark = 16 * 1024 } = {}) {
cursor.on('end', () => res.end());
-}
+ cursor.on('error', (err) => res.destroy(err));
+}
cursor.on('end', () => res.end());
cursor.on('error', (err) => res.destroy(err));
res.once('close', () => cursor.destroy()); // client left: release the connection
test/export/stream.test.js
Hunk 4: the slow-consumer test
@@ -0,0 +1,21 @@
+const test = require('node:test');
+const assert = require('node:assert');
+const { once } = require('node:events');
+const { Readable, Writable } = require('node:stream');
+const { streamExport } = require('../../src/export/stream');
+
+test('keeps the response buffer bounded for a slow consumer', async () => {
+ const cursor = Readable.from(Array.from({ length: 1000 }, (_, i) => ({ i })));
+ let peak = 0;
+ const res = new Writable({
+ highWaterMark: 256,
+ write(_chunk, _enc, cb) { setTimeout(cb, 1); },
+ });
+ const timer = setInterval(() => {
+ peak = Math.max(peak, res.writableLength);
+ }, 5);
+ streamExport(cursor, res);
+ await once(res, 'finish');
+ clearInterval(timer);
+ assert.ok(peak < 1024, `peak buffer ${peak} B`);
+});
write(chunk, _enc, cb) {
peak = Math.max(peak, this.writableLength);
setTimeout(cb, 1);
},
All findings
| id | severity | where | summary | action |
|---|---|---|---|---|
| B1 | blocking | stream.js 17 to 18 | cursor never released when client disconnects while paused | add res.once('close', ...) and a test |
| N1 | nit | stream.js 3 | highWaterMark option is unused | remove it |
| N2 | nit | stream.js 4 | paused name hides its meaning | rename to waitingForDrain |
| N3 | nit | test 13 to 15 | interval sampling can miss the peak | record inside write() |
| Y1 | nice | stream.js 7 | uses the write() return value | keep |
| Y2 | nice | stream.js 11 to 14 | once('drain') self-cleans | keep |
| Y3 | nice | test 9 to 12 | slow Writable exercises drain | keep |
Before you approve
- B1 fixed and the disconnect test added
- Run the new test locally and confirm it passes
- Confirm the DB pool size is shown in the connection metrics after a cancelled export
- Check that
highWaterMarkhas no other caller before removing it
- 1Reproduce B1 with curl --limit-rate 1k and Ctrl-CnowReviewer
- 2Author applies the close handler and test2026-10-20PR author
- 3Re-review the delta only2026-10-21Reviewer