Skip to content

Commit 1e3fc87

Browse files
authored
Merge pull request #56 from PostHog/jakob/destination-lifecycle
feat: destination lifecycle state machine (active|paused|draining|retired)
2 parents 9959914 + 5a613b7 commit 1e3fc87

13 files changed

Lines changed: 1105 additions & 19 deletions

File tree

AGENT.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ two-step flushes (buffer swap → commit/fail), concurrent per-destination
169169
flush workers, seeding, and commit/cursor-gap scenarios both with and
170170
without process death — ALL checked unconditionally; the tombstone rule
171171
retired the everCrashed phantom conditioning entirely — checking 7
172-
invariants across 19,886,377 distinct states. Modify the spec when changing
172+
invariants across 85,012,333 distinct states. Modify the spec when changing
173173
the CDC algorithm or adding new failure modes — and when designing semantic
174174
changes, extend the spec FIRST and let TLC pass judgment before implementing.
175175
Always run `just tlc` after spec changes.
@@ -188,6 +188,7 @@ Always run `just tlc` after spec changes.
188188
- **Schema projection** (`schema_projection.py`): per-destination drops/reorder/safe-casts onto the destination's existing schema; build-time guards refuse key/routing-column mutation and NOT-NULL-violating casts; per-value cast fallback nulls unparseable values and alarms via `projection_cast_null_fallback_total`
189189
- **Scan-based seeding with REPLACE semantics**: new destinations bulk-load from a filtered source scan; a cursor-0 destination with leftover rows (crashed prior seed) is truncated first (`routing.seed_truncate`, default true). Configurable via `seed_mode` (default: `scan`)
190190
- **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"
191+
- **Destination lifecycle state machine** (`lifecycle.py` + `<state_table>_lifecycle` table — name derives from the cursor table so pipelines sharing a PG never share intent): per-DESTINATION operator intent — `active | paused | draining | retired`; absent row = active (no backfill), unknown value = paused + one ERROR per transition (not per cycle). Paused/retired = controlled crash: buffer discarded via the FlushFail machinery (`delivery.discard_buffer` rewinds position to the durable cursor + bumps the epoch; counted on `lifecycle_discarded_rows_total`), connection evicted ONLY once `delivery.is_clean` (an in-flight flush's retry loop re-creates the pool entry, so a transition-time evict latch leaks the connection for the stint), cursor is the resume point. A flush that COMPLETES after a discard restores `position >= flushed` in its success path (epoch-bumped) — without it, resume re-reads a committed range (deterministic duplicates in append_only). Draining = no new reads, flush out, evict when clean; the drain-complete log distinguishes "flushed out" from "ended via a flush-failure rewind" (the rewound range was NOT delivered; draining excludes re-reads, so retiring on the latter abandons it). RETIRED IS NEVER WRITTEN BY CODE (`StateManager.set_lifecycle_state` refuses) and viaduck SEVERS the cursor rows (all instances, idempotent per cycle + startup backstop against the in-flight-upsert resurrect race) — re-add = new tenant = fresh seed per `seed_mode`, deterministic regardless of partition drift. Seeding is lifecycle-gated: only ACTIVE destinations seed; a skipped cursor-0 destination stays read-gated after resume until a restart seeds it (ERROR per cycle). States re-read every poll cycle (live pause without restart); a state-store blip keeps last-known states (the lifecycle table shares the cursor store's PG — fail-to-paused would turn any PG blip into a fleet-wide self-inflicted mass discard). `/lifecycle` (read-only: state + reason/updated_by/updated_at + staleness age) + one-hot `viaduck_destination_lifecycle_state{destination,state}`; `/status` destination status short-circuits to the lifecycle state — join lag alerts on `state!="active"`. TLA: the spec HAS a `PauseDest` action (buffer discard + position rewind, in-flight flush PRESERVED — unlike ProcessCrash/FlushFail) and FlushCommit carries the implementation's success-path position restore; removing the restore lets TLC produce a 6-step BufferPositionBound counterexample (SrcInsert → BufferRead → FlushStart → PauseDest → FlushCommit), which is the formal witness for the pause-races-in-flight-flush duplicate-delivery bug. Paused DURATION needs no modeling (an action not firing is a pause; resume is BufferRead from the rewound position). The lifecycle table's DDL CHECK freezes the state vocabulary: adding a state later requires a migration on existing tables, not just a VALID_STATES change.
191192

192193
## Module Layout
193194

README.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,34 @@ Cursor advances are single `INSERT ... ON CONFLICT DO UPDATE` upserts with a mon
317317

318318
State is keyed by `(destination_id, instance_id)`, enabling multiple viaduck instances to independently track their assigned destinations without conflicts.
319319

320+
## Destination Lifecycle (operator runbook)
321+
322+
Each destination has an operator-intent state in `viaduck.<state_table>_lifecycle` (per-destination — pausing pauses it on every instance; the table name derives from `state.table` so pipelines sharing a Postgres never share intent). Absent row = `active`. States re-read every poll cycle, so changes apply live, no restart. Observability: `viaduck_destination_lifecycle_state{destination,state}` (one-hot), the read-only `/lifecycle` endpoint (state + reason/updated_by/updated_at + staleness age), `viaduck_lifecycle_discarded_rows_total`, and `/status` destination status strings (`paused`/`draining`/`retired` short-circuit `lagging` — join lag alerts against `viaduck_destination_lifecycle_state{state!="active"}` so intentional pauses don't page).
323+
324+
| state | effect |
325+
|---|---|
326+
| `active` | normal delivery |
327+
| `paused` | no reads, no flushes; buffer discarded (durable in source), connection released, cursor = resume point. Resume is gap-free (crash-recovery re-read) |
328+
| `draining` | no new reads; buffered data flushes out, then the connection is released. Reversible. **Check the drain-complete log line**: "drain complete (flushed out)" is a clean drain; "drain ended via a flush-failure rewind" means the read-but-unflushed range was NOT delivered — resume to re-read it before retiring |
329+
| `retired` | terminal. Excluded at startup; cursor rows are severed (all instances), so **re-add = new tenant = fresh seed** per `seed_mode` |
330+
331+
```sql
332+
-- Pause (live, applies within one poll cycle):
333+
INSERT INTO viaduck.viaduck_state_lifecycle (destination_id, state, reason, updated_by, updated_at)
334+
VALUES ('team-2', 'paused', 'RDS maintenance', 'jakob', now())
335+
ON CONFLICT (destination_id) DO UPDATE
336+
SET state = EXCLUDED.state, reason = EXCLUDED.reason,
337+
updated_by = EXCLUDED.updated_by, updated_at = EXCLUDED.updated_at;
338+
339+
-- Resume: same statement with state = 'active'.
340+
-- Drain (pre-retirement): state = 'draining'; wait for the drain-complete log.
341+
-- Retire (the explicit human ack — viaduck code REFUSES to write this value):
342+
-- state = 'retired'. Cursor rows are deleted by viaduck when it observes
343+
-- the state; re-adding the destination later re-seeds from scratch.
344+
```
345+
346+
A destination paused while it has never been seeded (cursor 0) skips seeding at startup and stays read-gated after resume until a restart seeds it (loud ERROR each cycle).
347+
320348
## New Destination Seeding
321349

322350
When a new destination is added to the config, it needs the current source data. Three modes are available via `routing.seed_mode`:

tests/integration/test_state_integration.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,3 +229,83 @@ def _boot(mgr, n):
229229
for m in managers:
230230
m.close()
231231
assert not errors
232+
233+
234+
# ---------------------------------------------------------------------------
235+
# Destination lifecycle (viaduck/lifecycle.py semantics live in the tracker;
236+
# these pin the StateManager's storage contract against real Postgres)
237+
# ---------------------------------------------------------------------------
238+
239+
240+
def test_lifecycle_round_trip_and_isolation(sm):
241+
sm.initialize_destinations(["d1"])
242+
# Absent rows: empty result (tracker normalizes absent -> active).
243+
assert sm.load_lifecycle_states(["d1", "d2"]) == {}
244+
245+
sm.set_lifecycle_state("d1", "paused", reason="broken catalog", updated_by="test")
246+
assert sm.load_lifecycle_states(["d1"]) == {"d1": "paused"}
247+
rows = sm.load_lifecycle_rows(["d1"])
248+
assert rows["d1"]["state"] == "paused"
249+
assert rows["d1"]["reason"] == "broken catalog"
250+
assert rows["d1"]["updated_by"] == "test"
251+
assert rows["d1"]["updated_at"] is not None
252+
253+
# Update in place (upsert path).
254+
sm.set_lifecycle_state("d1", "active", reason="fixed", updated_by="test2")
255+
assert sm.load_lifecycle_states(["d1"]) == {"d1": "active"}
256+
257+
258+
def test_lifecycle_table_is_per_pipeline(pg_uri, state_table_name):
259+
# The lifecycle table derives from the cursor table name — two
260+
# pipelines with colliding destination ids must not share operator
261+
# intent (review finding: shared hard-coded table let one pipeline's
262+
# pause hit the other).
263+
a = StateManager(pg_uri, "i1", StateConfig(table=state_table_name))
264+
b = StateManager(pg_uri, "i1", StateConfig(table=state_table_name + "_other"))
265+
try:
266+
a.initialize_destinations(["d1"])
267+
b.initialize_destinations(["d1"])
268+
a.set_lifecycle_state("d1", "paused", reason="pipeline A only", updated_by="test")
269+
assert a.load_lifecycle_states(["d1"]) == {"d1": "paused"}
270+
assert b.load_lifecycle_states(["d1"]) == {}
271+
finally:
272+
a.close()
273+
b.close()
274+
275+
276+
def test_lifecycle_check_constraint_rejects_unknown_state(sm, pg_uri, state_table_name):
277+
sm.initialize_destinations(["d1"])
278+
# Direct SQL (bypassing the code-level guard): the DB CHECK holds the
279+
# vocabulary, which is what makes the tracker's unknown-state paused
280+
# fallback forward-compat-only rather than a live path.
281+
with psycopg.connect(pg_uri, autocommit=True) as conn:
282+
with pytest.raises(psycopg.errors.CheckViolation):
283+
conn.execute(
284+
f"INSERT INTO viaduck.{state_table_name}_lifecycle "
285+
"(destination_id, state, updated_at) VALUES ('d1', 'frobnicated', now())"
286+
)
287+
288+
289+
def test_retirement_severs_cursor_rows_for_all_instances(pg_uri, state_table_name):
290+
# Two instances own rows for the same destination (partitioning drift
291+
# over time); retirement is per-destination and must sever both, so a
292+
# re-add seeds fresh regardless of which instance picks it up.
293+
i1 = StateManager(pg_uri, "i1", StateConfig(table=state_table_name))
294+
i2 = StateManager(pg_uri, "i2", StateConfig(table=state_table_name))
295+
try:
296+
i1.initialize_destinations(["d1"])
297+
i2.initialize_destinations(["d1"])
298+
i1.advance_cursor("d1", snapshot_id=9, cumulative_rows=10)
299+
300+
deleted = i1.delete_destination_state("d1")
301+
assert deleted == 2
302+
assert i1.load_cursors(["d1"]) == {}
303+
assert i2.load_cursors(["d1"]) == {}
304+
# Idempotent (the per-cycle resurrect-race sweep).
305+
assert i1.delete_destination_state("d1") == 0
306+
# Re-add: initialize creates a FRESH cursor-0 row -> re-seed path.
307+
i1.initialize_destinations(["d1"], initial_snapshot_id=0)
308+
assert i1.load_cursors(["d1"])["d1"].last_snapshot_id == 0
309+
finally:
310+
i1.close()
311+
i2.close()

tests/unit/test_delivery.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -551,3 +551,104 @@ def test_on_flush_success_fires_for_data_not_for_idle_persists():
551551
mgr.maybe_flush()
552552
assert mgr.wait_idle()
553553
assert hits == [1]
554+
555+
556+
# ---------------------------------------------------------------------------
557+
# Destination lifecycle hooks (viaduck/lifecycle.py)
558+
# ---------------------------------------------------------------------------
559+
560+
561+
def test_discard_buffer_rewinds_position_and_bumps_epoch():
562+
mgr, _, _ = _manager(cursors={"d1": 5})
563+
plan = mgr.read_plan()
564+
pos, epoch = plan["d1"]
565+
assert pos == 5
566+
mgr.buffer("d1", _table(4), through_snapshot=9, epoch=epoch)
567+
assert mgr.positions() == {"d1": 9}
568+
569+
dropped = mgr.discard_buffer("d1")
570+
assert dropped == 4
571+
# Position rewound to the durable cursor — the discarded range will be
572+
# re-read on resume (controlled-crash semantics, same as FlushFail).
573+
assert mgr.positions() == {"d1": 5}
574+
# A read that overlapped the discard is rejected by the epoch guard.
575+
mgr.buffer("d1", _table(2), through_snapshot=9, epoch=epoch)
576+
assert mgr.status_snapshot()["d1"].buffer_rows == 0
577+
assert mgr.positions() == {"d1": 5}
578+
579+
580+
def test_discard_buffer_noop_when_clean():
581+
mgr, _, _ = _manager(cursors={"d1": 5})
582+
assert mgr.discard_buffer("d1") == 0
583+
# Epoch untouched on the no-op path: an in-flight read may still land.
584+
_, epoch = mgr.read_plan()["d1"]
585+
mgr.buffer("d1", _table(1), through_snapshot=6, epoch=epoch)
586+
assert mgr.status_snapshot()["d1"].buffer_rows == 1
587+
588+
589+
def test_suspended_destination_never_flushes():
590+
mgr, _, _ = _manager()
591+
fake, calls = _recording_flush(mgr)
592+
with patch.object(mgr, "_flush", fake):
593+
mgr.buffer("d1", _table(3), through_snapshot=7)
594+
mgr.set_suspended({"d1"})
595+
assert mgr.maybe_flush(shutdown=True) == 0
596+
assert calls == []
597+
# Unsuspend: the same trigger now fires.
598+
mgr.set_suspended(set())
599+
assert mgr.maybe_flush(shutdown=True) == 1
600+
assert calls[0][0] == "d1"
601+
602+
603+
def test_is_clean_tracks_buffer_and_position():
604+
mgr, _, _ = _manager(cursors={"d1": 5})
605+
assert mgr.is_clean("d1")
606+
_, epoch = mgr.read_plan()["d1"]
607+
mgr.buffer("d1", _table(2), through_snapshot=8, epoch=epoch)
608+
assert not mgr.is_clean("d1")
609+
mgr.discard_buffer("d1")
610+
assert mgr.is_clean("d1")
611+
612+
613+
def test_position_only_advance_is_not_clean():
614+
# An advanced position with no data still means the durable cursor is
615+
# behind (a lazy persist is pending) — draining must wait for it.
616+
mgr, _, _ = _manager(cursors={"d1": 5})
617+
_, epoch = mgr.read_plan()["d1"]
618+
mgr.advance_position("d1", 9, epoch=epoch)
619+
assert not mgr.is_clean("d1")
620+
621+
622+
def test_flush_success_after_discard_restores_position():
623+
# Review finding: pause racing an in-flight flush left position <
624+
# flushed after the flush committed — on resume the already-applied
625+
# range was re-read and re-applied (deterministic duplicates in
626+
# append_only). The success path must restore position >= through.
627+
import threading
628+
629+
mgr, sm, _ = _manager(cursors={"d1": 5})
630+
_, epoch = mgr.read_plan()["d1"]
631+
mgr.advance_position("d1", 9, epoch=epoch) # position-only: no data write path
632+
633+
gate = threading.Event()
634+
entered = threading.Event()
635+
636+
def _blocking_advance(dest_id, through, cumulative, attempts=3):
637+
entered.set()
638+
gate.wait(timeout=10)
639+
sm.advance_cursor(dest_id, through, cumulative)
640+
641+
with patch.object(mgr, "_advance_cursor_with_retry", _blocking_advance):
642+
assert mgr.maybe_flush(shutdown=True) == 1 # real _flush, empty tables
643+
assert entered.wait(timeout=10)
644+
# Lifecycle pause lands mid-flush: rewinds position to flushed=5.
645+
mgr.discard_buffer("d1")
646+
assert mgr.positions()["d1"] == 5
647+
gate.set()
648+
assert mgr.wait_idle(timeout_s=10)
649+
650+
# Flush succeeded through 9: position restored, no re-read of (5, 9].
651+
assert mgr.positions()["d1"] == 9
652+
snap = mgr.status_snapshot()["d1"]
653+
assert snap.flushed_snapshot == 9
654+
assert mgr.is_clean("d1")

0 commit comments

Comments
 (0)