Repository navigation
fix(metadata): keep edits made during ABS merges, catalogue sync and author refresh (#2926) - #3038
Conversation
…author refresh (#2926) Three more paths fetched from a provider and then wrote a whole row back from a snapshot taken before the call, silently undoing an edit saved in between. #3008 guarded the Audnex and rebind paths; these are the follow-ups it listed. - ABS mergeUpstreamBook (also reached from matchHardcoverSeries): the merge is planned in memory and written with BookRepo.UpdateIfUnchanged. On a lost guard the upstream record already in hand is merged once more onto a fresh read; a second loss drops the merge for this import. The conflict rows and the relinked row's old identifier are recorded only after the row is written, so they always describe what was stored. - Catalogue sync, title match branch: the row is re-read just before the change instead of trusting the start of sync snapshot, then written guarded on that read. A row deleted or excluded meanwhile is left alone. - Scheduled author refresh: new AuthorRepo.UpdateIfUnchanged (same stored text updated_at guard). Each author is re-read before its lookup and the write is skipped, logged at INFO, when it loses; the job runs again on schedule. Authors now carry UpdatedAtRaw from CAST(updated_at AS TEXT). Every UPDATE authors that a user edit or the refresh can race bumps updated_at; the three that do not (sort key backfill, last_discovery_at, catalogue_populated_at) touch columns the full row write does not. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9 Signed-off-by: vavallee <vavallee@protonmail.com>
enrichAuthor looked the author up upstream and then wrote the whole row back with authors.Update, so an edit saved during the lookup (unmonitoring, a new profile) was undone. It now follows mergeUpstreamBook's rules: - the merge is planned on a copy and written with AuthorRepo.UpdateIfUnchanged on the row as read before the lookup; - a lost guard merges the record already in hand once more onto a fresh read, re-checking that the ABS name still matches the author; a second loss drops the merge for this import and the next import retries it; - the variant alias for a rename, the identifier for a non-replaceable identity and the conflict rows are recorded only after the write lands, and the matcher only learns a row that was written. applyConflictField had no callers left, so planConflictField replaces it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9 Signed-off-by: vavallee <vavallee@protonmail.com>
- Catalogue sync: when a title match write loses its guard, skip recording the work's ids and its series link as well. Recorded ids sent the next sync down the id branch, which never relinks a calibre stub or widens the format, so the lost change was never retried. - ABS book merge: keep the ABS id in the identity table before the relink write again, so a crash between the two cannot leave a row no ABS key lookup finds (#1691). A lost guard leaves an identifier that still names the row's current id. - ABS book and author merges: a row deleted during the upstream lookup is skipped with an INFO log and a run message instead of failing the item with "sql: no rows". - Move refreshTitleMatch and writeTitleMatch above runCatalogueSync's doc comment so godoc attaches it to the right function. - Wiki and changelog: say which fields the ABS merge protects. Fields it takes from upstream (cover, description and the like) still follow the metadata conflict rules on the retry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9 Signed-off-by: vavallee <vavallee@protonmail.com>
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
Clean. A few things I checked:
preserveRelinkedBookIdentifier runs before the guarded write (internal/abs/import_upserts.go, mergeUpstreamBook loop): intentional for the #1691 invariant — on a double-loss the leftover identifier is benign because it still names the current row id. That's documented and correct.
upsertIdentifierTx is gated on the guard (internal/db/authors.go, update): the early return false, nil on n == 0 is before the upsertIdentifierTx call, so a lost guard leaves no identifier row behind. Matches BookRepo.
Trigger SQL in tests (internal/abs/import_upserts_test.go:718, internal/api/authors_test.go:1317): string-interpolates mergeRaceUpstreamID / "OL-RETRY-W" into a CREATE TRIGGER. These are compile-time constants in test files, not user input — fine.
writeTitleMatch reload on a lost guard (internal/api/authors.go:1095): the reload is best-effort; a failure is warned and false is returned. The lostGuard flag then prevents the stale row from being used downstream, so the only consequence is the sync will see a stale snapshot in seenTitles for any subsequent title with the same key in the same run — which can't happen in practice (one entry per title).
All four paths have first-loss and second-loss tests with real in-memory DB state, plus controls. TestAuthorRepoUpdateIfUnchanged pins the legacy timestamp shape. No blocking findings.
— 🤖 Bindery triage bot (automated). Reply to correct me; a human will see it.
Summary
These are the follow-ups #3008 left. Four more paths fetched from a metadata provider and then wrote the whole book or author row from a snapshot taken before the call, so an edit saved during the call was silently undone. Each one now writes guarded on
updated_at, and each has its own answer for what happens when the guard loses.Refs #2926 (closed by #3008).
Implementation notes
abs/import_upserts.gomergeUpstreamBook(also frommatchHardcoverSeries)BookRepo.Updateafter the ISBN/ASIN/title lookup or the Hardcover series fetchBookRepo.UpdateIfUnchangedbookends up holding the stored row.abs/import_upserts.goenrichAuthor→ newmergeUpstreamAuthorAuthorRepo.Updateafter the upstream author search andGetAuthorAuthorRepo.UpdateIfUnchangedapi/authors.gorunCatalogueSync,seenTitlesbranch (calibre stub upgrade, dual format merge, same format ratings)Updateof the row read byListByAuthorIncludingExcludedat sync startrefreshTitleMatchre-reads the row right before the change, thenwriteTitleMatchwrites withUpdateIfUnchangedguarded on that readscheduler.refreshMetadataAuthorRepo.Updateof the author listed at the start of the runGetAuthor, then written with the newAuthorRepo.UpdateIfUnchangedapplyConflictFieldis replaced byplanConflictField, which applies the value but does not write the conflict. Both merges collect their bookkeeping and record it only afterUpdateIfUnchangedreturns true (or when the merge needs no write at all), and the author matcher only learns a row that was written. So a dropped merge leaves nothing behind for a change that never landed, and a retried merge records the values it actually wrote. The one exception is the ABS id a book relink replaces: it is written before the row, so a crash in between cannot leave a row no ABS key lookup finds (ABS importer matches by title, so retitling an item in ABS + re-import mints a duplicate book record — including 'wanted' twins of owned books #1691). If the write then loses, that identifier still names the row's current id.AuthorRepo.UpdateIfUnchangedmirrors the book method: the same full row write plusAND CAST(updated_at AS TEXT)=?. When the guard loses it writes nothing, not even theauthor_identifiersupsert, and it does not stamp the caller's struct.Updateis now a thin wrapper over the shared helper, with its SQL unchanged.models.AuthorgainsUpdatedAtRaw, read viaCAST(updated_at AS TEXT)appended toauthorSelectCols/authorSelectColsA. The cast is needed because the driver reformats a DATETIME it can parse, so the plain column cannot reproduce the stored text. A test pins this on a legacy2026-09-01 10:00:00row.updated_ataudit. I checked everyUPDATE authorsin non test Go code. The full rowupdate,UpgradeSyntheticDNBand the four alias merge setters all bumpupdated_at. Three statements do not: thesort_key/name_sort_keybackfill indb.go(runs at migration time),last_discovery_at, andcatalogue_populated_at. None of them is a user edit, and the full row update writes none of those columns, so no data is lost in either direction. All handler and importer author writes go throughUpdate.Checklist
git commit -sdocs/User-Guide-Wiki.md, paragraph after the fix(metadata): guard audnex enrichment writes against concurrent edits (#2926) #3008 note)changelog.d/(concurrent-overwrite-followups.md)Test plan
Fail before: I ran each new test with only the test files applied on top of
origin/mainsource.absTestMergeUpstreamBookRetriesOnTopOfConcurrentEditmonitored=true narrator=""(edit overwritten)absTestMergeUpstreamBookSkipsWhenTheBookKeepsChangingabsTestMergeUpstreamAuthorRetriesOnTopOfConcurrentEditabsTestMergeUpstreamAuthorSkipsWhenTheAuthorKeepsChangingschedulerTestRefreshMetadata_PreservesEditDuringProviderCallmonitored=true image="https://upstream.test/a.jpg"apiTestRefreshAuthorBooks_TitleMatchKeepsEditMadeDuringSyncmonitored=true image=""apiTestRefreshAuthorBooks_LostTitleMatchIsRetriedNextSyncforeignID="calibre:book:77"absTestMerge*SkipsWhen*IsDeletedDuringLookupsql: no rows in result setIn each test the fake provider commits a user edit through the ordinary
Updatewhile it handles the request. That request is the ISBN lookup for the ABS book merge,GetAuthorfor the ABS author merge,GetAuthorfor the scheduler, and the MinPages edition prefetch for the catalogue sync. The ABS double loss tests use a test onlyRAISE(IGNORE)trigger on the relink write so that every attempt loses.TestMergeUpstreamBookPersistsWithoutConcurrentEditandTestMergeUpstreamAuthorPersistsWithoutConcurrentEditare the controls.TestAuthorRepoUpdateIfUnchangedcovers the repo method: a legacy timestamp shape, no identifier written when the guard loses, and invalid snapshots rejected.gofmt -l internal cmd(clean)go vet ./...golangci-lint run ./...v2.11.4 (0 issues)go test -race -timeout 40m ./internal/db/ ./internal/abs/go test -timeout 40m ./internal/api/ ./internal/scheduler/ ./internal/models/8ecf7761golangci-lint run ./...(0 issues),go test -timeout 40m ./internal/abs/ ./internal/db/ ./internal/scheduler/ ./internal/api/go vet ./internal/abs/,golangci-lint run ./...(0 issues),go test -timeout 40m ./internal/abs/ ./internal/db/and the same with-racego test ./cmd/... ./internal/...: every package passed exceptinternal/api, which hit the 30m timeout on a host with load average around 40. It passed on its own in the 40m run above.🤖 Generated with Claude Code
https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9