Skip to content

records(export): stream installation exports without buffering them - #69

Open
TusanHomichi wants to merge 1 commit into
mainfrom
codex/47-stream-installation-exports
Open

TusanHomichi wants to merge 1 commit into
mainfrom
codex/47-stream-installation-exports

Conversation

@TusanHomichi

Copy link
Copy Markdown
Member

Primary Issue

Closes #47

Problem And Outcome

An installation export read every finalized version with fetch_all and assembled the whole ZIP in memory before the handler answered, so its memory cost was proportional to the installation's history. It now streams: the same documented bytes, bounded memory, and an incomplete transfer that can never be mistaken for a complete export.

Changes

  • crates/consolebook-server/src/export_stream.rsEntryBuffer, a Write + Seek view of an append-only destination that holds one entry and applies the zip writer's patches to it. A patch is classified by position (inside the held entry), so the CRC-32/size patch that seeks backwards and the ZIP64 extra-field update that seeks forwards both land correctly. This is what keeps a non-seekable destination byte-identical: no data descriptors, real CRC-32 and sizes in the local header, unchanged central directory.
  • crates/consolebook-server/src/record_export.rsexport_to writes the container into any seekable sink: one read transaction, a metadata-only pass for the manifest (which must be the container's first entry), then a pass that streams each unit's stored bytes and unit manifest. The audit event is its own committed statement outside the read transaction, so an export reserves no writer while it streams (ADR 0019) and a scope with nothing to export is never audited. export_at keeps the buffered API the packet and the tests use.
  • crates/consolebook-server/src/exports_http.rs — the handler produces the archive on a blocking thread into a bounded-channel body: a slow client bounds the producer, a disconnect ends it and releases its connection, and a stall or any production failure after the response starts ends the transfer without its final chunk. The failure sentinel is actually sent (a dropped send future was a silent no-op) and a Drop guard covers a producer panic.
  • crates/consolebook-server/tests/export_stream_bytes.rs — byte identity, a real socket, memory, backpressure, stall, and the write-reservation proof; unit tests in exports_http.rs for the failure signal.
  • docs/decisions/0014-record-export-format.md, docs/formats/record-export.md, docs/development.md, web/src/lib/api.ts.
  • New dependencies: futures-util (stream rows one at a time) and http-body (the incremental response body). Cargo.lock changes only the dependency list.

Scope

  • In scope: producing and delivering installation and scoped record exports without buffering them, with unchanged bytes.
  • Out of scope: the container format itself (no version bump; docs/formats/record-export.md is unchanged in its normative claims); the browser download helper, which still reads the whole response into a Blob and is documented as such.

Verification

  • Listed the exact verification commands run below
  • Added or updated tests when behavior changed
  • Added or updated an ADR when a durable decision changed
  • All fixtures and examples are invented; no real agency data
- cargo fmt --check
- cargo clippy --workspace --all-targets -- -D warnings
- cargo test --workspace — 27 test binaries, 0 failures, 0 panics
- cargo build -p consolebook-server
- web/: npm ci, npm run check (0 errors, 0 warnings), npm run build
- web/: npm run e2e (system Chromium at /usr/bin/google-chrome; CI uses Playwright's bundled chromium)
  → 9 passed

Byte identity: streamed bytes equal an independent ZipWriter<Cursor<Vec<u8>>> reference across eight entry-size shapes (including 0/1/13/14/15/1024/65536/300000), a byte-level walk checks the descriptor flag, the patched CRC-32, the patched sizes, and every local header against the reference, and a live socket response is re-exported at the exported_at its own manifest states and compared with assert_eq!.

Memory, peak growth measured in a fresh process against a pre-seeded database with the sink discarding every byte: 500 units / 8.7 MB archive → 5.2 MiB; the same 500 units at 4x payload / 33.3 MB archive → 7.5 MiB; 4,000 units / 69.7 MB archive → 13.6 MiB. The corpus's bytes do not drive the peak; the remaining linear term is the unit metadata and its JSON manifest, which ADR 0014 states.

Reservation: an export observed mid-stream while the installation's own writer commits in well under a second — this fails against the audit-inside-the-transaction design. Failure: a stalled client's transfer carries no terminal chunk and is shorter than the complete archive; a destination that stops accepting ends the export. export verify, API byte delivery, determinism, packet bytes, and packet pin-history verification are unchanged and green.

Review And Merge Notes

  • Review focus: EntryBuffer's patch invariant, the audit's new commit point, and the failure path after the response has started.
  • User or operator impact: a large installation export no longer costs memory in proportion to the history, and a truncated download is visibly incomplete rather than a file that only export verify would reject.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 12, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-12T01:43:46.517534Z 4755731 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c1eae2fe25

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// starts delivering. The read transaction stays open around it, so
// the manifest, the payloads, and the audited instant still come
// from one snapshot.
audit_export(pool, actor_user_id, &audited).await?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid acquiring an audit connection while holding the pool

With five concurrent valid exports, each task can hold its read transaction connection here and then wait for a sixth connection to write the audit event; the production pool is capped at five connections in storage.rs:75-77. All five exports then time out or fail together while unrelated database operations are also starved, and an authorized user can repeat this with parallel requests. Restructure the preflight so it cannot acquire another pooled connection while every export already owns one.

Useful? React with 👍 / 👎.

Comment on lines +240 to +241
if send_before(sender, StreamItem::Failed(detail), deadline).is_err() {
tracing::error!("the export's failure could not reach the response body");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve a failure marker when the body queue is full

If a client stops reading for longer than both 10-second send windows and then resumes, the failed data send is followed by a failure send into the same still-full queue; this branch discards that second failure after only logging it. Once the queued chunks drain, all senders have been dropped, so ExportBody observes None and reports a clean end, allowing HTTP/1 to emit its terminal chunk and the browser's response.blob() to resolve with a truncated ZIP. Keep failure/completion state outside the bounded data queue, or otherwise guarantee that channel closure after an unsuccessful producer yields a body error.

AGENTS.md reference: AGENTS.md:L38-L40

Useful? React with 👍 / 👎.

@TusanHomichi TusanHomichi left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Merge review at c1eae2f: hold this PR. I inspected the export transaction/audit lifecycle, EntryBuffer, response producer/body, and failure tests, and independently agree with both existing unresolved Codex findings. pr-gate run 34661990490 is green; these are uncovered boundary cases, not a claim that CI failed.

  1. Pool ownership: export_to() holds a pooled read transaction while audit_export(pool, ...) waits for another pooled connection. The production pool has five connections. Coordinated exports can occupy all five and then each wait for the audit connection, starving exports and unrelated requests until acquisition timeouts. Repair the reservation/admission/connection lifecycle rather than merely increasing the pool or moving an audit write into a transaction held for the full download. Add a deterministic concurrency regression that holds all competing requests at the relevant boundary; prove unrelated ordinary work still progresses, and exercise a one-connection pool or explicitly prove the supported admission contract. Preserve one snapshot for manifest and payload, the intended authorization boundary, and honest audit semantics.

  2. Terminal outcome: failure is sent through the same bounded queue that just stalled. If both send windows expire, signal_failure() only logs, the producer disarms its guard and drops all senders, and ExportBody treats channel closure as successful EOF. A late reader can therefore receive a truncated prefix with a clean HTTP completion. Give success/failure an explicit reliable terminal state independent of queue capacity; only an explicitly successful, finalized producer may permit clean EOF. A channel closing without that success must fail closed. Cover ordinary producer error, panic, full queue, disconnect, and finalization failure.

The decisive new stall regression should leave the data queue full beyond BOTH send deadlines, then resume reading and assert a body/transport error rather than clean EOF. Existing tests that inject a failure while there is queue space, or only test one timed-out send, cannot establish that guarantee. Keep short injectable test deadlines and add a real transport-level check as appropriate; do not weaken timeouts or retry until green.

Keep the existing byte-identity/CRC/header, verifier, snapshot, cancellation, and writer-progress proof. State memory cost precisely: streaming removes whole-corpus payload buffering, but unit metadata, the serialized manifest, and ZIP central-directory state still scale with unit count; browser Blob buffering also remains a documented separate limitation. Do not claim constant memory or end-to-end browser streaming.

Revalidate against current main (now containing #68) and rerun the full gates. No merge or deployment was performed for this PR. This review is source inspection plus GitHub CI/review verification, not an independently executed Rust or browser reproduction.

Export read every finalized version with `fetch_all` and assembled the
whole ZIP in memory before the handler answered, so an installation
export cost memory in proportion to its history (#47).

`record_export::export_to` now writes the container into any seekable
sink as it produces it: one read transaction, a metadata-only pass for
the manifest (which must be the container's first entry), then a pass
that streams each unit's stored bytes and unit manifest.
`export_stream::EntryBuffer` holds one entry and applies the `zip`
writer's patches to it, so the destination sees an append-only stream and
the bytes stay identical to the buffered archive: no data descriptors,
real CRC-32 and sizes in the local header, the same central directory. A
patch is classified by position — inside the held entry — so the CRC and
size patch that seeks backwards and the ZIP64 extra-field update that
seeks forwards both land correctly.

The export holds one pooled connection at a time, which is what the
review's first blocker needed. Its audit event is written in its own
short write transaction, committed before the read snapshot that both
content passes share, and that snapshot is a reader only (ADR 0019): an
export never reserves the writer while it streams, and exports sharing a
pool never hold one connection while waiting for another. A scope that
holds nothing to export is refused before anything is recorded, so the
audit still attests exactly what it says it does.

Delivery ends only on an explicit success. Whether a transfer was
complete is a flag the producer sets when the archive was produced *and*
its tail flushed — not the channel closing, which a client that outlasted
the failure signal would otherwise read as a complete download. Every
other ending fails the body: a production failure, a producer that
panicked (a drop guard covers it), a stalled client, and a client that
stayed away past both send windows.

Proof: streamed and buffered bytes compared across entry sizes including
the patch boundary, and the bytes a live socket delivers compared against
the buffered export at the instant the delivered manifest states; the
installation's peak memory measured in a fresh process against
pre-seeded databases (500 units / 8.7 MB archive at 5.1 MiB, the same
units at four times the payload at 7.4 MiB, 4,000 units / 69.7 MB at
14.1 MiB), so the payload volume does not drive it while the unit count
does; a whole export on a one-connection pool; two exports overlapping on
a two-connection pool with ordinary work still progressing; an export
observed mid-stream while the installation's own writer commits; a client
that outlasts both stall windows and still cannot receive a complete
transfer; and unit tests for each terminal case (success, closed channel
without success, failure item, guard on drop, stall, departed client).
Each of these was falsified against the design it replaces before it was
kept. ADR 0014 and the format document state the connection ownership,
the memory terms, and what an interrupted transfer means.
@TusanHomichi
TusanHomichi force-pushed the codex/47-stream-installation-exports branch from c1eae2f to 4755731 Compare September 12, 2026 01:37
@TusanHomichi

Copy link
Copy Markdown
Member Author

Repairs at 4755731, rebased onto main (7936a4e, which contains #68)

Both merge blockers are fixed, with regressions that fail against the design they replace.

1. Connection ownership: one connection at a time, no admission limit. export_to no longer holds a pooled connection while acquiring another. The audit is written first, in its own short storage::write_tx transaction that commits before anything else happens; only then is the read snapshot taken, and that snapshot is a reader from beginning to end. The SELECT 1 … LIMIT 1 scope check sits inside the audit's transaction, so "nothing to export" is still decided before any record is written, and the audit still attests exactly what it says it does. Consequences: an export works on a one-connection pool, exports sharing a pool never wait for a connection another holds, and the writer is never reserved for a download (ADR 0019). I chose ownership over admission deliberately — a slot limit would have to be re-tuned against the pool and would still be a cliff; this cannot enter the state at all.

Regressions (all fail against the old design): a whole export on a one-connection pool; two exports overlapping on a two-connection pool, both reaching their stream; and ordinary work (an audit write and a read) completing in under a second while an export streams mid-flight. Against the old design these fail with pool acquisition timeouts and database is locked respectively, which is exactly the reported failure mode.

2. Terminal outcome independent of queue space. Whether a transfer was complete is now an explicit fact — a flag the producer sets only when the archive was produced and its tail flushed — carried beside the channel rather than through it. ExportBody treats channel closure without that flag as an error, so losing the failure detail to a full queue costs the log line and never the outcome. A drop guard covers a producer panic.

Regressions: unit tests for every terminal case (success ends cleanly; a closed channel without success errors; a failure item errors; the guard fires on drop and stays silent when disarmed; a stall is not a departed client); and the decisive transport test — the client reads nothing past both send windows, then resumes and must receive an incomplete transfer rather than a clean end of body. Falsified by making channel closure authoritative again: the transport test passes a truncated prefix as complete.

3. Wording. The claim is now stated precisely, in the code, ADR 0014, and the format document: streaming removes whole-corpus payload buffering; unit metadata, the serialized manifest, and the ZIP central directory still scale with unit count, the driver buffers a bounded number of rows per query, and the browser's own download helper buffers the response it saves. No constant-memory or end-to-end-browser-streaming claim is made anywhere.

pr-gate is running on this head; the full local sequence is green (fmt, clippy, cargo test --workspace 28 binaries with 0 failures, npm run check, and 10/10 browser scenarios).

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4755731cb3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// instant. It is a reader from beginning to end — the audit is already
// committed — so a download, however slow, reserves no writer, and the
// export holds exactly one connection throughout.
let mut tx = pool.begin().await.context("beginning export read")?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Bound the lifetime of the export read snapshot

When a client frees one queue slot before each 10-second send deadline, this transaction remains open for the entire arbitrarily slow download. Because the installation uses SQLite in WAL mode, that long-lived reader pins its WAL end mark while ordinary writes continue, preventing checkpoints from advancing and allowing the WAL to grow until the export ends; a sufficiently large or deliberately paced installation export can therefore exhaust the single data directory. Decouple the database snapshot from socket pacing or impose a total bound on its lifetime.

AGENTS.md reference: AGENTS.md:L3-L5

Useful? React with 👍 / 👎.

Comment on lines +219 to +222
Ok(Err(_)) | Err(_) => {
// A `spawn_blocking` task cannot be cancelled once it runs; the
// producer stops on its own at its first send, because the
// response body's receiver is dropped with this return.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Cancel producers after preflight abandonment

If an authenticated export request exceeds the 30-second preflight limit or disconnects before preflight completes, dropping the receivers does not stop this detached spawn_blocking task. Its first channel send occurs only after authorization, the audit write, the complete metadata scan, manifest serialization, and buffering of the manifest entry, so many abandoned requests can continue consuming blocking workers, pooled connections, memory, and audit rows long after their responses are gone. Propagate cancellation and check it before the audit and expensive metadata work rather than relying on the eventual first send.

Useful? React with 👍 / 👎.

Comment on lines +455 to +456
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.send_bytes(buf.to_vec())?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Bound the response queue by bytes

For exports containing large finalized records, each completed ZIP entry is cloned into one Bytes item here, while the bounded channel limits only the number of items. The persisted canonical_bytes constraint has no size ceiling, so a stalled transport can retain several full record entries plus the producer's current copies, effectively buffering a substantial installation and risking an OOM despite the streaming change. Split released entries into fixed-size chunks or enforce a byte budget across the queue.

Useful? React with 👍 / 👎.

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.

Stream installation exports instead of assembling the archive in memory

1 participant