Repository navigation
libsql-server: memory-bounded streaming dump importer (opt-in, benchmarkable against the buffered one) - #51
Conversation
3ca3765 to
ecb9431
Compare
Reviewer walkthrough
TL;DRThis adds an opt-in streaming implementation for namespace creation from No default behavior changes: End-to-end flowflowchart LR
A[POST namespace create] --> B[CreateNamespaceReq]
B --> C[DumpSource<br/>stream + optional importer]
C --> D[resolve request override<br/>or server default]
D --> E{importer}
E -->|buffered| F[legacy whole-dump importer]
E -->|streaming| G[async DumpStream reader]
G --> H[StatementFramer<br/>linear complete.c state machine]
H --> I[mpsc queue<br/>depth bound]
I --> J[Semaphore permit travels<br/>with frame: byte bound]
J --> K[one BLOCKING_RT executor<br/>owns PrimaryConnection]
K --> L[UTF-8 + parse/policy check]
L --> M[execute original SQL text]
M --> N[COMMIT or best-effort ROLLBACK]
The explicit Suggested reading order1. API and selection plumbing (small)
2. Dispatcher and unchanged control arm
3. Statement boundary detection (most correctness-sensitive)
Why the local port: calling non-resumable 4. Bounded reader/executor pipeline
Memory model: one stream chunk + one unfinished frame + at most 5. Failure semantics and tests
The main integration anchors are chunking + truncated body, cross-importer data equivalence and large-dump backpressure, and EOF, saturated-queue failure, and cancellation. Intentional compatibility differencesThe full matrix is in design §10. The important ones:
These differences are opt-in because buffered remains the default. Evidence
Where reviewer judgment is most valuable
Explicitly out of scope / deferred
The full rationale, memory model, behavior matrix, benchmark procedure and adversarial-review resolutions are in |
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.
The design doc pointed to internal tracking for the pre-existing namespace lifecycle issues. Say instead that they are not fixed here and that #53, which proposed all-or-nothing creation, is closed for now.
b61f5d2 to
0a4acf9
Compare
The design doc described how the reader hands statements to the executor, but not why the queue is bounded or why it has both a message limit and a byte limit. Add that to the architecture section, and replace the "< 32 MiB" memory estimate with "tens of MiB". Also note that #53 is stacked on this change instead of closed.
95d2b70 to
820aa86
Compare
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 samelibsql-serverinstance.Design:
docs/STREAMING_DUMP_IMPORT_DESIGN.md.Why
The current importer does
read_to_stringon the whole dump (plus a lowercase copy for theattachcheck), so import RSS is proportional to dump size. Everything downstream (SQLite page cache, replication log) is already bounded or flushed to disk; the importer'sStringwas the only size-proportional allocation.The buffered importer also rejects any dump whose data contains the substring
attach(the check iscontains("attach")over the whole file). Ordinary text often contains the word (attached,attachment), and a database with such text cannot be imported with the shipped code at all.What
"dump_importer": "buffered" | "streaming"(400 if given withoutdump_url, 422 for unknown values)--dump-importer/SQLD_DUMP_IMPORTER(defaultbuffered: no behavior change until opted in)--dump-import-max-statement-size(64 MiB → HTTP 413),--dump-import-queue-bytes(16 MiB),--dump-import-queue-depth(256)StatementFramer(single-pass resumable port of SQLite'scomplete.cstate machine, absolute line/column tracking) → boundedmpsc+ byte-budgetSemaphore→ oneBLOCKING_RTexecutor thread owning the connection. The queue has a message limit for small rows and a byte limit for large statements; design doc §4 explains why both are needed. ExplicitEndmessage; a channel closed withoutEnd(stream error, framing error, cancelled admin request) ⇒ROLLBACKon the executor thread.sqlite3_parserfor policy (empty frames,libsql_wasm_func_tableskip,ATTACH/DETACH→ 400 with the existing message, samen_stmt > 2 && autocommit ⇒ NoTxnrule) → execute the original statement text. Parser errors are remapped to absolute dump positions, so the existing error snapshots apply to both importers unchanged.libsql_server_dump_import_{duration_seconds,bytes,statements,max_statement_bytes}{importer}plusfailures{importer,kind}; start/progress/success logs, and failure logs with the failure category and partial progress.file:dumps are read in 64 KiB chunks (was 4 KiB); benefits both importers.Behavior differences (streaming vs buffered), see §10 of the design
attachinside data no longer rejects the dump (the streaming check is statement-level, with the authorizer as backstop). A standaloneDETACHis rejected with 400 (buffered: fails at execution with 500).sqlite_schemais the dump's DDL verbatim; buffered stores the parser's re-serialization (e.g.ON plain (a), multi-line trigger bodies). Data is identical.Validation
Validated on a pre-production deployment with large real databases. Peak importer memory stayed flat as dump size grew, every imported database dumped back byte-identical to its input, and
PRAGMA integrity_checkreturnedok. That build predates the rebase onto #56, which did not change this PR's runtime code.Local run (debug build, 36 MB dump / 300 007 statements,
scripts/bench-dump-import.sh): streaming 15.2 s / +15 MB, buffered 19.7 s / +135 MB, identical data.Tests
dump_import::{complete,framer,mod}): framer cases run for every chunk size1..=len; a differential test checks the framer against SQLite's ownsqlite3_completeon 2 000 random token soups × 5 chunkings; linear framing of a semicolon-dense 1 MiB statement; zero-copy hand-over of large frames._streaming(existing snapshots reused by name, including 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_importerfield validation, server-wide default, WASM-table skip, empty dump, EOF without;, executor failure under a saturated queue, abandoned admin request (executor rolls back and releases the connection), a 50k-statement dump under a 64 KiB queue budget, and an equivalence test comparing both importers' exported data.cargo nextest run -p libsql-server: same result as the base branch (embedded_replica::local::local_sync_with_writesfails identically on the base commit on macOS).Commits
extract dump loading into a selectable importer: pure refactor + plumbing; buffered importer moved verbatim, streaming stubbed; existing tests/snapshots untouchedadd incremental SQL statement frameradd memory-bounded streaming dump importer+ testsdocs+ benchmark scriptaddress adversarial review of the streaming dump importer: resumablecomplete.cport replacing a per-semicolonsqlite3_complete()call that was O(n²) in interior semicolons; zero-copy large frames; config hardening; progress/partial-stats logging; testsdocs: describe the linear dump statement scannerdocs: drop internal references from the streaming importer designdocs: explain the import queue's two limits; also notes that libsql-server: make namespace creation all-or-nothing (stacked on #51) #53 is stacked on this PRFollow-ups
--dump-importertostreamingby default, then remove the buffered importer after a soak. The §15 acceptance criteria are met by the validation above.BLOCKING_RTthread and its own queue budget for its duration.libsql-ffi/build.rspasses a header toccas a source, which current Xcodearsilently drops, sosqldfails to link; see libsql-ffi: do not compile pcre2_internal.h on Apple targets #57. Not part of this PR.