Repository navigation
Share one race-safe find_or_create helper (locking re-read) across 15 models - #897
Merged
Merged
Conversation
|
ArchLens - No architecturally relevant changes to the existing views |
Fifteen models hand-rolled the same SELECT, then INSERT in a savepoint, then re-query on IntegrityError. Most re-queried with a plain SELECT, which under REPEATABLE READ reads the transaction's old snapshot, misses the winner's row and raises NoResultFound. The helper re-reads with a locking read (LOCK IN SHARE MODE), as UserWord already did. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#893 hand-rolled the same savepoint + locking-read recovery; now that it is merged it can use race_safe.find_or_create like the other models. Only behaviour difference: the old copy also committed after recovering the other request's row; every caller passes commit=False, so none sees it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mircealungu
force-pushed
the
refactor/race-safe-find-or-create
branch
from
October 25, 2026 16:43
2100dfe to
1ce1630
Compare
This was referenced Oct 25, 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.
Fifteen models each had their own copy of the same race-safe
find_or_create. Fourteen of those copies had the bug that #893 fixed forArticleCefrAssessment. This PR moves them all onto one helper,zeeguu/core/model/race_safe.py, which does not have the bug. #893 (ArticleCefrAssessment) is now merged, and its hand-rolled copy moves onto the helper too, in a separate commit (16 models in total).1. The race
Every copy did the same three steps:
SELECTthe row (e.g. the(bookmark, article)summary context).INSERTit insidesession.begin_nested(), which is aSAVEPOINT.INSERTfails withIntegrityError,SELECTagain and return the row that the other request created.Here is what happens when two requests create the same row at once. MySQL runs InnoDB at REPEATABLE READ:
SELECTin A's transaction sees the database as it was then.SELECTreads the snapshot, so it doesn't see B's row. It goes on to insert.INSERTis checked against the real, current table, not the snapshot. B's row is there, so the unique key rejects it:1062 Duplicate entry. Because the insert ran inside the savepoint, only that insert is rolled back. Whatever A had pending stays, and the session can still be used. Without the savepoint, a failed flush would leave the whole session needing a rollback, and that rollback would discard the caller's other changes.SELECTreads the same snapshot again. It still doesn't see B's row, so.one()raisesNoResultFound. The request fails with a 500.The fix for step 3 is a locking read:
SELECT ... LOCK IN SHARE MODE, written in SQLAlchemy as.with_for_update(read=True). A locking read always reads the latest committed version of a row, not the snapshot, so it finds B's row.UserWord.find_or_createalready did this, and #893 did the same forArticleCefrAssessment.Reproduced on real MySQL
I wrote a script for local MySQL
zeeguu_test(REPEATABLE-READ) with two connections. Connection A reads (so its snapshot is taken). Connection B inserts and commits the row. Then A callsLevelAdaptedArticleSummaryContext.find_or_create(commit=False). All test rows were deleted afterwards.OK returned <LevelAdaptedArticleSummaryContext als:4, b:812214>, and the table has exactly one row for the pair.(I first tried
ArticleSummaryContext, which has the same code. But localzeeguu_testdoesn't have its unique key yet, because migration26-08-13--add_unique_bookmark_context_joins.sqlwas never applied there. Without the key, both inserts went through and the table got two rows. So I used the level-adapted summary context, whose key does exist locally.)2. The helper, line by line
filtersis passed tofilter_by, so it can hold relationship objects (dict(bookmark=bookmark, article=article)) or ids (dict(article_id=article_id)). This matches what the call sites already filtered on.makeis a function that builds the new object. It is called only when the row is missing.one_or_none(): found, so return it. If there are duplicates it raisesMultipleResultsFound, just like the old.one()/.one_or_none()did.begin_nested()opens a savepoint. When the block exits, it flushes theINSERT. If the flush fails, it rolls back to the savepoint, so only this insert is lost (see section 1).make()runs inside the block. That way the new object can't be flushed bybegin_nested()'s own pre-flush, which runs outside the savepoint. Some old copies built the object before thewith.except IntegrityError: we lost the race, so we take the winner's row with the locking read. The comment explaining the snapshot is in the helper now, in place of the copies that were in each file.commit: commits only when this call created the row. A row that was found, or recovered after losing the race, is returned without a commit. All the old copies behaved this way.3. Migrated sites
Before/after for one of them,
ArticleSummaryContext.find_or_create:Signatures are unchanged everywhere. The rest of the migrated sites:
commitpassedArticleTitleContext,ArticleFragmentContext,ExampleSentenceContext,VideoTitleContext,VideoCaptionContext,LevelAdaptedArticleSummaryContext,LevelAdaptedArticleTitleContextcommit(default True)SearchFilter,SearchSubscription,TopicFilterTrueTopicSubscription,UserLanguageUserWordTrueis_user_addedupdate after the lookup is kept unchangedArticlePublicCode.for_articleException; now it catchesIntegrityErrorlike the restI removed the
import sqlalchemy/IntegrityErrorimports that were no longer used.4. Not migrated, and why
PublicTranslation.store,AudioLessonRefusal.record: these use a savepoint but are not find-or-create. They insert, and if they lose the race they just drop their own row (except: pass). They never re-read, so they don't have the stale-snapshot bug.url.py,text.py,domain_name.py,source_text.py,new_text.py,phrase.py,user_preference.py. These handle the race by committing or rolling back the whole session in retry loops. Moving them to the helper changes their transaction behaviour, so each needs its own look.5. Why this is safe
NoResultFound.UserWordalready used the locking read, so its behaviour is the same as before.ArticlePublicCodenow catchesIntegrityErrorand no longer catches everyException. Any other error (e.g. a lock timeout) used to fall through to a re-query that would have found nothing anyway.LOCK IN SHARE MODEholds a shared lock on one row until the transaction ends, and only on the losing side of a race that already failed. The happy path does no locking read.6. How it was tested
New
zeeguu/core/test/test_race_safe.py:make;commit=Truecommits a created row but not a found one;The tests run on SQLite, which ignores
with_for_updateand has no snapshot. They cover the savepoint and recovery path. The locking read itself is covered by the MySQL reproduction above.The existing
test_bookmark_context_uniqueness.pypasses.MySQL reproduction: before and after results in section 1.
Full suite:
1289 passed, 18 skipped.Added after #893 merged
The branch is rebased on master. Commit
1ce16308replaces the copy inArticleCefrAssessment.find_or_createwith a call to the helper. There is one difference: the old copy also committed after recovering the other request's row, and the helper doesn't. All six callers passcommit=False(four inarticle.py, two inendpoints/cefr_assessment.py), so none of them can notice. Full suite after the rebase: 1281 passed, 17 skipped.🤖 Generated with Claude Code