Skip to content

perf(images): downscale via libvips before decoding for thumbhash - #821

Merged
Quick104 merged 1 commit into
mainfrom
perf/thumbhash-downscale-before-decode
Aug 29, 2026
Merged

Quick104 merged 1 commit into
mainfrom
perf/thumbhash-downscale-before-decode

Conversation

@Quick104

Copy link
Copy Markdown
Contributor

Problem

imageutil.Thumbhash decodes the full original bytes in pure Go before scaling to 100px. A large provider poster materializes well over a hundred MiB of raster on the Go heap, and this runs once per concurrent image-cache worker — under the raised worker pool from #817, several GiB of transient decode memory in the worst case. The memory-efficient path already existed (normalizeThumbhashSource shrinks to 100px through libvips with shrink-on-load) but only ran as a fallback when the Go decode failed.

Change

The order is reversed: libvips downscales first and Go decodes the resulting ≤100px PNG, with the pure-Go decode of the raw bytes kept as the fallback for anything libvips cannot parse — the supported format set is unchanged. Hashing a 6000×4000 JPEG now allocates ~0.5 MiB on the Go heap instead of 36+ MiB.

Hash-stability analysis (done before changing anything): the emitted hash bytes change for a given image, so every comparison site was audited. All four Thumbhash callers were checked (imagecache.CacheBytes, collection artwork, chapter thumbnails, ebook scan); only one compares hashes for equality: ebook scan cover change detection (internal/scanner/ebook_scan.go). That site stores the freshly computed hash whenever it re-caches, so the pipeline change costs one re-cache per scan-covered ebook on its next scan and then converges — it cannot loop. Stored thumbhashes elsewhere are display placeholders and stay valid; nothing needs regeneration.

Tests

  • TestThumbhashDoesNotDecodeFullRasterInGo — pins the property this PR exists for: hashing a 6000×4000 JPEG must not allocate more than 15 MiB on the Go heap (the old path allocates 36+ MiB, the new one ~0.5 MiB).
  • TestThumbhashDeterministic, TestThumbhashRejectsGarbage — the package previously had no tests.
  • BenchmarkThumbhashLargeJPEG — ~9.5 ms/op, ~538 KB/op, 6.7k allocs/op on a 6000×4000 JPEG (Apple M4 Max).

Validation

  • make test-go (CI-equivalent) — passes, including the consumer packages: imagecache, scanner (ebook cover change detection), chapterthumbs, api/handlers, ebooks, audiobooks
  • go build ./..., go vet, gofmt -l clean
  • golangci-lint run --new-from-merge-base=origin/main ./... — 0 issues

Related issue: N/A — narrow fix (flagged during review of #817; internal helper only, no API, client, or jellycompat surface changes)

AI Disclosure

  • Tool(s): Claude Code
  • Model(s): claude-fable-5
  • Involvement: Fully AI-generated, human verified
  • Adversarial review: The main risk was hash instability breaking equality comparisons, so every Thumbhash call site was traced before the change: the ebook scan comparison was confirmed self-healing (it stores the hash it computes on re-cache, so the mismatch fires once per item, not per scan), and the other three sites were confirmed display-only. The memory claim is enforced by a test, not asserted in prose.

🤖 Generated with Claude Code

imageutil.Thumbhash decoded the full original bytes in pure Go before
scaling to 100px, materializing the whole raster on the heap — a large
provider poster reaches well over a hundred MiB — once per concurrent
image-cache worker. The memory-efficient path already existed:
normalizeThumbhashSource shrinks to 100px through libvips with
shrink-on-load, but only ran as a fallback when the Go decode failed.

The order is now reversed: vips downscales first and Go decodes the
tiny PNG, with the pure-Go decode of the raw bytes kept as the fallback
for anything vips cannot parse. Hashing a 6000x4000 JPEG now allocates
about 0.5 MiB on the Go heap instead of over 36 MiB, pinned by a test.

The emitted hash bytes change for a given image. Stored thumbhashes
remain valid placeholders, and the one equality-comparison site — ebook
scan cover change detection (internal/scanner/ebook_scan.go) — stores
the freshly computed hash whenever it re-caches, so the change costs
one re-cache per scan-covered ebook on its next scan and then
converges; it cannot loop.

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-28T23:55:34.602445Z 26ca448 PR opened
ℹ️ 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.

@Quick104
Quick104 merged commit df4da27 into main Aug 29, 2026
4 checks passed
@Quick104
Quick104 deleted the perf/thumbhash-downscale-before-decode branch August 29, 2026 00:12
Quick104 added a commit that referenced this pull request Aug 29, 2026
…de fallback

The vips shrink-on-load rewrite of Thumbhash (#821) removed the
header-dimension check this branch had added, leaving the pure-Go decode
fallback — the path vips-unparseable bytes take — free to materialize an
arbitrarily large raster. Re-apply checkSourceDimensions on the fallback
before decoding; TestThumbhashRejectsOversizedHeaderBeforeDecoding pins it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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