Skip to content

Drop coveragerecords and equivalentscoveragerecords tables (PP-4653) - #3521

Draft
dbernstein wants to merge 2 commits into
mainfrom
chore/drop-coverage-tables
Draft

dbernstein wants to merge 2 commits into
mainfrom
chore/drop-coverage-tables

Conversation

@dbernstein

@dbernstein dbernstein commented Jun 30, 2026 •

Copy link
Copy Markdown
Contributor

Description

Follow-up to #3744 ("Stop using the coveragerecords table"), now merged. With no running code mapping or reading the coverage tables, this PR removes the CoverageRecord and (already-dormant) EquivalencyCoverageRecord models and drops the coveragerecords and equivalentscoveragerecords tables along with their shared coverage_status enum.

It also removes the now-orphaned BaseCoverageRecord mixin. Its only remaining user was the workcoveragerecords-removal migration (01b1e464a9d1), which imported it solely for the coverage_status enum values; that migration is now self-contained (the enum values are inlined), so the mixin can go. coverage.py is left containing only the Timestamp model.

Timestamp and its separate service_type enum are retained.

One test from #3744 is adapted here. test_upgrade_does_not_leak_lock_timeout ran the truncate migration against the schema create_all builds from the models — which no longer contains coveragerecords once this PR deletes the class. It now steps back to that revision first, whose downgrade recreates the table. The test still earns its place after this release: a database older than the truncate revision upgrades through it and this drop in a single transaction, which is exactly the leak the lock_timeout reset guards against.

Rescoped. This PR previously also removed the coverage_records relationships and the model's tests, and was based directly on #3520. Both of those moved to #3744.

Motivation and Context

JIRA (PP-4653)

This is the final step of the CoverageProvider retirement, which became a three-release sequence rather than two:

  1. Retire the CoverageProvider machinery (PP-4468) #3520 — retire the CoverageProvider machinery (the code that used the records).
  2. Stop using the coveragerecords table (PP-4653) #3744 — remove the coverage_records relationships, empty the table, drop the model's tests. This is what actually stops the reads.
  3. This PR — remove the models and drop the tables.

The middle step turned out to be necessary. #3520 left the models in place as "dormant", but the coverage_records relationships on Identifier/DataSource/Collection meant SQLAlchemy still SELECTed from coveragerecords on every parent delete. Running the backwards-compatibility gate against the #3520 release proved it: 30 tests failed with relation "coveragerecords" does not exist, 24 of them from relationship walks on collection and identifier deletes rather than from any direct use of the model.

The split has to fall between the relationship and the model, not around the model, because a fresh database's schema is built with create_all from the models rather than by replaying migrations — so removing the classes any earlier would drop the table out from under N-1 servers on new installs.

⚠️ Draft until the release containing #3744 has shipped. #3744 is merged but not yet released; merging this before that release goes out would break N-1 webservers, which still map these tables.

How Has This Been Tested?

  • mypy clean; pre-commit clean.
  • The migration round-trips on a real Postgres: upgrade drops both tables and the coverage_status enum (leaving timestamps / service_type intact); downgrade recreates both tables with identical indexes, constraints, and foreign keys; a second upgrade drops them again.
  • tests/migration/, tests/manager/sqlalchemy/ and tests/manager/data_layer/ pass under tox -e py312-docker (666 tests), incl. pytest-alembic single-head / up-down-consistency / model-matches-DDL checks and a drop test. The 01b1e464a9d1 down-migration still recreates workcoveragerecords correctly with its inlined enum.
  • The adapted lock_timeout test was re-checked by mutation: with the reset removed from the truncate migration it fails (assert '5s' == '0'), so it still catches the leak rather than passing vacuously.
  • Backwards compatibility verified locally against main, which now contains Stop using the coveragerecords table (PP-4653) #3744 and is what N-1 will be:
    ./docker/ci/test_backwards_compatibility.sh <main>
    → 3399 passed — "The previous release works against the current schema 🎉"
    

Checklist

  • I have updated the documentation accordingly.
  • All new and existing tests passed.

🤖 Generated with Claude Code

@dbernstein dbernstein added the DB migration This PR contains a DB migration label Jun 30, 2026
@claude

claude Bot commented Jun 30, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @dbernstein's task in 2m 44s —— View job


Code review

  • Gather context (PR diff, CLAUDE.md, preceding 58ebd34c5092 migration)
  • Review migration 5ca948100902
  • Review changes to 01b1e464a9d1 migration
  • Review model changes in coverage.py
  • Review tests
  • Post review

Summary

This final removal step looks correct. Outside the alembic revisions, nothing in src/ or tests/ still refers to CoverageRecord, EquivalencyCoverageRecord, BaseCoverageRecord or coverage_status. The unused Index import is gone from coverage.py. With its enum values inlined, 01b1e464a9d1 is self-contained. The downgrade matches the removed model definitions: every index including the partial unique one, the ON DELETE CASCADE FK, equivalency_id NOT NULL, and the unique constraint. The test update for 58ebd34c5092 (step back down before the TRUNCATE) is also right. What's left is deploy-time locking on the drop and one test gap.

Details

Minor: alembic/versions/20260916_5ca948100902_drop_coveragerecords_and_.py:42-81

Each DROP TABLE also takes ACCESS EXCLUSIVE on every table it points to by foreign key: identifiers, datasources, collections and equivalents. Postgres needs that lock to remove the FK triggers it installed on those tables. This only bites if, when the deploy runs, some long-running transaction (a Celery import, a report) holds any lock on one of them. In that case the drop waits with no limit, and meanwhile every new N-1 query on identifiers or collections queues behind it. The 58ebd34c5092 docstring guards TRUNCATE against exactly this stall, and these tables are much busier than coveragerecords. I'd suggest the same bound:

def upgrade() -> None:
    op.execute("SET LOCAL lock_timeout = '5s'")
    ...
    coverage_status.drop(op.get_bind(), checkfirst=False)
    # Scope the timeout to this revision; see 58ebd34c5092.
    op.execute("SET LOCAL lock_timeout = DEFAULT")

def upgrade() -> None:
op.drop_index(
op.f("ix_equivalentscoveragerecords_equivalency_id"),
table_name="equivalentscoveragerecords",
)
op.drop_index(
op.f("ix_equivalentscoveragerecords_operation"),
table_name="equivalentscoveragerecords",
)
op.drop_index(
op.f("ix_equivalentscoveragerecords_status"),
table_name="equivalentscoveragerecords",
)
op.drop_index(
op.f("ix_equivalentscoveragerecords_timestamp"),
table_name="equivalentscoveragerecords",
)
op.drop_table("equivalentscoveragerecords")
op.drop_index(
op.f("ix_coveragerecords_data_source_id_operation_identifier_id"),
table_name="coveragerecords",
)
op.drop_index(op.f("ix_coveragerecords_exception"), table_name="coveragerecords")
op.drop_index(
op.f("ix_coveragerecords_identifier_id"), table_name="coveragerecords"
)
op.drop_index(op.f("ix_coveragerecords_status"), table_name="coveragerecords")
op.drop_index(op.f("ix_coveragerecords_timestamp"), table_name="coveragerecords")
op.drop_index(
"ix_identifier_id_data_source_id_operation", table_name="coveragerecords"
)
op.drop_index(
"ix_identifier_id_data_source_id_operation_collection_id",
table_name="coveragerecords",
)
op.drop_table("coveragerecords")
# The coverage_status enum was used only by the two tables just dropped.
coverage_status.drop(op.get_bind(), checkfirst=False)

Minor: tests/migration/test_20260916_5ca948100902_drop_coverage_tables.py:34-38

The docstring says the test covers the shared coverage_status enum, but nothing checks it. If coverage_status.drop(...) were deleted, this test would still pass. So would all the enabled pytest-alembic built-ins: autogenerate doesn't compare enum types, and test_up_down_consistency only runs the downgrade from a stamped head. The type would just be left behind silently. I'd add an assertion after the upgrade, and optionally the reverse after the step down:

with alembic_engine.connect() as connection:
    assert not connection.execute(
        text("SELECT 1 FROM pg_type WHERE typname = 'coverage_status'")
    ).first()

tables = set(inspect(alembic_engine).get_table_names())
assert "coveragerecords" not in tables
assert "equivalentscoveragerecords" not in tables
# The unrelated timestamps table is left in place.
assert "timestamps" in tables

Nit: tests/migration/test_20260916_58ebd34c5092_empty_coveragerecords.py:89-91

The comment says to step back to this revision, "whose downgrade recreates it". But 58ebd34c5092's own downgrade is a pass; the table actually comes back from the next revision's downgrade (5ca948100902). Suggested wording: "Step back to this revision; undoing the next revision's drop recreates it."

# The next revision drops coveragerecords, so it is absent from the schema
# built from the current models. Step back to this revision, whose
# downgrade recreates it, to give the TRUNCATE something to act on.


I didn't run the test suite here: starting a local Postgres container needed approval this job didn't have. The locking point above comes from how Postgres drops FK triggers, not from a live run.

@greptile-apps

greptile-apps Bot commented Jun 30, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 5/5

[Critical risk] Database migration drops two tables and an enum type.

The PR appears safe to merge once the preceding release that stops using the coverage tables has shipped.

Summary

This PR removes the two dormant coverage models, their tables, and the shared coverage_status enum. It makes the older work-coverage migration independent of the removed mixin and adds migration tests. The drop must remain sequenced after the release that stopped using the tables.

Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A[Prior release stops table use] --> B[Drop migration removes both tables]
  B --> C[Drop shared coverage_status enum]
  B --> D[Keep timestamps and service_type]
Loading

Reviews (5) · Last reviewed commit: "Remove the now-orphaned BaseCoverageReco..."

@dbernstein
dbernstein force-pushed the chore/retire-coverage-provider branch from 27bf685 to ac18fb5 Compare July 1, 2026 18:08
@dbernstein
dbernstein force-pushed the chore/retire-coverage-provider branch from e5b1dc0 to c7d813d Compare July 14, 2026 17:06
@dbernstein
dbernstein force-pushed the chore/retire-coverage-provider branch from c7d813d to 593e380 Compare August 24, 2026 14:15
@dbernstein
dbernstein force-pushed the chore/retire-coverage-provider branch 2 times, most recently from 27b613c to 601d261 Compare September 11, 2026 18:11
Base automatically changed from chore/retire-coverage-provider to main September 11, 2026 18:19
dbernstein added a commit that referenced this pull request Sep 11, 2026
## Description

Retires the legacy `CoverageProvider` machinery. Overdrive bibliographic
coverage now runs as a direct metadata-apply step inside
`OverdriveAPI.update_licensepool` instead of routing through
`OverdriveBibliographicCoverageProvider.ensure_coverage`. The base
`CoverageProvider` classes, the Overdrive/Bibliotheca providers, the
coverage-provider runner scripts and their `bin/repair` entry points,
and the now-dead readers (`Identifier.missing_coverage_from`, the
`Explain` script's coverage block) are deleted.

`BibliographicData.apply` no longer writes `CoverageRecord` rows (the
`create_coverage_record` parameter is removed; the Celery apply task and
the Bibliotheca updater already passed `create_coverage_record=False`).

The new `_ensure_bibliographic_coverage` helper wraps
`bibliographic.apply(...)` in a `BasePalaceException` guard so bad or
incomplete data for a single title is logged and skipped rather than
aborting the batch the caller is working through. A failed metadata
fetch (Overdrive unreachable, bad credentials) and non-Palace exceptions
from `apply` (a `KeyError`, a SQLAlchemy error) are deliberately left to
propagate: those affect the whole run and should stop it loudly instead
of quietly leaving thousands of pools without Works. The docstring
spells that split out.

The dead admin `refresh_metadata` endpoint is removed outright, along
with the route, its tests, and the two now-unused `METADATA_REFRESH_*`
problem details. It was unreachable by any client: `circulation-admin`
only renders its "Refresh Metadata" button when the admin OPDS entry
carries a `http://librarysimplified.org/terms/rel/refresh` link, and
nothing has emitted that rel since the metadata wrangler was removed
(#792); `ios-core` never touched the admin API. With no provider wired
up, any request that did arrive got an unconditional 400.

The `CoverageRecord` model and `coveragerecords` table are intentionally
left in place (marked dormant) for one release, per the online-migration
/ N-1 rule. The stacked follow-up #3521 drops the `coveragerecords` and
dormant `equivalentscoveragerecords` tables.

> Note: this PR supersedes #3503; it is opened from a branch on the
upstream repository and rebased on the latest `main`.

## Motivation and Context

JIRA (PP-4468)

The `CoverageProvider` batch-processing pattern is being retired in
favor of Celery. Investigation showed nothing live reads coverage rows:

- The only live caller of `ensure_coverage` (Overdrive's
`update_licensepool`) passes `force=True` and ignores the result.
- The admin `refresh_metadata` endpoint is wired with no provider and no
client can reach it, so it always returned `METADATA_REFRESH_FAILURE`.
- `Identifier.missing_coverage_from` / `should_update` / the `Explain`
coverage block are only reachable through the already-dead runner
scripts.

The Celery `bibliographic_apply` task and the Bibliotheca updater
already skip coverage-record writes, so Overdrive was the last writer.

## How Has This Been Tested?

- `mypy` and the project's `pre-commit` hooks pass on all changed files.
- Full-suite collection is clean (6136 tests, no dangling imports), and
every `bin/` script's imports were checked to resolve — `bin/` is not
imported by CI, which is how the two stale `bin/repair` coverage entry
points survived the first pass.
- Targeted suites pass under `tox -e py312-docker` (619 tests):
Overdrive API including the rewritten `update_licensepool`
bibliographic-coverage path and the tests covering the
`bibliographic.apply` guard (Palace errors swallowed, non-Palace errors
propagating), the admin work-editor and route tests, the Bibliotheca
package, data-layer `apply`, admin feeds, scripts, and the affected
model tests.

## Checklist

- [x] I have updated the documentation accordingly.
- [x] All new and existing tests passed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
@dbernstein
dbernstein force-pushed the chore/drop-coverage-tables branch from e5361af to 9b9dca6 Compare September 16, 2026 19:22
@codecov

codecov Bot commented Sep 16, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.74%. Comparing base (5dde763) to head (325e5db).

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3521      +/-   ##
==========================================
- Coverage   93.74%   93.74%   -0.01%     
==========================================
  Files         510      510              
  Lines       46417    46390      -27     
  Branches     6300     6300              
==========================================
- Hits        43513    43487      -26     
+ Misses       1876     1875       -1     
  Partials     1028     1028              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@dbernstein
dbernstein force-pushed the chore/drop-coverage-tables branch from 9b9dca6 to 9a206a7 Compare September 16, 2026 20:53
@dbernstein
dbernstein changed the base branch from main to chore/stop-using-coverage-relationships September 16, 2026 20:54
@dbernstein
dbernstein force-pushed the chore/stop-using-coverage-relationships branch from d67aa37 to 8f46bc2 Compare September 23, 2026 20:36
@dbernstein
dbernstein force-pushed the chore/drop-coverage-tables branch from 9a206a7 to 3c7f990 Compare September 23, 2026 20:43
@dbernstein
dbernstein force-pushed the chore/stop-using-coverage-relationships branch 4 times, most recently from d2fa83c to 664289f Compare September 28, 2026 23:16
dbernstein added a commit that referenced this pull request Sep 28, 2026
## Description

Release-1 half of retiring the `coveragerecords` table, split out of
#3521.

#3520 retired the CoverageProvider machinery and left the
`CoverageRecord` model "dormant" for a release. It wasn't dormant:
`Identifier`, `DataSource` and `Collection` still mapped a
`coverage_records` relationship, 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 every `session.delete()` on one of
those parents still SELECTed from `coveragerecords`.

This PR actually stops using the table:

- Removes the `coverage_records` relationships from `Identifier`,
`DataSource` and `Collection`, and the `identifier` / `data_source` /
`collection` relationships that pointed back at them from
`CoverageRecord`.
- Reduces `CoverageRecord` to a bare table definition — columns,
`__table_args__` and the trailing module-level `Index`. `lookup`,
`add_for`, `bulk_add`, `assert_coverage_operation`, `human_readable` and
`__repr__` are gone, along with the five `*_OPERATION` constants that
only fed them, and `BaseCoverageRecord.not_covered` with the
`ALL_STATUSES` / `PREVIOUSLY_ATTEMPTED` / `DEFAULT_COUNT_AS_COVERED`
lists 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 by `add_for`/`bulk_add` could no longer be cleaned
up when its parent is deleted, so it would make that delete fail on a
foreign key. `status_enum` and the four status constants stay — they are
the column type for both dormant models.
- Empties `coveragerecords` in a migration. Its foreign keys carry no
`ON DELETE` clause, 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, so
`TRUNCATE` is cheaper than teaching a table we drop next release to
cascade. It runs under `SET LOCAL lock_timeout = '5s'`: N-1 servers
still read the table, so `TRUNCATE`'s `ACCESS EXCLUSIVE` lock can queue
behind an in-flight parent delete and block every later reader — failing
fast and retrying beats stalling instance startup.
- Removes the `CoverageRecord` tests and the `db.coverage_record`
fixture, so the next release's backwards-compatibility gate doesn't 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
(`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.

`equivalentscoveragerecords` needs no change here — it has no ORM
relationships pointing at it and its one foreign key already declares
`ON 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?

- `mypy` clean; `pre-commit` clean.
- `tests/migration/`, `tests/manager/sqlalchemy/` and
`tests/manager/data_layer/` pass under `tox -e py312-docker` (664
tests), including a new migration test covering the truncate and
confirming the table itself survives this release.
- **Backwards compatibility verified locally**, which is the point of
the PR:
  ```
./docker/ci/test_backwards_compatibility.sh
7a3274e # the #3520 release
→ 3346 passed — "The previous release works against the current schema
🎉"
  ```
For contrast, the same gate run against the old single-PR #3521 failed
with **30 tests** erroring on `relation "coveragerecords" does not
exist`, 24 of them purely from the relationships being walked on parent
deletes.
- The stacked #3521 passes the same gate against this branch's content
(3391 passed). It needs a rebase onto the current head of this branch
before that result applies to the pushed commits.

## Checklist

- [x] I have updated the documentation accordingly.
- [x] All new and existing tests passed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Base automatically changed from chore/stop-using-coverage-relationships to main September 28, 2026 23:25
dbernstein and others added 2 commits September 29, 2026 11:02
Release-2 half of the CoverageProvider retirement. The previous release removed
the coverage_records relationships, which is what actually stopped the ORM
reading these tables, so N-1 no longer maps either one and both can go.

- Remove the CoverageRecord and EquivalencyCoverageRecord models from
  sqlalchemy/model/coverage.py, leaving the Timestamp model and its separate
  service_type enum.
- Add a migration dropping coveragerecords and equivalentscoveragerecords along
  with the now-orphaned coverage_status enum; the downgrade recreates both
  tables with their indexes, constraints and foreign keys.
- Adapt the previous release's lock_timeout test. It ran the truncate migration
  against the schema built by create_all, which no longer has coveragerecords
  now that the model is gone, so it steps back to that revision first -- the
  drop's downgrade recreates the table. The test still earns its place: a
  database older than that revision upgrades through it and this drop in a
  single transaction, which is exactly the leak the reset guards against.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
With the CoverageProvider machinery and the CoverageRecord /
EquivalencyCoverageRecord models gone, BaseCoverageRecord had no remaining
users except the workcoveragerecords-removal migration (01b1e464a9d1), which
imported it only for the coverage_status enum values. Inline those values in
that migration so it is self-contained, then drop the mixin. coverage.py now
contains only the Timestamp model.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@dbernstein
dbernstein force-pushed the chore/drop-coverage-tables branch from 3c7f990 to 325e5db Compare September 29, 2026 18:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

DB migration This PR contains a DB migration

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant