feat(usfm): move async export to Azure Blob with SAS downloads and reliable jobs - #212
Conversation
…liable jobs Hardens the async export pipeline per the reviewer decision (Azure Blob + signed URLs). The four audit defects: - Failures marked as success: the worker now runs batchSize 1 and rethrows, so pg-boss retries fire and terminal failures land on a new usfm-export-dlq dead-letter queue (previously Promise.allSettled swallowed rejections and failed jobs were marked completed). - Cross-process storage: the worker streams the archive into Azure Blob Storage (Azurite in local compose); the API serves downloads via a 302 redirect to a short-lived SAS URL. Local disk storage (file-storage.ts, EXPORTS_DIR) is removed. - Memory: the ZIP is never buffered — streamed upload with a byte-counting transform (uploadStream, 4MB blocks). - Idempotency + owner binding: requestedBy is the authenticated user (was a hardcoded string); jobs and downloads 404 for anyone else (blob metadata carries the owner). singletonKey + a new 'exclusive' queue policy collapse duplicate requests into 409. expireInSeconds 3600 -> 600. ensureExportQueues() self-converges queue policy/options on boot (createQueue no-ops on existing queues and policy is immutable, so a mismatched queue is recreated — safe pre-enablement). Endpoints respond 503 when AZURE_STORAGE_CONNECTION_STRING is unset; the worker refuses to start. Verified end-to-end against compose + Azurite: 202 -> duplicate 409 -> job completed -> 302 SAS -> valid ZIP; owner-binding 404s for another user; Azurite outage -> job retry -> recovery to completed after restart. Closes #196.
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (5)
🚧 Files skipped from review as they are similar to previous changes (3)
📝 WalkthroughWalkthroughThis PR replaces local async USFM export files with Cloudflare R2 storage. It adds R2 configuration and cleanup, updates queue and worker retry behavior, and changes export routes to enforce ownership and signed download redirects. ChangesR2-backed async USFM export
Estimated code review effort: 4 (Complex) | ~60 minutes Possibly related PRs
Suggested reviewers: Sequence Diagram(s)sequenceDiagram
participant Client
participant USFMRoute
participant PgBoss
participant USFMExportWorker
participant BlobStorage
Client->>USFMRoute: POST async export request
USFMRoute->>PgBoss: enqueue requester-bound job
PgBoss->>USFMExportWorker: deliver one export job
USFMExportWorker->>BlobStorage: upload ZIP stream
BlobStorage-->>USFMExportWorker: filename, sizeBytes, expiresAt
Client->>USFMRoute: GET download endpoint
USFMRoute->>BlobStorage: validate metadata and generate signed URL
BlobStorage-->>USFMRoute: signed download URL
USFMRoute-->>Client: 302 redirect
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 7
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@compose.yaml`:
- Around line 19-47: The Azurite service image is unpinned and currently pulls
the implicit latest tag, so update the azurite image reference in compose.yaml
to a specific version to match the repo’s reproducibility convention. Keep the
existing azurite service configuration intact, but change only the image
declaration for azurite so the dev environment consistently uses the same
emulator version across rebuilds.
In `@docs/proposals/security-and-export-hardening/README.md`:
- Line 23: The README intro is out of sync with the Ticket 3 status shown in the
table. Update the opening status block in the proposal index so it matches the
current wording for Ticket 3 being implemented, and keep the intro aligned with
the ticket summary in the table. Use the existing proposal index text near the
status block and the Ticket 3 entry to make the wording consistent.
In `@src/domains/usfm/usfm.route.ts`:
- Around line 388-399: The usfm export deduplication key in usfm.route.ts is too
broad because singletonKey only uses projectUnitId and bookIds, so requests from
different users can collide. Update the singletonKey logic in the export flow to
also include the requester identity from requestedBy/user metadata, so boss.send
only collapses duplicate exports for the same user. Keep the existing dedupe
behavior for identical requests from one requester, but ensure different users
get distinct jobs and jobId values even when the unit and book selection match.
In `@src/lib/blob-storage.ts`:
- Around line 150-169: deleteExpiredExports currently performs a full
client.listBlobsFlat() sweep and sequential deleteBlob calls on every cleanup
cycle, which does not scale as export volume grows. Refactor the blob cleanup
path in deleteExpiredExports (and its callers in the cleanup scheduling code) to
avoid application-level polling, and plan to move TTL enforcement to an Azure
Blob lifecycle management policy so expiry is handled server-side instead of by
hourly scans.
In `@src/lib/queue.ts`:
- Around line 68-91: `ensureExportQueues` currently deletes
`QUEUE_NAMES.USFM_EXPORT` whenever `getQueue` shows a non-exclusive policy, but
`deleteQueue` can fail once jobs already exist. Update the recreation logic in
`ensureExportQueues` to guard the `deleteQueue` call with an explicit
empty-queue check or a migration/drain step before recreating `usfm-export`, and
keep the existing `createQueue`/`updateQueue` flow intact.
In `@src/workers/standalone-worker.ts`:
- Around line 68-72: The hourly cleanup is duplicated in both the worker and the
API server, which causes redundant shared-blob deletions and spurious cleanup
errors when both race on deleteExpiredExports(). Update the ownership so only
one process schedules the setInterval cleanup (for example, keep it in
standalone-worker.ts or move it to src/index.ts, but not both), and ensure the
other startup path does not register the interval. Use the deleteExpiredExports
and cleanupInterval symbols to locate and remove the duplicate scheduling.
- Around line 60-64: The startup reconciliation in ensureExportQueues(boss) is
destructive because it deletes usfm-export before recreating it whenever the
policy is not exclusive, which can break existing jobs and race across the two
boot paths. Update ensureExportQueues and its callers in src/index.ts and
src/workers/standalone-worker.ts so only one owner performs the migration, or
make the delete/recreate flow idempotent and retry-safe. Preserve any existing
queue state if possible, and use the existing initializeQueue,
ensureExportQueues, and registerUSFMExportWorker flow to keep startup
non-destructive.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: cb9c3c9f-4df9-4039-bd8a-6efe7e2f1566
⛔ Files ignored due to path filters (1)
package-lock.jsonis excluded by!**/package-lock.json
📒 Files selected for processing (14)
.env.examplecompose.yamldocs/proposals/security-and-export-hardening/03-async-export-pipeline-hardening.mddocs/proposals/security-and-export-hardening/README.mdpackage.jsonsrc/domains/usfm/usfm.route.tssrc/env.tssrc/index.tssrc/lib/blob-storage.tssrc/lib/file-storage.tssrc/lib/queue.tssrc/workers/standalone-worker.tssrc/workers/usfm-export.worker.test.tssrc/workers/usfm-export.worker.ts
💤 Files with no reviewable changes (1)
- src/lib/file-storage.ts
…ingle cleanup owner Address CodeRabbit review on the async export pipeline (PR #212): - usfm.route.ts: scope the export singletonKey to the requester (usfm-export:<user>:<unit>:<books>) so identical selections from different users no longer collide on a 409 for a job they can't see. - queue.ts: make ensureExportQueues policy migration non-destructive — only delete+recreate the usfm-export queue when it holds no created/retry/active jobs, and tolerate the API/worker two-boot race with a try/catch; otherwise warn and converge on a later boot. - index.ts + blob-storage.ts: make the worker the single cleanup owner by removing the API-side deleteExpiredExports interval, and ignore 404s on per-blob deletes so any residual sweep race stays silent. - blob-storage.ts: note that an Azure Blob lifecycle policy can replace the app-level sweep once hosted storage is provisioned. - compose.yaml: pin the Azurite emulator image to 3.34.0. - docs/.../README.md: sync the proposal intro with the ticket table. Refs: #212
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/lib/queue.ts`:
- Around line 85-91: The pending-job guard in queue policy recreation is using
the wrong pg-boss stats fields, so it never detects queued work. Update the
logic in the queue stats handling around getQueueStats and pendingJobs to use
the actual returned fields from pg-boss, namely queuedCount, activeCount, and
deferredCount, instead of created, retry, and active. Keep the warning path in
the same recreate guard so the delete+recreate flow is skipped whenever any jobs
are still pending.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: 47508c81-924f-4aee-bbae-ce3951ab88b1
📒 Files selected for processing (6)
compose.yamldocs/proposals/security-and-export-hardening/README.mdsrc/domains/usfm/usfm.route.tssrc/index.tssrc/lib/blob-storage.tssrc/lib/queue.ts
…-hardening # Conflicts: # .env.example # src/env.ts # src/lib/queue.ts
kaseywright
left a comment
There was a problem hiding this comment.
The decision has been made to use CloudFlare R2 as cloud storage for the project. We should ensure that the GDPR jurisdiction is set so that the files at rest in the R2 buckets are located in known regions in Europe to start with. Some of these settings might be out of scope for this PR while integrating CloudFlare.
getQueueStats() returns queuedCount/activeCount/deferredCount, not created/retry/active, so the `as any`-cast stats.created/retry/active were always undefined. That left pendingJobs at 0, so ensureExportQueues would delete+recreate the usfm-export queue even with jobs in flight, and the worker heartbeat's queueSize always logged 0. Use the typed QueueResult fields and drop the `as any` cast so the compiler catches future drift. Refs: #212
Rewrite the async-export storage layer on top of the S3-compatible AWS SDK against Cloudflare R2, keeping the blob-storage.ts export surface stable so routes/workers/tests are unchanged apart from awaiting the now-async presigned download URL. - streaming upload via @aws-sdk/lib-storage Upload (unknown length) - HeadObject metadata, ListObjectsV2 + LastModified TTL sweep, 15-min presigned GET downloads - EU-jurisdiction endpoint derived from env (R2_JURISDICTION, default eu) so files at rest stay in the EU (GDPR) - credentials optional: async endpoints stay 503 when R2 is unset - drop @azure/storage-blob and the Azurite compose service Refs: #212
Note in .env.example (and the export-hardening proposal) that the exports bucket must be created in R2's EU jurisdiction for GDPR data-at-rest — an infra/dashboard step the code cannot perform — and that local dev uses MinIO or a real R2 dev bucket (no emulator). Refs: #212
|
swapped azure blob → cloudflare r2 for the async export path. it's on the s3-compatible aws sdk now — streaming multipart upload, headobject metadata, same app-level ttl sweep, presigned downloads still 15 min. creds are optional so the endpoints still 503 when r2 isn't set, and the sync export path is untouched. for gdpr: the client is pinned to the eu-jurisdiction endpoint via env ( |
deleteExpiredExports listed every object in the exports bucket and deleted anything older than EXPORT_TTL_MS, so a shared or misconfigured bucket could lose unrelated objects to the TTL cleanup. Constrain the sweep to keys this module wrote: list with the `export-` prefix and delete only keys matching /^export-[a-f0-9-]+\.zip$/ (belt and braces; mirrors the download route's filename validation). Single-source the key prefix in uploadExportStream so upload and cleanup can't drift. Bucket organization stays a second layer of defence rather than the only guard. Refs: #212
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In
`@docs/proposals/security-and-export-hardening/03-async-export-pipeline-hardening.md`:
- Around line 9-12: Align the proposal’s 503 documentation with the implemented
isBlobStorageConfigured() readiness check by either limiting the statement to
missing R2 credentials or extending bucket validation to require a provisioned
R2_EXPORTS_BUCKET. If adding validation, update the related readiness tests so
absent or invalid bucket configuration returns 503 before storage operations.
In `@src/lib/blob-storage.ts`:
- Around line 56-79: Update src/lib/blob-storage.ts lines 56-79 in getS3Client
and buildR2Endpoint to use the configured S3-compatible endpoint when present,
requiring R2_ACCOUNT_ID only for the Cloudflare R2 fallback; update src/env.ts
lines 91-101 to declare the optional, URL-validated endpoint variable, add a
MinIO URL example in .env.example lines 46-75, and update compose.yaml lines
37-39 and 73-75 with the endpoint setting and local-development guidance
requiring it for MinIO.
- Around line 187-190: Update generateExportDownloadUrl to derive the export’s
remaining lifetime from its creation timestamp, cap the signed URL duration at
SAS_TTL_MINUTES while never exceeding the one-hour export TTL, and reject
missing or already elapsed timestamps. Reuse getExportBlobInfo or the existing
export metadata source to obtain createdOn, and pass the resulting positive
remaining duration to getSignedUrl.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 5e6ddcf7-efa3-41af-9203-4d8422ca5f41
⛔ Files ignored due to path filters (1)
package-lock.jsonis excluded by!**/package-lock.json
📒 Files selected for processing (11)
.env.examplecompose.yamldocs/proposals/security-and-export-hardening/03-async-export-pipeline-hardening.mdpackage.jsonsrc/domains/usfm/usfm.route.tssrc/env.tssrc/index.tssrc/lib/blob-storage.test.tssrc/lib/blob-storage.tssrc/lib/queue.tssrc/workers/standalone-worker.ts
🚧 Files skipped from review as they are similar to previous changes (4)
- src/index.ts
- src/workers/standalone-worker.ts
- src/lib/queue.ts
- src/domains/usfm/usfm.route.ts
generateExportDownloadUrl always signed 15-minute URLs, but getExportBlobInfo treats an export as gone (404) once it is older than EXPORT_TTL_MS. A URL signed just before that boundary kept working for up to 15 minutes after the export was already considered expired. Cap the presigned lifetime at min(15m, remaining TTL), reject missing or already-elapsed timestamps, and thread createdOn through from the download route. Refs: #212
…ked at boot The async endpoints return 503 solely on the R2 credential presence check (isBlobStorageConfigured). A missing or misprovisioned exports bucket is caught at startup by initializeBlobStorage (fail-fast, the process refuses to boot), not as a per-request 500. Narrow the proposal wording to match. Refs: #212
Conflict was in .env.example only; both sides appended a new block at the same spot, so both are kept — main's AI suggestion tunables stay next to the fluent-ai section, followed by the R2 export storage block. Two follow-ups the textual merge could not see: - Drop the EXPORTS_DIR doc block main added: this branch deletes file-storage.ts and the EXPORTS_DIR env var, so it documents a var nothing reads. - Restore the QUEUE_NAMES import in src/index.ts. This branch replaced the inline USFM createQueue call (its only use) with ensureExportQueues(), while main added a createQueue call for AI_SUGGESTION_TRIGGER; git merged both hunks cleanly and left the identifier unimported.
kaseywright
left a comment
There was a problem hiding this comment.
Review: async export → R2 hardening
Verified locally on this branch: typecheck, lint, and test (183/183) all pass. Overall the four audit defects from #196 are genuinely fixed — streaming upload, batchSize:1 + rethrow + DLQ, owner-bound jobs/downloads, singletonKey dedupe. Requesting changes on one blocking item; the other two need action before/around merge as noted.
Blocking
1. Startup hard-fails the whole API if R2 is misconfigured — for a feature that isn't wired to the UI yet (src/index.ts:15-21)
if (isBlobStorageConfigured()) {
await initializeBlobStorage();
} else {
logger.warn(...);
}initializeBlobStorage() throws on HeadBucketCommand failure, and that throw propagates to startServer()'s outer catch, which calls process.exit(1). Once ops sets the three R2_* vars, a bucket typo, a rotated key, or a transient R2 outage takes down the entire API — not just async export. Since this pipeline is explicitly not exposed to fluent-web yet, that's a disproportionate blast radius for a pre-enablement feature.
Please degrade this to a warning (matching the "credentials unset" path) instead of blocking boot, so a storage misconfiguration only 503s the async export endpoints rather than the whole service.
Needs discussion (not blocking this PR, but let's align before it lands elsewhere)
2. usfm-export-dlq has no consumer (src/lib/queue.ts:75,86)
The dead-letter queue is created and wired as the deadLetter target, but nothing reads from it — no worker, no alert, no count/log distinguishing "exhausted retries, moved to DLQ" from an ordinary retry attempt. Terminal failures are still visible via the per-attempt logger.error in usfm-export.worker.ts, so this isn't silent, but there's no positive signal when a job actually lands in the DLQ. Let's discuss a general strategy for dead-letter queues across the worker fleet (this is likely to recur beyond USFM export) rather than solving it one-off here.
Required before merge
3. Stale doc: README table still says "Azure Blob" (docs/proposals/security-and-export-hardening/README.md:24)
The linked 03-async-export-pipeline-hardening.md was correctly updated to describe the R2 migration, but the summary table row still reads "Implemented — Azure Blob + SAS, worker retries, owner binding (#196)". Please update that line to reflect Cloudflare R2 before merge so the two docs don't contradict each other.
…unreachable initializeBlobStorage's throw propagated to startServer's catch and process.exit(1), so once the R2_* vars are set, a bucket typo, rotated key, or transient R2 outage took down the whole API — a disproportionate blast radius for a pipeline not wired to the UI yet. The API entrypoint now calls verifyBlobStorageOnBoot, which never throws: unconfigured credentials keep the warn + endpoint-503 behavior, and a failed bucket check logs a warning and lets boot continue, with failures surfacing per-job in the worker. The dedicated export worker keeps the fail-fast initializeBlobStorage, since storage is load-bearing there. Proposal doc updated to match. Refs: #212
The README table still said "Azure Blob + SAS" while the linked proposal doc already describes the R2 migration; align the two. Refs: #212
|
addressed the review: 1 (blocking) — boot blast radius: fixed in e3c15b4. the api entrypoint now calls 3 (stale doc): fixed in f1c0a18 — the readme table row now reads cloudflare r2 (eu) + presigned downloads. the blocking fix also invalidated the "process refuses to boot" wording in the 03 doc, so that sentence was re-worded in e3c15b4. 2 (dlq consumer): agreed this should be a fleet-wide decision rather than a one-off here — the ai-suggestion trigger queue will hit the exact same question. happy to open a follow-up issue to track the dlq strategy discussion (dedicated consumer vs. alerting on dlq depth vs. periodic sweep-and-log) so it does not get lost. branch is also updated with main (12585ac). suite after the changes: typecheck + lint clean, 186/186 tests. |
|
opened #256 to track the fleet-wide dlq strategy discussion (consumer vs. depth alerting vs. sweep-and-log, plus retention) — kept out of this pr per the review. |
kaseywright
left a comment
There was a problem hiding this comment.
Verified the two fixup commits (e3c15b4, f1c0a18) against the blocking review:
- #1 (boot failure on R2 misconfig): fixed correctly via
verifyBlobStorageOnBoot(), which never throws — the API stays up and only async export degrades on an unreachable bucket. The dedicated worker correctly keeps its fail-fast behavior since storage is load-bearing there. New unit tests cover all three paths (unconfigured, reachable, unreachable). - #3 (stale doc): README table now correctly says Cloudflare R2.
typecheck, lint, and test (186/186) all pass locally on the updated branch.
#2 (dead-letter queue strategy) remains open as a separate, non-blocking discussion per my earlier comment — not something to solve in this PR. Approving.
R2 credentials are account-level, so environments pointed at the same account
are separated by nothing but their bucket names. R2_EXPORTS_BUCKET and
R2_AUDIO_BUCKET each carried a hardcoded default, so an environment whose
deploy config omitted one still booted, on the fallback name. Audio keys are
deterministic (unit-{id}/text-{id}), so two environments landing on the same
name would read and OVERWRITE each other's recordings rather than miss and
404 — silent and destructive instead of loud and safe (kaseywright, #224).
Both vars are now optional in the schema and required by a superRefine as soon
as any R2 credential is present, so a missing one fails env validation before
anything starts. audioBucket()/exportsBucket() are the single accessors that
narrow the type, with that invariant documented in place rather than asserted
at each call site.
An unreachable bucket stays non-fatal, per the #212 call that R2 trouble must
not take the whole API down: initializeAudioStorage now records its probe
result and the verse-audio routes read it through isAudioStorageAvailable, so
a bad bucket answers one clean 503 instead of a 500 per request.
.env.example documents the real per-environment bucket names, since the
invented ones were exactly the trap.
Refs: #224
Claude-Session: https://claude.ai/code/session_016ej1G6uozJyR5TYwypKxfz
* feat(audio): add azure audio storage module and env wiring
* feat(db): add verse_audio_recordings table
* feat(audio): add verse-audio error codes and domain types
* feat(audio): add verse-audio repository
* feat(audio): add verse-audio service with upload, playback URLs, delete
* feat(audio): add verse-audio auth middleware
* feat(audio): add verse-audio routes (upload, get, chapter list, delete)
* fix(audio): declare 400 response on DELETE verse-audio route
* chore: ignore agent scratch dir in prettier
* test: raise whole-app OpenAPI render test timeout to 20s
The dynamic import('@/app') pays every route module's transform cost under
parallel collect; the 5s default started flaking when the app graph gained
@azure/storage-blob. Verified against origin/main baseline (38.5s vs 44.8s
aggregate collect).
* fix(audio): declare 400 response on GET verse-audio route
* chore: gitignore local screenshot scratch dir
* fix(audio): use the standard message shape for the 503 body
Every other error response on these routes returns { message } via
createMessageObjectSchema; the storage-unavailable 503 returned
{ error, details }, so clients had to handle two error shapes from the
same four routes. The shape was inherited from usfm.route.ts, which uses
{ error, details } for all of its errors and is internally consistent —
verse-audio was not. Status and "storage not configured" semantics are
unchanged; only the body shape and its OpenAPI schema move.
* chore: gitignore the agent scratch dir
* test: exclude agent worktrees from vitest collection
Agent tooling checks out other branches into .claude/worktrees/<branch>/ —
full repo copies whose suites vitest collected, reporting another branch's
29 failures as ours. Excluded there and gitignored; unscoped npm test is
183/183 again. CI unaffected (clean checkouts have no .claude/).
* feat(audio): move verse audio to Cloudflare R2 and track storage objects
Storage: swaps Azure Blob for R2 (S3-compatible) behind the same exported
surface, pinned to the EU jurisdiction endpoint for GDPR data-at-rest. The
presigner is async, so generateAudioDownloadUrl now returns a promise.
Orphan reclaim (reviewer call on #224): a storage_objects table records every
object written (bucket, key, timestamps) and verse_audio_recordings references
it WITHOUT cascading. Dropping a project unit cascades the recordings away but
Postgres cannot delete an object, so the storage row survives as the marker a
periodic sweep uses to delete the bytes and stamp deletedAt. Chosen over a DB
trigger because triggers are invisible from application code.
The claim is written before the upload, so a crash between the two leaves a
reclaimable marker rather than untracked bytes.
* fix(audio): harden delete ordering, reclaim races and R2 client setup
Review follow-ups on the storage-objects work:
- deleteRecording now removes the row BEFORE the object. A failed row write
destroys nothing and can be retried; a failed object delete leaves an orphan
the sweep collects. The old order could strand a recording pointing at bytes
that were already gone, which no sweep can repair.
- The reclaim sweep skips rows younger than AUDIO_RECLAIM_GRACE_MS, and claim()
refreshes createdAt on revive. An upload claims its row before writing the
object, so without this a sweep landing mid-upload could delete live bytes.
- initializeAudioStorage() is now actually called: the sweep only starts once
the bucket answers. Audio is optional, so a failed probe logs and leaves the
API serving rather than killing startup.
- S3 client gets connection/request timeouts (the SDK defaults to none, so an
unreachable endpoint would hang uploads and the sweep indefinitely).
- R2_ENDPOINT override makes the documented MinIO local-dev path real.
- Drops @aws-sdk/lib-storage: recordings are capped at 30 MB and buffered, so
PutObject is used and the multipart helper was never imported.
* fix(env): require explicit R2 bucket names instead of defaulting them
R2 credentials are account-level, so environments pointed at the same account
are separated by nothing but their bucket names. R2_EXPORTS_BUCKET and
R2_AUDIO_BUCKET each carried a hardcoded default, so an environment whose
deploy config omitted one still booted, on the fallback name. Audio keys are
deterministic (unit-{id}/text-{id}), so two environments landing on the same
name would read and OVERWRITE each other's recordings rather than miss and
404 — silent and destructive instead of loud and safe (kaseywright, #224).
Both vars are now optional in the schema and required by a superRefine as soon
as any R2 credential is present, so a missing one fails env validation before
anything starts. audioBucket()/exportsBucket() are the single accessors that
narrow the type, with that invariant documented in place rather than asserted
at each call site.
An unreachable bucket stays non-fatal, per the #212 call that R2 trouble must
not take the whole API down: initializeAudioStorage now records its probe
result and the verse-audio routes read it through isAudioStorageAvailable, so
a bad bucket answers one clean 503 instead of a 500 per request.
.env.example documents the real per-environment bucket names, since the
invented ones were exactly the trap.
Refs: #224
Claude-Session: https://claude.ai/code/session_016ej1G6uozJyR5TYwypKxfz
Closes #196.
Summary
Implements the async-export hardening per Kasey's decision (Azure Blob Storage + signed URLs). The async pipeline is still not wired to the UI — this makes it safe to enable. All four audit defects are addressed:
completed(retries never fired)batchSize: 1and rethrows; pg-boss retries apply; terminal failures land on a newusfm-export-dlqdead-letter queueGET /downloads/{filename}302-redirects to a 15-min SAS URL after an ownership checkrequestedByrequestedBy= authenticated user id;/jobs/{id}+/downloads/{filename}404 for anyone but the requester (blob metadata carries the owner);singletonKey+exclusivequeue policy collapse duplicates into 409;expireInSeconds3600 → 600Design notes
ensureExportQueues()(inlib/queue.ts) self-converges queue config on boot:createQueueno-ops on existing queues andpolicyis immutable, so a queue created by older code is dropped and recreated with theexclusivepolicy (safe pre-enablement), and tunable options converge viaupdateQueue.AZURE_STORAGE_CONNECTION_STRING, the async endpoints respond 503 and the worker refuses to start. The sync export is unaffected.azuriteservice (--skipApiVersionCheckfor SDK/emulator version skew);.env.exampledocuments the well-known dev connection string.file-storage.ts/EXPORTS_DIRare removed.E2E (compose + Azurite, full pipeline)
completed→GET /downloads/…→ 302 with SASLocation→ fetch → valid ZIP (PK, 552 bytes)/jobs/{id}and/downloads/…; owner still 302active → retry(failure logged for App Insights) → Azurite restarted → retry recovered tocompletedHosted environments need a storage account + container and
AZURE_STORAGE_CONNECTION_STRING(must include anAccountKey, which signs the SAS URLs) set for both the API and the worker, plus optionalEXPORTS_CONTAINER(defaultusfm-exports). Until then the async endpoints 503 — nothing else changes.Test plan
npm run typecheck/npm run lint/npm run format:checknpm run test— 105/105Screenshots
1) OpenAPI — async endpoint contract (202 / 409 / 503)
The Scalar reference for
POST /project-units/{projectUnitId}/usfm/asyncdocuments the new409 Conflict(duplicate export collapsed via singletonKey) and503 Service Unavailable(storage not configured) alongside the 202.2) OpenAPI — download is now a 302 redirect to a signed URL
GET /downloads/{filename}documents302(redirect to a short-lived SAS URL) and503, replacing the old direct-zip 200.3) Live pipeline — enqueue → 409 dedupe → completed → 302 SAS → valid ZIP
Full pipeline against the running compose stack (API + worker + Azurite): 202 with jobId; an immediate identical request gets 409; the job completes (552 bytes, 74 ms); the download responds
302 Foundwith an Azurite SASLocation; fetching it yields a valid ZIP (PKmagic).4) Failure & retry recovery — the core fix
Azurite stopped before enqueue: the job goes
created → active → retry(upload fails, error logged). After restarting Azurite, the 60s retry fires and the job completes at t+70s with a downloadable ZIP. Under the old code this exact scenario was markedcompletedwith no file and no retry.Summary by CodeRabbit
New Features
Bug Fixes
503, while duplicate requests return409.Documentation
Chores