Repository navigation
Url, DomainName, NewText, SourceText: use race_safe.find_or_create - #917
Merged
Merged
Conversation
The migration that created both tables gave each a UNIQUE HASH_INDEX on content_hash (tools/migrations/25-02-28--01-new-content-tables copy.sql), but the models never declared it, so the create_all test DB lacked it. No effect on existing databases: create_all doesn't alter existing tables. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Their find_or_creates recovered from a lost insert race with a full session.rollback() and a retry loop, which threw away whatever the caller had pending. The shared helper rolls back only a savepoint around its own insert and fetches the winner's row with a locking read. Each keeps its signature and commit behaviour: Url and DomainName commit a row they create; NewText and SourceText do so when commit is set (the default). Url still returns None for a path over 255 chars and resolves the domain first. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
NewTextRule and SourceTextRule built NewText(content)/SourceText(content) directly. With HASH_INDEX now declared, the test DB enforces the unique content_hash, so a second rule for the same text (e.g. BookmarkContextRule called twice with one article's content) would raise IntegrityError. They now go through find_or_create, as production code does. Co-Authored-By: Claude Opus 5.5 <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.

Moves four of the seven older
find_or_creates ontorace_safe.find_or_create(#897):Url,DomainName,NewText,SourceText. Refactor only. Three more (Phrase,UserPreference,Text) are left alone, for the reasons in section 3.1. What the old pattern did wrong
DomainName.find_or_createon master (Url,NewText,SourceTexthave the same shape):session.rollback()then rolls back the loser's whole transaction, including anything its caller had changed but not yet committed. The caller isn't told and carries on. Its objects are now expired, and its changes are gone.DomainNameswallows every exception with a bareexcept:, andUrlcan fall out of the loop and returnNone.LOCK IN SHARE MODE), which sees the latest committed row.commit=Falsewasn't race-safe at all (NewText,SourceText). Withcommit=Falsethey onlysession.add()the new row and never flush, so theexcept IntegrityErrorcan't fire. A lost race came back as an IntegrityError at the caller's later commit, outside any recovery.The helper rolls back only a savepoint around its own insert, then gets the winner's row with a locking read. The caller's pending work stays in the session.
2. Per site
DomainName.find_or_create(session, url)domain_name; add + commit; on IntegrityError: full rollback, retry up to 10×find_or_create(session, cls, {"domain_name": _domain}, …, commit=True)Url.find_or_create(session, url, title)Noneif path > 255; find by (path, domain); add + commit; on IntegrityError: full rollback, re-resolve domain, retry up to 10×, Sentry message per failed roundfind_or_create(session, cls, {"path": path, "domain": domain}, …, commit=True)prints and the "Exception of second degree in url" Sentry message go away, along with the loop they were inNewText.find_or_create(session, text, commit=True)long_hash; add, commit ifcommit; full rollback + retryfind_or_create(session, cls, {"content_hash": long_hash(clean_text)}, …, commit=commit)commit=Falsethe new row is now flushed (inside the savepoint), so it has anidstraight away, and a lost race is recovered rather than failing at the caller's commitSourceText.find_or_create(session, text, commit=True)text_hashtext_hashEvery unique key matches its
filters, which the helper needs:domain_name.domain_name:unique=Truein the model.url (path, domain_name_id):_path_domain_unique_constraintin the model (local MySQL calls itunique_domain_and_path).new_text.content_hash,source_text.content_hash:UNIQUE HASH_INDEXfromtools/migrations/25-02-28--01-new-content-tables copy.sql. The models never declared it, so the SQLite test DB didn't have it. The first commit declares it, the wayUserWorddeclaresunique_user_word.create_alldoesn't alter existing tables, so the declaration has no effect on prod.On my local
zeeguu_test(MySQL) all four keys exist, and the four lookup columns have binary collations (utf8mb3_bin/utf8mb4_bin). TheWHEREand the key therefore agree on what counts as equal. I didn't check prod. If prod lacked one of these keys, the helper would act like the old code (no IntegrityError, so a duplicate row), so nothing gets worse. Worth a look:SHOW INDEX FROM new_text/source_text.3. Skipped sites
Phrase.find_or_create: needs a decision, not a refactor.25-05-21--rename_user_word_to_phrase.sqldroppedunique_word_language, and local MySQL has only non-unique indexes onphrase. The model'sUniqueConstraint(content, language_id)exists only in the SQLite test DB. In prod, two racing requests would both insert a row and get no IntegrityError, so the helper has nothing to recover.Phrase.find: case-insensitive oncontent_lower, then a Python re-check withMeaning._canonicalize. Afilter_bydict can't express that. The column's collation (utf8mb4_unicode_ci) also ignores accents, so a unique key on it would makeaccompagneandaccompagnécollide._calculate_rank_async(new.id)after the commit.Deciding what a phrase's identity is (and whether to add a key) is a separate question.
UserPreference.find_or_create: no unique key on(user_id, key)in the model or in local MySQL (only a non-uniqueuser_idindex). A race therefore inserts a duplicate preference and never raises IntegrityError, so the retry loop is dead code and the helper would add nothing. A fix needs a prod duplicate check, a dedupe and a migration. Drop what nobody reads of user_preference #905 / c6b0918 reworkeduser_word.user_preference, a different thing; this model wasn't touched.Text.find_or_create: the legacytexttable. Text migration: stop dual-write to legacytexttable (Deploy 1/3) #650 (open) stops writing to it. It also doesn't fit the helper. The lookup filters on(content_hash, article), but local MySQL's unique key is oncontent_hashalone. When the same sentence already exists for another article, the helper would re-raise the IntegrityError, where the old fallback (content_hashonly) returns the other article's row. Changing behaviour in code that's being retired isn't worth it.4. Why this is safe
Commit behaviour. The helper's
commit=Truecommits only when it created the row. The old code was the same: it committed after creating, never after finding, and on the race path it rolled back without committing. So the found and created paths behave as before. Only the race path differs, as described above.Callers.
Url.find_or_createhas about 30 call sites (crawler, article/upload/translate endpoints,starred_article,video,yt_channel,skipped_url, tools). Some rely on the commit-on-create, e.g.save_translated_article's docstring andendpoints/article.py("Url.find_or_create commits, and must not run inside save_translated_article's savepoint"). That's kept. They all use the returned.idor relationship, which a created, found or recovered row all have.DomainName.find_or_create:Url.find_or_createandtools/fix_feed.py. Commit-on-create is kept.NewText.find_or_create:Caption.create(commit=False),ArticleFragment.find_or_createandBookmarkContext.find_or_create(passcommitthrough).SourceText.find_or_create:Source.find_or_create(passescommitthrough).commit=True(the default) nothing changes. Withcommit=False(article fragments while building an article,Sourcefrom the crawler/upload paths, captions) the row is now flushed early. It's still in the caller's transaction, so it's still undone if the caller rolls back.ArticleFragment.find_or_create(commit=False)filters ontext_id=text_row.id. For a brand-new text that used to beNone(text_id IS NULL, no match, so a new fragment). Now it's the real id, which no committed fragment can reference yet, so the result is the same (a new fragment).5. How it was tested
zeeguu/core/test/test_legacy_find_or_create_races.py, one test per site. The row already exists, the site's first lookup is forced to miss (as on MySQL when the loser's snapshot is older than the winner's commit), and the caller has a pending change (a user's name). Each test checks three things: the loser gets the winner's id, only one row exists, and the pending change survives the commit. One more test checks thatUrlstill returnsNonefor a path over 255 chars.HASH_INDEXdeclarations applied, so SQLite has the keys) all four race tests fail:DomainNameandUrllose the caller's change (assert 'George Wright' == 'changed by the caller').NewTextandSourceText(commit=False) return an unflushed duplicate (assert None == 1).test_cloze_alternatives.py::test_a_translation_verdict_is_not_a_cloze_verdict, which is flaky on master too. Run alone 20 times, it failed on the 5th run here and the 7th on master (True is not false).zeeguu_test, REPEATABLE READ),Url. Request A starts its snapshot and changes another url's title (pending). Request B inserts and commits A's url on a separate connection. A's plain SELECT can't see B's row. Then A callsUrl.find_or_create:doing a rollback … Found url after recovering from race. It returns the winner, but the pending title is lost ('original title').'changed by the caller').Review follow-up
A code review found that the test rules
NewTextRuleandSourceTextRulebuiltNewText(content)/SourceText(content)directly. WithHASH_INDEXnow declared, the test DB enforces the uniquecontent_hash, so a second rule for the same text would raiseIntegrityError. They now go throughfind_or_create. A new test,test_text_rules_reuse_the_row_for_the_same_content, fails without that change. Full suite: 1323 passed, 17 skipped.Prod indexes were checked on 25 Oct, and all four keys exist and match the lookups:
url.unique_domain_and_path (domain_name_id, path),domain_name.domain_name,new_text.HASH_INDEX,source_text.HASH_INDEX.🤖 Generated with Claude Code