Skip to content

perf(metadata): stop the artwork GC sweep rewriting referenced revisions - #2094

Open
blurbery wants to merge 1 commit into
Silo-Server:mainfrom
blurbery:perf/metadata-artwork-gc-dormant-cursor
Open

blurbery wants to merge 1 commit into
Silo-Server:mainfrom
blurbery:perf/metadata-artwork-gc-dormant-cursor

Conversation

@blurbery

@blurbery blurbery commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Related issue: N/A
Validation tasks: none affected. Artwork that GC keeps or deletes doesn't change, only how often parked rows are rewritten.

The artwork revision GC rewrites every parked revision that is still in use once a day, even though nothing about the row changes. Its dormant sweep re-verifies parked revisions (next_attempt_at IS NULL), so a reference that disappears through a surface without a displacement trigger still becomes collectible. It picks rows whose updated_at is more than 24 hours old, then sets updated_at = NOW() on every row that is still referenced. updated_at is the key of the sweep's own index (artwork_revision_gc_dormant_idx), so none of those updates can be HOT, and each adds an entry to all seven of the table's indexes.

On my server (read-only statistics, about three days): 110,862 parked revisions. The touch UPDATE ran 41 times, rewrote 258,011 rows and wrote 753 MB of WAL. n_tup_hot_upd was 0 of 327,314 updates. The original_path unique index is 271 MB for 140k rows, and the heap 654 MB.

This keeps the sweep's checks and stops it rewriting rows that didn't change.

Approach

  • A singleton artwork_revision_gc_dormant_cursor row (after_id, cycle_started_at) holds the sweep's position. Each run reads up to 10,000 parked rows with id > after_id in id order, still skipping rows changed within the last 24 hours as before, and requeues the ones nothing references. It writes nothing to the rows that are still referenced. Then it moves the cursor to the last id it read.
  • A batch shorter than the limit ends the cycle (after_id = 0). A new cycle starts only once cycle_started_at is 24 hours old, so each parked row is still checked about once a day.
  • The cursor is advanced with a compare-and-set on the position the sweep read. If two nodes sweep at once they read the same batch, requeue the same rows (the requeue is already idempotent), and only one moves the cursor.
  • The migration drops artwork_revision_gc_dormant_idx, which only the old ORDER BY updated_at, id used. The new query walks the primary key. Down recreates the index exactly as 20260714120826 defined it.

A parked row is still checked about once a day, but in the worst case it now waits about two days instead of one (see Risks). With 10,000 rows a run and an hourly task, a cycle over my 110k parked rows takes about 11 runs.

Validation

  • TestArtworkRevisionGCDormantSweep now also checks that the referenced row keeps its row version (ctid and xmin). It fails with main's sweepDormant (referenced dormant row was rewritten) and passes here.
  • New TestArtworkRevisionGCDormantSweepCycles covers six things: a bounded sweep stops at its last row and the next resumes there; a row behind the cursor waits for the next cycle; the next cycle doesn't start before the recheck interval; once it does, it records its start and re-arms the unreferenced row; and a missing cursor row is recreated. Each test passes on its own on a fresh database. Both tests are added to scripts/ci/db-contracts.txt.
  • New TestArtworkRevisionGCDormantCursorMigrationChangesIndexConcurrently checks the migration is NO TRANSACTION and that Down drops a failed build concurrently before recreating the index.
  • go test ./internal/metadata/ against a migrated PostgreSQL 18 database passes. Applying the migration, rolling back with --migrate-down-to, and applying again leaves the expected table and index. make migrate-validate passes.
  • gofmt and go vet are clean. golangci-lint with gocritic on ./internal/metadata/ and ./migrations/, filtered to changed lines, reports 0 issues. (make lint-changed lints the whole tree whenever a .sql file under migrations/ changes, which CI's Go lint job covers.)

Benchmarks

Baseline main at ca186fe, changed this branch. PostgreSQL 18.6 with pgvector 0.8.7 on an Apple Silicon Mac, shared_buffers 512 MB, autovacuum off for the table during the run.

Workload: two databases, one migrated to main and one to this branch, each seeded the same way: 100,000 parked revisions still referenced by a movie poster and 1,000 that lost their reference, all last changed two days ago. Then 24 calls to the real sweepDormant with the production batch size, one day of hourly runs, through a temporary test harness. WAL is the pg_current_wal_insert_lsn() difference, and updates are pg_stat_user_tables after the sweep connections closed. One repetition, because the first day's writes change the state for a second.

One day of sweeps main This branch
Rows checked / requeued 101,000 / 1,000 101,000 / 1,000
Rows updated 101,000 (0 HOT) 1,000 (the requeues)
WAL 58.0 MB 0.54 MB
Heap 19.7 MB → 39.4 MB 19.7 MB → 19.9 MB
Indexes 21.0 MB → 27.1 MB 17.9 MB → 18.0 MB (no dormant index)
Sweep time, all 24 runs 525 ms 202 ms

Both versions check every parked row once in the day and requeue the same 1,000 rows. On main the same 100,000 rows are rewritten again the next day.

Limitations: synthetic rows on one machine, so this local WAL per row is smaller than on my server, where rows are wider and the indexes bloated. Most of each run's time in production is the reference check (referencedPaths, 3.3 to 4.0 s per 10,000-path batch on my server), which this doesn't change. A serial plan for that query measured 0.6 s; that's a separate change.

Evidence

No user-visible change: the same artwork is kept and deleted; only the bookkeeping writes stop. The raw output behind Benchmarks and Validation:

My server, read-only statistics
pg_stat_statements reset 2026-10-05 01:25 UTC

artwork_revision_gc_candidates: n_live_tup 140932, n_tup_upd 327314, n_tup_hot_upd 0, heap 654 MB, indexes 377 MB (7)
parked (next_attempt_at IS NULL): 110862 of 140060 rows

calls | rows   | wal    | mean_ms | statement
41    | 258011 | 753 MB | 446     | UPDATE artwork_revision_gc_candidates SET updated_at = NOW() WHERE id = ANY($1) AND next_attempt_at IS NULL  (the touch)
64    | 258012 | 242 MB | 87      | SELECT id, original_path FROM artwork_revision_gc_candidates WHERE next_attempt_at IS NULL AND ... (the sweep's read)

index sizes:
artwork_revision_gc_candidates_original_path_key  271 MB
artwork_revision_gc_candidates_pkey                68 MB
artwork_revision_gc_lease_idx                      14 MB
artwork_delivery_due_idx                           8416 kB
artwork_delivery_pending_idx                       7640 kB
artwork_revision_gc_dormant_idx                    6304 kB
artwork_revision_gc_due_idx                        2432 kB
Local benchmark, raw output
PostgreSQL 18.6 (local), autovacuum off for the table. Seed per database: 100,000 parked revisions
referenced by a movie poster plus 1,000 unreferenced, all last changed two days ago.
24 calls to sweepDormant(ctx, 10000) (one day of hourly runs) through a temporary harness.

== main
run 1: checked=10000 requeued=0 84 ms
run 2: checked=10000 requeued=0 47 ms
...
run 10: checked=10000 requeued=0 50 ms
run 11: checked=1000 requeued=1000 7 ms
run 12..24: checked=0 requeued=0
RESULT runs=24 checked=101000 requeued=1000 elapsed=525ms wal_bytes=60832800 n_tup_upd=101000 n_tup_hot_upd=0 index_bytes=21979136->28385280 heap_bytes=20668416->41336832

== branch
run 1: checked=10000 requeued=0 43 ms
run 2: checked=10000 requeued=0 18 ms
...
run 10: checked=10000 requeued=0 23 ms
run 11: checked=1000 requeued=1000 5 ms
run 12..24: checked=0 requeued=0
RESULT runs=24 checked=101000 requeued=1000 elapsed=202ms wal_bytes=562104 n_tup_upd=1000 n_tup_hot_upd=0 index_bytes=18776064->18857984 heap_bytes=20668416->20856832
Tests
== main's sweepDormant with the adapted TestArtworkRevisionGCDormantSweep
    artwork_revision_gc_test.go:903: referenced dormant row was rewritten: row version (0,6)/16327, want (0,3)/16324
FAIL

== this branch, fresh database, each test on its own
ok  	github.com/Silo-Server/silo-server/internal/metadata	0.607s   (TestArtworkRevisionGCDormantSweep)
ok  	github.com/Silo-Server/silo-server/internal/metadata	0.405s   (TestArtworkRevisionGCDormantSweepCycles)

== mutation check: cursor upsert keeps the old cycle start
    artwork_revision_gc_test.go:996: new cycle started 24h1m0.00097s ago, want it recorded as starting now
FAIL

== whole package against a fresh migrated database
ok  	github.com/Silo-Server/silo-server/internal/metadata	5.227s

== migration down/up
INFO database migration rollback finished to_version=20261007094051 rolled_back=1
table_gone=t old_index_valid=t
INFO database migration applied version=20261008041147
cursor: after_id=0 cycle_started_at=-infinity old_index_gone=t

Risks

  • A new table and a dropped index. During a rolling upgrade an old node's sweep still runs its ORDER BY updated_at, id query without the index, as a sequential scan of the candidates table, and still touches rows, which the new code doesn't mind.
  • The worst-case time before a parked row is re-checked grows from about one day to about two. Rows still skip a check within 24 hours of their last change, and parking or re-tracking a revision updates that time. A row the cursor reaches just before it turns 24 hours old therefore waits for the next cycle, which starts at least 24 hours after this one. That only affects how soon a revision whose reference vanished through an untriggered surface is collected. Dropping the age filter would bring back the one-day bound, at the cost of re-checking rows that were just verified.

Checklist

  • I read and can explain the complete diff.
  • This pull request addresses one concern.
  • The Evidence section shows every change a user can see, or says there is none.

AI Disclosure

  • Harness: Claude Code (desktop app)
  • Tool(s): Claude Code, with a read-only subagent for an adversarial review
  • Model(s): claude-opus-5-5
  • Involvement: AI-assisted
  • Adversarial review: a separate read-only agent reviewed the change for starvation of parked rows, other users of the dropped index, the cursor compare-and-set under concurrent sweeps, the migration and Down, and test validity under parallel packages. It found no production defects. It did find that the existing test depended on test order on a fresh database, and that the cycle-start CASE, the missing cursor row and long-lived databases weren't covered. I fixed the helper and added those cases: with the CASE broken, the cycle test now fails. It also pointed out the longer worst-case recheck, which is under Risks.

AI-assisted with Claude Opus. I directed the task and designed the work.

The artwork revision GC's dormant sweep re-verifies parked revisions so a
reference that disappears through a surface without a displacement
trigger still becomes collectible. It picked rows by updated_at and
re-stamped updated_at on every row still referenced, so each parked row
was rewritten once a day. updated_at is the key of the sweep's own index,
so none of those updates could be HOT and each added entries to all seven
indexes. On a server with about 110k parked revisions that was 258k row
rewrites and 753 MB of WAL in three days, for rows that didn't change.

The sweep now walks parked rows in id order from a cursor persisted in
artwork_revision_gc_dormant_cursor and writes only the rows it requeues.
It still skips rows changed within the recheck interval, and starts a new
pass at most once per interval. The cursor only advances from the
position a sweep read, so concurrent sweeps repeat work at worst. The
(updated_at, id) index existed only for the old ordering and is dropped.
@silo-kody

silo-kody Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Silo Kody — review complete

Review finished. Check the inline comments for findings and verify each suggestion against the code and tests.

Reviewing changes in Silo
  • Include the related issue, expected behavior, and validation steps in the PR description.
  • For API changes, describe the effect on Apple and Android clients and Jellyfin compatibility.
  • For plugin changes, identify the affected SDK contract, plugin, and catalog entry.
  • Follow this repository's AGENTS.md and CONTRIBUTING.md.
  • Request another review with @kody start-review in a PR comment.
  • React with 👍 or 👎 to give feedback on individual suggestions.
Review settings
Review Options

The following review options are enabled or disabled:

Options Enabled
Bug ✅
Performance ✅
Security ✅
Business Logic ❌

@Quick104 Quick104 added priority: P2 Limited scope, workaround exists, or polish impact: perf Unusable slowness on normal hardware labels Oct 8, 2026 — with Cursor
@coderabbitai

coderabbitai Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 8f9229c6-98fb-4ca8-80ac-fc1142f1bc65
📥 Commits

Reviewing files that changed from the base of the PR and between ca186fe and 585eb76.

📒 Files selected for processing (5)
  • internal/metadata/artwork_revision_gc.go
  • internal/metadata/artwork_revision_gc_test.go
  • migrations/artwork_revision_gc_dormant_cursor_test.go
  • migrations/sql/20261008041147_artwork_revision_gc_dormant_cursor.sql
  • scripts/ci/db-contracts.txt

Included review availability: This review used your included allowance. Your plan provides up to 4 included reviews per hour; 1 remain after this review.


📝 Walkthrough

Walkthrough

The dormant artwork-revision sweep now processes parked candidates in bounded batches using a persisted ID cursor. A migration adds the cursor table and updates the dormant-candidate index. Tests cover cursor continuation, cycle timing, orphan requeueing, and migration behavior.

Changes

Dormant Revision Sweep

Layer / File(s) Summary
Persisted sweep cursor
migrations/sql/20261008041147_artwork_revision_gc_dormant_cursor.sql, migrations/artwork_revision_gc_dormant_cursor_test.go
The migration creates a singleton cursor table and removes the prior index. Its Down section restores the index and removes the table. The migration test checks the concurrent index operations.
Bounded dormant sweep
internal/metadata/artwork_revision_gc.go, internal/metadata/artwork_revision_gc_test.go, scripts/ci/db-contracts.txt
The sweep reads and advances a persisted cursor, selects eligible candidates after that ID, and requeues only unreferenced candidates. Tests cover bounded cycles, recheck timing, missing cursor state, and unchanged referenced rows. The database-contract list includes the sweep tests.

Priority: ➖ Normal

Estimated code review effort: 3 (Moderate) | ~25 minutes

Change: Refactor

Sequence Diagram(s)

sequenceDiagram
  participant sweepDormant
  participant cursor_table as artwork_revision_gc_dormant_cursor
  participant database
  sweepDormant->>cursor_table: Read cursor and cycle time
  sweepDormant->>database: List eligible candidates after cursor ID
  sweepDormant->>database: Check candidate references
  sweepDormant->>database: Requeue unreferenced candidates
  sweepDormant->>cursor_table: Advance or reset cursor
Loading

Suggested reviewers: rhainland

Merge Risk: ⚪ Minimal · up to 585eb

The cursor sweep is mergeable after normal checks; no concrete issue requiring a pre-merge fix was identified.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 8 functions across 3 files. (2 skipped: 2… Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
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.
Title check ✅ Passed The title clearly and concisely describes the primary change: preventing the artwork GC sweep from rewriting referenced revisions.
Description check ✅ Passed The description is directly related to the changeset and explains the problem, cursor-based approach, migration, risks, validation, and benchmark results.
Full details: Docstring Coverage

Explanation

Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 8 functions across 3 files. (2 skipped: 2 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create a new PR
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

impact: perf Unusable slowness on normal hardware priority: P2 Limited scope, workaround exists, or polish

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants