Retire the CoverageProvider machinery (PP-4468) - #3520
Conversation
|
Claude finished @dbernstein's task in 6m 3s —— View job SummaryThe deletion side of this is careful and well-verified — no dangling imports, Details
|
8f4574b to
27bf685
Compare
Greptile SummaryThis PR retires the legacy
Confidence Score: 5/5
|
| Filename | Overview |
|---|---|
| src/palace/manager/integration/license/overdrive/api.py | Core change: removes OverdriveBibliographicCoverageProvider dependency and introduces _ensure_bibliographic_coverage. New method is well-guarded — handles Overdrive errorCode responses, missing bibliographic data, and BasePalaceException from apply, with the remaining propagation behavior clearly documented. Tests cover both the catch and re-raise paths. |
| src/palace/manager/data_layer/bibliographic.py | Removes the create_coverage_record parameter and the CoverageRecord.add_for call inside apply. Clean removal — callers already passed False or are updated; the corresponding test is also updated. |
| src/palace/manager/core/coverage.py | Entire file deleted (1,364 lines). Verified that no remaining production code imports from this module. |
| src/palace/manager/sqlalchemy/model/coverage.py | CoverageRecord model retained as dormant with a clear docstring explaining the N-1 migration convention. Timestamp model unchanged and still actively used by Monitors. |
| src/palace/manager/api/admin/controller/work_editor.py | refresh_metadata method fully removed. The route, its tests, and the two METADATA_REFRESH_* problem details are all consistently cleaned up across routes.py and problem_details.py. |
| tests/manager/integration/license/overdrive/test_api.py | Adds two targeted tests for _ensure_bibliographic_coverage: one confirms BasePalaceException is caught and logged without propagating; the other confirms non-Palace exceptions (e.g. KeyError) re-raise. The existing test_update_licensepool is updated to remove the now-defunct coverage-record assertion. |
| tests/manager/data_layer/test_bibliographic.py | Removes test_apply_creates_coverage_records. Updates test_apply_does_not_create_coverage_records to call apply without the now-removed create_coverage_record=False kwarg — test body is still correct, but the name is now misleading since there's no longer an alternative behavior to contrast it against. |
Sequence Diagram
sequenceDiagram
participant Caller
participant OverdriveAPI
participant Overdrive as Overdrive API
participant BibliographicData
participant LicensePool
Caller->>OverdriveAPI: update_licensepool(book_id)
OverdriveAPI->>Overdrive: get availability
Overdrive-->>OverdriveAPI: availability JSON
OverdriveAPI->>LicensePool: for_foreign_id()
LicensePool-->>OverdriveAPI: license_pool, is_new
alt is_new OR no work
OverdriveAPI->>OverdriveAPI: _ensure_bibliographic_coverage(pool)
OverdriveAPI->>Overdrive: metadata_lookup(identifier)
Overdrive-->>OverdriveAPI: metadata JSON
alt "errorCode = NotFound / InvalidGuid"
OverdriveAPI-->>OverdriveAPI: log warning, return
else metadata valid
OverdriveAPI->>BibliographicData: book_info_to_bibliographic(info)
BibliographicData-->>OverdriveAPI: bibliographic (or None)
alt bibliographic is None
OverdriveAPI-->>OverdriveAPI: log warning, return
else
OverdriveAPI->>BibliographicData: apply(db, edition, collection)
alt BasePalaceException
BibliographicData-->>OverdriveAPI: raises
OverdriveAPI-->>OverdriveAPI: log warning, return
else success
BibliographicData-->>OverdriveAPI: (edition, changed)
OverdriveAPI->>LicensePool: calculate_work()
LicensePool-->>OverdriveAPI: work
OverdriveAPI->>LicensePool: work.set_presentation_ready()
end
end
end
end
OverdriveAPI->>OverdriveAPI: update_licensepool_with_book_info(...)
OverdriveAPI-->>Caller: (pool, is_new, changed)
Reviews (5): Last reviewed commit: "Say exactly what the bibliographic apply..." | Re-trigger Greptile
| try: | ||
| bibliographic.apply( | ||
| self._db, | ||
| edition, | ||
| self.collection, | ||
| replace=ReplacementPolicy.from_license_source(), | ||
| ) | ||
| except Exception as e: | ||
| # Mirror the resilience of the former | ||
| # ``BibliographicCoverageProvider.set_bibliographic``: a database | ||
| # error or unexpected data shape during apply should not crash | ||
| # ``update_licensepool``. Log and leave the pool without updated | ||
| # coverage, matching the prior "silently continue" behavior. | ||
| self.log.warning( | ||
| "Error applying Overdrive bibliographic data to edition %s: %s", | ||
| edition.id, | ||
| e, | ||
| exc_info=e, | ||
| ) | ||
| return |
There was a problem hiding this comment.
metadata_lookup exceptions propagate outside the guard
The except Exception block covers only bibliographic.apply(). A transient HTTP error or malformed JSON response from metadata_lookup() (lines 1812–1813) will propagate to update_licensepool and abort the entire availability update, preventing update_licensepool_with_book_info from running. This matches the old behaviour — the retired process_batch also did not wrap process_item in a try/except — so this is not a regression, but it is worth calling out explicitly because the guard communicates an intent ("bibliographic failures must not crash availability updates") that isn't fully satisfied if the fetch itself throws. Consider wrapping metadata_lookup in the same guard, or adding a dedicated try/except around just that call with a return on failure.
There was a problem hiding this comment.
Leaving metadata_lookup outside the guard on purpose, and the docstring now says so instead of implying blanket resilience.
The asymmetry is the point. A failed apply is usually about one title's data. A failed fetch usually isn't: Overdrive is unreachable, or the credentials are wrong, or the collection token is stale — conditions that will hit every identifier in the batch. Callers run this in a loop over batches of identifiers, so swallowing a fetch failure would walk the whole batch logging warnings and finish looking like a successful run, having left every pool without a Work. Letting it propagate fails the task, which is the honest outcome and the one that gets retried.
So the guard's intent isn't "bibliographic failures must not crash availability updates" — it's narrower than that, and the docstring is now explicit about which failures are skipped and which are allowed to stop the run.
There was a problem hiding this comment.
That's a well-reasoned split — the asymmetry makes sense. Per-title apply failures are ignorable noise; a fetch failure is a signal about the whole run that should surface loudly rather than be buried in warnings. If the updated docstring now explicitly documents which failures are skipped vs. allowed to propagate, the intent is clear to future readers. No further change needed.
|
@dbernstein it looks like tests are failing on this one, and greptile has a valid review comment about unconditionally calling I'll let you resolve those issues before reviewing this one. When its ready you can request a review from @ThePalaceProject/backend again. |
jonathangreen
left a comment
There was a problem hiding this comment.
Some comments on this for consideration while you are resolving the test issues
| Metadata refresh used to run through the per-source CoverageProvider | ||
| machinery, which has been retired. No provider is wired up, so this | ||
| endpoint is retained for API compatibility but no longer performs a | ||
| refresh; it always reports failure. |
There was a problem hiding this comment.
Since this does nothing can we deprecate it, or just remove it entirely? What calls this endpoint? I think we need a plan to deal with this endpoint since its no longer functional.
There was a problem hiding this comment.
There a 👍🏻 on this, but do we have a ticket or plan for it?
There was a problem hiding this comment.
No ticket needed — I checked what calls it, and the answer is nothing, so it's removed outright in this PR rather than deprecated.
circulation-admin only renders its "Refresh Metadata" button when the admin OPDS entry carries a http://librarysimplified.org/terms/rel/refresh link (editorAdapter.ts looks for that rel, and BookDetailsEditor gates the button on bookData.refreshLink). Nothing in the manager has emitted that rel since the metadata wrangler came out in #792 — the current admin annotators emit only suppress / unsuppress / edit / search. ios-core has no reference to the rel or the endpoint, and doesn't use the admin API at all.
So the button never appears and the route was unreachable; anything that did reach it got an unconditional 400. Removed the route, the controller method, its tests, and the two orphaned METADATA_REFRESH_* problem details. Also dropped test_feed_includes_refresh_link, which despite the name only asserted the absence of that rel — a leftover from the wrangler removal that no longer has an endpoint behind it.
Tagged the PR incompatible changes since it drops a registered admin route, though in practice no client could reach it.
27bf685 to
ac18fb5
Compare
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #3520 +/- ##
==========================================
+ Coverage 93.61% 93.69% +0.07%
==========================================
Files 514 510 -4
Lines 47065 46398 -667
Branches 6408 6301 -107
==========================================
- Hits 44060 43471 -589
+ Misses 1944 1891 -53
+ Partials 1061 1036 -25 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
e5b1dc0 to
c7d813d
Compare
| try: | ||
| bibliographic.apply( | ||
| self._db, | ||
| edition, | ||
| self.collection, | ||
| replace=ReplacementPolicy.from_license_source(), | ||
| ) | ||
| except BasePalaceException as e: | ||
| # A failure while applying the bibliographic data must not abort the | ||
| # surrounding availability update, so log it and leave the pool's | ||
| # presentation edition unchanged. | ||
| self.log.warning( | ||
| "Error applying Overdrive bibliographic data to edition %s: %s", | ||
| edition.id, | ||
| e, | ||
| exc_info=e, | ||
| ) | ||
| return |
There was a problem hiding this comment.
Exception guard scope excludes SQLAlchemy errors from
apply
The except BasePalaceException block only catches Palace-domain exceptions. BibliographicData.apply performs multiple SQLAlchemy writes (contributors, links, identifiers, equivalencies) that can raise IntegrityError, OperationalError, or DataError — none of which inherit from BasePalaceException. When those slip through, the exception propagates out of _ensure_bibliographic_coverage and into update_licensepool, where there is no further guard, so the outer availability update is aborted for that title.
The old BibliographicCoverageProvider.set_bibliographic wrapped bibliographic.apply in a bare except Exception, which is what the PR description ("broad except Exception") implies was the intent. The two new tests only cover BasePalaceException / KeyError side-effects from a mock, so SQLAlchemy exceptions are untested here.
If the intentional design is to let non-Palace exceptions surface as bugs, the docstring's claim that "a database error … does not propagate out of update_licensepool" should be tightened to match. If the intent truly is to swallow all apply-time errors, the guard should be widened to except Exception.
There was a problem hiding this comment.
Took the first option: kept the narrow guard and tightened the docs to match.
Widening back to except Exception isn't available — that's what @jonathangreen pushed back on above ("they catch and mask so much, like this would catch and hide a key name typo in the code"), and he's right. So the split is now deliberate and spelled out in the docstring:
BasePalaceExceptionfromapplymeans bad or incomplete data for this one title. Logged, skipped, batch continues.- A SQLAlchemy error means a broken session or a genuine data-integrity problem, and a
KeyErrormeans a bug. Both propagate, because neither is specific to one title and both should stop the run loudly rather than quietly leaving thousands of pools without Works.
Good catch on the PR description — it still claimed a "broad except Exception" from an earlier revision. Corrected.
jonathangreen
left a comment
There was a problem hiding this comment.
Looks like there are some valid AI code review comments that still should be resolved on this one, so I'm going to leave it until you get back from vacation @dbernstein
c7d813d to
593e380
Compare
593e380 to
27b613c
Compare
Apply Overdrive bibliographic metadata directly instead of routing it through the CoverageProvider machinery, then delete the now-dead machinery. Nothing read the coverage rows: the only live caller of ensure_coverage was OverdriveAPI.update_licensepool (force=True, return value ignored), and the admin refresh_metadata endpoint was wired with no provider. The Celery apply task and the Bibliotheca updater already skipped coverage-record writes, so Overdrive was the last writer. - Rewrite OverdriveAPI.update_licensepool to fetch + apply metadata and make the work presentation-ready via a new _ensure_bibliographic_coverage helper, replacing OverdriveBibliographicCoverageProvider.ensure_coverage. - Stop writing coverage records: drop create_coverage_record from BibliographicData.apply and its call sites. - Delete core/coverage.py, the Overdrive and Bibliotheca coverage providers, the coverage-provider runner scripts, Identifier.missing_coverage_from, and the Explain script's coverage block. - Neutralize the now-dead admin refresh_metadata endpoint. - Leave the CoverageRecord model and coveragerecords table in place (dormant) for one release; the drop migration follows in a stacked PR. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Restore the resilience of the retired BibliographicCoverageProvider: catch and log exceptions from bibliographic.apply in _ensure_bibliographic_coverage so a database error or unexpected data shape during apply does not propagate out of update_licensepool. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address review feedback: the _ensure_bibliographic_coverage docstring, its exception-handling comment, and the matching test comment described the retired CoverageProvider classes instead of what the code does now. Rewrite them to explain the current behavior. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address review feedback: a bare `except Exception` around bibliographic.apply masks programming errors (a mistyped attribute raising AttributeError/KeyError would be silently swallowed). Catch BasePalaceException instead -- apply raises PalaceValueError for invalid data -- so genuine bugs propagate while a single bad title still does not abort the availability update. Update the guard test to raise PalaceValueError and add a test that an unexpected error propagates. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
test_ensure_bibliographic_coverage_apply_error asserted on caplog.text without setting a capture level, so it relied on the ambient root log level. Under pytest-randomly, a prior test that raises that level leaves the warning uncaptured, so caplog.text is empty and the assertion fails. Set the level explicitly with caplog.set_level(LogLevel.warning), matching the convention used by other caplog tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The endpoint could not be reached 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; ios-core never used the admin API at all. With no provider wired up, any request that did arrive got an unconditional 400. Drop the route, the controller method, its tests, and the two now-unused METADATA_REFRESH_* problem details. Also drop the misnamed test_feed_includes_refresh_link, which only asserted the absence of a rel that no longer has an endpoint behind it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The guard catches BasePalaceException, but the comment promised that no failure would abort the surrounding availability update, which is not what the code does. Describe the actual split instead: bad data for one title is logged and skipped so the batch continues, while a failed metadata fetch (Overdrive down, bad credentials) and non-Palace exceptions from apply (a KeyError, a SQLAlchemy error) propagate, because those affect the whole run and should stop it loudly. Also drop the last CoverageProviders mention from Script.update_timestamp. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Rebase fixups for changes that landed on main after this branch was cut: - metadata_lookup now returns a MetadataResponse model (#3705) rather than a raw dict, so the two _ensure_bibliographic_coverage tests mock it with a model instead of a dict. - test_coverage.py, deleted here, had gained a test for the case where Overdrive returns a 200 with a document the extractor cannot use. That case and the unrecognized-ID case are now covered against _ensure_bibliographic_coverage in test_api.py, so retiring the provider does not take their coverage with it. - Drop the now-dead palace.manager.core.coverage mypy override.
27b613c to
601d261
Compare
## 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>
Description
Retires the legacy
CoverageProvidermachinery. Overdrive bibliographic coverage now runs as a direct metadata-apply step insideOverdriveAPI.update_licensepoolinstead of routing throughOverdriveBibliographicCoverageProvider.ensure_coverage. The baseCoverageProviderclasses, the Overdrive/Bibliotheca providers, the coverage-provider runner scripts and theirbin/repairentry points, and the now-dead readers (Identifier.missing_coverage_from, theExplainscript's coverage block) are deleted.BibliographicData.applyno longer writesCoverageRecordrows (thecreate_coverage_recordparameter is removed; the Celery apply task and the Bibliotheca updater already passedcreate_coverage_record=False).The new
_ensure_bibliographic_coveragehelper wrapsbibliographic.apply(...)in aBasePalaceExceptionguard 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 fromapply(aKeyError, 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_metadataendpoint is removed outright, along with the route, its tests, and the two now-unusedMETADATA_REFRESH_*problem details. It was unreachable by any client:circulation-adminonly renders its "Refresh Metadata" button when the admin OPDS entry carries ahttp://librarysimplified.org/terms/rel/refreshlink, and nothing has emitted that rel since the metadata wrangler was removed (#792);ios-corenever touched the admin API. With no provider wired up, any request that did arrive got an unconditional 400.The
CoverageRecordmodel andcoveragerecordstable are intentionally left in place (marked dormant) for one release, per the online-migration / N-1 rule. The stacked follow-up #3521 drops thecoveragerecordsand dormantequivalentscoveragerecordstables.Motivation and Context
JIRA (PP-4468)
The
CoverageProviderbatch-processing pattern is being retired in favor of Celery. Investigation showed nothing live reads coverage rows:ensure_coverage(Overdrive'supdate_licensepool) passesforce=Trueand ignores the result.refresh_metadataendpoint is wired with no provider and no client can reach it, so it always returnedMETADATA_REFRESH_FAILURE.Identifier.missing_coverage_from/should_update/ theExplaincoverage block are only reachable through the already-dead runner scripts.The Celery
bibliographic_applytask and the Bibliotheca updater already skip coverage-record writes, so Overdrive was the last writer.How Has This Been Tested?
mypyand the project'spre-commithooks pass on all changed files.bin/script's imports were checked to resolve —bin/is not imported by CI, which is how the two stalebin/repaircoverage entry points survived the first pass.tox -e py312-docker(619 tests): Overdrive API including the rewrittenupdate_licensepoolbibliographic-coverage path and the tests covering thebibliographic.applyguard (Palace errors swallowed, non-Palace errors propagating), the admin work-editor and route tests, the Bibliotheca package, data-layerapply, admin feeds, scripts, and the affected model tests.Checklist
🤖 Generated with Claude Code