Skip to content

perf(metadata): batch artwork reconcile bulk resets - #818

Merged
Quick104 merged 7 commits into
mainfrom
perf/batch-artwork-reconcile-resets
Aug 29, 2026
Merged

Quick104 merged 7 commits into
mainfrom
perf/batch-artwork-reconcile-resets

Conversation

@Quick104

@Quick104 Quick104 commented Aug 28, 2026 •

Copy link
Copy Markdown
Contributor

Problem

bulkResetSurface and bulkResetChapterThumbnails in internal/metadata/artwork_reconcile.go issue single full-table UPDATEs. On a ~600k-item library that is 603k media_items rows (1.32M media_files) locked by one statement. One observed run held row locks for 1h51m, during which 12 of 16 pool connections sat blocked behind it and ordinary playback and metadata writes stalled.

Change

Ported from RXWatcher/silo-server@3b377f5 (credited as commit author), plus tests written here.

Both resets now update in batches of 5000 through a FOR UPDATE CTE over the surface's unique key order, committing per batch, so concurrent writers interleave instead of queueing behind a table-wide writer. A retryOnDeadlock helper covers deadlocks against unrelated writers that touch the same rows in a different order (an observed 40P01 source).

Design points, verified against the code rather than taken from the source commit:

  • Termination. Each loop runs until a batch matches zero rows. Both SET clauses falsify the predicate that selected the row: resetSet writes the provider URL into the path column, which cachedPredicate excludes via NOT LIKE '%://%'; clearSet writes '', excluded by NOT IN ('', '-'). The chapter rewrite empties every thumbnail_path element the EXISTS predicate looks for.
  • No SKIP LOCKED. Skipping a contended row would end the loop early and silently leave rows unreset; a blocked batch waits instead.
  • Atomicity. Per-batch commits mean a crash leaves a partial reset, which is safe: the reconciler is idempotent and the remaining rows still match on the next run.

Deviation from the source commit: the batch size is a var instead of a const, so tests can shrink it to drive the multi-batch path without seeding thousands of rows.

Tests

New in this PR (the source commit had none):

  • TestBulkResetSurfaceBatches — drives bulkResetSurface across multiple batches against a real database, verifying requeue-vs-clear routing, final row state, and stats.
  • TestBulkResetChapterThumbnailsBatches — multi-batch JSONB rewrite: cached elements emptied and stripped of retry state, elements without thumbnails untouched, chapter_thumbnail_retry_after nulled.
  • TestRetryOnDeadlock — retry-until-success, attempt exhaustion, non-retryable errors returned immediately, cancellation honored between attempts.

The DB tests gate on SILO_TEST_DATABASE_URL like the other *_db_test.go files; both pass against a migrated pgvector/pg18 database.

Validation

  • go build ./..., go vet ./..., gofmt -l clean
  • golangci-lint run --new-from-merge-base=origin/main ./... — 0 issues
  • make test-go (CI-equivalent; DB tests skip without SILO_TEST_DATABASE_URL) — passes
  • go test ./internal/metadata/... with SILO_TEST_DATABASE_URL pointing at a migrated pgvector/pg18 database — the new DB tests pass; the only failure is TestImageLadderBackfillLateOldArtworkReopensCompletedVersion, which fails identically on origin/main (pre-existing ambiguous image_type column reference, unrelated; being filed separately). Other packages' DB-gated tests also carry pre-existing failures on origin/main; CI never sets the variable, so they are not maintained.
  • Source commit's EXPLAIN verification on a production-scale database: all three statement shapes (single key, composite key, chapter-thumbnail JSONB) plan as index scans under a bounded Limit

Related issue: N/A — narrow fix (batching an existing maintenance operation; no API, client, or jellycompat surface changes)

AI Disclosure

  • Tool(s): Claude Code (this PR); the source commit on the fork reports Claude Code as well
  • Model(s): claude-fable-5 (port, review, and tests); source commit reports claude-opus-5
  • Involvement: Fully AI-generated, human verified
  • Adversarial review: The port was reviewed independently of the source commit's claims — termination was re-derived from the actual predicates and SET clauses for all three statement shapes, the (a, b) IN (SELECT ...) composite-key form and per-statement autocommit behavior were checked, and the loss of whole-reset atomicity was assessed against reconciler idempotence. Findings: the source commit shipped without tests and with a const batch size that made the multi-batch path untestable; both addressed here.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Improved reliability of bulk artwork resets by processing records in manageable batches.
    • Added automatic retries for temporary database conflicts.
    • Ensured reset progress and statistics remain accurate across batches.
    • Context cancellation now stops processing cleanly between batches.
    • Improved chapter-thumbnail cleanup reliability during bulk resets.

RXWatcher and others added 2 commits August 28, 2026 18:30
bulkResetSurface and bulkResetChapterThumbnails issued single full-table
UPDATEs. On a ~600k-item library that is 603k media_items rows (1.32M
media_files) locked by one statement; one observed run held locks for
1h51m, during which 12 of 16 pool connections sat blocked behind it and
ordinary playback and metadata writes stalled.

Both now update in batches of 5000 through a FOR UPDATE CTE over the
surface's unique key order, committing per batch, so concurrent writers
interleave instead of queueing behind a table-wide writer. Consistent
ordering keeps batches from deadlocking against each other;
retryOnDeadlock (added here) covers deadlocks against unrelated writers
that touch the same rows in a different order, an observed 40P01 source.

SKIP LOCKED is deliberately not used: skipping a contended row would end
the loop early and silently leave rows unreset. The loops terminate
because both SET clauses falsify the predicate that selected the row —
resetSet writes the provider URL into pathCol, which cachedPredicate
excludes via NOT LIKE '%://%', and clearSet empties it.

Ported from RXWatcher/silo-server@3b377f5c2 (batch-size const made a var
so tests can exercise the multi-batch path).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Exercises bulkResetSurface and bulkResetChapterThumbnails through
multiple batches against a real database (batch size shrunk via the new
var), verifying requeue-vs-clear routing, the JSONB chapter rewrite,
loop termination, and that elements without thumbnails survive
untouched. retryOnDeadlock gets unit coverage for retry-until-success,
attempt exhaustion, non-retryable errors, and cancellation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Aug 28, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-08-29T00:05:09.580172Z 8b07d29 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@coderabbitai

coderabbitai Bot commented Aug 28, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

Next included review available in 1 minute.

View limit details

Limit details: You’ve used all 2 included reviews currently available.

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: b9f59649-44d4-4e2d-81e1-ef2869beee38

📥 Commits

Reviewing files that changed from the base of the PR and between 967073b and 8b07d29.

📒 Files selected for processing (2)
  • internal/metadata/artwork_reconcile.go
  • internal/metadata/artwork_reconcile_bulk_db_test.go

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: ef5d16df-ab1f-4fb9-b6ec-595b9c796ec2

📥 Commits

Reviewing files that changed from the base of the PR and between 8d839dc and 967073b.

📒 Files selected for processing (2)
  • internal/metadata/artwork_reconcile.go
  • internal/metadata/artwork_reconcile_bulk_db_test.go

Included review availability: Your plan provides up to 10 included reviews per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

Bulk artwork resets now use bounded, ordered database batches with row locking, PostgreSQL deadlock and serialization retry handling, context cancellation, accumulated statistics, and database-backed coverage for poster and chapter-thumbnail resets.

Changes

Artwork reset reliability

Layer / File(s) Summary
Retry and batch controls
internal/metadata/artwork_reconcile.go, internal/metadata/artwork_reconcile_bulk_db_test.go
The reconciliation code adds configurable batch limits, retry attempts, exponential backoff, and cancellation handling. Tests cover retry limits, immediate non-retryable errors, and canceled contexts.
Poster reset batches
internal/metadata/artwork_reconcile.go, internal/metadata/artwork_reconcile_bulk_db_test.go
Poster resets process ordered locked batches and accumulate statistics. Tests verify remote-source requeueing, source-less clearing, timestamps, and multi-batch behavior.
Chapter-thumbnail reset batches
internal/metadata/artwork_reconcile.go, internal/metadata/artwork_reconcile_bulk_db_test.go
Chapter-thumbnail resets clear JSONB thumbnail and retry fields in batches. Tests verify preserved chapter elements, cleared metadata, timestamps, and statistics.

Estimated code review effort: 4 (Complex) | ~45 minutes

Merge Risk: ⚪ Minimal · up to 96707

This PR changes artwork reconciliation to process resets in bounded batches, reducing prolonged database locking while preserving the existing reconciliation behavior. No actionable merge-blocking risk remains.

Sequence Diagram(s)

sequenceDiagram
  participant Context
  participant ArtworkReconciliation
  participant PostgreSQL
  Context->>ArtworkReconciliation: Provide cancellation state
  ArtworkReconciliation->>PostgreSQL: Lock and update one ordered batch
  PostgreSQL-->>ArtworkReconciliation: Return success or retryable error
  ArtworkReconciliation->>PostgreSQL: Retry failed batch with backoff
  Context-->>ArtworkReconciliation: Cancel between batches or retries
Loading

Suggested reviewers: blurbery

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 6 functions across 2 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: batching artwork reconciliation bulk resets for performance.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/batch-artwork-reconcile-resets

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 967073b822

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/metadata/artwork_reconcile_bulk_db_test.go
The bulk resets under test sweep their whole table, and the shared test
database may hold rows from other tests or a populated snapshot. Each
test now snapshots every pre-existing row its reset would touch and
restores it on cleanup, so only the seeded fixtures change durably.

Verified by seeding decoy cached rows before the run and checking their
poster path, last_refreshed, chapter thumbnail path, and retry
timestamp all survive the tests byte-for-byte.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fb342ce41d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/metadata/artwork_reconcile.go Outdated
bulkUpdateInBatches returns the rows already committed alongside an
error, but bulkResetSurface returned before adding them to stats, so an
interrupted bulk reset serialized zero requeued/cleared rows despite
having durably modified thousands. Counts are now recorded before the
error check in both phases, and a regression test interrupts the clear
phase to prove the requeue phase's committed rows stay counted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 18ba29738e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/metadata/artwork_reconcile.go Outdated
Each batch restarted its ordered scan at the smallest key, so the
database rechecked every previously reset row — still in the key index
but no longer matching — before reaching the next batch, making the
sweep O(N²/batchSize); on 1.32M chapter-thumbnail rows that is
hundreds of millions of repeated predicate checks including
jsonb_array_elements evaluation. Both loops now carry the batch's last
key into the next batch's WHERE, so each key range is scanned once,
termination no longer depends on predicate falsification alone, and a
row re-cached by a concurrent writer behind the cursor is left for the
next reconcile instead of being reset twice in one sweep.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d577183d04

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/metadata/artwork_reconcile_bulk_db_test.go
Inserting media_items rows with local cached poster paths fires the
reopen_image_ladder_backfill_v2 trigger; on a database that has
completed ladder v2 that lowers the image_ladder_backfill_state
singleton, and deleting the fixture rows does not restore it. Both
tests that seed such rows now snapshot the singleton and restore it
last (t.Cleanup runs LIFO, and the poster-row restores themselves
re-fire the trigger).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c0143495ee

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/metadata/artwork_reconcile_bulk_db_test.go
Displacing a cached poster path ending in /original.<ext> fires
queue_displaced_artwork_revision, which inserts an
artwork_revision_gc_candidates row or resets an existing candidate's
schedule, attempts, lease, and error state. The media_items row restore
alone does not undo that. Both poster tests now snapshot the candidates
for every displaceable path before running, delete candidates the reset
or the fixtures created, and restore pre-existing candidates
column-for-column. Verified with decoys: a candidate with distinctive
attempt/lease/error state survives a test run byte-for-byte, and a
displaced row that had no candidate ends with none.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Quick104
Quick104 merged commit ce2ab17 into main Aug 29, 2026
7 checks passed
@Quick104
Quick104 deleted the perf/batch-artwork-reconcile-resets branch August 29, 2026 00:03

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review


P2 Badge Restore the original GC candidate timestamp

When a populated test database already has a dormant GC candidate for a poster touched by this reset, the cleanup restores its other fields but sets updated_at to the current time. sweepDormant only considers candidates whose timestamp is more than 24 hours old (internal/metadata/artwork_revision_gc.go:398-408), so merely running this test can postpone an already-due candidate's reference check and cleanup for another day. Include updated_at in the snapshot and restore its original value rather than writing NOW().

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

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.

1 participant