Skip to content

Walk the Overdrive full import over the product list - #3641

Closed
jonathangreen wants to merge 3 commits into
bugfix/overdrive-reap-set-differencefrom
feature/overdrive-full-harvest-crawl
Closed

Walk the Overdrive full import over the product list#3641
jonathangreen wants to merge 3 commits into
bugfix/overdrive-reap-set-differencefrom
feature/overdrive-full-harvest-crawl

Conversation

@jonathangreen

Copy link
Copy Markdown
Member

Description

Moves the Overdrive full import (import_all=True) off the update feed and onto the same product-list crawl the reaper walks. Stacked on #3628, which built the crawl; this PR makes it serve both consumers that need every title.

  • Hydration splits out of the page fetch. fetch_book_info_list fused two jobs: walking the update feed's pages, and fetching each title's metadata and availability documents. The per-title half moves to hydrate_products, which takes raw product dictionaries from any source and attaches the documents; fetch_book_info_list delegates to it, unchanged in behavior. This is also the single place a later switch to Overdrive's bulk metadata and bulk availability endpoints will land.
  • The full import walks the product list. It used to sweep the whole collection through the update feed with lastUpdateTime pinned to the epoch — an ordering that reshuffles on every circulation event anywhere in the collection, so a multi-hour walk over it skips titles exactly the way the reaper's forward walk did, and totalItems decrements in step so no count check can see it. The import_all path now drives the same CrawlCursor over the dateAdded:asc product list — backwards, overlapping pages, ending on a fresh page at offset 0 — with the importer hydrating each page's raw products (OverdriveImporter.import_products_page). The parent/child metadata optimization carries over unchanged: an Advantage collection's crawl still fetches metadata lazily, skipping titles the parent already imported.
  • The crawl's faults become consumer-neutral. The cursor's fault messages ended with the reaper's consequence ("Refusing to reap across a gap"), which stopped making sense the moment a second consumer appeared. The reasons now state the arithmetic only, and each consumer appends its own consequence: the reaper aborts exactly as before, while the import — where nothing acts on a title's absence — keeps what it imported. A structural fault ends the import's crawl but still returns the identifier set (a child import can use a partial set), leaving the import timestamp unchanged so the incomplete sweep is not recorded as a finished import; a count shortfall at the completeness gate is a warning and the import finishes.

The delta import is untouched — the update feed is the right primitive for harvesting changes; what it is wrong for is enumerating everything. Per-title hydration still costs a metadata and an availability request per title; cutting that ~50× with the bulk endpoints is the next PR in the stack. A full-import page queued by the previous release carries its feed url and is finished the way it was started, so in-flight runs survive a deploy; the crawl takes over runs that begin afterwards.

Motivation and Context

Follow-on to #3628, second step of unifying Overdrive's harvest paths onto one crawl machinery. The skip mechanism this fixes is the one that PR measured live: removing (or re-updating) a title mid-walk shifts everything behind it down one position under offset paging, and the update-feed ordering churns constantly — the worst possible ordering to page a multi-hour backfill over. The product list's dateAdded ordering is immutable and its ties are stable, which is why the reaper's cursor walks it; a full import needs exactly the same coverage guarantees, just with a different response to faults.

How Has This Been Tested?

  • Unit tests covering: import_products_page at the importer level — hydration flags for main vs Advantage collections, cursor hand-back, identifier-set population, and lazy metadata for titles in the parent set; the task-level crawl path — a fresh cursor on import_all, the cursor round-tripping through task.replace(), a fault keeping the partial result while leaving the timestamp untouched, the completeness gate warning without failing the run, and a legacy feed-url page being honored on the old path; and hydrate_products hydrating raw product dictionaries in place. The end-to-end group test now drives a full import from raw HTTP fixtures (product page over the sync client, metadata and availability over the async client) through the apply queue to database records.
  • tox -e py312-docker over tests/manager/celery, tests/manager/integration/license/opds, tests/manager/integration/license/overdrive, and tests/manager/scripts/test_overdrive.py — 886 passing.
  • mypy clean across 1,160 source files.

Checklist

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

fetch_book_info_list fused two jobs: walking the update feed's pages and
fetching each title's metadata and availability documents. The second
job is not specific to the feed -- any enumeration of products needs
it -- so it moves to hydrate_products, which takes raw product
dictionaries from any source and attaches the per-title documents.
fetch_book_info_list now delegates to it, unchanged in behavior. This is
also the one place a later change to bulk metadata and availability
lookups will land.
The cursor's fault messages ended with the reaper's consequence --
'Refusing to reap across a gap' -- which was wrong the moment a second
consumer appeared: a full import walking the same crawl stops on the
same faults but reaps nothing. The reasons now state the arithmetic and
nothing else, and each consumer appends its own consequence when it
logs; the reaper's lines read as before.
A full import used to sweep the whole collection through the update
feed with lastUpdateTime pinned to the epoch. That feed is ordered by
update time, which reshuffles on every circulation event anywhere in
the collection, so a multi-hour walk over it skips titles the same way
the reaper's forward walk did -- and totalItems moves in step, so no
count can see it. The import_all path now drives the same CrawlCursor
the reaper walks: the dateAdded:asc product list, backwards, ending on
a fresh page at offset 0, with the importer hydrating each page's raw
products through the split-out hydrate_products.

Faults read differently here than in the reaper, because nothing acts
on a title's absence. A structural fault ends the crawl but keeps what
was imported: the identifier set is still returned, so a child import
can use a partial set, while the import timestamp is left unchanged so
the incomplete sweep is not recorded as a finished import. A count
shortfall at the end is only a warning, and the import finishes.

A full-import page queued by the previous release still carries its
feed url and is finished the way it was started; the crawl only takes
over runs that begin after the deploy.
@jonathangreen jonathangreen added the feature New feature label Aug 11, 2026
@claude

claude Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Claude finished @jonathangreen's task in 9m 13s —— View job


Review

  • Gather PR diff and context
  • Review crawl.py fault-message changes
  • Review api.py hydration split
  • Review importer.py import_products_page
  • Review overdrive.py task crawl path
  • Review test coverage
  • Post review

Summary

The change is well-shaped: the hydration split is behavior-preserving (_hydrate_products is the old loop verbatim, still inside the same async with client), the crawl/feed branch is correctly gated on page is None so in-flight legacy runs finish the way they started, signature_with carries parent_identifiers and import_all forward automatically so the crawl re-queue doesn't need to restate them, and the fault/completeness split matches the stated consumer-neutral design. I checked the two things most likely to bite — product-id casing (Identifier.prepare_foreign_type_and_identifier lowercases Overdrive ids, so product-list ids landing uppercase is a non-issue) and whether a partial identifier set could feed a reap step (it can't; the import set only feeds child metadata skipping and remove_identifier_set) — and both are fine. No correctness bugs found; the notes below are about the completeness gate's calibration under the new page size, one untested documented behavior, and comments the second consumer makes stale.

Details

Minor: src/palace/manager/integration/license/overdrive/importer.py:316

CrawlComplete.completeness_fault caps its allowance at page_limit * CRAWL_ALLOWANCE_PAGE_FRACTION, a constant sized against the reaper's 2000-title pages. Driving the same gate with DEFAULT_PAGE_SIZE = 100 collapses that cap to int(100 * 0.25) = 25, so a 300k-title collection gets an allowance of 25 for the import versus 300 for the reaper — on a crawl that runs for hours rather than the reaper's minutes, and therefore absorbs far more mid-crawl churn. The gate is only a warning here so nothing breaks, but as calibrated it will fire on most large collections and stop carrying information. Worth either fetching the product page at a larger size and hydrating it in chunks, or exempting the import from the page-fraction cap.

Minor: src/palace/manager/integration/license/overdrive/importer.py:353-365

The docstring makes a specific promise — "The page's books are hydrated and processed whatever the cursor decides: a fault ends the crawl, but the titles this page carried are still real" — and it is the one behavior in import_products_page that a reasonable refactor would silently undo (an early return ProductsImportResult(step=step) right after cursor.advance reads as the obvious shortcut). None of the four new importer tests exercise a CrawlFault step; test_importer.py doesn't even import it. A test asserting that a faulting page still hydrates, still processes, and still reports its processed_count would pin it down.

step = cursor.advance(page)
# Metadata is fetched upfront for main collections and lazily for
# advantage collections, exactly as in import_collection: the parent
# identifier set implies the parent already imported the metadata.
fetch_metadata = self._parent_identifiers is None
books = asyncio.run(
self._api.hydrate_products(
page.products,
fetch_metadata=fetch_metadata,
fetch_availability=True,
)
)

Minor: src/palace/manager/integration/license/overdrive/crawl.py:9-12

This PR de-reaper-ified CrawlFault's docstring but left the module docstring describing the second consumer as hypothetical and as something it isn't: "a future delta harvest can treat the same faults as advisory, because a missed update is recovered by the next run's window." The consumer that arrived is the full import, and its reason for tolerating faults is different (nothing acts on absence, so an unfinishable crawl costs staleness). api.py:747-749 has the same problem from the other direction — product_page_endpoint still explains itself as existing because "the reaper needs every title the collection currently holds". Both are worth rewording alongside the CrawlFault change.

identifiers, and deciding what a fault costs them. The reaper aborts on any
fault, because it acts on titles' *absence* from the crawl; a future delta
harvest can treat the same faults as advisory, because a missed update is
recovered by the next run's window.

• branch feature/overdrive-full-harvest-crawl

@greptile-apps

greptile-apps Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR moves Overdrive full imports from the mutable update feed to the stable product-list crawl and extracts per-product hydration for reuse.

  • Adds cursor-based, backward, overlapping pagination for full imports.
  • Hydrates product-list entries before applying bibliographic and circulation updates.
  • Makes crawl-fault messages consumer-neutral while retaining stricter reaper handling.
  • Preserves legacy feed-page continuations across deployment.

Confidence Score: 4/5

The PR should not merge until an incomplete full crawl leaves the import timestamp unchanged so scheduled delta imports can still recover missed titles.

A completeness shortfall currently logs only a warning and then advances the timestamp used by future delta imports, allowing unchanged titles missed by the crawl to remain absent indefinitely.

Files Needing Attention: src/palace/manager/celery/tasks/overdrive.py

Important Files Changed

Filename Overview
src/palace/manager/celery/tasks/overdrive.py Adds cursor-driven full-import orchestration and fault handling, but advances the delta-import timestamp despite a completeness shortfall.
src/palace/manager/integration/license/overdrive/importer.py Adds product-list page hydration and application while preserving parent/child metadata behavior.
src/palace/manager/integration/license/overdrive/api.py Extracts reusable asynchronous product hydration without changing update-feed hydration behavior.
src/palace/manager/integration/license/overdrive/crawl.py Makes crawl-fault wording consumer-neutral while preserving cursor arithmetic and completeness checks.

Sequence Diagram

sequenceDiagram
    participant Task as Overdrive import task
    participant Cursor as CrawlCursor
    participant API as Overdrive API
    participant Importer as OverdriveImporter
    participant Apply as Apply queue
    participant Timestamp as Import timestamp
    Task->>Cursor: Restore or create cursor
    Task->>Importer: import_products_page(cursor)
    Importer->>API: Fetch product-list page
    API-->>Importer: Raw products and page totals
    Importer->>API: Hydrate metadata and availability
    API-->>Importer: Hydrated products
    Importer->>Apply: Queue bibliographic/circulation updates
    Importer->>Cursor: advance(page)
    alt More pages
        Cursor-->>Task: Next CrawlCursor
        Task->>Task: replace(cursor)
    else Structurally complete
        Cursor-->>Task: CrawlComplete
        Task->>Task: Check distinct-title completeness
        Task->>Timestamp: Mark import finished
    else Structural fault
        Cursor-->>Task: CrawlFault
        Task-->>Task: Return partial identifier set without timestamp update
    end
Loading

Reviews (1): Last reviewed commit: "Walk the full import over the product li..." | Re-trigger Greptile

Comment on lines +253 to +255
timestamp = importer.get_timestamp()
timestamp.start = start_time
timestamp.finish = utc_now()

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.

P1 Incomplete crawl advances timestamp

When a completed crawl reports a distinct-title shortfall beyond its churn allowance, this branch still advances the import timestamp. Subsequent delta imports start at that new timestamp, causing unchanged titles missed by the full crawl to remain absent indefinitely because no scheduled full import revisits them.

Knowledge Base Used: Celery Tasks and Background Jobs

@codecov

codecov Bot commented Aug 11, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.77108% with 6 lines in your changes missing coverage. Please review.
✅ Project coverage is 93.53%. Comparing base (024ab16) to head (deff7c8).

Files with missing lines Patch % Lines
src/palace/manager/celery/tasks/overdrive.py 95.12% 1 Missing and 1 partial ⚠️
...alace/manager/integration/license/overdrive/api.py 89.47% 0 Missing and 2 partials ⚠️
.../manager/integration/license/overdrive/importer.py 91.30% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@                           Coverage Diff                            @@
##           bugfix/overdrive-reap-set-difference    #3641      +/-   ##
========================================================================
- Coverage                                 93.56%   93.53%   -0.04%     
========================================================================
  Files                                       510      510              
  Lines                                     47005    46891     -114     
  Branches                                   6422     6408      -14     
========================================================================
- Hits                                      43980    43858     -122     
- Misses                                     1955     1960       +5     
- Partials                                   1070     1073       +3     

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

@jonathangreen
jonathangreen deleted the feature/overdrive-full-harvest-crawl branch August 13, 2026 13:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature New feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant