Repository navigation
fix(metadata): guard audnex enrichment writes against concurrent edits (#2926) - #3008
Conversation
#2926) Three paths made an Audnex call and then wrote the whole book row from the snapshot taken before it, so an edit saved during the call was lost: the ABS importer, the Hardcover list syncer and the manual enrich endpoint. They now write through the same updated_at guard hydration uses since #2794. The background paths use UpdateHydratedMetadata, which writes only the enrichment columns and, on a lost guard, reloads the row so the rest of the import or sync works from the user's edit. The manual endpoint retries once from a fresh read (the Audnex answer is cached, so this normally costs no second request) and returns 409 if the row changes again. When the endpoint also maps the book onto the canonical work for its ASIN it needs the whole row, so BookRepo gains UpdateIfUnchanged, the guarded sibling of Update. Rebind had the same shape around its provider fetch and now uses UpdateIfUnchanged too, answering 409 on a lost guard. 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>
…cts (#2926) The rebind modal turned every 409 without force_required into "This metadata record conflicts with an existing book", so a user whose book was edited during the rebind was told the target id was taken. Both new 409s (rebind and enrich) now carry reason "changed", and the modal shows its own string for it. The enrich button already showed the server's message; a test now pins that. List sync logs a dropped enrichment at INFO, since it is not retried on later syncs, and the comment no longer claims the reloaded row reaches the widen path's queued search. 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>
There was a problem hiding this comment.
Reviewed the guard mechanics end-to-end. No blocking defects.
Correctness
UpdateHydratedMetadataguard miss →ReloadHydratedBookreloadsbin-place (consistent with #2794). Both background callers (enrichAudiobookFromASIN,enrichAudiobook) correctly usebafter the call.internal/abs/importer.go:49comment is accurate.UpdateIfUnchangedintentionally does not reloadbon miss. Both callers (Rebind,EnrichAudiobook) return immediately on!updatedwithout touchingbagain, so there's no stale-pointer hazard. Worth a note in the godoc for future callers, but not a bug now.- The retry loop in
EnrichAudiobook(internal/api/books.go:218) is afor attempt := 0; ; attempt++with all exits as explicitreturns. Max two iterations, correct. Slightly unusual idiom but not wrong. - Ownership re-checked on the fresh read at
internal/api/books.go:266— correct. - The
tryMapAudiobookMetadataByASIN→UpdateIfUnchangedvsUpdateHydratedMetadatabranch is correct: the bool return distinguishes "identity rewritten" (needs full-row write) from "enrichment only" (partial-column write).
Security
No new surface. The 409 reason: "changed" field is safe to expose. The tenancy check on the reloaded book (auth.CheckOwnership) mirrors the pattern used throughout internal/api/.
Tests
Controls (PersistsWithoutConcurrentEdit) plus regression cases for each path. TestBookRepoUpdateIfUnchanged covers the legacy timestamp shape. TestEnrichAudiobook_ConflictWhenEditsKeepLanding verifies the second miss is a 409, not a 200.
Nit
internal/db/books.go:670 godoc for UpdateIfUnchanged could say explicitly that b is not reloaded on a missed guard, to match the equivalent note UpdateHydratedMetadata implies through ReloadHydratedBook. Low stakes given the tests, but a future caller scanning the signature will miss that distinction.
— 🤖 Bindery triage bot (automated). Reply to correct me; a human will see it.
…author refresh (#2926) (#3038) * fix(metadata): keep edits made during ABS merges, catalogue sync and 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> * fix(abs): guard the ABS author merge against concurrent edits (#2926) 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> * fix(metadata): address review of the concurrent edit guards (#2926) - 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> --------- Signed-off-by: vavallee <vavallee@protonmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Summary
Three paths made an Audnex call and then wrote the whole book row from the snapshot taken before it, so an edit saved during the call (unmonitor, new cover, narrator fix) was silently undone. They now use the same
updated_atguard #2794 added for hydration. Rebind had the same shape around its provider fetch and is fixed too.Closes #2926.
Implementation notes
abs/importer.goenrichAudiobookFromASIN(background)UpdateUpdateHydratedMetadata(book, snapshot.UpdatedAtRaw)hardcoverlistsyncerenrichAudiobook(background)UpdateUpdateHydratedMetadataapi/books.goEnrichAudiobook(manual)UpdateUpdateHydratedMetadata, orUpdateIfUnchangedwhen the ASIN map rewrote the book's identityapi/books.goRebind(manual)UpdateafterGetBookFromProviderUpdateIfUnchangedreason: "changed";RebindModalpreviously turned any 409 withoutforce_requiredinto the duplicate message ("This metadata record conflicts with an existing book"), so it now branches onreasonand showsbookRebind.changed(en.json only). The duplicate foreign ID 409 is unchanged.BookRepo.UpdateIfUnchangedis the guarded sibling ofUpdate: the same full-row write withAND CAST(updated_at AS TEXT)=?, comparing stored text likeUpdateHydratedMetadataso legacy timestamp shapes still match. On a lost guard it writes nothing and doesn't reloadb.Updateis now a thin wrapper over the shared helper. Its SQL and behaviour are unchanged.tryMapAudiobookMetadataByASINnow returns whether it did). Otherwise only the enrichment columns are written.updated_atis invisible to the guard (fix(db): SetFilePath fallback changes status without bumping updated_at #2793).Follow-ups (not in this PR)
Other fetch-then-full-
Updatepaths found by grepping everyBookRepo.Updatecall site. Each one needs its own decision about what a lost guard means, so I left them out:abs/import_upserts.gomergeUpstreamBook(line ~573). It snapshots afterapplyBookFields, then runs the upstream lookup (lookupUpstreamBook, ISBN/search) and the merge. It is also reached frommatchHardcoverSeries(import_hardcover_series.go:158) afterSearchSeries/GetSeriesCatalog. The merge records conflicts and identifiers, so a lost guard needs a decision about those.api/authors.gorunCatalogueSync, theseenTitlesdedup branch (lines ~2529/2557/2568). Rows come fromListByAuthorIncludingExcludedat the start of the sync and are written after cover enrichment, edition fetches and earlier iterations' hydration, so the window is the whole catalogue loop.scheduler.refreshMetadatadoesmeta.GetAuthorthen a fullauthors.Update, which is the same bug on the authors table.Checked and not affected (no network between the read and the write):
BookHandler.Update, the bulk setters,applyGenresToBooks, add book core, queue removal, ABS conflict choice,adoptDirectInsertMatch,upsertManualBook, ABS and CalibreapplyBookFields, both rollbacks,refreshBookStatus, and the list syncer's widenUpdate.Checklist
git commit -serrortext for any failure, so its 409 already reads sensibly; a vitest now pins thatdocs/User-Guide-Wiki.mdparagraph next to the fix(metadata): preserve concurrent edits during hydration (#2758) #2794 hydration note)changelog.d/(2926-audnex-concurrent-edits.md)Test plan
Fail before (each test run against
origin/maincode, with only the new tests applied):absTestEnrichAudiobookFromASINPreservesConcurrentEditmonitored=true narrator="Audnex Narrator" image="https://audnex.test/cover.jpg"hardcoverlistsyncerTestSync_EnrichmentPreservesConcurrentBookEditapiTestEnrichAudiobook_RetriesAfterConcurrentEditmonitored=true image="https://audnex.test/cover.jpg"apiTestEnrichAudiobook_MappedRetriesAfterConcurrentEditapiTestEnrichAudiobook_ConflictWhenEditsKeepLandingapiTestRebind_ConflictWhenBookChangesDuringFetchThe fake Audnex (
AudnexBookClientstub, or the list syncer's enricher interface) commits a user edit throughBookRepo.Updatewhile it handles the request. Controls (...PersistsWithoutConcurrentEdit,TestEnrichAudiobook_PersistsAudnexFields) confirm the guarded write still lands when nothing races it.TestBookRepoUpdateIfUnchangedcovers the new repo method on a legacyCURRENT_TIMESTAMPshaped row.gofmt -l internal/ cmd/(clean)go vet ./...golangci-lint run ./...v2.11.4 (0 issues)go test -raceon./internal/db/,./internal/abs/,./internal/hardcoverlistsyncer/(ok), and./internal/api/ -run 'TestEnrichAudiobook|TestRebind|TestBook'(ok). The whole api package under-racehit a 45m timeout on this machine with no failures or races before it; left to CI's sharded race job.go test -count=1 ./cmd/... ./internal/...(all ok)2eeba271(main had touchedinternal/db/books.go):go build ./..., andgo testondb,abs,hardcoverlistsyncer,importerand the api enrich/rebind/book tests (all ok)cd web && npm run lint(0 errors; 7 warnings, all in untouched files)&& npm run typecheck && npm run buildnpx vitest run src/components/RebindModal.test.tsx src/pages/BookDetailPage.test.tsx(127 passed, including the new changed-book rebind case and the enrich 409 case)go vetandgo testoninternal/apiandinternal/hardcoverlistsyncer, golangci-lint on both (0 issues)🤖 Generated with Claude Code
https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9