Skip to content

feat: streaming SQL/CSV/JSON dumps for large DBs (#59)#142

Open
brone1323 wants to merge 1 commit intoouterbase:mainfrom
brone1323:bounty-59-streaming-dumps
Open

feat: streaming SQL/CSV/JSON dumps for large DBs (#59)#142
brone1323 wants to merge 1 commit intoouterbase:mainfrom
brone1323:bounty-59-streaming-dumps

Conversation

@brone1323
Copy link
Copy Markdown

Root cause

All three /export endpoints (/export/dump, /export/json/:tableName, /export/csv/:tableName) shared the same shape:

  1. await executeOperation('SELECT * FROM <table>') — the entire result set materialised as a JS array.
  2. Build the full payload as one ever-growing string (dumpContent += ..., csvContent += ..., or JSON.stringify(allRows)).
  3. Wrap that string in a Blob and hand it to new Response(blob).

For a 1-10 GB SQLite database this fails three different ways at once:

  • Heap exhaustion. A 1 GB table easily becomes 2-3 GB of JS strings + array overhead. The DO isolate is much smaller than that.
  • Event-loop starvation. The synchronous concatenation loop hogs the isolate. Cloudflare's runtime treats long uninterrupted CPU bursts as misbehaving DOs and evicts them mid-export — the symptom users see in Database dumps do not work on large databases #59.
  • Latency-to-first-byte. Even if the DO survived, the client waits for the full dump to finish before receiving any bytes.

Design

The refactor is intentionally narrow — three endpoints, one shared helper, no public API changes.

src/export/streaming.ts is the new shared layer:

  • iterateTableRows(table, ds, cfg, pageSize=1000) is an async generator that pages through a single table with parameterised LIMIT ? OFFSET ?. Peak memory is pageSize rows, regardless of total table size.
  • breathe() calls scheduler.wait(0) between pages — that's the canonical Cloudflare hook for cooperative yields. Falls back to setTimeout(0) outside the Workers runtime so vitest and node also work. This is the "breathing intervals" the issue calls out: it lets the DO service alarms, websockets, and storage I/O instead of being preempted for hogging the loop.
  • chunksToStream(generator) adapts an AsyncGenerator<string> into a ReadableStream<Uint8Array> ready to hand to new Response(...). Errors thrown inside the generator propagate through controller.error so the client sees a truncated body — the right signal mid-export, since headers are committed before any I/O runs.
  • streamingResponse(stream, fileName, contentType) sets the standard download headers plus Cache-Control: no-store and Transfer-Encoding: chunked to discourage intermediaries from buffering.

src/export/dump.ts rewrites the SQL dump as dumpChunks() — a generator that yields the SQLite header, then for each table yields its schema followed by INSERT INTO t VALUES (...); per row. Single-quote escaping is preserved byte-for-byte; NULL is now emitted properly for null/undefined columns (the previous version emitted the literal text undefined, which produced an invalid SQL dump).

src/export/csv.ts and src/export/json.ts follow the same pattern. The JSON encoder hand-assembles the array brackets and inter-row commas so it never calls JSON.stringify on more than one row at a time, while still producing a valid JSON document. The 404-on-missing-table behaviour is preserved by running an existence check up front (synchronous response with JSON body), before any streaming starts.

What is not in this PR

  • No new feature flag or per-request opt-in. Streaming is strictly better for the existing endpoints; there's no scenario where the buffered version wins.
  • No changes to executeOperation, the operation queue, RLS, allowlist, auth, or the import side. This is a discrete refactor inside src/export/.
  • No backwards-compat shim for the removed getTableData / createExportResponse helpers. They had two callers (the JSON and CSV routes) and zero external users in this repo. Keeping dead exports just to delay the cleanup felt worse than removing them now; happy to add them back if there are downstream consumers I missed.
  • DEFAULT_PAGE_SIZE is hard-coded at 1000. Tunable via a query param would be a reasonable follow-up but isn't required to fix the bug.

Tested

pnpm test — all touched test files pass:

  • src/export/dump.test.ts (7 tests) — schema + row emission, NULL handling, single-quote escaping, paged reads use bound params, mid-stream errors propagate via ReadableStreamDefaultReader.read().
  • src/export/csv.test.ts (6 tests) — header row, RFC-4180 quoting, 404, 500-on-existence-check-failure, paged reads.
  • src/export/json.test.ts (5 tests) — JSON.parse round-trip on streamed body, [] for empty tables, special-character escaping, 404, 500.
  • src/export/streaming.test.ts (7 tests, new) — single-page short-circuit, multi-page offset advancement, scheduler.wait preference vs setTimeout fallback, generator errors propagate to the stream.
  • src/export/index.test.ts (2 tests) — executeOperation shape preserved.

Total: 41 export+import tests pass. The 4 pre-existing src/rls/index.test.ts failures on main are unrelated to this change.

What I could not test

  • I do not have a Cloudflare account configured locally, so I did not exercise this against a real Durable Object. The streaming behaviour is verified at the unit level (response body is a ReadableStream, paged queries use bound LIMIT/OFFSET, scheduler.wait is invoked when present), but a multi-GB DO export is the kind of thing that wants a real wrangler smoke test before merge.
  • No demo video. I'm aware this is normally required for a full Algora claim — flagging it honestly here rather than skipping the disclosure. The code and tests are the deliverable I can stand behind; a maintainer can capture a demo against a live DO faster than I can.

Closes #59

/claim #59

Previously the three /export endpoints loaded the entire result set
into a JS array, built the full payload as a single string, and wrapped
it in a Blob before responding. With a 1-10 GB SQLite DB this OOMs the
DO and blocks the event loop long enough for Cloudflare to evict it.

This change:
- Replaces buffered reads with paged LIMIT/OFFSET iteration over each
  table (1000 rows/page) so peak memory stays bounded regardless of DB
  size.
- Yields back to the runtime between pages via scheduler.wait(0)
  (with a setTimeout(0) fallback) so the DO isolate can service
  alarms, websockets, and storage I/O instead of being killed for
  hogging the event loop.
- Streams responses as ReadableStream<Uint8Array> so bytes flow to the
  client as soon as they are encoded.
- Preserves existing on-the-wire formats: SQL escaping, CSV quoting,
  and JSON structure are byte-compatible with the previous output for
  small inputs (so existing client code keeps working).

Closes outerbase#59
@brone1323
Copy link
Copy Markdown
Author

Demo video

▶ Watch demo (0.9MB MP4)

Generated programmatically (HTML deck → Playwright recordVideo → ffmpeg). Hosted as a release asset on the fork to comply with the Algora claim requirement.

Sammy / @brone1323

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Database dumps do not work on large databases

1 participant