Skip to content

feat: destination lifecycle state machine (active|paused|draining|retired) - #56

Merged
jghoman merged 1 commit into
mainfrom
jakob/destination-lifecycle
Jul 23, 2026
Merged

feat: destination lifecycle state machine (active|paused|draining|retired)#56
jghoman merged 1 commit into
mainfrom
jakob/destination-lifecycle

Conversation

@jghoman

@jghoman jghoman commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

What

M3 of the dynamic team-sourcing plan: per-destination operator-intent lifecycle states in a new <state_table>_lifecycle Postgres table, re-read every poll cycle — live pause/drain/retire with no restart, for the legacy static destinations today and as the substrate the C2 discovery poller drives next (CP removal → draining + alert; retired stays a human ack, never automatic).

state effect
active normal delivery
paused no reads/flushes; buffer discarded (durable in source), connection released once clean, cursor = resume point; resume is the crash-recovery re-read, gap-free
draining no new reads; flushes out, connection released when clean; completion log is honest about how it ended (flushed-out vs failure-rewind, where the rewound range was NOT delivered)
retired terminal; cursor rows severed (all instances) → re-add = new tenant = fresh seed

Review-hardened semantics

Two adversarial rounds (lead-QE + principal-SWE), all findings closed in-branch. Highlights:

  • Pause racing an in-flight flush: the flush is left to finish (aborting mid-write is worse); its success path restores position >= through with an epoch bump — otherwise resume re-reads a committed range (deterministic duplicates in append_only). Pinned by a threaded race test and by the model checker (below).
  • Evict-latch waits for clean: an in-flight flush's retry loop re-creates the pool entry, so eviction latches only once nothing is in flight — a paused destination genuinely costs ~nothing.
  • Retirement severs the resume point (design decision): cursor rows deleted for all instances, idempotently per cycle (closes the resurrect race where a completing flush upserts the row back) + startup backstop. Re-seed behavior is deterministic regardless of partition drift.
  • Seeding is lifecycle-gated: pausing a broken cursor-0 destination no longer crashloops the instance at seed time.
  • Per-pipeline lifecycle table (derived from state.table): pipelines sharing a Postgres cannot pause each other's identically-named destinations.
  • Fail-safe choices: absent row = active (no backfill); unknown state = paused + one ERROR per transition; state-store blip keeps last-known states (fail-to-paused would turn any PG blip into a fleet-wide mass discard).

TLA+

New PauseDest action — discard + position rewind with the in-flight flush preserved (unlike ProcessCrash/FlushFail) — plus the FlushCommit position restore. All 7 invariants hold over 85,012,333 distinct states (1.18B generated, up from 19.9M pre-lifecycle). Deleting the restore produces a 6-step BufferPositionBound counterexample (SrcInsert → BufferRead → FlushStart → PauseDest → FlushCommit) — the spec now permanently witnesses why that line of _flush exists.

Operator surface

Documented SQL runbook in README (pause/resume/drain/retire), read-only /lifecycle (state + reason/updated_by/updated_at + staleness age for keep-last-known), one-hot viaduck_destination_lifecycle_state{destination,state}, viaduck_lifecycle_discarded_rows_total, and /status short-circuiting to the lifecycle state so intentional pauses stop reading as "lagging" (join lag alerts on state!="active").

Testing

just ci exit 0: 564 unit (20+ new: tracker semantics, discard/epoch/suspension, threaded discard-vs-inflight-flush race, /lifecycle payload) + 79 integration (lifecycle round-trips, per-pipeline isolation, DB CHECK, cross-instance cursor severing — real Postgres), fmt/lint/semgrep/docker green. TLC clean as above.

…ired)

Per-destination operator intent in <state_table>_lifecycle (name derives
from the cursor table: pipelines sharing a PG never share intent).
Absent row = active; unknown value = paused + one ERROR per transition.
States re-read every poll cycle — live pause with no restart; a
state-store blip keeps last-known states (lifecycle shares the cursor
store's PG, so fail-to-paused would turn any PG blip into a fleet-wide
self-inflicted mass discard).

Semantics (two adversarial review rounds + operator feedback):
- paused/retired: controlled crash — buffer discarded via the FlushFail
  machinery (position rewound to the durable cursor, epoch bumped;
  lifecycle_discarded_rows_total), connection evicted only once CLEAN
  (an in-flight flush's retry loop re-creates the pool entry, so a
  transition-time evict leaks it for the stint); cursor = resume point.
- a flush that SUCCEEDS after a discard restores position >= through in
  its success path (epoch-bumped) — without it, resume re-reads a
  committed range: deterministic duplicates in append_only. TLA-proven
  load-bearing (below).
- draining: no new reads, flush out, evict when clean; the completion
  log distinguishes 'flushed out' from 'ended via a flush-failure
  rewind' (that range was NOT delivered; resume to re-read before
  retiring).
- retired: terminal, never written by code (StateManager refuses; the
  ack is an operator UPDATE with updated_by). Viaduck SEVERS the cursor
  rows (all instances, idempotent per cycle + startup backstop) —
  re-add = new tenant = fresh seed per seed_mode, deterministic
  regardless of partition drift.
- seeding is lifecycle-gated: paused destinations take no bulk writes
  and a broken one can't crashloop the instance; a skipped cursor-0
  destination stays read-gated after resume until a restart seeds it.

Observability: one-hot viaduck_destination_lifecycle_state, read-only
/lifecycle (state + reason/updated_by/updated_at + staleness age),
/status short-circuits to the lifecycle state (intentional pauses stop
reading as 'lagging'; join lag alerts on state!="active"). README
gains the operator runbook with the canonical SQL.

TLA+: new PauseDest action (discard + rewind with the in-flight flush
PRESERVED, unlike ProcessCrash/FlushFail) and the FlushCommit position
restore. All 7 invariants hold over 85,012,333 distinct states (1.18B
generated); removing the restore yields a 6-step BufferPositionBound
counterexample (SrcInsert, BufferRead, FlushStart, PauseDest,
FlushCommit) — the formal witness for the pause-races-in-flight-flush
duplicate-delivery bug.

M3 of the dynamic sourcing plan; the substrate C2 discovery drives
(CP removal -> draining + alert; retire stays a human ack).
@jghoman
jghoman merged commit 1e3fc87 into main Jul 23, 2026
16 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