Stop using the coveragerecords table (PP-4653) - #3744
Conversation
|
Claude finished @dbernstein's task in 2m 57s —— View job SummaryThe PR does what it says. It removes the
The one real issue is in the new lock-timeout test: it passes on this branch but will fail once the stacked #3521 is rebased onto it. DetailsMinor:
|
|
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #3744 +/- ##
==========================================
+ Coverage 93.71% 93.74% +0.02%
==========================================
Files 510 510
Lines 46515 46409 -106
Branches 6314 6299 -15
==========================================
- Hits 43593 43507 -86
+ Misses 1888 1874 -14
+ Partials 1034 1028 -6 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
d67aa37 to
8f46bc2
Compare
|
Both points addressed in 1.
|
| result | |
|---|---|
#3744 vs the #3520 release (7a3274e) |
3346 passed ✅ |
| #3521 vs #3744 | 3391 passed ✅ |
Plus tests/migration/ + tests/manager/sqlalchemy/ + tests/manager/data_layer/ at 664 (this PR) and 665 (#3521), mypy and pre-commit clean on both. #3521 was rebased onto this branch as well, so the stack still applies cleanly.
Thanks — the second one in particular was a real hazard, not just dead weight.
5197add to
15eab5d
Compare
|
Good catch, and the leak is real — fixed in I confirmed the premise before changing anything: Applied exactly as suggested: op.execute("SET LOCAL lock_timeout = '5s'")
op.execute("TRUNCATE TABLE coveragerecords")
# Scope the timeout to the statement above; see the module docstring.
op.execute("SET LOCAL lock_timeout = DEFAULT")The module docstring now spells out why the reset is there — that it's transaction-scoped rather than revision-scoped, and which two cases would otherwise inherit it (an instance upgrading across this release and the stacked #3521 drop in one pass, or any revision added before this ships) — so it doesn't look redundant to whoever reads it next.
|
15eab5d to
8ca6fa8
Compare
tdilauro
left a comment
There was a problem hiding this comment.
The code here looks good. 🎉
There is an assertion about the migration retrying that doesn’t seem correct and what I think is a missing test.
There are a few comments about some of the comments. In some cases they seem excessively long and more detailed that needed or make references to code that has been removed in the PR and, thus, will not be helpful to future readers.
| short, but ``lock_timeout`` bounds it: the migration fails fast and is retried rather | ||
| than blocking instance startup. |
There was a problem hiding this comment.
the migration fails fast and is retried rather than blocking instance startup.
What retries the migration, if it fails? It seems like there would be no instance startup in that case. It would just fail. In that case we’d need to redeploy it.
There was a problem hiding this comment.
You are right, and it was worse than imprecise. Nothing retries it — and it does not just get logged past either: initialize_database catches CommandError, but a lock_timeout abort surfaces as OperationalError, which is not caught, so instance init fails and the deploy has to be re-run exactly as you describe.
Reworded to claim only what the timeout actually buys — a bounded, loud failure instead of a TRUNCATE that queues ahead of every reader of the table and stalls them indefinitely:
With it, the statement gives up after 5s and the migration fails -- nothing retries it, so the deploy has to be re-run, which is the better failure: loud and bounded rather than a spreading stall.
|
|
||
| Everything else on this mixin went with the queries it supported; it is | ||
| removed along with those models and their tables in the follow-up PR. |
There was a problem hiding this comment.
I don’t think we should reference stuff that went away. This would be a helpful comment on the PR itself, but just adds noise to the codebase.
There was a problem hiding this comment.
Agreed — that is PR commentary, not something a future reader of this file needs. Cut to a single line:
class BaseCoverageRecord:
"""Holds the ``coverage_status`` enum shared by the two dormant coverage models."""|
|
||
| Removing those relationships is what actually stops the reads: a mapped | ||
| relationship is loaded by SQLAlchemy whenever its parent is deleted (to | ||
| cascade the delete, or to null the child's foreign key), so while they | ||
| existed every ``session.delete(collection)`` still SELECTed from this table. | ||
|
|
||
| The model is kept for one more release because the schema of a freshly | ||
| initialized database is built with ``create_all`` from these models, not by | ||
| replaying migrations -- dropping the class would remove the table from new | ||
| installs immediately, while N-1 app servers still expect it. The model and | ||
| the table are removed together in a follow-up PR that ships after this | ||
| release. | ||
|
|
||
| Only the columns remain. The query and write helpers (``lookup``, ``add_for``, | ||
| ``bulk_add`` and friends) are gone: nothing called them, and with the parent | ||
| relationships removed a row they wrote could no longer be cleaned up when its | ||
| Identifier, DataSource or Collection is deleted -- it would just make that | ||
| delete fail on a foreign key. Keeping the class a bare table definition makes | ||
| it impossible to write such a row. |
There was a problem hiding this comment.
Minor - This probably doesn’t need four paragraphs. But it’s okay if it will all go away in the final PR of this series.
There was a problem hiding this comment.
Trimmed rather than left for the final PR — same reasoning as your other comment, and it is cheap now. Four paragraphs down to one, keeping only why the class still exists and why it is columns-only, dropping the history of what was removed.
There was a problem hiding this comment.
I don’t see a test that verifies that the lock timeout gets reset. If it’s not reset, it could affect later migrations.
There was a problem hiding this comment.
Added, and it is a real one rather than a passing assertion — thank you for pushing on this.
The catch is that a test driven through alembic_runner cannot fail: it commits between revisions, so the setting is discarded regardless of what the migration does. The new test runs upgrade() against a transaction it holds open itself, which is the situation env.py actually creates, then asserts lock_timeout is unchanged.
I checked it fails when the reset is removed, rather than assuming:
> assert after == before
E AssertionError: assert 5s == 0
|
Thanks @tdilauro : I will make the suggested changes before merging. |
The CoverageProvider machinery that read and wrote coveragerecords was retired last release, but the table was not actually dormant: Identifier, DataSource and Collection still mapped a coverage_records relationship. SQLAlchemy loads a relationship whenever its parent is deleted -- to cascade the delete, or to null the child's foreign key -- so every session.delete() on one of those parents still SELECTed from coveragerecords. Dropping the table while those relationships existed would break N-1 app servers during a rolling deploy. - Remove the coverage_records relationships from Identifier, DataSource and Collection, and the matching back_populates on CoverageRecord. - Empty coveragerecords in a migration. Its foreign keys carry no ON DELETE clause, so with the relationships gone a surviving row would make deleting its parent fail; the rows are dead data, so TRUNCATE is cheaper than teaching a soon-to-be-dropped table to cascade. The TRUNCATE runs under a lock_timeout: N-1 servers still read the table, so its ACCESS EXCLUSIVE lock can queue behind an in-flight parent delete and stall every later reader -- failing fast and retrying beats blocking instance startup. The timeout is reset right afterwards: SET LOCAL lasts to the end of the transaction, and env.py runs every pending revision in one transaction, so without the reset later revisions in the same upgrade would inherit a timeout they never asked for. - Reduce CoverageRecord to a bare table definition. Nothing called lookup, add_for, bulk_add or the helpers around them, and with the parent relationships gone a row they wrote could no longer be cleaned up when its parent is deleted -- it would just make that delete fail on a foreign key. Dropping them removes the footgun, along with BaseCoverageRecord.not_covered and the constants that only fed it. - Remove the CoverageRecord tests and the db.coverage_record fixture, so the next release's backwards-compatibility gate does not run them against a schema where the table is gone. The CoverageRecord and EquivalencyCoverageRecord models stay for one more release: a fresh database's schema is built with create_all from the models rather than by replaying migrations, so deleting the classes now would drop the table out from under N-1 servers on new installs. The models and the tables are removed together in the stacked follow-up. equivalentscoveragerecords needs no such change -- it has no ORM relationships and its one foreign key already declares ON DELETE CASCADE. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…eset
Tim's review points:
- The migration docstring claimed a failed migration "is retried rather than
blocking instance startup". Nothing retries it, and a lock_timeout abort
raises OperationalError, which initialize_database does not catch (it only
catches CommandError), so the deploy does fail and has to be re-run. Say
that instead: the timeout buys a loud, bounded failure rather than a
TRUNCATE that queues ahead of every reader and stalls them indefinitely.
- Drop the BaseCoverageRecord docstring's account of what was deleted, and cut
the CoverageRecord docstring from four paragraphs to one. Explaining removed
code is PR commentary, not something a future reader of this file needs.
- Add a regression test for the lock_timeout reset. Driving the migration
through alembic_runner cannot catch a leak, because it commits between
revisions and discards the setting either way; the test runs upgrade()
against a transaction it holds open, which is what env.py actually does.
Verified it fails ('5s' != '0') with the reset removed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
d2fa83c to
664289f
Compare
Description
Release-1 half of retiring the
coveragerecordstable, split out of #3521.#3520 retired the CoverageProvider machinery and left the
CoverageRecordmodel "dormant" for a release. It wasn't dormant:Identifier,DataSourceandCollectionstill mapped acoverage_recordsrelationship, and SQLAlchemy loads a relationship whenever its parent is deleted — to cascade the delete (mapper.cascade_iterator) or to null the child's foreign key (dependency.presort_deletes). So everysession.delete()on one of those parents still SELECTed fromcoveragerecords.This PR actually stops using the table:
coverage_recordsrelationships fromIdentifier,DataSourceandCollection, and theidentifier/data_source/collectionrelationships that pointed back at them fromCoverageRecord.CoverageRecordto a bare table definition — columns,__table_args__and the trailing module-levelIndex.lookup,add_for,bulk_add,assert_coverage_operation,human_readableand__repr__are gone, along with the five*_OPERATIONconstants that only fed them, andBaseCoverageRecord.not_coveredwith theALL_STATUSES/PREVIOUSLY_ATTEMPTED/DEFAULT_COUNT_AS_COVEREDlists that only fed it. Nothing called any of them, and they had become a hazard rather than merely dead: with the parent relationships gone, a row written byadd_for/bulk_addcould no longer be cleaned up when its parent is deleted, so it would make that delete fail on a foreign key.status_enumand the four status constants stay — they are the column type for both dormant models.coveragerecordsin a migration. Its foreign keys carry noON DELETEclause, so with the relationships gone SQLAlchemy no longer cleans up children by hand and a surviving row would make deleting its parent fail with a foreign-key violation. The rows are dead data, soTRUNCATEis cheaper than teaching a table we drop next release to cascade. It runs underSET LOCAL lock_timeout = '5s': N-1 servers still read the table, soTRUNCATE'sACCESS EXCLUSIVElock can queue behind an in-flight parent delete and block every later reader — failing fast and retrying beats stalling instance startup.CoverageRecordtests and thedb.coverage_recordfixture, so the next release's backwards-compatibility gate doesn't run them against a schema where the table is gone.The
CoverageRecordandEquivalencyCoverageRecordmodels stay for one more release. A fresh database's schema is built withcreate_allfrom the models rather than by replaying migrations (InstanceInitializationScript.initialize_database_schema), so deleting the classes now would drop the table out from under N-1 servers on new installs. The models and the tables go together in the stacked follow-up, #3521.equivalentscoveragerecordsneeds no change here — it has no ORM relationships pointing at it and its one foreign key already declaresON DELETE CASCADE.Motivation and Context
JIRA (PP-4653)
This is the missing middle step in the CoverageProvider retirement. The original plan was #3520 (stop using) → #3521 (drop), but #3520 did not stop the ORM-level reads, so the drop in #3521 would have broken N-1 webservers on any collection or identifier delete during a rolling deploy.
Splitting at the relationship rather than at the model is what makes the sequence work: release 1 (this PR) removes the reads but keeps the table in fresh schemas; release 2 (#3521) removes the model and the table together.
How Has This Been Tested?
mypyclean;pre-commitclean.tests/migration/,tests/manager/sqlalchemy/andtests/manager/data_layer/pass undertox -e py312-docker(664 tests), including a new migration test covering the truncate and confirming the table itself survives this release.relation "coveragerecords" does not exist, 24 of them purely from the relationships being walked on parent deletes.Checklist
🤖 Generated with Claude Code