Skip to content

fix(ingest): reconcile orphan rows from hard-killed runs before retry ingest#369

Open
LukasWodka wants to merge 1 commit into
developfrom
fix/1028-orphan-rows-on-hard-kill
Open

fix(ingest): reconcile orphan rows from hard-killed runs before retry ingest#369
LukasWodka wants to merge 1 commit into
developfrom
fix/1028-orphan-rows-on-hard-kill

Conversation

@LukasWodka

@LukasWodka LukasWodka commented Jul 12, 2026

Copy link
Copy Markdown
Collaborator

Summary

An OOMKill/SIGKILL mid-ingest bypasses the #227 compensating delete (it only runs on a caught failure), so the dead run's rows stay in MySQL while its dataset was never registered — and the k8s Job retry then re-ingests the same source next to them under fresh data_ids, duplicating every row. This adds a cluster-local run journal and a reconcile-on-start pass: every ingest first reclaims rows left by prior runs that were journaled as started but never registered, so a retry converges instead of duplicating.

Related

Ref tracebloc/backend#1028 (item 2)

Type of change

  • Feature
  • Bug fix
  • Tech-debt / refactor
  • Docs
  • Security / hardening
  • Breaking change

Design

"Registered" exists only in the central backend (send_ingest_summary, no lookup endpoint), so the journal lives in the same cluster MySQL the rows land in — a tracebloc_ingest_runs table created lazily like the #225 salt store (no migration step):

Why it's safe:

  • Rows are reclaimed only when the journal witnessed the run start and never saw it register. Registered runs are excluded by the mark; rows that predate the journal (every pre-fix dataset, registered or not) have no started-entry and are never touched — shipping this cannot delete rows of any existing dataset.
  • Idempotent: the pass excludes the current run and a second pass finds nothing; both journal writes are re-entrant.
  • Failure-isolated: a reconcile error is logged CRITICAL and the ingest proceeds (degrades to today's status quo — orphans stay, and the run's own summary counts are unaffected because every query is scoped to its ingestor_id). A mark_ingest_registered failure after successful registration is likewise swallowed with CRITICAL — raising would fail a healthy run and hand the retry a journal entry telling it to purge a registered dataset's rows.
  • Residual two-generals window (killed between send_ingest_summary returning and the one-row journal UPDATE committing → the retry reclaims a backend-registered dataset's rows and re-registers under a new id): strictly narrower than the Atomicity gap: committed MySQL rows can outlive a failed dataset registration (from backend#772 P0.2) #227 window already documented in base.py for the caught-failure path, with the same recovery (the retry re-ingests from source).

Known limitation (called out, not hidden): orphans left by hard kills before this ships have no journal entry and are deliberately not reclaimed — locally they are indistinguishable from legacy registered datasets, and constraint one is never deleting registered rows. They remain sweepable by ingestor_id as before. Also note the reclaim runs when the next ingest on that table actually starts; on PVC-backed clusters a hard-killed run's stale table lock can delay that retry (existing 12h stale-reclaim behavior, unchanged here).

Test plan

  • pytest --cov=tracebloc_ingestor --cov-fail-under=95 (mirrors tests.yml, Python 3.11): 1626 passed, 1 xfailed, coverage 96.09% — all new lines covered.
  • docker compose -f e2e/docker-compose.yml up -d + pytest e2e/ -v against real MySQL 8 (mirrors e2e.yml): 47 passed, including 4 new tests:
    • hard-killed run (journaled start + rows, no registration) → retry reclaims exactly those rows and converges (DB-level and full-engine-level via run.main() on the bundled tabular template: 8 rows, not 8+3);
    • registered runs and pre-journal legacy rows survive the pass untouched;
    • reclaim is idempotent and never touches the current run's rows.
  • New unit tests pin the orchestration order (create_table → reclaim → journal start → first insert; mark-registered only after a successful summary) and the failure isolation (reclaim/mark failures never fail a healthy run, #227 delete still fires on caught failures).
  • Not tested: a real OOMKilled pod on a cluster — simulated instead as "rows present + journaled started + never registered", which is byte-identical to the post-kill DB state.

Deployment notes

No migration: the journal table is created lazily on first use, and the first deploy sees an empty journal (reconcile no-ops). No config/env changes. The two bookkeeping table names are now rejected as dataset table names.

Checklist

  • Tests added / updated and passing locally
  • Docs updated if behavior or config changed
  • No secrets / credentials in the diff
  • For security-sensitive paths: appropriate reviewer requested

🤖 Generated with Claude Code


Note

Medium Risk
Deletes rows by ingestor_id when the journal says started-but-unregistered; mis-journaling or the narrow post-registration window could affect live data, though registered and pre-journal rows are explicitly excluded.

Overview
Fixes duplicate rows on k8s Job retry when a prior ingest was hard-killed (OOM/SIGKILL) after inserting into MySQL but before backend registration — a path that bypasses the existing #227 compensating delete.

Adds a cluster-local tracebloc_ingest_runs journal (lazy DDL, like the salt store) with record_ingest_started, mark_ingest_registered, and reclaim_dead_run_rows. BaseIngestor.ingest now runs reconcile → journal start → inserts, then marks registered after a successful send_ingest_summary. Reclaim only targets journaled started + never registered ingestor_ids (join with data rows); legacy rows without journal entries and registered runs are excluded. create_table also rejects tracebloc_ingest_runs as a dataset name.

Failure isolation: reconcile errors are logged CRITICAL and ingest continues; post-registration journal failures are swallowed so a registered dataset is not failed or deleted. Unit and MySQL e2e tests cover ordering, safety, idempotency, and full-engine retry convergence.

Reviewed by Cursor Bugbot for commit ab4481c. Bugbot is set up for automated code reviews on this repo. Configure here.

… ingest

The #227 compensating delete only runs on a CAUGHT failure. An OOMKill /
SIGKILL mid-ingest bypasses it, leaving the dead run's rows in MySQL with
its dataset never registered - and the k8s Job retry then re-ingests the
same source next to them under fresh data_ids, duplicating every row.

Add a cluster-local run journal (tracebloc_ingest_runs, lazily created
like the #225 salt store) recording each run's lifecycle:

- record_ingest_started: journaled before the run's first row insert
- mark_ingest_registered: right after send_ingest_summary returns
- reclaim_dead_run_rows: at ingest start (under the table lock), delete
  rows whose ingestor_id was journaled as started but never registered

Rows are reclaimed ONLY when the journal witnessed the run start and
never saw it register, so registered runs and pre-journal (legacy) rows
are never touched. The reclaim pass is idempotent, excludes the current
run, and a reconciliation failure degrades to today's status quo instead
of blocking the ingest.

Ref tracebloc/backend#1028 (item 2)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@LukasWodka

Copy link
Copy Markdown
Collaborator Author

👋 Heads-up — Code review queue is at 52 / 30

Above the WIP limit. The team convention is to review existing PRs before opening new work.

Open PRs currently in Code review (oldest first):

Pull from review before opening new work. (This is a nudge from the kanban WIP check, not a block.)

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