Skip to content

feat(output): add transactional record spool - #50

Open
ronheichman wants to merge 7 commits into
perplexityai:mainfrom
ronheichman:feat/transactional-record-spool
Open

feat(output): add transactional record spool#50
ronheichman wants to merge 7 commits into
perplexityai:mainfrom
ronheichman:feat/transactional-record-spool

Conversation

@ronheichman

@ronheichman ronheichman commented Sep 1, 2026

Copy link
Copy Markdown

A short or disk-full append can leave a partial NDJSON record in the live output file. The next hook append then completes the same line with a different record, so HTTP ingestion rejects the malformed line and the legacy ship --input-file checkpoint cannot advance past it — one poisoned line stalls the whole queue.

This PR adds a durable, opt-in spool output and, in the same change, hardens the existing legacy file path so already-installed hooks stop producing glued lines and drain past any that predate the fix.

Transactional spool

An opt-in bbolt-backed spool output that:

  • commits each complete JSON record transactionally;
  • drains records in FIFO batches and acknowledges only the exact prefix delivered after an HTTP 2xx;
  • retains the batch across transport failures and allows concurrent producers to append during delivery;
  • rejects partial, multi-line, malformed, and oversized records before they enter the queue;
  • prevents hook sequence state and the record spool from sharing one bbolt file; and
  • keeps ship --input-file available for existing append-only NDJSON deployments.

Operators opt in with --output spool --spool-file PATH and run numbat ship --spool-file PATH --http-url URL separately. Delivery remains at least once, so receivers must tolerate duplicate record IDs after ambiguous HTTP outcomes. Existing hook installs continue to default to file output and are not migrated automatically.

Legacy file path hardening

The file sink now keeps the records file ending on a record boundary. Under the same file lock it holds today, it repairs a missing trailing newline before writing (append sinks open read-write so the last byte can be read) and truncates a short or failed write back to the pre-write size. Either way the next record starts on its own line instead of concatenating onto a partial one.

ship --input-file now skips a complete line that is not a single JSON object, the same way it already skips an oversized record: it ships any good records buffered before the bad line, advances the checkpoint past it with a stderr diagnostic, and continues. The line stays in the input file for inspection; only its HTTP delivery is skipped. This drains files that were already poisoned before the append-side fix.

Tests

  • File sink: a fragment with no trailing newline is closed off before the next append; a short append (forced with an RLIMIT_FSIZE cap in a child process) is rolled back so the next record does not glue onto it.
  • Shipper: a glued, non-object line is skipped while the good record after it is delivered and the checkpoint drains past the bad line.
  • Spool: a disk-full Put (same RLIMIT_FSIZE technique) commits nothing and the store stays byte-identical and usable.

ronheichman and others added 7 commits September 1, 2026 17:54
Store complete records in a transactional queue and acknowledge them only after successful HTTP delivery.

Built with Codex
The failure that corrupted the legacy append file was a write that could not
grow its backing file: a short or failed append left a partial NDJSON line that
the next record concatenated with. Assert the spool is immune to that trigger by
capping RLIMIT_FSIZE in a child process (so the cap cannot disturb the test
harness) and forcing a multi-megabyte Put to fail during bbolt's file growth.

The store must stay byte-identical to its pre-Put state and remain usable once
space is available, so a failed write can never leave a partial or glued
record.
A hook appends one NDJSON record per process to the shared records file. On a
full disk the kernel wrote what fit in the last block and then failed, leaving a
record with no trailing newline; the next hook's record appended onto the same
line, and the shipper delivered the glued pair as one line that ingestion
rejected as invalid JSON, stalling the queue.

Hold the existing file lock across the whole append and keep the file ending on
a record boundary: repair a missing trailing newline before writing (append
sinks now open read-write so the last byte can be read), and truncate a short or
failed write back to the pre-write size. Either way the next record starts on
its own line instead of concatenating onto a partial one.
An already-poisoned records file, written before the append-side fix, can still
contain a line that is two records glued together by an interrupted append.
Shipping it makes ingestion reject the whole batch, and the checkpoint never
advances past it, so the queue stalls indefinitely.

Skip a complete line that is not a single JSON object the same way an oversized
record is skipped: ship any good records buffered before it, advance the
checkpoint past it with a diagnostic, and continue. The record stays in the
input file for inspection; only its HTTP delivery is skipped. The object check
mirrors the spool sink.
spoolSink.Write and isShippableRecord both required a record to be a single
JSON object, and the two open-coded the same check. Route the sink through
isShippableRecord so the enqueue-time and ship-time validations cannot drift.
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.

2 participants