Skip to content

Restore Deep Archive books on demand when a Pro user plays them - #52

Open
GianniCarlo wants to merge 4 commits into
mainfrom
feat/glacier-on-demand-restore
Open

GianniCarlo wants to merge 4 commits into
mainfrom
feat/glacier-on-demand-restore

Conversation

@GianniCarlo

Copy link
Copy Markdown
Contributor

Why

A lapsed Pro subscriber's library is archived to DEEP_ARCHIVE by a per-user lifecycle rule and nothing ever brings it back. After they resubscribe, presigned URLs for archived objects answer InvalidObjectState (403) forever, and the only fix has been a hand-run thaw per support case. This is sub-phase A of glacier-cleanup/docs/glacier-unfreeze-plan.md: the on-demand hook. The Lambda finalizer (B), the single-lifecycle-writer move (C) and housekeeping (D) follow as their own PRs.

What

  • Hook in LibraryService.getLibrary. When the request names ONE item (a non-root path with no trailing slash resolving to a single non-folder row) for a PRO caller on the presigned branch, HEAD the object first. If it sits in DEEP_ARCHIVE with no restore in flight, request a Standard-tier restore (Days=30), record it, and mark the item storageState: "restoring". Listings never touch S3 (a root with one item is still a listing); a warm item costs one HEAD; the hook never fails the request (HEAD 404/failure or a refused restore → URL returned as before).
  • Background expansion, gated. Only on the tap that issued the restore do the item's artwork and, for a bound book, its other chapters and the container's cover follow in the background. A bound book resolved by uuid probes up to three of its files (synced first) before walking; any definitive answer ends the probe. An in-process guard collapses concurrent taps on the same book into one walk. Legacy rows with a NULL type count as files.
  • glacier_restore_requests (new table): one row per (user_id, key), re-opened with attempts+1 on a later freeze; library_item_id (nullable, set null on delete), kind object|thumbnail, state requested|finalized|failed|archived. READY thawed copies are recorded too, so hand-run restores get finalized by B.
  • S3Service.headObject (tri-state restore header) and restoreObject (409 in-flight = success); StorageService passthroughs; LibraryItem.storageState.
  • No hook on the root sync's resume-item rider (it would HEAD on the apps' hottest request; the player's one-shot URL refresh hits the single-item request where the hook runs). No hook on the proxy branch (dead web client).

Deploy notes

  • Migration 20260925120000_glacier_restore_requests runs with the deploy.
  • IAM: s3:RestoreObject is already on bookplayer-api-task-role/S3Access (verified with simulate-principal-policy).
  • Verification after deploy: freeze user 29's library via a hand-added glacier-migrate-29 rule + glacier_migrations row, then tap a book.

Tests

  • GlacierRestoreHook.test.ts (29, DB-backed): listings never HEAD; path and uuid single-item taps; warm / thawing / ready / missing / refused; repeated taps keep one row and a finalized row re-opens; bound-book sibling + artwork + cover expansion; probe fall-through, ordering, stop-on-warm, give-up; concurrent taps walk once; cross-user isolation; NULL type; folder by uuid; resume item never checked; non-PRO never HEADs.
  • S3ServiceRestore.test.ts: wire shapes for HEAD and RestoreObject.
  • Whole suite 470/470, tsc clean.

A lapsed Pro subscriber's library is archived to DEEP_ARCHIVE by a per-user
lifecycle rule, and nothing ever brings it back: after they resubscribe,
presigned URLs for archived objects answer InvalidObjectState (403) forever.

When GET /v1/library names one item (a non-root path with no trailing slash
resolving to a single non-folder row) for a PRO caller on the presigned
branch, HEAD the object first. If it sits in DEEP_ARCHIVE with no restore in
flight, request a Standard-tier restore (Days=30), record it in the new
glacier_restore_requests table (one row per user+key, re-opened on a later
freeze) and mark the item storageState: "restoring". Listings never touch
S3; a warm item costs one HEAD; the hook never fails the request.

Only on the tap that issued the restore do the item's artwork and, for a
bound book, its other chapters and the container's cover follow in the
background. A bound book resolved by uuid probes up to three of its files
(synced first) before walking. An in-process guard collapses concurrent
taps on the same book into one walk. Legacy rows with a NULL type count as
files, since they get a URL like any book.

The thawed copy is temporary; the Lambda finalizer that copies restored
objects back to INTELLIGENT_TIERING lands in the next PR, which is why the
restore window is 30 days rather than 7. The task role already has
s3:RestoreObject.
Comment thread src/services/LibraryService.ts
Comment thread src/services/GlacierRestoreService.ts
Comment thread src/services/GlacierRestoreService.ts Outdated
Comment thread src/services/db/GlacierRestoreDB.ts Outdated
Comment thread src/services/LibraryService.ts Outdated
@github-actions

github-actions Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

✅ Claude PR Review — PASS

Adds an on-demand hook that restores books from Deep Archive. It runs inside LibraryService.getLibrary when a PRO user requests a presigned URL for one item. It adds GlacierRestoreService, GlacierRestoreDB and a new glacier_restore_requests table, plus headObject/restoreObject in S3 and passthroughs in StorageService.

Authorization: every library query in the new code (the parent lookup, the child walk, the uuid and path lookups) filters on user.id_user. S3 keys are built only from the server-side storage prefix plus the stored source_path/key or thumbnail, never from client input. The walk guard is keyed per user, and a test covers cross-user isolation. Middleware: no route changed. The hook sits behind the existing auth + checkSubscription middleware and also checks for the PRO tier itself. SQL: the one db.raw upsert binds every value. Its only interpolated piece (guard) is a fixed string.

Migration: only creates a table. The user_id foreign key has no delete rule, but nothing in the code hard-deletes users. The hook can't fail a request: S3 and DB errors are caught and logged, and a 2 s budget caps the added latency. Open finding #1 (chapter artwork never restored) looks fixed: the probe and the walk now call thawArtwork for every file. The only remaining gap is the tapped item's own artwork when its thaw was started by hand, which is minor. Worth knowing: the single-walk guard only works within one process, so two app instances can walk the same book twice. That wastes some HEAD requests but causes no harm, since each step can safely run again.

Findings: no findings

Previously raised

Finding Status
src/services/GlacierRestoreService.ts:340 (warn) ✅ verified fixed in 2ce9170

Converged: nothing new this round, and every earlier finding is settled.

Model claude-opus-5-5 · run log · 0 new · 0 carried over · 1 verified closed · 0 resolved · advisory (a human should still review). Findings are de-duplicated across pushes; an earlier finding closes only when the verification pass judges it against the current code — fixed, no longer applicable, accepted by a maintainer, or a duplicate of a finding reported on this push.

Bound the Glacier check on the URL path: the request waits at most
HOOK_BUDGET_MS (2 s) for the HEAD/RestoreObject/insert and then answers
without a state, while the check finishes in the background so the restore
is still issued and recorded. Before this hook the path signed locally, so a
degraded S3 must not turn a URL refresh into a hang.

Bump requested_at/attempts whenever a RestoreObject was actually sent, even
on a row still `requested`: a copy whose window closed before the finalizer
ran now reads as a fresh request. Repeated taps on an already-thawing object
still leave the row alone.

Log at error when a thaw could not be recorded (the copy would silently
refreeze), and inject GlacierRestoreService through the constructor, sharing
LibraryService's StorageService and LibraryDB, per the DI convention.
fileExists and the delete path's size check each sent their own HEAD and
threw away most of the answer; the on-demand thaw added a third for the
storage class and restore header. Keep that one as the base request — it
carries the tri-state rule (404 is 'missing', a 403 or any other failure is
null, never "absent") and the 403 note that used to live on fileExists — and
turn the other two into mappings over it. One wire call, one place where a
HEAD answer is classified.
Comment thread src/services/GlacierRestoreService.ts
Thaw each sibling chapter's own artwork during a bound-book walk, and the
probed file's when a container tap issues the restore. Chapters carry their
own thumbnails, archived under the sibling `_thumbnail/` prefix, and the app
shows them in the book's chapter list; only the tapped chapter's artwork was
being restored, so the rest would have stayed frozen until the Lambda's
thumbnails job exists.

This branch was successfully deployed

1 active deployment
reviewer — 2ce91706 Deployed Sep 25, 2026 by GianniCarlo via review #100
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