Skip to content

Commit b1357fe

Browse files
authored
Merge pull request #21 from PostHog/jakob/tla-buffered-delivery
Buffered, concurrently-flushed delivery: TLA+-verified, vectorized, Postgres-backed state
2 parents a1d445e + 661269d commit b1357fe

37 files changed

Lines changed: 5110 additions & 1596 deletions

.github/workflows/ci.yaml

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,27 @@ jobs:
4444
- uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4.9.0
4545
with:
4646
fail-on-severity: moderate
47-
allow-licenses: Apache-2.0, MIT, BSD-2-Clause, BSD-3-Clause, PSF-2.0
47+
# SPDX `AND` is conjunction: every atom in a package's license
48+
# expression must be on this list. Python-2.0 and 0BSD appear in
49+
# PSF-family expressions (typing-extensions, etc.).
50+
allow-licenses: Apache-2.0, MIT, BSD-2-Clause, BSD-3-Clause, PSF-2.0, Python-2.0, 0BSD
51+
# Per-package exemptions for license-metadata noise, mirroring the
52+
# millpond CI precedent:
53+
# - typing-extensions PSF-licensed in substance (same family as
54+
# CPython); its PEP 639 expression includes GPL-1.0-or-later
55+
# atoms from the historical CNRI/BeOpen license chain, which
56+
# the conjunction rule would otherwise reject.
57+
# - psycopg, psycopg-binary LGPL-3.0-or-later: we link, don't
58+
# modify; LGPL terms are satisfied — accepted as a
59+
# PostHog-cloud-infra precedent (see millpond's ci.yaml).
60+
# - certifi MPL-2.0: file-level copyleft satisfied by using the
61+
# unmodified CA bundle; dev-only transitive of
62+
# testcontainers -> requests.
63+
allow-dependencies-licenses: >-
64+
pkg:pypi/typing-extensions,
65+
pkg:pypi/psycopg,
66+
pkg:pypi/psycopg-binary,
67+
pkg:pypi/certifi
4868
4969
build:
5070
runs-on: ubuntu-latest

AGENT.md

Lines changed: 77 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -33,29 +33,43 @@ Prefer fixup commits over amending and force-pushing.
3333

3434
## What This Is
3535

36-
A standalone Python app that replicates data from a source DuckLake table to N destination DuckLake tables using pyducklake's CDC (Change Data Capture) API. Supports INSERT, DELETE, and UPDATE replication. Single thread, single poll loop, no framework.
36+
A standalone Python app that replicates data from a source DuckLake table to N destination DuckLake tables using pyducklake's CDC (Change Data Capture) API. Supports INSERT, DELETE, and UPDATE replication. One poll thread reads and buffers; a flush worker pool writes destinations. No framework.
3737

38-
Routes rows by a configurable field (e.g. `company`) to per-destination tables. Designed for high fanout (100s-1000s of destinations).
38+
Routes rows by a configurable field (e.g. `company`) to per-destination tables. Designed for high fanout (measured flat at ~43 destinations/s through 1000 destinations).
3939

4040
## Architecture
4141

4242
```
4343
Source DuckLake
44-
├── {source_table} ← CDC source (table_changes / table_insertions)
45-
└── _viaduck_state ← per-destination replication cursors
46-
47-
Viaduck (single-threaded poll loop)
48-
1. current_snapshot() on source table
49-
2. Group destinations by last_snapshot_id → grouped CDC reads
50-
3. If key_columns: table_changes() → Phase 1-2-3 apply
51-
Else: table_insertions() → append()
52-
4. Update _viaduck_state on source
44+
└── {source_table} ← CDC source (table_changes / table_insertions)
45+
46+
Postgres (same DB as source ducklake metadata by default)
47+
└── viaduck.viaduck_state ← persisted cursors (plain table in a dedicated schema, NOT ducklake)
48+
49+
Viaduck
50+
poll thread (poll cadence):
51+
1. current_snapshot() on source table
52+
2. Group destinations by in-memory read position → grouped CDC reads,
53+
half-open ranges (position, current]
54+
3. If key_columns: table_changes() → Phase 1 → route → buffer
55+
Else: table_insertions() → route → buffer
56+
4. Evaluate flush triggers (interval/rows/bytes/memory/shutdown)
57+
flush workers (delivery.workers threads, flush cadence):
58+
5. Phase 2 (conflict resolution) on the concatenated buffer
59+
6. Phase 3 (Winner(k) dedup, delete+upsert in one txn)
60+
7. advance_cursor() → Postgres upsert, monotonicity-guarded
5361
5462
Destination DuckLakes (N independent catalogs)
5563
└── {dest_table} ← receives routed rows
5664
```
5765

58-
## CDC Algorithm: Three Assumptions
66+
Position model: `flushed` (persisted cursor) <= `position` (in-memory
67+
bufferedThrough). Reads issue from `position`; a flush failure drops the
68+
buffers and resets `position = flushed` (range re-read, at-least-once).
69+
Read epochs make the slow CDC read atomic against concurrent failure
70+
resets. See `viaduck/delivery.py` module docstring and `tla/Viaduck.tla`.
71+
72+
## CDC Algorithm: Four Assumptions
5973

6074
The 3-phase CDC algorithm is eventually consistent under these assumptions:
6175

@@ -75,6 +89,12 @@ The 3-phase CDC algorithm is eventually consistent under these assumptions:
7589
at-least-once idempotency — a retried delete could remove a row inserted by
7690
another writer.
7791

92+
4. **Key uniqueness**: `key_columns` must be unique per row in the source (DuckLake
93+
has no unique constraints to enforce this). Violations mean delete-by-key
94+
over-deletes and duplicate-key upserts duplicate. Verified at seed time per
95+
partition (`main.py:_verify_seed_key_uniqueness`, fails the seed loudly);
96+
post-seed inserts are not re-verified.
97+
7898
## CDC Algorithm: Three Phases
7999

80100
**Phase 1: Preimage Resolution** (before routing) — `_resolve_preimages()`
@@ -84,57 +104,80 @@ The 3-phase CDC algorithm is eventually consistent under these assumptions:
84104
- Orphaned preimages → convert to delete (defensive)
85105
- Post-condition assertion: no preimages remain
86106

87-
**Phase 2: Conflict Resolution** (per-destination, after routing) — `_resolve_conflicts()`
107+
**Phase 2: Conflict Resolution** (per-destination, at flush time) — `apply.py:_resolve_conflicts()`
108+
- Runs on the concatenation of all buffered reads for the flush
88109
- insert + delete for same rowid → cancel both (net no-op)
89110
- update_postimage + delete for same rowid → drop postimage, keep delete
111+
- insert + update_postimage for same rowid → drop insert, keep postimage
90112
- Post-condition assertion: no rowid in both insert and delete
91113

92-
**Phase 3: Apply** (per-destination, atomic) — `_apply_changes()`
93-
- Within `catalog.begin_transaction()`: delete first, then upsert
114+
**Phase 3: Apply** (per-destination, atomic) — `apply.py:_apply_changes()`
115+
- Winner(k): per-key last-write-wins dedup of upsert candidates by
116+
(snapshot_id, rowid) — a buffered window can carry several upserts per key
117+
- Within `catalog.begin_transaction()`: chunked deletes first, then upsert
94118
- Crash mid-apply → transaction rolled back, no partial state
95119

96-
CDC batches are processed as unordered sets. This is sound because each batch
97-
covers a closed snapshot range, batches are applied in ascending snapshot order,
98-
and within-batch conflicts are resolved by rowid grouping.
120+
CDC batches are processed as unordered sets. This is sound because each
121+
flush covers the union of adjacent half-open snapshot ranges
122+
`(flushed, position]`, flushes apply in ascending range order, and
123+
cross-read conflicts resolve by rowid grouping at flush time exactly like
124+
within-read conflicts.
125+
126+
CDC read ranges are EXCLUSIVE of the cursor snapshot (`after_snapshot` in
127+
`source.py`): ducklake's `table_changes`/`table_insertions` are inclusive
128+
on both bounds, and re-reading the cursor snapshot lets a re-read insert
129+
cancel a genuine later delete in Phase 2 (permanent phantom — found by
130+
the M3 soak at the seed boundary, locked by integration tests).
99131

100132
## TLA+ Formal Verification
101133

102134
The CDC algorithm is formally specified in `tla/Viaduck.tla` and verified by
103135
TLC. Run via `flox activate` then `just tlc`. The spec models source operations,
104-
poll cycles, seeding, and crash scenarios, checking 5 invariants across 730K
105-
states. Modify the spec when changing the CDC algorithm or adding new failure
106-
modes. Always run `just tlc` after spec changes.
136+
buffered CDC reads, two-step flushes (buffer swap → commit/fail), concurrent
137+
per-destination flush workers, seeding, and commit/cursor-gap scenarios both
138+
with and without process death (safe buffer-loss crashes checked
139+
unconditionally; phantom-window events conditioned via everCrashed — except
140+
NoDataLoss and PartitionCorrectness, which are also checked through crash
141+
windows), checking 9 invariants across 31.4M distinct states (~5 min). Modify the spec when changing
142+
the CDC algorithm or adding new failure modes — and when designing semantic
143+
changes, extend the spec FIRST and let TLC pass judgment before implementing.
144+
Always run `just tlc` after spec changes.
107145

108146
## Key Design Decisions
109147

110148
- **Config via YAML** with `_env` suffix convention for credential indirection
111149
- **At-least-once semantics**: no cross-catalog transactions; destinations tolerate duplicates
112-
- **State on source DuckLake**: `_viaduck_state` table tracks per-destination cursors
113-
- **LRU connection pool**: bounds memory at high fanout (default 50 open connections)
114-
- **Per-destination error isolation**: one broken destination doesn't block others
115-
- **Grouped CDC reads**: destinations at the same cursor share a single CDC call
150+
- **Buffered delivery**: reads at poll cadence, writes at flush cadence (default 120s) — decouples lag visibility from write amplification; `workers: 1, flush_interval_seconds: 0` reproduces unbuffered behavior
151+
- **State on plain Postgres**: cursor advances must not create catalog snapshots (the snapshot treadmill); lives in a dedicated `viaduck` schema so it never pollutes the ducklake catalog's namespace; upserts carry a monotonicity guard
152+
- **LRU connection pool with lease pinning**: bounds memory at high fanout (default 100 open connections); eviction never closes a connection mid-transaction
153+
- **Per-destination error isolation**: one broken destination doesn't block others; a failed flush drops only that destination's buffers
154+
- **Grouped CDC reads**: destinations at the same read position share a single CDC call
116155
- **Scan-based seeding**: new destinations bulk-load from a filtered source scan instead of replaying CDC history. Configurable via `seed_mode` (default: `scan`)
156+
- **Worker threads are a concurrency knob, not a CPU multiplier**: Arrow's compute pool and DuckDB's threads are process-global underneath every flush worker — see README "Worker-thread sizing"
117157

118158
## Module Layout
119159

120160
| Module | Responsibility |
121161
|--------|---------------|
122-
| `main.py` | Entry point, poll loop, 3-phase CDC algorithm, signal handling |
162+
| `main.py` | Entry point, poll loop, Phase 1 preimage resolution, seeding, signal handling |
163+
| `delivery.py` | DeliveryManager: per-destination buffers, flush triggers, worker pool, position model |
164+
| `apply.py` | Phase 2 conflict resolution, Phase 3 delete/upsert + Winner(k), write retry |
123165
| `config.py` | YAML parsing, env var resolution, frozen dataclass |
124-
| `source.py` | Source catalog connection, CDC reading (table_changes / table_insertions) |
166+
| `source.py` | Source catalog connection, CDC reading (table_changes / table_insertions, exclusive start) |
125167
| `router.py` | Arrow splitting by routing field |
126-
| `destination.py` | LRU connection pool for destination catalogs |
127-
| `state.py` | `_viaduck_state` table CRUD |
128-
| `metrics.py` | Prometheus metric definitions (19 metrics) |
168+
| `destination.py` | LRU connection pool for destination catalogs, lease pinning |
169+
| `state.py` | Per-destination cursors on plain Postgres (psycopg) |
170+
| `arrowutil.py` | Shared Arrow kernel helpers (row_indices, full_bool) |
171+
| `metrics.py` | Prometheus metric definitions (26 metrics) |
129172
| `server.py` | HTTP /metrics, /healthz, /readyz, /status, /ui, /ui/sse |
130173
| `logging_config.py` | Structured logging setup |
131174

132175
## Testing
133176

134-
- Unit tests: `tests/unit/` — mocked pyducklake, fast (200 tests)
135-
- Integration tests: `tests/integration/` — real pyducklake with local DuckDB (8 tests)
136-
- Performance tests: `tests/perf/`fanout, preimage, conflict, delete filter benchmarks (6 tests)
137-
- E2E tests: `tests/e2e/` — full docker-compose stack (planned)
177+
- Unit tests: `tests/unit/` — mocked pyducklake, fast (356 tests)
178+
- Integration tests: `tests/integration/` — real pyducklake with local DuckDB; Postgres-backed state tests via testcontainers (45 tests)
179+
- Performance tests: `tests/perf/`router, phases, delete filter, end-to-end delivery fanout at 200/500/1000 destinations (11 benchmarks)
180+
- Soak: manual docker-compose kill sequence (SIGKILL + SIGTERM + convergence diff) — run for delivery-semantics changes
138181

139182
Run all: `just ci` (lock-check + format + lint + unit + integration + docs-check + Docker build). Perf: `just test-perf`.
140183
Perf with JSON output: `just test-perf-json` → writes `perf-results.json`.

0 commit comments

Comments
 (0)