fix: don't publish a partial extract when the producer aborts - #89
Open
anaselmhamdi wants to merge 1 commit into
Open
fix: don't publish a partial extract when the producer aborts#89anaselmhamdi wants to merge 1 commit into
anaselmhamdi wants to merge 1 commit into
Conversation
A run that died mid-stream still swapped its partial extract into the
production table and recorded the job as `succeeded`.
The producer sent the same clean QUEUE_TERMINATION signal whether it had
exhausted the source or aborted on an error -- the `break` out of the error
path fell through to the very same `queue.terminate()` call as a successful
run. The consumer could not tell them apart, so it treated the abort as the
last iteration: `write_records_and_update_cursor(last_iteration=True)` set
JobStatus.SUCCEEDED and called `finalize()`, which on BigQuery is the
WRITE_TRUNCATE copy that publishes `{table}_temp` over the production table.
Observed in production on a full_refresh HubSpot stream that lost its OAuth
token at iteration 3517 of 4400: the production table was republished at ~80%
of its rows (~88k records missing) on three separate days, each recorded as
`succeeded`. Nothing downstream could detect it -- only the process exit code
indicated a failure at all.
The producer now sends QUEUE_TERMINATION_ERROR when it stops on an error, and
the consumer aborts on that signal without writing a last iteration, without
finalizing, and without marking the job succeeded. The job is left `running`,
which get_or_create_job() already recovers from: 0.5.2 cancels and restarts a
leftover `running` full refresh from page 1.
Also fixes the same defect from the runner side. `if result_producer.SUCCESS:`
in ThreadRunner is an attribute access on the enum *class*, which resolves to
the PipelineReturnStatus.SUCCESS member and is therefore always truthy -- the
`else` branch setting `consumer_stop_event` was unreachable, and the runner
logged "Producer thread has finished successfully" for every failure. (The
consumer branch three lines below always compared correctly.) The
producer/consumer signalling is the load-bearing fix, since by the time the
runner observes the result the consumer has usually already drained the
termination message and finalized.
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.
The bug
A run that dies mid-stream still swaps its partial extract into the production table and records the job as
succeeded.The producer sends the same clean
QUEUE_TERMINATIONsignal whether it exhausted the source or aborted on an error — thebreakout of the error path falls through to the very samequeue.terminate()call a successful run makes. The consumer can't tell them apart, so it treats the abort as the last iteration:producer.py—SOURCE_ERRORbreaks, thenself.queue.terminate(iteration=...), identical to the success pathconsumer.py— seesQUEUE_TERMINATION, callswrite_records_and_update_cursor(..., last_iteration=True), returnsSUCCESSdestination.py—last_iteration=TruesetsJobStatus.SUCCEEDEDand callsfinalize()(on BigQuery, theWRITE_TRUNCATEcopy publishing{table}_tempover the production table), with no check of producer status at allProduction impact
Observed on a
full_refreshHubSpot stream that lost its OAuth token at iteration 3517 of 4400:Confirmed in BigQuery
INFORMATION_SCHEMA.JOBS(COPY DONE OK) and instream_jobs, which recorded that run assucceeded. The production table was republished at ~80% of its rows (~88k records missing) on three separate days. Nothing downstream could detect it — only the process exit code indicated a failure at all.The fix
QUEUE_TERMINATION_ERRORsignal (QueueMessage.signalis alreadyOptional[str], so this is backward-compatible).terminate()takessignal, defaulting toQUEUE_TERMINATION; threaded through all three queue adapters.QUEUE_TERMINATION_ERRORwhenreturn_value != SUCCESS.The job is deliberately left
running— the stateget_or_create_job()already recovers from, since 0.5.2 cancels and restarts a leftoverrunningfull refresh from page 1.Also fixed:
thread.pytruthiness bugif result_producer.SUCCESS:is an attribute access on the enum class, which resolves to thePipelineReturnStatus.SUCCESSmember and is therefore always truthy. Theelsebranch settingconsumer_stop_eventwas unreachable, and the runner logged "Producer thread has finished successfully" for every failure. (The consumer branch three lines below always compared correctly with== SUCCESS.)Fixing this alone would not be sufficient — by the time the runner observes the producer result, the consumer has usually already drained the termination message and finalized. The producer/consumer signalling is the load-bearing fix; this is the belt to its braces.
Tests
tests/engine/test_consumer_error_termination.py— two tests mirroring the production shape (records land, then the stream closes):test_error_termination_does_not_finalize_or_mark_succeeded— assertsSOURCE_ERROR,finalize()never called, job notSUCCEEDEDtest_normal_termination_still_finalizes_and_marks_succeeded— the happy path is unchangedVerified the test actually catches the bug: neutering only the new consumer guard makes the first test fail with
assert success == source_errorwhile the happy path still passes.Full suite:
31 failed, 276 passed, 12 skipped, 3 errorson this branch vs31 failed, 274 passed, 12 skipped, 3 errorson pristinemain— identical pre-existing failures (they require live BigQuery/Postgres credentials), plus exactly the 2 new tests.ruff checkandruff format --checkclean.Reviewer note — behaviour change
This changes shared behaviour for every pipeline. Pipelines that today quietly "succeed" on a partial extract will start failing loudly and leaving
runningjobs. That's the intended outcome, but it's worth watching the first few nights after upgrading.🤖 Generated with Claude Code