feat: stream reset for incremental syncs - #84
Merged
Conversation
Re-fetch an incremental stream in full and replace the destination table
for one run, then resume incremental from it. Previously the only way to
rebuild a drifted table was to delete backend rows by hand: the watermark
(last_run, taken from the last succeeded job) had no escape hatch.
The whole feature hangs off a single config field, source.reset, which is
what keeps it small. init_job() runs in the parent before the producer and
consumer are submitted, and both are handed the same bizon_config/config
objects, so the flag is resolved once and reaches both sides with no
signature changes.
Three triggers converge on that flag:
bizon run config.yml --reset one-shot, manual
source.reset: true in the config
bizon stream reset <config> queued in the backend, consumed by the
next run
The last form is the one that reaches a pipeline whose command line is
owned by a scheduler: it records a row in a new stream_resets table and an
unchanged `bizon run config.yml` picks it up.
During a reset the producer skips the watermark lookup and calls get()
instead of get_records_after(). Destinations read a new
SyncMetadata.destination_sync_mode property, which maps a reset onto
FULL_REFRESH so the existing {table}_temp + WRITE_TRUNCATE copy-job path is
reused with no new finalize branch. The job row stays incremental, so
get_last_successful_stream_job picks the reset run up as the next watermark
automatically.
Crash safety: every reset job has a consumed stream_resets row bound to it,
so a retry recognises the in-flight job as a reset instead of silently
degrading into an append. BigQueryDestination._ensure_clean_temp_table()
drops a stale temp table once per reset, but only when the job has written
no destination cursor yet, since otherwise it would discard iterations the
resuming producer will not re-fetch.
Scope: bigquery and logger destinations. The others still branch on
sync_mode, so a reset there would append; that is rejected at config
validation rather than silently duplicating data. The stream_resets table
is created by the existing create_all_tables(), so no migration is needed.
Co-Authored-By: Claude <noreply@anthropic.com>
Resets were already scoped per stream: the marker is keyed on (name, source_name, stream_name), the same triple as get_last_successful_stream_job, so a reset is exactly as granular as the watermark it overrides. What was missing was a way to *choose* the stream without a dedicated config file, which matters when one config is templated across several streams. Also warn when no previous successful job exists for the target stream. Nothing validates the stream name here (the source is never instantiated), so a typo — most likely via --stream — would otherwise queue a reset that silently never fires. Co-Authored-By: Claude <noreply@anthropic.com>
… field Destinations branched on a derived `SyncMetadata.destination_sync_mode` property, which meant every destination had to know to read it instead of `sync_mode`. Ones that did not were kept safe by an allowlist, so reset only worked on bigquery and logger. Do the mapping once in SyncMetadata.from_bizon_config instead: a reset reaches destinations as `sync_mode: full_refresh`. This is the sync mode of the materialization, not of the job -- the job row is written from bizon_config and still stays `incremental`, so get_last_successful_stream_job keeps picking the reset run up as the next watermark, which was the whole point of not simply flipping source.sync_mode. Net effect: the derived property, the four destination call sites and the allowlist all go away, and every destination with a working full-refresh path supports reset for free. Verified end-to-end that `file` -- previously rejected outright -- now replaces its output on a reset (5 rows, not 10). The allowlist becomes a one-entry denylist: bigquery_streaming has no finalize() and no staging table, so it appends even on a plain full refresh. Co-Authored-By: Claude <noreply@anthropic.com>
This was referenced Aug 6, 2026
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.
Context
Incremental streams derive their watermark from the last SUCCEEDED
StreamJob(last_run = last_successful_job.created_at). Once that chain exists there is no way to say "forget the watermark, re-pull the whole stream, and replace the table" — the only escape hatch today is manually deleting backend rows.This adds a stream reset: one run that re-fetches the stream from scratch and replaces the destination table, after which incremental resumes normally from that run.
Design
Everything hangs off a single config field,
source.reset. That choice is what kept the change small:init_job()runs in the parent before the producer and consumer are submitted, and both are handed the samebizon_config/configobjects — so the flag is resolved once and reaches both sides with no signature changes.Three triggers, all converging on that flag:
The
bizon stream resetform is the important one. A--resetflag only helps someone typing the command by hand — if your pipeline is a cron/Airflow job whose command is hardcoded asbizon run myconfig.yml, you'd have to edit the job spec, run once, then edit it back. The marker form writes a row to a newstream_resetstable, and an unchangedbizon run myconfig.ymlpicks it up.Granularity is per stream, not per pipeline. The marker is keyed on
(name, source_name, stream_name)— the same triple asget_last_successful_stream_job— so a reset is exactly as scoped as the watermark it overrides, and resetting one stream never affects another under the same pipeline name. Multi-stream configs (thestreams:block) can't be reset at all: they requiresync_mode: stream, while reset is incremental-only.What the run does differently:
get()instead ofget_records_after().SyncMetadata.from_bizon_config()maps a reset ontosync_mode: full_refresh, so destinations replace their table through their existing full-refresh path. This is the sync mode of the materialization, not of the job — which is why the job row can stayincrementalwhile the destination still replaces.incremental, soget_last_successful_stream_jobpicks the reset run up as the next watermark automatically.Crash safety — the part that made the flag alone insufficient: every reset job has a consumed
stream_resetsrow bound to it, so a retry recognises the in-flight job as a reset instead of silently degrading into an append._ensure_clean_temp_table()drops a stale temp table once per reset, but only when the job has written no destination cursor yet — otherwise it would discard iterations the resuming producer will not re-fetch.Nothing validates the stream name in
bizon stream reset(the source is never instantiated), so a typo — most likely via--stream— would queue a reset that silently never fires. The command warns when the target stream has no previous successful job. It's a warning, not an error, since a stream can legitimately have run without ever succeeding.An earlier revision of this PR exposed a separate
destination_sync_modeproperty and gated it behind an allowlist, which limited reset tobigquery+logger. Collapsing it into the factory removed the property, four destination call sites and the allowlist, and made reset work everywhere. Verified end-to-end that thefiledestination — previously rejected outright — now replaces its output on a reset (5 rows, not 10).Verification
Ran the real CLI end-to-end across four runs (dummy → logger, sqlite backend):
fetching records after 12:28:23bizon stream reset→ third run logs the reset, cancels the stale job, re-fetches all 512:28:42)Inspected the backend afterwards: marker consumed exactly once and bound to the reset job; every job row still
incremental. Also confirmed the command itself only does one INSERT — job and cursor rows are untouched, nothing is deleted, and no pipeline runs.Per-stream isolation verified both directions: resetting
plantsleftcreatureson the normal incremental path, then--stream creaturesfired correctly.47 new tests across backend CRUD, reset resolution, producer dispatch, BigQuery staging/finalize, config validation, and the CLI. Full suite: 266 passed. The 28 failures are all pre-existing on a clean tree (verified by stashing) — they need real Postgres/GCP credentials.
Postgres-backed tests were run against the repo's
docker-compose.yml(postgres:14, same as CI): all 14my_pg_backendbackend tests pass, including the 3 newstream_resetsones. With Postgres up the full suite is 283 passed; the 14 remaining failures all require real GCP credentials (live BigQuery) and fail identically on a clean tree.Notes for review
Two decisions worth a second opinion:
bigquery_streaming(v1) is excluded. It has nofinalize()and no staging table, so it appends even on a plain full refresh — its full-refresh support is already broken independently of this PR. A reset there is a config validation error rather than silent duplication. Every other destination works.Backwards compatibility. Checked out
origin/main'stests/and ran them against this branch: all 233 pass, with no failures outside the GCP-credential set. Only 9 lines are removed acrossbizon/, and each reduces to the original whenresetis off (X or (False and …)→X, etc.); the BigQuery destination diff is purely additive, withtemp_table_idandfinalize()byte-identical to main.source.resetdefaults toFalse, andstream_resetsis a new table rather than a column onstream_jobsprecisely socreate_all_tables()handles it with no migration.One deliberate exception:
AbstractBackendgains 5 abstract methods, so an out-of-tree backend subclass would fail at instantiation.BackendFactoryis a closed enum dispatch with no plugin path, so there is no supported way to have written one — and failing loudly at construction beats a backend that silently can't reset. Flagging it rather than burying it.Also, two pre-existing bugs found while working here, deliberately not touched:
StreamJob.created_atusesdefault=datetime.now(tz=UTC)— a value bound at import time, not a callable — so every job inserted by a process shares the module-load timestamp. Since that value is the incremental watermark, this is load-bearing for correctness.destination.py:146-148returns early when the buffer is empty, skippingfinalize()— so a full refresh (and therefore a reset) over an empty source never replaces the main table.🤖 Generated with Claude Code