fix: don't trust state left behind by a killed process (v0.5.2) - #88
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Four fault-tolerance fixes found operating ~63 batch pipelines against BigQuery on Kubernetes (Argo Workflows), where pods are subject to a hard
activeDeadlineSecondskill. A killed process never gets to mark its jobfailed, and every recovery path trusted the state it left behind.Ordered by damage done.
1.
full_refreshresumed dead jobs — silent data outageget_or_create_job()resumed any job still inrunning. Right forincremental; a silent outage forfull_refresh: the run continues a stale item list, never reaches the last iteration, so never callsfinalize()— and because the job staysrunning, 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()returnsFalsefor any non-incremental mode, so they never reach this branch and keep their ownstream_resetsrecovery contract.2.
full_refreshnever cleared its staging tableFixing #1 alone would have made things worse, which is why these ship together. Loads always
WRITE_APPENDinto{table}_tempandfinalize()publishes it with aWRITE_TRUNCATEcopy, 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_tempacross 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_refreshalso stages into_tempand also publishes withWRITE_TRUNCATE. It now covers every run that does. Plain incremental stages into{table}_incrementaland 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 issuedCREATE TABLE, and every process but one died with409 Already Exists(DuplicateTableon Postgres). Hitbizon_linear(3 pipelines) andbizon_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 SQLNULLrather than"{}", while the reader calledjson.loads()on it unconditionally — so a perfectly successful cursor (get_last_cursor_by_job_idfilters onsuccess == True) crashed withthe 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_recoverencoded bug #1 — it asserted arunningjob is resumed, on a config that defaults tofull_refresh— so it is split into the incremental case (unchanged) and afull_refreshregression test. All five pre-existing_ensure_clean_temp_tablereset 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 separatechore(release):commit. Tag after merge to publish.Behavior change worth calling out in review: a
full_refreshjob left inrunningis no longer resumed.🤖 Generated with Claude Code