Repository navigation
Check vanished jobs in refresh() without nesting key_source in NOT IN - #1674
Open
dimitri-yatsenko wants to merge 3 commits into
Open
dimitri-yatsenko wants to merge 3 commits into
dimitri-yatsenko wants to merge 3 commits into
Conversation
refresh() removed jobs whose keys had left key_source with DELETE ... WHERE key NOT IN (key_source). MySQL materializes that subquery, and when key_source is restricted by two part tables (A * B & A.Part & B.Part) it builds the materialized key_source as the join of both parts: the product of their rows. With 21,087 and 6,407 parts per master and two masters, each refresh() took about 110 s on MySQL 8.0, so a loop that refreshes before every populate() spent minutes per cycle on a key_source of two keys. The cleanup now counts the candidate jobs, then counts the key_source keys restricted to them, with key_source at the top level of the query. Equal counts mean nothing vanished. Otherwise the keys are compared and the vanished jobs deleted by key, in batches. Same reproduction: 0.02 s. test_refresh_cleanup_is_set_based now uses 50 vanished keys, since the cleanup issues a few more statements (still a fixed number).
When the counts show that some candidate jobs have left key_source, _delete_vanished() fetched every candidate key and every present key_source key at once. With keep_completed=True the stale-job step's candidates are all old success jobs, so a single vanished key loaded the whole jobs table into memory. The comparison now reads the candidates one page of VANISHED_DELETE_BATCH keys at a time, ordered by key, and deletes each page's vanished jobs in one statement. Memory is bounded by the page size; the common case (nothing vanished) is still two counts. New tests use a master with two part tables and a key_source that requires rows in both (Master & Master.PartA & Master.PartB): - vanished success and error records are removed at once, a vanished key that still has a target row goes after stale_timeout, and the other jobs are kept; - no statement issued by refresh() nests the part tables inside a NOT IN subquery (fails before the fix on both backends); - with a page size of 2, vanished keys on and inside page boundaries are all removed.
A page of keys that do not match their own values as a restriction (an inexact float32 key, for example) was neither found present nor deleted, and the cursor did not move, so refresh() read the same page forever. The cursor now advances by the rows each page leaves in place, and a test covers a float32 key on both backends. Also drop the test schemas with the adapter's DROP statement, which is DROP SCHEMA ... CASCADE on PostgreSQL, and state the re-restriction guarantee in the docstring precisely.
dimitri-yatsenko
marked this pull request as ready for review
November 10, 2026 17:38
dimitri-yatsenko
requested review from
MilagrosMarin,
lum-agilyti and
ttngu207
November 10, 2026 17:38
This branch has not been deployed
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.
Fixes #1673
Why
Job.refresh()removed jobs whose keys had leftkey_sourcewithDELETE ... WHERE (pk) NOT IN (key_source). Whenkey_sourceis restricted by part tables (for exampleA * B & A.Item & B.Item), MySQL flattens the part restrictions inside that subquery into joins and deduplicates only at the end. It then builds the full join of the part tables before collapsing it to the few keys inkey_source. In the case described in the issue, 2 keys inkey_sourcecost about 270 million intermediate rows and 140 s perrefresh(), against about 1 ms forkey_sourceon its own. Users with akey_sourcerestricted by part tables seerefresh()take minutes, and a loop that refreshes before eachpopulate()pays that on every pass.What
refresh()now go throughJob._delete_vanished(candidates), which never putskey_sourceinsideNOT IN:stale_timeout). If there are none, stop.key_sourceshare the same primary key, count thekey_sourcekeys restricted to the candidates, withkey_sourceat the top level of the query. If the counts are equal, nothing has vanished. This is the common case and costs twoCOUNTqueries.VANISHED_DELETE_BATCH(1000) keys ordered by key. For each page, fetch thekey_sourcekeys restricted to that page and delete the page's other jobs in oneDELETE. Memory is bounded by the page size, even withkeep_completed=True, where 3b's candidates are all old success jobs.test_jobs_refresh.pypass unchanged, except thattest_refresh_cleanup_is_set_basednow uses 50 vanished keys instead of 20, because the cleanup issues a few more (still a fixed number of) statements.Semantics to review:
DELETEper step: it checks first, then deletes. Each page'sDELETEis restricted again by the candidate condition, so a job that has left the candidates in between is kept. For 3a that means a job whose status is no longer success or error; for 3b the condition is onlystatus != 'ignore'and the age, so a job that moved from pending to reserved is still removed, as the old singleDELETEalso did. A key that re-enterskey_sourcebetween the check and theDELETEcan still lose its job record. The nextrefresh()then adds it back as pending. Inside an enclosing transaction, all of these statements run in that transaction.float32key, is neither found present nor deleted: the cursor moves past it, sorefresh()still finishes, but such a job is not removed when its key vanishes (the oldNOT INcompared columns and did remove it).key_sourcehave different primary keys (possible with a customkey_source), the count shortcut does not apply: any candidates are compared page by page, on the attributes the two keys share.How tested
New tests in
tests/integration/test_jobs_refresh_part_restricted.py, run against both MySQL and PostgreSQL through testcontainers (pixi run -e test pytest tests/integration/test_jobs_refresh_part_restricted.py):test_refresh_master_with_two_parts: a master with two part tables andkey_source = Master & Master.PartA & Master.PartB, with four success and two error jobs. After a master is deleted and a part of each of two others,refresh()removes the vanished success and error records at once. The vanished key whose target row remains is removed afterstale_timeout. All other jobs are kept.test_refresh_never_negates_key_source: records every statementrefresh()issues and asserts that noNOT IN (...)subquery mentions the part tables. It fails on master on both backends and passes with this change.test_refresh_moves_past_keys_that_do_not_match_themselves:float32keys with a page size of 3, where the first page cannot be matched by value;refresh()moves on and removes the vanished key on the next page. It fails (reads the same page forever) without the cursor fix.test_refresh_pages_through_candidates: with a page size of 2, vanished keys at page boundaries and inside pages are all removed and the rest kept.test_refresh_removes_jobs_of_vanished_keys: the same checks forkey_source = A * B & A.Item & B.Item.test_refresh_does_not_join_the_parts(MySQL only): two masters with 3,000 parts each and one master with 2,000 parts. MySQL's materialization strategy is forced throughoptimizer_switch, since at this size it may otherwise choose FirstMatch. On masterrefresh()takes about 10 s and the test fails (limit 2 s). With this change it passes.The full job suites (
test_jobs.py,test_jobs_refresh.py,test_jobs_refresh_part_restricted.py,test_hidden_job_metadata.py,test_autopopulate.py) pass on both backends: 67 passed, 3 skipped (MySQL-only tests on PostgreSQL). On the dataset from the issue (2 keys, part tables of about 42,000 and 6,400 rows),refresh()went from 140 s to 0.01 s with the same result.