Repository navigation
fix(series): cover split edition parts listed in another series by the same author (#3324) - #3239
Conversation
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
|
Heads up on scope: this PR fixes the symptom we hit live (split-edition parts getting created/kept monitored forever when their whole is linked to a different series record than the part), not the underlying cause. The actual root cause is that nothing in Bindery detects or merges duplicate series records a provider returns for what is conceptually the same series (e.g. two separate Hardcover-sourced rows both representing "The Wheel of Time"). That widening is independent of and strictly more robust than series-id scoping either way, so it's worth keeping regardless. But duplicate series rows still cause other, unaddressed symptoms once they exist:
There's already a real fix for the actual cause — Happy to open a follow-up issue for automatic duplicate-series detection (candidate pairs per author by title/provider overlap, surfaced as a "merge?" prompt) if that seems worth doing — didn't want to scope-creep this PR to include it. |
vavallee
left a comment
There was a problem hiding this comment.
Thanks for digging this out. The gap is real: ListCoveredSplitEditionParts self joins series_books on series_id, so a whole on one Hardcover series record and its parts on a duplicate record never pair, and the sweep keeps searching the parts. I'd like to fix it, but in a smaller shape.
Why I don't want this version
-
It fixes one consumer out of three.
ListCoveredSplitEditionPartsfeeds the scheduled sweep (scheduler.go), the series page badges and the series Unmonitor button. This PR leaves that query alone and adds a second detector that only the author sync and the two new author routes use. After a sync the author page says the part is covered while the series page still counts it as missing. The comment at the top ofsplit_edition_parts.goalready asks for the SQL rule andeligibleSplitEditionWholeto stay in step; a third copy makes that harder. -
It turns read time suppression into a write. #3109 deliberately skips covered parts in the sweep without touching
monitored, and leaves unmonitoring as a button the user presses. Here every full author sync writesmonitored = 0, on by default for every install through a migration. Someone who re monitors a part on purpose (they own the whole as an ebook and want the split audiobook) gets it undone on the next sync, with only a count in the sync summary to say so. -
Size for the gain: a migration, a profile column, two routes, a checkbox, badges and a button, for what is one query change.
What I'd take instead
Widen the join in ListCoveredSplitEditionParts so the whole and the part pair when they share a series row or share an author (bw.author_id = bp.author_id), keeping the owner rule and SplitEditionPartOf as the gate. That fixes the sweep, the series page and the existing button in one place, with no migration, no toggle and no new UI. If you'd rather keep the series scope as the first choice, apply the author wide pairing only when no shared series row exists. The series page button then covers the manual cleanup you were adding to the author page.
Alongside that:
- Please open an issue first with the author and the two Hardcover series ids you hit, so the case is on record and the test can be built from it.
- A test that fails on
mainwith a whole and a part on two different series rows. - A
changelog.d/fragment.
Mechanical bits
- The DCO check needs
Signed-off-byon the commit (git commit -s). tscfails onMetadataTab.languages.test.tsx:29, which came in with #3213 after your branch was cut, so lint, validate (frontend) and smoke are all red on that one error. Moot if the profile column goes away.- Migration 108 is the next free number today, but it will collide with anything else that claims it. Another reason to avoid a migration here.
…e same author (vavallee#3324) Hardcover can list a split part only in another numbering of the series, where every part is its own volume at an integer position: "The Eye of the World, Part 1 of 2" at 1 in The Wheel of Time (Finnish Numbering), while the whole sits at 1 in The Wheel of Time. ListCoveredSplitEditionParts only paired rows on one series_id, and the fractional-position signal can't hold there anyway, so the sweep kept searching the part. The query gains a second branch: a part and an eligible whole by the same author, where the whole is in a series and the part's own series holds no book with the whole's title, are matched by the title signal alone (seriesmatch.SplitEditionTitleOf, the title half of SplitEditionPartOf). Positions from two series aren't comparable; inside a shared series the two-signal rule still decides alone. The sweep, the series page, the series unmonitor button and fill's re-queue all read this query. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WA2CUNg5o4wUBaX6yWXNNk Signed-off-by: gchahcg <7381837@proton.me>
e1a3015 to
8c754e0
Compare
|
Thanks, agreed on all three points. I've rewritten the branch to your shape: one signed-off commit that only changes I opened #3324 with the author and both Hardcover series ids first, as you asked. Building the test from the real rows turned up something I'd missed before, and it corrects my earlier comment on this PR:
So the new branch pairs across series by the title signal alone, but only when the part's own series holds no book with the whole's title, and only when the whole is itself in a series. Positions from two series aren't comparable, so signal 1 can't be evaluated there. Inside a shared series, only the two-signal rule decides. The details and the guard tests are in the PR body. This is the one place I went beyond what you described, so tell me if you'd rather not loosen signal 1. In that case I'll drop it, but then #3324 stays open. There's one known gap I didn't take on: the catalogue diff still pairs within one series only, so Fill on the Finnish series can still create the part (see the PR body). On the mechanical points: the commit is signed off, and the branch is rebased on current |
vavallee
left a comment
There was a problem hiding this comment.
Thanks, the rewrite is the shape I asked for: one commit, only ListCoveredSplitEditionParts, no migration, and CI is green. I read it and ran the new tests, and found one blocker and a few test gaps.
Blocker: the new branch of the query is too slow on a real library
The second branch of the UNION ALL in internal/db/split_edition_parts.go (around lines 83 to 96) gets this plan from SQLite: SCAN bw, then SEARCH bp USING idx_books_owner. It never uses idx_books_author_id. On a seeded library of 10k books, 400 series and 100 authors, that branch alone took about 30 s, against under 1 ms for the old branch. The sweep and the three series page callers in internal/api/series.go all run it.
Driving the join from the part fixes it. Writing the branch as FROM books bp CROSS JOIN series_books sp ON sp.book_id = bp.id CROSS JOIN books bw ON bw.author_id = bp.author_id AND bw.id <> bp.id took about 1.3 ms on the same data, with the rest of the branch unchanged. A test that seeds a few thousand books and fails past a generous time limit would keep it from creeping back.
Tests that would not catch a regression
I broke each of these in turn and everything stayed green:
- Removing the Go side
SplitEditionTitleOfcheck (split_edition_parts.go:129). Only the SQLGLOBstands between you and a same author book such as "The Eye of the World Companion, Part 2". A case for that would pin it. - Narrowing the
NOT EXISTStox.series_id = sp.series_id. A part that sits in two series, one of them shared with the whole at an integer position, is correctly left uncovered today, and nothing tests that. - Making the title comparison case sensitive, flipping
same_series DESCtoASC, and removing the dedup. Two copies of the whole in another series is reachable, and today it returns one row.
The owner scoping, author join, eligibility gate and imported part filter are all covered, nice.
One small thing
changelog.d/3324-split-parts-across-series.md has an em dash on line 2 (after "(#3324)"). Release notes avoid dashes, so a comma or a full stop works.
…ex (vavallee#3324) Review follow-up. The cross-series branch reached the whole through idx_books_owner, which on a single user library is every book against every book: 5.2 s on 3,000 books here, about 30 s on 10k. It now starts from the part, or from the one series' rows under a series filter, and reaches the whole by author; a unary + keeps the owner index out of both branches, which also fixes the same-series branch's plan under a series filter. The shared-series guard moves from a per-pair NOT EXISTS into Go. It only runs for parts that already title-matched, compares with CleanTitle as the match does instead of lower(), and only counts books of the part's own owner, since series rows carry no owner. Each part title is parsed once (seriesmatch.SplitEditionBase). Tests pin the plan (EXPLAIN QUERY PLAN, also under -race), a 3,000-book timing budget (skipped under -race), and the guard, ordering and dedup cases the review found uncovered. The changelog fragment drops its dash. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WA2CUNg5o4wUBaX6yWXNNk Signed-off-by: gchahcg <7381837@proton.me>
|
Thanks for running it. Everything is addressed in a new signed-off commit on top, not a force-push. Performance I reproduced it first. On a seeded library of 3,000 books (50 authors, 150 series), with a quarter of them "Part N" titles so there are about 44k candidate pairs, the old branch took 5.2 s.
The result is about 150 to 270 ms on that seed, measured with a load average around 5.
Test gaps I re-ran each regression you described, plus a few more, against the new code:
One honest exception: flipping Changelog Fixed: the dash is now a full stop. |
vavallee
left a comment
There was a problem hiding this comment.
Re-checked on 905cbaf: the join now starts from the part and uses idx_books_author_id (about 1 s on 10k books, down from about 59 s), the plan test and the guard tests fail when each guard is broken, and the fragment is clean. Thanks for the quick turnaround.
Summary
ListCoveredSplitEditionPartsnow also covers a split edition part listed only in another series by the same author. The scheduled sweep, the series page badges, the series Unmonitor N split parts button and series fill all read this query, so all four pick the case up with no change on their side. There's no migration, no toggle, no new UI and no sync-time write.Closes #3324.
What changed
The query is now a
UNION ALLof two branches:series_id, any authorSplitEditionPartOf: both signalsSplitEditionTitleOf: title signal onlyBoth branches keep the same part filter (not imported), the same whole gate (not excluded; imported or monitored) and the owner rule (
bw.owner_user_id IS bp.owner_user_id).SeriesIDis still the part's series. Ordering puts the same-series match first, so a part matched both ways reports its same-series whole.SplitEditionTitleOfis the title half ofSplitEditionPartOf, extracted unchanged.SplitEditionPartOfnow calls it, with identical behaviour.Why title-only across series (please check this)
This is the one place the PR goes beyond the review. In the live case (details in #3324), Part 1 of The Eye of the World sits only in
hc-series:248644"The Wheel of Time (Finnish Numbering)", where every part is its own volume, at the integer position1. Widening the join but keeping signal 1 (fractional position under the whole's) still rejects it.Positions from two different series aren't comparable, so across series the title signal is the only one that means anything. The loosening is bounded:
If you'd rather not loosen signal 1, the fallback is to drop the
UNIONbranch's title-only gate and require a fractional position there too. That keeps the shape but doesn't fix #3324.Tests
AcrossSeriesis seeded from the live rows in Split edition part listed only in another series (alternate numbering) is never covered by its owned whole #3324. It fails onmainand passes here.AcrossSeriesGuardscovers what must not pair:It also has a positive control.
GuardIgnoresOtherOwners: another user's copy of the whole beside the part doesn't block it.ReportsOneWholePerPart: same-series whole preferred, and one row per part when two copies of the whole exist elsewhere.PlanStartsFromThePart:EXPLAIN QUERY PLAN, with and without a series filter. No step reaches the whole throughidx_books_owner, and the cross-series branch reaches it by author. It also runs under-race.LargeLibraryIsFast: 3,000 books, fails past 2 s (the old query fails it), asserts the result. Skipped under-race.TestSplitEditionTitleOf,TestSplitEditionBase.The existing consumer tests pass untouched.
TestListCoveredSplitEditionPartsgives each case its own author.Each regression from the review was re-run as a mutation and fails a test.
Performance
The cross-series branch starts from the part, or from the one series' rows under a series filter. It reaches the whole by author (
idx_books_author_id). A unary+on the owner comparison keepsidx_books_ownerout of both branches. Given the choice, SQLite used it, which on a single-user library is every book against every book: 5.2 s on 3,000 books before, about 150 to 270 ms now on an adversarial seed with ~44k candidate pairs.The shared-series guard runs in Go, only for parts that have already title-matched. It compares with
CleanTitle, as the match does, and only counts books of the part's own owner. Each part title is parsed once, throughseriesmatch.SplitEditionBase.Known limitation (not in this PR)
The Hardcover catalogue diff (
series.go,presentWholes/eligibleSplitEditionWhole) still pairs only within one series. On the Finnish Numbering series page, a catalogue part whose whole sits in the main series still shows as missing, and Fill can create it. Once it's stored, this query covers it: the sweep skips it, and it gets the badge and the button. Taking the diff across series would mean looking up same-author wholes outside the series being diffed. I left that out to keep this to the one query you asked for, but I can follow up if you want it.Checklist
docs/Hardcover-Series-Wiki.md, one sentence in the split-edition paragraph)Test plan
upstream/main, passes on this branchgo test ./internal/db/... ./internal/seriesmatch/... ./internal/scheduler/... ./internal/api/...golangci-lint run --concurrency 2 ./...🤖 Generated with Claude Code
https://claude.ai/code/session_01WA2CUNg5o4wUBaX6yWXNNk