Restore Deep Archive books on demand when a Pro user plays them - #52
GianniCarlo wants to merge 4 commits into
Conversation
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.
✅ Claude PR Review —
|
| 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.
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.
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 ofglacier-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
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 itemstorageState: "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).typecount as files.glacier_restore_requests(new table): one row per(user_id, key), re-opened withattempts+1on a later freeze;library_item_id(nullable, set null on delete),kindobject|thumbnail,staterequested|finalized|failed|archived. READY thawed copies are recorded too, so hand-run restores get finalized by B.S3Service.headObject(tri-state restore header) andrestoreObject(409 in-flight = success);StorageServicepassthroughs;LibraryItem.storageState.Deploy notes
20260925120000_glacier_restore_requestsruns with the deploy.s3:RestoreObjectis already onbookplayer-api-task-role/S3Access(verified with simulate-principal-policy).glacier-migrate-29rule +glacier_migrationsrow, 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.tscclean.