Skip to content

libsql-server: memory-bounded streaming dump importer (opt-in, benchmarkable against the buffered one) - #51

Draft
tszymczyszyn-shopify wants to merge 5 commits into
v0.9.30-shopify-patchesfrom
tszymczyszyn/streaming-dump-importer
Draft

tszymczyszyn-shopify wants to merge 5 commits into
v0.9.30-shopify-patchesfrom
tszymczyszyn/streaming-dump-importer

Conversation

@tszymczyszyn-shopify

@tszymczyszyn-shopify tszymczyszyn-shopify commented Oct 7, 2026 •

Copy link
Copy Markdown

Summary

Adds an opt-in, memory-bounded streaming importer for POST /v1/namespaces/:ns/create + dump_url, selectable per request or server-wide, alongside the existing (now called buffered) importer, which is kept byte-for-byte so both can be benchmarked and validated on the same libsql-server instance.

Design: docs/STREAMING_DUMP_IMPORT_DESIGN.md. Context: Retail #35846 / LibSQL DB Mover P0 copy path.

Why

The current importer does read_to_string on the whole dump (plus a lowercase copy for the attach check), so import RSS is proportional to dump size. Everything downstream (SQLite page cache, replication log) is already bounded/flushed to disk — the importer's String was the only size-proportional allocation.

What

  • Runtime selection
    • request body: "dump_importer": "buffered" | "streaming" (400 if given without dump_url, 422 for unknown values)
    • server default: --dump-importer / SQLD_DUMP_IMPORTER (default buffered — no behavior change until opted in)
    • streaming knobs: --dump-import-max-statement-size (64 MiB → HTTP 413), --dump-import-queue-bytes (16 MiB), --dump-import-queue-depth (256)
  • Streaming pipeline: async reader → StatementFramer (memchr(';') + sqlite3_complete() as the boundary oracle, absolute line/column tracking) → bounded mpsc + byte-budget Semaphore → one BLOCKING_RT executor thread owning the connection. Explicit End message; a channel closed without End (stream error, framing error, cancelled admin request) ⇒ ROLLBACK on the executor thread.
  • Per statement: UTF-8 check → sqlite3_parser for policy (empty frames, libsql_wasm_func_table skip, ATTACH/DETACH → 400 with the existing message, same n_stmt > 2 && autocommit ⇒ NoTxn rule) → execute the original statement text. Parser errors are remapped to absolute dump positions, so the existing error snapshots apply to both importers unchanged.
  • Metrics libsql_server_dump_import_{duration_seconds,bytes,statements,failures,max_statement_bytes}{importer} and start/finish logs for both importers.
  • file: dumps are now read in 64 KiB chunks (was 4 KiB) — benefits both importers.

Behavior differences (streaming vs buffered) — see §10 of the design

  • The word "attach" inside data no longer rejects the dump (buffered's substring check is a false positive; the streaming check is statement-level, plus the authorizer backstop).
  • Schema SQL stored in sqlite_schema is the dump's DDL verbatim; buffered stores the parser's re-serialization (e.g. ON plain (a), multi-line trigger body). Data is identical.
  • Invalid UTF-8 / NUL → 400 (was 500); oversized statement → 413; row-returning statements (EXPLAIN, stray SELECT) execute with rows discarded (was 500).

Results (debug build, macOS arm64, 36 MB dump / 300 007 statements, one server, scripts/bench-dump-import.sh)

importer wall RSS before → peak Δ RSS
streaming 15.2 s 38 → 54 MB +15 MB
buffered 19.7 s 56 → 195 MB +135 MB (≈3.7× dump)

Both: integrity_check ok, 300 300 identical data rows.

Adversarial review (commit 5: address adversarial review)

Independent reviewers (scope / correctness / security / performance / testing / architecture / operations) plus a manual pass; full table in design doc §18. Verdict before fixes: NEEDS CHANGES.

  • HIGH — fixed: framing called sqlite3_complete() once per candidate ;, i.e. O(n²) in interior semicolons, on a tokio worker. Measured: 1 MiB value with 50k ; → 20 s CPU; 200 KB CSS-like value → 0.36 s per row. Replaced with complete.rs, a resumable Rust port of complete.c (same tokenizer + 8×8 state machine), differential-tested against the real sqlite3_complete (2000 random token soups × 5 chunkings). No unsafe left in the importer. The CSS-shaped 10 MB dump now imports in 0.56 s.
  • MEDIUM — fixed: 2× transient copy of the largest statement; queue_bytes/queue_depth = 0 panics for programmatic configs; failure log duplicated error text that may quote dump SQL (now category + progress); no progress/partial stats for long imports (now every 10 s + on failure); dump_importer JSON value case-sensitive while the CLI flag wasn't; bench script portability (macOS date %N, bc, bare $(curl) under set -e, unverified PID).
  • LOW — fixed/documented: column in the 413 message; standalone DETACH is 400 (streaming) vs 500 (buffered), now documented; executor panic path (unwinding drops the connection → SQLite rolls back; documented).
  • Tests added: WASM-table skip + empty dump (both importers), EOF without ;, executor failure under a saturated queue, abandoned admin request (executor rolls back and releases the connection), linear framing of a semicolon-dense statement, zero-copy hand-over of large frames. 39 dump integration tests + 20 unit tests, stable over repeated runs.
  • Deferred (documented): per-statement policy duplicated between importers until buffered is removed; no admission control for concurrent streaming imports on BLOCKING_RT; PR could be split at commit 1 (pure refactor).

Tests

  • 20 unit tests (dump_import::{complete,framer,mod}), framer cases run for every chunk size 1..=len, plus the differential test against sqlite3_complete.
  • Every existing dump integration test now also runs with _streaming; existing snapshots are reused by name (incl. the absolute-position one: line 7, column 11).
  • New: 1/7-byte HTTP chunking, truncated body → 500 + rollback, 413, NUL/invalid UTF-8 → 400, dump_importer field validation, server-wide default, 50k-statement dump under a 64 KiB queue budget, and an equivalence test comparing both importers' exported data.
  • Full cargo test -p libsql-server: all pass except embedded_replica::local::local_sync_with_writes, which fails identically on the base commit in my environment (pre-existing, unrelated).

Commits

  1. extract dump loading into a selectable importer — pure refactor + plumbing; buffered importer moved verbatim, streaming stubbed; existing tests/snapshots untouched
  2. add incremental SQL statement framer
  3. add memory-bounded streaming dump importer + tests
  4. docs + benchmark script

Out of scope / known pre-existing issues (design §13)

  • Enormous statements/BLOBs (the size cap is a safety valve, not support).
  • Namespace lifecycle on failure (metastore row kept after a failed create, no cleanup when the admin request is cancelled) — DB Mover fence/quarantine work.
  • Local macOS build note (not in this PR): libsql-ffi/build.rs passes regexp/pcre2/pcre2_internal.h to cc as a source; current Xcode ar silently drops members when it meets the resulting non-Mach-O object, so sqld fails to link with undefined sqlite3_* symbols. GNU ar on Linux CI tolerates it. Deleting that one sqlean_patterns.push(...) line fixes it; happy to send it as a separate PR.

Rollout

Keep buffered as default until the §15 acceptance criteria are met on production-shaped data (settings/catalog schemas incl. FTS); then flip --dump-importer to streaming in a follow-up and remove the buffered importer after a soak.

Move the existing dump loader verbatim into namespace/dump_import/buffered.rs
and introduce the plumbing needed to select an importer at runtime:

- DumpImporterKind { Buffered, Streaming } and DumpImportConfig (default
  importer, max statement size, in-flight queue bytes/depth) on DbConfig and
  BaseNamespaceConfig.
- RestoreOption::Dump now carries a DumpSource { stream, importer }.
- Admin API: optional `dump_importer` field on POST /v1/namespaces/:ns/create
  (400 when given without dump_url).
- CLI: --dump-importer (SQLD_DUMP_IMPORTER, default buffered),
  --dump-import-max-statement-size, --dump-import-queue-bytes,
  --dump-import-queue-depth.
- LoadDumpError gains ImporterWithoutDumpUrl (400) and StatementTooLarge (413).
- file: dumps are read in 64 KiB chunks instead of 4 KiB.
- Import duration/bytes/statements/failure metrics and start/finish logs.

The streaming importer is a stub in this commit; the buffered importer's
behavior is unchanged and existing dump tests/snapshots pass as-is.
StatementFramer accumulates dump chunks and emits complete statements using
sqlite3_complete() as the boundary oracle, so semicolons inside strings,
quoted identifiers, comments and CREATE TRIGGER ... END bodies are handled
the same way the sqlite3 shell handles them. It tracks the absolute
line/column of every frame so parser errors can be reported against the
whole dump, rejects NUL bytes, and fails closed when a single statement
exceeds the configured size limit.

Unit tests cover every chunk size from 1 to the input length for each case.
Select it with `"dump_importer": "streaming"` on the create-namespace
request or `--dump-importer streaming` server-wide. The buffered importer
remains the default.

An async reader drives the dump stream through StatementFramer and hands
complete statements to a single executor thread (BLOCKING_RT) that owns the
connection for the whole import. A bounded mpsc channel plus a byte-budget
semaphore cap the statements in flight, so memory is bounded by
configuration plus the largest single statement instead of the dump size.

Per statement the executor validates UTF-8, parses with sqlite3_parser for
the policy checks (empty frames, libsql_wasm_func_table skip, ATTACH/DETACH
rejection, the same must-be-in-a-transaction rule as before) and then
executes the *original* statement text, so the schema SQL stored in
sqlite_schema is exactly what the dump contained. Parser errors are
remapped to absolute dump positions, so the existing error snapshots apply
to both importers. An explicit End message distinguishes a clean EOF from a
dropped reader (stream error, framing error, cancelled admin request); in
every failure path the transaction is rolled back on the executor thread.

Tests: every existing dump test now also runs against the streaming
importer, plus streaming-specific coverage for 1/7-byte HTTP chunking,
truncated bodies, statement size limit (413), NUL bytes and invalid UTF-8
(400), the dump_importer request field, the server-wide default, a
50k-statement dump under a tiny queue budget, and an equivalence test that
imports the same dump with both importers and compares the data.
- ADMIN_API.md: dump_url semantics and the new dump_importer field.
- USER_GUIDE.md: creating a database from a SQLite dump, importer selection
  and the --dump-import-* flags.
- STREAMING_DUMP_IMPORT_DESIGN.md: the design the implementation follows,
  with 'as built' notes where it was refined (422 for unknown importer,
  size limit also applies to terminated statements, exact UTF-8 error
  position, DDL text differences between importers) and first results.
- scripts/bench-dump-import.sh: runs both importers against one server,
  samples RSS, compares exported data and integrity_check.
Framing is now a single linear pass. The first implementation called
sqlite3_complete() for every candidate semicolon of the pending statement,
which is O(n^2) in interior semicolons: a 1 MiB text value with 50k
semicolons cost 20 s of CPU on a tokio worker, a 200 KB CSS-like value
0.36 s per row. complete.rs is a resumable port of complete.c (same
tokenizer rules and 8x8 state machine); a differential unit test frames
2000 random token soups under five chunkings and requires identical results
to the real sqlite3_complete, which is now only used in that test. No unsafe
code remains in the importer.

Other findings fixed:
- frames >= 1 MiB are handed over without copying, so peak memory is one
  copy of the largest statement rather than two;
- queue_bytes/queue_depth of 0 in a programmatically built DumpImportConfig
  no longer panic (clamped at the point of use);
- the failure log records the failure category plus statements/bytes
  processed instead of repeating the error text (which may quote dump SQL
  and is already logged by the HTTP layer); progress is logged every 10 s;
- dump_importer in the request body is parsed like the CLI flag
  (case-insensitive, trimmed);
- the 413 message includes the statement's column;
- bench script: no bc/date %N, explicit tool check, curl and
  integrity_check failures reported, caller-supplied PID must be sqld.

New tests: WASM-table skip and empty dump (both importers), EOF without a
trailing semicolon, executor failure under a saturated queue, abandoned
admin request (executor rolls back and releases the connection), linear
framing of a semicolon-dense statement, zero-copy hand-over of large frames.

Documented the remaining accepted differences (DETACH: 400 vs 500) and the
review outcome in the design doc.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant