Skip to content

fix: don't trust state left behind by a killed process (v0.5.2) - #88

Merged
anaselmhamdi merged 4 commits into
mainfrom
anaselmhamdi/bizon-upstream-report
Aug 7, 2026
Merged

fix: don't trust state left behind by a killed process (v0.5.2)#88
anaselmhamdi merged 4 commits into
mainfrom
anaselmhamdi/bizon-upstream-report

Conversation

@anaselmhamdi

Copy link
Copy Markdown
Collaborator

Four fault-tolerance fixes found operating ~63 batch pipelines against BigQuery on Kubernetes (Argo Workflows), where pods are subject to a hard activeDeadlineSeconds kill. A killed process never gets to mark its job failed, and every recovery path trusted the state it left behind.

Ordered by damage done.

1. full_refresh resumed dead jobs — silent data outage

get_or_create_job() resumed any job still in running. Right for incremental; a silent outage for full_refresh: the run continues a stale item list, never reaches the last iteration, so never calls finalize() — and because the job stays running, the next run resumes it too. No attempt ever completes a pass.

Observed in production: a table sat at its week-old contents (142,454 rows) through seven consecutive "successful" runs. Nothing surfaced as failing.

Such a job is now cancelled and started fresh. Resets are structurally unaffected — resolve_reset() returns False for any non-incremental mode, so they never reach this branch and keep their own stream_resets recovery contract.

2. full_refresh never cleared its staging table

Fixing #1 alone would have made things worse, which is why these ship together. Loads always WRITE_APPEND into {table}_temp and finalize() publishes it with a WRITE_TRUNCATE copy, but _ensure_clean_temp_table() only dropped a stale temp table for reset runs. A fresh job would have published its own rows alongside the killed run's — trading stale-but-consistent data for duplicates. (132,602 rows had accumulated in _temp across the seven attempts above, with overlapping and missing cursor ranges.)

The guard's own docstring justified the exclusion with "a reset … is the one incremental case that publishes with WRITE_TRUNCATE" — but plain full_refresh also stages into _temp and also publishes with WRITE_TRUNCATE. It now covers every run that does. Plain incremental stages into {table}_incremental and still appends across runs, unchanged.

3. Concurrent pipelines crashed creating the state tables

create_all_tables() inspects before creating (checkfirst=True), but inspect-then-create is not atomic: pipelines sharing a source — and so a dataset — starting on the same cron all saw a table missing, all issued CREATE TABLE, and every process but one died with 409 Already Exists (DuplicateTable on Postgres). Hit bizon_linear (3 pipelines) and bizon_customerio (6) simultaneously.

Losing that race is not an error. Verified by re-inspecting rather than by catching a dialect-specific exception, since this adapter also serves SQLite and Postgres and must not import the BigQuery client libraries. A genuine failure still raises.

This race was always present for every state table — it is only reachable while a table is missing, which is why it first surfaced when 0.5.0 added stream_resets. It would fire again for anyone bootstrapping a fresh dataset, and again on the next new state table.

4. Unattributable crash resuming a cursor with no pagination

create_destination_cursor() stores a falsy pagination as SQL NULL rather than "{}", while the reader called json.loads() on it unconditionally — so a perfectly successful cursor (get_last_cursor_by_job_id filters on success == True) crashed with the JSON object must be str, bytes or bytearray, not NoneType, naming neither the job, the stream, nor a remedy. That sent one investigation down an API/auth dead end.

Failing is correct: Cursor.update_state() treats an empty pagination as source exhausted, so there is nothing to resume from, and handing it back would restart the source at page one and re-fetch the whole stream. It now fails with the job id, the stream, and how to recover.

Tests

6 new/rewritten, all passing. test_create_job_and_recover encoded bug #1 — it asserted a running job is resumed, on a config that defaults to full_refresh — so it is split into the incremental case (unchanged) and a full_refresh regression test. All five pre-existing _ensure_clean_temp_table reset tests pass untouched.

Verified against a stashed pristine tree: the full suite's FAILED/ERROR sets are byte-identical before and after (34 each), so no regressions. Those 34 are pre-existing and environmental — Postgres fixtures and live-BigQuery tests.

Not verified locally: the Postgres-backed paths. Port 5432 was held by an unrelated container, so those fixtures could not run here and will first be exercised in CI.

Release

Bumped to v0.5.2 (pyproject.toml, uv.lock, CHANGELOG.md) in a separate chore(release): commit. Tag after merge to publish.

Behavior change worth calling out in review: a full_refresh job left in running is no longer resumed.

🤖 Generated with Claude Code

anaselmhamdi and others added 4 commits August 7, 2026 11:38
Four defects found operating ~63 batch pipelines against BigQuery on
Kubernetes, where pods are subject to a hard activeDeadlineSeconds kill.
A killed process never gets to record that it failed, and every recovery
path trusted what it left behind.

get_or_create_job() resumed any job still in `running`. That is right for
incremental, and a silent outage for full_refresh: the run continues a
stale item list, never reaches the last iteration, so never calls
finalize() - and because the job stays `running`, the next run resumes it
too. One table served its week-old contents through seven consecutive
"successful" runs. Such a job is now cancelled and started fresh.

That fix alone would have made things worse. Loads WRITE_APPEND into
{table}_temp and finalize() publishes it with a WRITE_TRUNCATE copy, but
_ensure_clean_temp_table() only dropped a stale temp table for *reset*
runs - so a fresh job would have published its own rows alongside the
killed run's, trading stale data for duplicates (132k rows had piled into
_temp across seven attempts). The guard now covers every run that
publishes with WRITE_TRUNCATE. Plain incremental stages into
{table}_incremental and still appends across runs, unchanged.

create_all_tables() inspects before creating, but inspect-then-create is
not atomic: pipelines sharing a schema and starting on the same cron all
saw a table missing, all issued CREATE TABLE, and all but one died with
409 Already Exists. Losing that race is not an error. Verified by
re-inspecting rather than by catching a dialect-specific exception, since
this adapter also serves SQLite and Postgres and must not import the
BigQuery client libraries.

create_destination_cursor() stores a falsy pagination as NULL rather than
"{}", while the reader called json.loads() on it unconditionally - so a
perfectly successful cursor crashed with "the JSON object must be str,
bytes or bytearray, not NoneType", naming neither the job nor a remedy.
Such a job genuinely cannot be resumed: an empty pagination means the
source was exhausted, and handing it back would restart the source at page
one and re-fetch the whole stream. It now fails with the job id, the
stream, and how to recover.

test_create_job_and_recover encoded the first bug - it asserted a `running`
job is resumed, on a config defaulting to full_refresh - and is split into
the incremental case (unchanged) and a full_refresh regression test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Patch release for four fault-tolerance fixes, the most serious of which
let a full_refresh table go stale indefinitely while every run reported
success.

Note the behavior change: a full_refresh job left in `running` is no
longer resumed, it is cancelled and restarted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four corrections found auditing it against the code:

- The stream-reset "crash safety" note claimed _ensure_clean_temp_table()
  is reset-specific. As of 0.5.2 it keys off WRITE_TRUNCATE publication,
  so it covers plain full_refresh too. Replaced with a pointer to a new
  "Job recovery semantics" section covering all three recovery rules,
  since getting any one of them wrong reintroduces a silent data bug.

- The finalize() template shows CREATE OR REPLACE / INSERT INTO DML, but
  the bigquery destination has published with copy jobs since 0.4.0.
  Noted the difference and why it matters (copy jobs are free, DML is
  billed on bytes scanned).

- pyproject declares requires-python >=3.9 but the code does not import
  on 3.9: PEP 604 unions in def signatures, evaluated at import, with no
  `from __future__ import annotations`. Verified on a real 3.9.12. Hits
  the core SQLAlchemy backend and 8 bundled sources; no CI job runs 3.9,
  which is why it went unnoticed. Documented 3.10 as the real minimum
  pending a decision on which end to fix.

Verified but left alone: syncCursorInDBEvery: 2 and retry_limit: 10 are
right — they are the defaults used when the block is omitted, which
differ from the field defaults (10 and 100).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- "a pipeline resumes from its last committed cursor after a crash" was
  the behavior 0.5.2 deliberately changed. It holds for incremental and
  stream; full_refresh now restarts, because resuming it left the
  published table stale indefinitely. Qualified in both the Features
  bullet and the Checkpointing & recovery section.

- Installation claimed Python >= 3.9. bizon does not import on 3.9 (PEP
  604 annotations evaluated at import). Documented 3.10 as the real
  floor, with a note that the metadata still says 3.9.

- Noted force_ignore_checkpoint is now redundant for full_refresh. It was
  the workaround for the resume bug, so anyone who set it fleet-wide can
  drop it.

Verified unchanged: connector counts (10 sources, 5 destinations),
syncCursorInDBEvery default 2, api_config.retry_limit default 10.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@anaselmhamdi
anaselmhamdi merged commit c59c2d2 into main Aug 7, 2026
1 check 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