Skip to content

Retire the CoverageProvider machinery (PP-4468)#3520

Open
dbernstein wants to merge 6 commits into
mainfrom
chore/retire-coverage-provider
Open

Retire the CoverageProvider machinery (PP-4468)#3520
dbernstein wants to merge 6 commits into
mainfrom
chore/retire-coverage-provider

Conversation

@dbernstein

@dbernstein dbernstein commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

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 the now-dead readers (Identifier.missing_coverage_from, the Explain script's coverage block, the always-failing admin refresh_metadata provider path) 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 broad except Exception that logs a warning and returns, preserving the resilience of the retired BibliographicCoverageProvider.set_bibliographic so a database error or unexpected data shape during apply does not propagate out of update_licensepool.

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

Note: this PR supersedes #3503; it is opened from a branch on the upstream repository, rebased on the latest main, and addresses the review feedback there (the bibliographic.apply exception guard).

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, so it always returns METADATA_REFRESH_FAILURE without invoking a provider.
  • 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 ruff pass on all changed files.
  • Targeted suites pass under tox -e py312-docker: Overdrive API including the rewritten update_licensepool bibliographic-coverage path and a new test covering the bibliographic.apply error guard, the Bibliotheca circulation updater, data-layer apply, and the affected model tests.

Checklist

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

🤖 Generated with Claude Code

@claude

claude Bot commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Claude finished @dbernstein's task in 7m 6s —— View job


Code Review

I reviewed the diff against origin/main (base 2b3f90b). This is a clean, well-scoped retirement of the CoverageProvider machinery. I traced the new inline _ensure_bibliographic_coverage path against the retired OverdriveBibliographicCoverageProvider and confirmed the important behaviors are preserved.

Summary

The retirement is sound and the inline replacement is faithful to what it replaces. I verified: the availability endpoint's real behavior is unchanged (refresh_metadata was already always returning METADATA_REFRESH_FAILURE because the route passes no provider); the new path's ReplacementPolicy.from_license_source() matches the old provider's _default_replacement_policy (via CollectionCoverageProvider); set_presentation_ready() defaults to exclude_search=False, matching the old EXCLUDE_SEARCH_INDEX = False; the extra calculate_work() after apply() is necessary because apply only recalculates presentation for pools that already have a work; and the greptile note about metadata_lookup errors propagating is not a regression — the old process_batch didn't wrap process_item either. No dangling imports or references to the deleted symbols remain. The narrowing of the apply guard from except Exception to except BasePalaceException correctly addresses @jonathangreen's feedback and is covered by a test — but it does mean the comment and PR description now overstate the resilience (see below). Two minor cleanups follow.

Details

Minor: src/palace/manager/integration/license/overdrive/api.py:1839-1849

The narrowing to except BasePalaceException is a good change, but the accompanying comment ("A failure while applying the bibliographic data must not abort the surrounding availability update") and the PR description ("a database error or unexpected data shape during apply does not propagate out of update_licensepool") now overstate what is actually caught. A raw SQLAlchemy error (e.g. IntegrityError/DataError) or a stray ValueError/KeyError from apply is not a BasePalaceException and will propagate, aborting the reaper's update_licensepool loop (celery/tasks/overdrive.py:551) — which is exactly the intent your new test_ensure_bibliographic_coverage_reraises_unexpected_error asserts. Consider tightening the comment (and the PR description) to say only Palace-level failures are tolerated, so a future reader isn't misled about database errors.

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

Minor: src/palace/manager/scripts/informational.py:236

Explain.TIME_FORMAT is now dead — its only consumer was _explain_coverage_record, which this PR deletes. Nothing else in the repo references it (ruff won't flag an unused class attribute). Remove it along with the coverage-record explain code.

TIME_FORMAT = "%Y-%m-%d %H:%M"

Note on the refresh_metadata endpoint

Not a code finding, but it echoes @jonathangreen's open question: the endpoint is now a permanent-failure stub. Its behavior is unchanged (it already always failed), so nothing regresses, but it may be worth a follow-up ticket to decide whether to deprecate/remove the route and the admin UI "refresh" affordance rather than leave a button that always errors.
• Branch: chore/retire-coverage-provider

@dbernstein dbernstein force-pushed the chore/retire-coverage-provider branch from 8f4574b to 27bf685 Compare June 30, 2026 00:01
@dbernstein dbernstein requested a review from a team June 30, 2026 00:02
@greptile-apps

greptile-apps Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR retires the legacy CoverageProvider batch-processing machinery, replacing Overdrive bibliographic coverage with a direct _ensure_bibliographic_coverage inline step inside update_licensepool. The CoverageRecord model and table are intentionally left dormant for one release, following the project's N-1 online-migration convention.

  • BibliographicData.apply drops the create_coverage_record parameter; coverage-record writes are removed from the Overdrive code path (the Celery task and Bibliotheca updater already opted out).
  • The new _ensure_bibliographic_coverage helper wraps bibliographic.apply() in a BasePalaceException guard so unexpected data-shape errors do not abort the surrounding availability update; new tests cover both the guarded and unguarded paths.
  • The admin refresh_metadata endpoint is simplified to always return METADATA_REFRESH_FAILURE (it was already returning that in every real-world case), dropping the now-dead provider parameter and the unnecessary load_work call.

Confidence Score: 5/5

This PR is safe to merge. It removes a substantial amount of dead code, inlines the one active call site with equivalent behavior, and leaves the database table dormant per the project's N-1 migration convention.

The refactoring is well-scoped: every removed code path was verified dead before removal, the new _ensure_bibliographic_coverage helper is covered by targeted tests for both the success and guarded-failure paths, and the N-1 table-retention strategy is correctly applied. No behavioral regressions were identified in the active paths.

No files require special attention.

Important Files Changed

Filename Overview
src/palace/manager/integration/license/overdrive/api.py Replaces ensure_coverage call with _ensure_bibliographic_coverage; new helper handles metadata fetch, apply, and presentation-ready in one place with a BasePalaceException guard.
src/palace/manager/data_layer/bibliographic.py Removes the create_coverage_record parameter and its CoverageRecord.add_for call; apply() now purely applies bibliographic data without side-effectful record writes.
src/palace/manager/api/admin/controller/work_editor.py Simplifies refresh_metadata to always return METADATA_REFRESH_FAILURE after require_librarian check; removes dead load_work, ensure_coverage, and provider parameter.
src/palace/manager/sqlalchemy/model/coverage.py Adds dormant-model docstring to CoverageRecord; table retained per N-1 migration rule, with a clear note that it will be dropped in a follow-up.
src/palace/manager/core/coverage.py File deleted — entire CoverageProvider class hierarchy removed including CoverageFailure, IdentifierCoverageProvider, CollectionCoverageProvider, and BibliographicCoverageProvider.
src/palace/manager/integration/license/overdrive/coverage.py File deleted — OverdriveBibliographicCoverageProvider removed; its functionality is inlined into api.py._ensure_bibliographic_coverage.
src/palace/manager/integration/license/bibliotheca.py Removes BibliothecaBibliographicCoverageProvider and its BibliographicCoverageProvider import; clean deletion with no remaining references.
tests/manager/integration/license/overdrive/test_api.py Adds two new tests for _ensure_bibliographic_coverage: one verifying BasePalaceException is caught and logged, one verifying non-Palace exceptions propagate; updates existing coverage-record assertion removal.
tests/mocks/mock.py Removes all MockCoverageProvider, InstrumentedCoverageProvider, AlwaysSuccessfulCoverageProvider, and related mock classes no longer needed.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Caller
    participant OverdriveAPI
    participant _ensure_bibliographic_coverage
    participant metadata_lookup
    participant OverdriveRepresentationExtractor
    participant BibliographicData
    participant LicensePool

    Caller->>OverdriveAPI: update_licensepool(identifier)
    OverdriveAPI->>OverdriveAPI: update_licensepool_from_availability()
    OverdriveAPI->>LicensePool: for_foreign_id() → is_new
    alt is_new OR no work
        OverdriveAPI->>_ensure_bibliographic_coverage: _ensure_bibliographic_coverage(license_pool)
        _ensure_bibliographic_coverage->>metadata_lookup: metadata_lookup(identifier)
        metadata_lookup-->>_ensure_bibliographic_coverage: info dict
        alt errorCode NotFound / InvalidGuid
            _ensure_bibliographic_coverage-->>OverdriveAPI: return (log warning)
        end
        _ensure_bibliographic_coverage->>OverdriveRepresentationExtractor: book_info_to_bibliographic(info)
        OverdriveRepresentationExtractor-->>_ensure_bibliographic_coverage: BibliographicData
        _ensure_bibliographic_coverage->>BibliographicData: apply(db, edition, collection, replace)
        alt BasePalaceException raised
            BibliographicData-->>_ensure_bibliographic_coverage: exception
            _ensure_bibliographic_coverage-->>OverdriveAPI: return (log warning)
        end
        BibliographicData-->>_ensure_bibliographic_coverage: (edition, changed)
        alt no work or not presentation_ready
            _ensure_bibliographic_coverage->>LicensePool: calculate_work()
            LicensePool-->>_ensure_bibliographic_coverage: work
            _ensure_bibliographic_coverage->>LicensePool: work.set_presentation_ready()
        end
    end
    OverdriveAPI->>OverdriveAPI: update_licensepool_with_book_info(...)
    OverdriveAPI-->>Caller: (pool, is_new, changed)
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Caller
    participant OverdriveAPI
    participant _ensure_bibliographic_coverage
    participant metadata_lookup
    participant OverdriveRepresentationExtractor
    participant BibliographicData
    participant LicensePool

    Caller->>OverdriveAPI: update_licensepool(identifier)
    OverdriveAPI->>OverdriveAPI: update_licensepool_from_availability()
    OverdriveAPI->>LicensePool: for_foreign_id() → is_new
    alt is_new OR no work
        OverdriveAPI->>_ensure_bibliographic_coverage: _ensure_bibliographic_coverage(license_pool)
        _ensure_bibliographic_coverage->>metadata_lookup: metadata_lookup(identifier)
        metadata_lookup-->>_ensure_bibliographic_coverage: info dict
        alt errorCode NotFound / InvalidGuid
            _ensure_bibliographic_coverage-->>OverdriveAPI: return (log warning)
        end
        _ensure_bibliographic_coverage->>OverdriveRepresentationExtractor: book_info_to_bibliographic(info)
        OverdriveRepresentationExtractor-->>_ensure_bibliographic_coverage: BibliographicData
        _ensure_bibliographic_coverage->>BibliographicData: apply(db, edition, collection, replace)
        alt BasePalaceException raised
            BibliographicData-->>_ensure_bibliographic_coverage: exception
            _ensure_bibliographic_coverage-->>OverdriveAPI: return (log warning)
        end
        BibliographicData-->>_ensure_bibliographic_coverage: (edition, changed)
        alt no work or not presentation_ready
            _ensure_bibliographic_coverage->>LicensePool: calculate_work()
            LicensePool-->>_ensure_bibliographic_coverage: work
            _ensure_bibliographic_coverage->>LicensePool: work.set_presentation_ready()
        end
    end
    OverdriveAPI->>OverdriveAPI: update_licensepool_with_book_info(...)
    OverdriveAPI-->>Caller: (pool, is_new, changed)
Loading

Reviews (3): Last reviewed commit: "Set the log level in the apply-error tes..." | Re-trigger Greptile

Comment on lines +1832 to +1851
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Comment on lines 462 to 468
if isinstance(work, ProblemDetail):
return work

if provider is None:
return METADATA_REFRESH_FAILURE

assert work.presentation_edition is not None
primary_identifier = work.presentation_edition.primary_identifier
try:
record = provider.ensure_coverage(primary_identifier, force=True)
except Exception:
# The coverage provider may raise an HTTPIntegrationException.
return REMOTE_INTEGRATION_FAILED

if record.exception:
# There was a coverage failure.
if str(record.exception).startswith("201") or str(
record.exception
).startswith("202"):
# A 201/202 error means it's never looked up this work before
# so it's started the resolution process or looking for sources.
return METADATA_REFRESH_PENDING
# Otherwise, it just doesn't know anything.
return METADATA_REFRESH_FAILURE

return Response("", 200)
return METADATA_REFRESH_FAILURE

def classifications(
self, identifier_type: str, identifier: str

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 load_work is called even though the result is always discarded

The method now calls self.load_work(library, identifier_type, identifier) (and its auth check) before returning METADATA_REFRESH_FAILURE unconditionally. Since the endpoint never performs a refresh, the work lookup is dead work on every request. The auth check (require_librarian) is still valuable, but the load_work call could be removed to avoid the unnecessary database query.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@jonathangreen

Copy link
Copy Markdown
Member

@dbernstein it looks like tests are failing on this one, and greptile has a valid review comment about unconditionally calling self.load_work.

I'll let you resolve those issues before reviewing this one. When its ready you can request a review from @ThePalaceProject/backend again.

@jonathangreen jonathangreen left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some comments on this for consideration while you are resolving the test issues

Comment on lines +453 to +456
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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +1806 to +1809
This replaces the former ``OverdriveBibliographicCoverageProvider``
path that ran inside :meth:`update_licensepool`. A bad or unrecognized
Overdrive ID simply leaves the pool without a Work, matching the prior
behavior (``update_licensepool`` ignored the coverage result).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I find referring to old removed code in a new code like this does not add much value. Claude likes to do this a lot.

If anything it causes confusion as you have to go back in git history looking for OverdriveBibliographicCoverageProvider to try to understand the comment. This should just tell us what we are currently doing not give a history lesson.

Comment on lines +1840 to +1844
# 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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again we should explain why we are doing this, not talk about old removed code in the comment.

self.collection,
replace=ReplacementPolicy.from_license_source(),
)
except Exception as e:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I really really don't like broad exceptions like this. We should avoid them as much as we possibly can. They catch and mask so much, like this would catch and hide a key name typo in the code.

):
# An exception raised while applying bibliographic data is caught and
# logged rather than propagating out of update_licensepool, mirroring
# the resilience of the retired BibliographicCoverageProvider.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment referring to removed code

dbernstein and others added 5 commits July 1, 2026 10:38
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>
Address Greptile feedback: refresh_metadata always returns
METADATA_REFRESH_FAILURE, so loading the Work only to discard it was a needless
database query on every request. Keep the librarian authorization check and
return the failure directly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@dbernstein dbernstein force-pushed the chore/retire-coverage-provider branch from 27bf685 to ac18fb5 Compare July 1, 2026 18:08
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>
@codecov

codecov Bot commented Jul 2, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 70.83333% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 93.53%. Comparing base (2b3f90b) to head (e5b1dc0).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
...alace/manager/integration/license/overdrive/api.py 68.18% 5 Missing and 2 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3520      +/-   ##
==========================================
+ Coverage   93.45%   93.53%   +0.07%     
==========================================
  Files         511      508       -3     
  Lines       46494    45847     -647     
  Branches     6346     6239     -107     
==========================================
- Hits        43450    42881     -569     
+ Misses       1968     1915      -53     
+ Partials     1076     1051      -25     

☔ 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 requested a review from jonathangreen July 13, 2026 19:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants