Skip to content

Buffered, concurrently-flushed delivery: TLA+-verified, vectorized, Postgres-backed state - #21

Merged
jghoman merged 9 commits into
mainfrom
jakob/tla-buffered-delivery
Jun 11, 2026
Merged

Buffered, concurrently-flushed delivery: TLA+-verified, vectorized, Postgres-backed state#21
jghoman merged 9 commits into
mainfrom
jakob/tla-buffered-delivery

Conversation

@jghoman

@jghoman jghoman commented Jun 10, 2026

Copy link
Copy Markdown
Collaborator

Summary

Rework of the delivery path, spec-first: CDC reads stay at poll cadence, destination writes move to a flush cadence (1–5 min) served by a concurrent worker pool. Scales fanout from tens of destinations to 1000+ (measured flat ~42 dests/s end-to-end) without changing the correctness model: per-destination cursors, idempotent delete+upsert, at-least-once.

Nine commits, one milestone each, every milestone gated by Lead QE + architect review:

Commit Milestone
cf1f36b PR 0 — spec first: Viaduck.tla extended with BufferRead / FlushStart / FlushCommit / FlushFail / CrashDuringFlush / ProcessCrash before any implementation. Buffer-loss crashes checked unconditionally
c2da859 + 54c13b0 M1 — vectorize CDC phases: Arrow-native kernels replace Python row loops (router 1M rows/10K dests: 15.6s → 0.15s); balanced delete-filter trees, chunked deletes
65d6c4f + 6af30bf M2 — cursor state on plain Postgres: kills the snapshot treadmill (cursor writes on a DuckLake table minted catalog snapshots that retriggered CDC; OLTP workload now on an OLTP store). Dedicated viaduck schema; monotonicity-guarded upserts
dd67727 + 0c5cd1e M3 — buffered + threaded delivery: per-destination buffers, dual position model (flushedposition), interval/rows/bytes/memory/shutdown triggers, buffer-swap flushes on a worker pool, in-flight guard, read epochs (restore the spec's atomic BufferRead against concurrent failure resets), drain-on-SIGTERM, pool lease pinning
f1f8371 M3 soak fixes — two bugs the kill-sequence soak caught: missing Winner(k) per-key last-write-wins dedup (duplicate destination rows), and inclusive CDC read bounds re-reading the cursor snapshot — a re-read insert cancelled a genuine delete in Phase 2, leaving a permanent phantom. Reads are now (after_snapshot, end], matching the spec
661269d M4 — audit + docs + bench: committed spec-impl audit, FlushCommitNoCursor modeled (commit/cursor gap without process death), crash-unconditioned NoDataLoss/PartitionCorrectness invariants, end-to-end fanout bench, seed-time key-uniqueness verification, README/AGENT.md rewrite, web UI status semantics (buffering ≠ lagging) + tooltips, just demo, grafana provisioning fix

Verification

  • TLC: 9 invariants over 31,406,425 distinct states (332.7M generated, depth 21), no errors. NoDataLossEvenAfterCrash and PartitionCorrectnessEvenAfterCrash hold even through commit/cursor-gap windows — the "no data loss path" claim is machine-checked, not argued
  • Spec-impl audit (spec-impl-audit.md): every spec action and invariant mapped to code; documented refinements (read epochs, Winner tiebreak); the inclusive-bounds bug recorded as a conformance-gap case study (TLC verifies the spec — the bug lived between spec and code and fell only to the soak)
  • Soak: docker-compose stack, SIGKILL mid-stream + SIGTERM drain + restart; exact source/destination convergence on all destinations, zero duplicate keys
  • CI: 356 unit + 45 integration (testcontainers Postgres), perf suite, semgrep, Docker build — all green
  • Perf: fanout flat at ~42 destinations/s through 200/500/1000 dests (8 workers, continuous-eviction worst case); a 1000-destination flush cycle drains in ~25s against the 120s default interval

Known limitations (documented, precisely stated)

  • At-least-once: the commit/cursor-gap phantom window (crash or PG outage after a destination commit) is inherent without cross-catalog transactions; conditioned in the spec, narrowed by in-process retry, repairable by re-seed
  • Permanent per-destination failures (e.g. schema mismatch) retry forever with growing re-read ranges; halted state + circuit breaker logged as follow-up
  • Key uniqueness verified at seed time only; post-seed inserts trust the contract

Rollback

Revert the branch; nothing is deployed anywhere yet. workers: 1, flush_interval_seconds: 0 reproduces pre-buffering behavior if needed at runtime.

jghoman added 9 commits June 10, 2026 16:05
Extends the spec ahead of implementation (design-first verification):
BufferRead/FlushStart/FlushCommit split so TLC explores CDC reads
racing in-flight flushes (the buffer-swap design), dual position
tracking (persisted cursor vs in-memory bufferedThrough), and a
worker-pool model via interleaved per-destination flushes with the
in-flight guard keeping per-destination flushes serial.

Crash model is now finer-grained: ProcessCrash (lose all buffers,
keep persisted state) and FlushFail (destination txn rollback +
drop-buffer recovery) are checked UNCONDITIONALLY — TLC proves buffer
loss can never violate safety. Only CrashDuringFlush (destination
commit lands, process dies before cursor persist) keeps the
everCrashed conditioning, preserving the original spec's precisely
stated at-least-once limitation.

Two new invariants: BufferPositionBound (cursor <= bufferedThrough <=
srcSnap, always) and FlushStateConsistency. All 7 invariants hold:
26,753,473 distinct states (251.8M generated, depth 20, ~3 min),
up from 730K in the unbuffered model.

TLC also pinned down a non-obvious rule the implementation must
follow: a failed flush discards the LIVE buffer too and resets
bufferedThrough to the persisted cursor — keeping it would leave a
coverage gap over (cursor, inflightThrough].
Replaces the to_pylist() + Python row loops with Arrow compute kernels
(which release the GIL — prerequisite for the threaded flush milestone):

- _resolve_preimages: preimage↔postimage pairing is a hash join on
  rowid; classification (orphan / cross-tenant / same-tenant) and the
  change_type rewrite are kernels. Null-safe routing comparison; a
  typed __matched marker disambiguates join misses from genuinely-null
  routing values; last-postimage-wins preserved for out-of-contract
  duplicate rowids. The routing-mutation ERROR log is aggregated (one
  line per batch with count + sample rowids, not one per row).
- _resolve_conflicts: per-rowid presence flags via group_by, joined
  back; drop rules and the metric increment-per-conflicting-rowid
  match the predecessor exactly.
- Router.split_and_count: one index_in pass + stable sort + contiguous
  takes, replacing the filter-per-destination loop.
- _apply_changes: deletes chunked at 1,000 keys per delete() call
  (same transaction) — kills the O(rows) Or(And(...)) expression tree
  on composite keys.

Equivalence with the predecessors is locked by
tests/unit/test_phase_equivalence.py: frozen copies of the old row
loops serve as oracles against randomized CDC batches (conflicts,
orphans, cross-tenant mutations, null routing values, shuffled order),
asserting identical output including row order. 103 tests.

Perf (tests/perf, before → after):
  router 100K rows / 1000 dests: 0.156s → 0.010s (15.6x)
  router 1M rows / 10K dests:   15.575s → 0.153s (102x)
  _resolve_preimages 50K rows:   0.054s → 0.005s (11x)
  _resolve_conflicts 50K rows:   0.044s → 0.006s (7x)
…act guards

From the milestone's QE + architect reviews:

- REAL BUG (found by writing the missing chunked-delete tests): the
  composite-key delete filter's Or-chain was right-deep and to_sql()
  recurses once per node — a single full 1000-row chunk blew Python's
  recursion limit. Now a balanced tree-reduce (depth log2(n)). Locked
  by tests/integration/test_chunked_delete_integration.py: boundary
  sizes around _DELETE_CHUNK_ROWS, composite keys spanning chunks,
  NULL keys across chunks, delete+upsert single-transaction.
- viaduck/arrowutil.py: row_indices/full_bool composed from C kernels
  (pa.nulls + fill_null + cumulative_sum) replacing pa.array(range(n))
  / [True]*n, which iterated n Python objects under the GIL — exactly
  what the threaded-flush milestone can't afford.
- Phase 1 preserves change_type's exact dtype (string vs large_string)
  so buffered delivery can concat_tables Phase-1 outputs across reads.
- Null rowids now fail fast in both phases (deliberate divergence:
  Arrow joins don't match null keys, so contract-violating input would
  be silently misclassified; the predecessor's dicts keyed None).
- Router: _typed_value_set casts string values directly to the column
  type (no datetime round-trip — preserves sub-microsecond timestamps,
  decimals); routing values that collide after conversion ("1"/"01" on
  an int column) are rejected (deliberate divergence: the predecessor
  silently double-delivered).
- Equivalence suite grown to 112 tests: duplicate-postimage last-wins,
  duplicate preimages, multi update pairs, null-mutation directions,
  int/bool typed router columns, metric counter-delta parity locks.
- Perf: matched-pairs preimage benchmark (join-probe-hit path).

Deferred follow-ups (reviewer-acknowledged): last-wins dedup vs
assert, key_columns validation placement, Acero CPU-pool tuning for
the worker-pool milestone.
Replaces the DuckLake-backed _viaduck_state table with plain Postgres
rows (by default in the same database that hosts the source catalog's
metadata; state.postgres_uri_env overrides). The DuckLake store put an
analytical format on an OLTP write pattern: every cursor advance was a
catalog commit → snapshot → "new" CDC range → empty read → advance —
a treadmill of empty reads and tiny parquet files at poll cadence even
with zero source traffic.

StateManager keeps its exact public API; an advance is now a single
INSERT ... ON CONFLICT DO UPDATE (atomic natively, clears recorded
errors, preserves rows_replicated when not supplied). One connection
guarded by a lock with a single reconnect retry — cursor writes are
per-flush, not per-cycle, so no pool. Table name is validated as a
safe identifier (interpolated into DDL). Atomicity vs the destination
apply is unchanged: they were always separate transactions; the
at-least-once + idempotent-apply story (tla/Viaduck.tla) is untouched.

Tests: mock-catalog unit tests replaced by 13 PG-backed integration
tests (session-scoped postgres:16-alpine testcontainer): round-trips,
instance/destination filtering, error semantics, batch advance, an
8-thread concurrency hammer (worker-pool prep), forced connection-kill
reconnect, and plain-SQL inspectability. Deps: +psycopg[binary],
+testcontainers (dev).
…uard, boot-race + timeout hardening

From the milestone's QE + architect reviews:

- The zero-config default was dead on arrival: source postgres_uri_env
  carries DuckDB's ATTACH format ("postgres:host=H port=P dbname=DB"),
  which libpq parses as an invalid keyword. _to_libpq_conninfo() strips
  the prefix into valid conninfo; real URIs and bare keyword/value
  strings pass through. Tested against the dev-compose format that
  exposed it.
- Cursor monotonicity enforced in the store: both advance paths now
  carry WHERE last_snapshot_id <= EXCLUDED.last_snapshot_id, dropping
  stale acks wholesale (cursor, rows_replicated, and error state all
  protected). Matches CursorMonotonicity in tla/Viaduck.tla;
  defense-in-depth under the upcoming flush worker pool.
- First-boot CREATE TABLE race: IF NOT EXISTS can still raise a unique
  violation when instances bootstrap concurrently — caught and logged;
  locked by a 4-way concurrent-bootstrap test.
- Timeouts under the serializing lock: connect_timeout=10s +
  statement_timeout=30s so a catalog-PG failover trips the loop's
  fatal path instead of hanging every worker silently.
- Test gaps closed: server-side pg_terminate_backend reconnect,
  advance-without-initialize (INSERT arm), stale single/batch advance.
- CI dependency review: Python-2.0/0BSD license atoms allowed; purl
  exemptions for typing-extensions (PSF in substance; the GPL atom is
  CNRI license-chain noise in its PEP 639 expression) and
  psycopg/psycopg-binary (LGPL, linked not modified — millpond
  precedent), with explanatory comments.
Implements the TLC-verified design (PR 0 / tla/Viaduck.tla): CDC reads
at poll cadence, destination writes at flush cadence on a worker pool.

viaduck/delivery.py — the core:
- Per-destination buffers with dual position tracking: `flushed`
  (persisted PG cursor) and `position` (in-memory bufferedThrough);
  the poll thread groups reads by position so ranges stay disjoint.
- Flush triggers: interval (default 120s; also lazily persists idle
  destinations' position-only advances), rows, bytes, memory (global
  watermark, largest-first), shutdown (drain on SIGTERM).
- FlushStart swaps the buffer; an in-flight guard keeps flushes
  per-destination serial while reads keep landing in the fresh buffer.
- FlushFail implements the TLC-pinned rule exactly: discard in-flight
  AND live buffer, reset position to the persisted cursor — keeping
  the live buffer would leave a coverage gap over (flushed, through].
- should_pause_reads(): reads pause only when over the watermark with
  every buffering destination already in flight.

viaduck/apply.py — Phase 2 conflict resolution + Phase 3 atomic
delete/upsert + retry, extracted from main.py to run on workers (all
Arrow kernels + pyducklake — GIL released). main.py keeps Phase 1 +
routing and becomes a thin read/buffer/trigger loop.

DestinationPool — lock + lease pinning: get() pins until release(),
LRU eviction skips pinned entries, force-evict of a pinned entry
defers the close to the final release, all-pinned overshoots instead
of deadlocking.

Config: DeliveryConfig (workers=8, flush_interval_seconds=120,
flush_max_rows/bytes, buffer_total_max_bytes), validated; workers=1 +
interval=0 reproduces the pre-buffering behavior. Observability: six
viaduck_delivery_* metrics, dest_write_seconds continuity, DestStatus
buffer_rows/buffer_age_s + "flushing" state.

Tests: 17 DeliveryManager unit tests (trigger matrix, swap during
in-flight flush, failure semantics, per-dest serialization); 5
end-to-end integration tests with nothing mocked below the delivery
API (real catalogs + real PG + real workers), incl. cross-read
conflict cancellation and a broken-destination failure path; poll
cycle tests rewritten — the full-CDC set runs through a real
DeliveryManager in flush-every-cycle mode; pool tests adapted to the
lease contract with three new pinning tests.
…light accounting

From the milestone's QE + architect reviews. The headline (found
independently by both): BufferRead was not atomic against FlushFail.
The poll thread snapshots positions, spends seconds in the CDC read,
then stamps the position via buffer() — if a flush failure reset the
position in that window, the stale write jumped it past the dropped
range, which would then never be re-read. Permanent data loss, and
exactly the atomicity the TLA spec's BufferRead action assumes.

- Per-destination read epochs: read_plan() atomically snapshots
  (position, epoch); buffer()/advance_position() discard deliveries
  carrying a stale epoch; _on_flush_failure bumps the epoch with its
  reset. A discarded read is equivalent to the read never happening —
  the range is re-read from the rewound position. Locked by a test
  replaying the exact interleaving.
- Hardened failure path: the invariant-restoring reset now runs FIRST;
  record_error and pool.evict are individually guarded (correlated PG/
  pool outages must not leave position ahead of dropped data); a
  done-callback logs anything escaping _flush at CRITICAL instead of
  vanishing into an unobserved Future.
- Drain loops trigger evaluation until quiet or deadline — rows
  buffered during an in-flight flush at shutdown get a second pass;
  abandoned buffers are logged (re-read on restart) and the executor
  shutdown no longer blocks past the deadline.
- In-flight (swapped-out) bytes now count toward the global watermark
  and the total-bytes gauge — real memory was watermark + workers x
  flush_max_bytes before.
- Pool: connection creation moved outside the pool lock via slot
  reservation (workers create for different destinations in parallel);
  pool size configurable as delivery.pool_max_open (default 100).
- Bounded advance_cursor retry after a successful destination commit —
  a transient PG blip no longer triggers the drop-buffer + evict path
  for a healthy catalog.
- Idle position-only persists no longer fire the readiness success
  signal or write-latency metrics (data flushes only).

Deferred (documented for the audit milestone): Arrow/DuckDB CPU-pool
sizing under cgroups.
The kill-sequence soak found two delivery bugs. (1) Multiple updates to
one key inside a buffered flush window all reached pyducklake's upsert
as duplicate join keys, growing duplicate destination rows — upsert
candidates now dedupe per key, last write wins (snapshot_id, rowid),
selected via take() so NULL keys survive (Acero joins drop them).
(2) CDC reads passed the cursor as an inclusive start bound to
ducklake's table_changes/table_insertions, re-reading the cursor
snapshot every cycle. Normally masked by idempotent upserts, but when
the cursor lands exactly on a row's insert snapshot (deterministic
after seeding) and the row is deleted in the next window, conflict
resolution cancels the re-read insert against the genuine delete — the
delete is lost and the seeded copy is a permanent phantom. Reads are
now (after_snapshot, end], matching Viaduck.tla's CDCReadFrom; the
parameter is renamed after_snapshot so the contract is in the
signature.

Also: certifi MPL-2.0 exemption (dev-only transitive of testcontainers)
and a delivery config section in the dev compose config for the soak.
Closing milestone of the delivery rework. Fresh spec-impl audit
(committed); spec gains FlushCommitNoCursor (the commit/cursor gap
without process death — cursor-persist failure after a destination
commit) and two crash-unconditioned invariants, making the "no data
loss path" claim machine-checked (TLC: 9 invariants, 31.4M states).
New end-to-end fanout benchmark: flat ~42 destinations/s through 1000
destinations. Seeding now verifies key_columns uniqueness per
partition (DuckLake can't enforce it; violations would silently
over-delete) at zero extra I/O, failing before the cursor advances.

Cursor state moves to a dedicated `viaduck` Postgres schema so
bookkeeping never pollutes the ducklake catalog's namespace and a
future scoped-down user gets a clean GRANT boundary.

Operational status now distinguishes buffering from lagging: between
flushes the cursor always trails the source — that's the design
working, not lag. Web UI gains a Buffered column, per-value and
per-column tooltips, mode/flush-interval display, and a `just demo`
recipe stands up the stack with live traffic. Grafana dashboard and
Prometheus datasource are now actually provisioned in the compose
stack (they never were).

README/AGENT.md rewritten for buffered delivery, including the full
error-states table and the honest permanent-failure caveat. Review
fixes: pool eviction closes catalogs outside the lock, dead web.port
and main.py constants removed, clean-exit-after-drain semantics
documented.
@jghoman jghoman changed the title TLA+: model buffered delivery with concurrent flush workers Buffered, concurrently-flushed delivery: TLA+-verified, vectorized, Postgres-backed state Jun 11, 2026
@jghoman
jghoman merged commit b1357fe into main Jun 11, 2026
15 checks passed
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