You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
flush workers, seeding, and commit/cursor-gap scenarios both with and
170
170
without process death — ALL checked unconditionally; the tombstone rule
171
171
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
173
173
the CDC algorithm or adding new failure modes — and when designing semantic
174
174
changes, extend the spec FIRST and let TLC pass judgment before implementing.
175
175
Always run `just tlc` after spec changes.
@@ -188,6 +188,7 @@ Always run `just tlc` after spec changes.
188
188
-**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`
189
189
-**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`)
190
190
-**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.
Copy file name to clipboardExpand all lines: README.md
+28Lines changed: 28 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -317,6 +317,34 @@ Cursor advances are single `INSERT ... ON CONFLICT DO UPDATE` upserts with a mon
317
317
318
318
State is keyed by `(destination_id, instance_id)`, enabling multiple viaduck instances to independently track their assigned destinations without conflicts.
319
319
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`|
-- 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
+
320
348
## New Destination Seeding
321
349
322
350
When a new destination is added to the config, it needs the current source data. Three modes are available via `routing.seed_mode`:
0 commit comments