Fix race condition creating duplicate data sources on fresh databases - #2359
Draft
Flix6x wants to merge 2 commits into
Draft
Fix race condition creating duplicate data sources on fresh databases#2359Flix6x wants to merge 2 commits into
Flix6x wants to merge 2 commits into
Conversation
On a fresh database, concurrent jobs (e.g. several workers computing their first-ever schedules) could each run the get-or-create logic for the same data source, find nothing, and insert duplicate rows. Every subsequent lookup then failed with MultipleResultsFound, permanently wedging all scheduling jobs. The existing unique constraint over (name, user_id, account_id, model, version, attributes_hash) never fired for such rows, because PostgreSQL treats NULLs as distinct and scheduler/forecaster sources have NULL user_id and account_id (and sometimes NULL attributes_hash). This commit: - replaces the NULL-blind unique constraint with a NULL-safe unique expression index (coalescing NULLs to sentinel values), via a migration that first deduplicates existing rows (keeping the lowest id and repointing timed_belief, annotation and legacy power/price/weather rows) - makes both get-or-create code paths (get_or_create_source and get_data_source) insert within a savepoint and re-fetch the winning row on IntegrityError - makes source lookups tolerate remaining (near-)duplicates by deterministically picking the lowest id with a logged warning, instead of raising MultipleResultsFound Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WtuVTVfL4fQ9QSqbLXmAGD Signed-off-by: F.N. Claessen <claessen@seita.nl>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WtuVTVfL4fQ9QSqbLXmAGD Signed-off-by: F.N. Claessen <claessen@seita.nl>
Documentation build overview
4 files changed± changelog.html± genindex.html± _autosummary/flexmeasures.data.services.data_sources.html± api/v3_0.html |
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.
Symptom
On a fresh database, running several concurrent scheduling jobs for the first time (e.g. 10 RQ workers all triggering
StorageSchedulerschedules) can leave thedata_sourcetable with two identical rows (same name, type, model and version). From that moment on, every subsequent scheduling job fails permanently with:because the get-or-create lookup uses
.scalar_one_or_none().Race mechanism
Two workers race through the get-or-create logic (
get_data_sourceinflexmeasures/data/utils.pyandget_or_create_sourceinflexmeasures/data/services/data_sources.py):One would expect the DB to reject the second insert:
data_sourcehas aUniqueConstraint("name", "user_id", "account_id", "model", "version", "attributes_hash"). But PostgreSQL treats NULLs as distinct in unique constraints, and scheduler/forecaster sources have NULLuser_idandaccount_id(and, on theget_data_sourcepath, NULLattributes_hash). So for exactly these sources the constraint never fires, and the duplicate insert succeeds silently.Why fresh installs are affected
On a long-lived database the source row already exists, so the lookup always succeeds and the insert path is never raced. The bug bites exactly where the source does not exist yet: fresh installs, CI environments, and new deployments that start with concurrent scheduling load.
The fix (3 layers)
c9d4f7a21e0b): replaces the NULL-blind unique constraint with a NULL-safe unique expression index, coalescing NULLs to sentinel values that cannot occur in real data (-1for ids,''for model/version, empty bytes for the attributes hash). The migration first deduplicates any existing duplicate rows: the lowest id is kept, andtimed_belief,annotationand (if present) legacypower/price/weatherrows are repointed to it, dropping only beliefs that collide on their full primary key with a belief of the kept source (i.e. duplicate recordings by the same logical source).session.begin_nested()) and, onIntegrityError, roll back to the savepoint and re-fetch the winning row — the standard race-safe get-or-create, which also keeps the enclosing transaction usable.MultipleResultsFound. This lets databases that already contain duplicates (created before this fix, or rows differing only in fields the caller didn't filter on, such as attributes) degrade gracefully instead of failing every job.Test coverage
New tests in
flexmeasures/data/tests/test_data_source.py:test_get_or_create_source_survives_insert_race: forces the losing worker's code path (initial lookup misses while the row exists) and asserts theIntegrityErrorpath re-fetches the winner and no duplicate is created.test_source_lookups_tolerate_duplicates: near-duplicate sources (differing only in attributes) no longer crashget_or_create_source/get_data_sourcelookups that don't filter on attributes; the oldest row is returned.test_exact_duplicate_sources_rejected_by_db: the NULL-safe unique index rejects exact duplicates with NULL key columns (the shape the old constraint let through).Ran
pytest flexmeasures/data/tests/test_data_source.py(19 passed) plustest_queries.py,test_scheduling_jobs.py,test_scheduling_repeated_jobs.pyandtest_utils.py(52 passed) against a sandboxed Postgres.The migration was validated end-to-end on a fresh database (
flexmeasures db upgrade), seeded at the previous head with exact-duplicate scheduler sources plus colliding and non-collidingtimed_belief,annotationand legacypowerrows: duplicates were removed, references repointed as intended, and a downgrade/upgrade roundtrip restores the old constraint and back.🤖 Generated with Claude Code
https://claude.ai/code/session_01WtuVTVfL4fQ9QSqbLXmAGD