Skip to content

feat(usfm): move async export to Azure Blob with SAS downloads and reliable jobs - #212

Merged
henrique221 merged 14 commits into
mainfrom
feat/196-async-export-hardening
Aug 5, 2026
Merged

feat(usfm): move async export to Azure Blob with SAS downloads and reliable jobs#212
henrique221 merged 14 commits into
mainfrom
feat/196-async-export-hardening

Conversation

@henrique221

@henrique221 henrique221 commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

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:

Defect Fix
Failed jobs marked completed (retries never fired) Worker runs batchSize: 1 and rethrows; pg-boss retries apply; terminal failures land on a new usfm-export-dlq dead-letter queue
Worker-local disk invisible to the API (downloads 404) Worker streams the ZIP into Azure Blob (Azurite in local compose); GET /downloads/{filename} 302-redirects to a 15-min SAS URL after an ownership check
Whole ZIP buffered in memory (OOM risk) Streaming upload (4 MB blocks) with a byte-counting transform — memory is flat regardless of export size
No idempotency / owner binding; hardcoded requestedBy requestedBy = authenticated user id; /jobs/{id} + /downloads/{filename} 404 for anyone but the requester (blob metadata carries the owner); singletonKey + exclusive queue policy collapse duplicates into 409; expireInSeconds 3600 → 600

Design notes

  • ensureExportQueues() (in lib/queue.ts) self-converges queue config on boot: createQueue no-ops on existing queues and policy is immutable, so a queue created by older code is dropped and recreated with the exclusive policy (safe pre-enablement), and tunable options converge via updateQueue.
  • Not configured ⇒ fail loud: without AZURE_STORAGE_CONNECTION_STRING, the async endpoints respond 503 and the worker refuses to start. The sync export is unaffected.
  • Local dev: compose gains an azurite service (--skipApiVersionCheck for SDK/emulator version skew); .env.example documents the well-known dev connection string. file-storage.ts / EXPORTS_DIR are removed.
  • 4 new worker unit tests (failure propagation — the core bug — plus upload metadata/cleanup); 105/105 suite passes.

E2E (compose + Azurite, full pipeline)

  • 202 enqueue → immediate duplicate → 409
  • job completedGET /downloads/…302 with SAS Location → fetch → valid ZIP (PK, 552 bytes)
  • translator (non-owner) → 404 on both /jobs/{id} and /downloads/…; owner still 302
  • Azurite stopped → job active → retry (failure logged for App Insights) → Azurite restarted → retry recovered to completed

⚠️ Deploy prerequisite (infra)

Hosted environments need a storage account + container and AZURE_STORAGE_CONNECTION_STRING (must include an AccountKey, which signs the SAS URLs) set for both the API and the worker, plus optional EXPORTS_CONTAINER (default usfm-exports). Until then the async endpoints 503 — nothing else changes.

Test plan

  • npm run typecheck / npm run lint / npm run format:check
  • npm run test — 105/105
  • Full E2E against compose + Azurite (above)

Screenshots

1) OpenAPI — async endpoint contract (202 / 409 / 503)

The Scalar reference for POST /project-units/{projectUnitId}/usfm/async documents the new 409 Conflict (duplicate export collapsed via singletonKey) and 503 Service Unavailable (storage not configured) alongside the 202.

01-scalar-async-endpoint

2) OpenAPI — download is now a 302 redirect to a signed URL

GET /downloads/{filename} documents 302 (redirect to a short-lived SAS URL) and 503, replacing the old direct-zip 200.

02-scalar-download-redirect

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 Found with an Azurite SAS Location; fetching it yields a valid ZIP (PK magic).

Uploading 03-live-pipeline-demo.png…


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 marked completed with no file and no retry.

04-retry-recovery-timeline

Summary by CodeRabbit

New Features

  • Async USFM exports are stored in Cloudflare R2 and delivered through short-lived signed download links.
  • Export requests support deduplication, expiration, streaming uploads, automatic cleanup, and resilient retries.

Bug Fixes

  • Unavailable storage returns 503, while duplicate requests return 409.
  • Export access is restricted to the requester, with improved retry and dead-letter handling.

Documentation

  • Updated R2 configuration, EU storage, endpoint, local development, and cleanup guidance.

Chores

  • Removed local export-directory storage and related container mounts.

…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.
@henrique221 henrique221 added the bug Something isn't working label Jul 2, 2026
@coderabbitai

coderabbitai Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It 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 reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 8eadb7bc-e07b-41e4-81a2-def15fe478f7

📥 Commits

Reviewing files that changed from the base of the PR and between ed54102 and f1c0a18.

📒 Files selected for processing (5)
  • docs/proposals/security-and-export-hardening/03-async-export-pipeline-hardening.md
  • docs/proposals/security-and-export-hardening/README.md
  • src/index.ts
  • src/lib/blob-storage.test.ts
  • src/lib/blob-storage.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/index.ts
  • docs/proposals/security-and-export-hardening/README.md
  • docs/proposals/security-and-export-hardening/03-async-export-pipeline-hardening.md

📝 Walkthrough

Walkthrough

This 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.

Changes

R2-backed async USFM export

Layer / File(s) Summary
R2 configuration and runtime wiring
.env.example, src/env.ts, compose.yaml, package.json, src/index.ts, src/workers/standalone-worker.ts
Adds R2 settings and AWS S3 dependencies, removes local export mounts, verifies storage at startup, and schedules R2 cleanup.
R2 export storage operations
src/lib/blob-storage.ts, src/lib/blob-storage.test.ts
Adds bucket validation, streaming uploads, metadata lookup, signed URLs, and expired-object cleanup with tests.
Queue reconciliation and retry policy
src/lib/queue.ts
Adds the export DLQ, exclusive queue reconciliation, retry settings, expiry, and requester typing.
Streaming worker execution
src/workers/usfm-export.worker.ts, src/workers/usfm-export.worker.test.ts
Streams ZIP output to R2, processes one job per invocation, rethrows failures for retries, and tests success and failure paths.
Export ownership and download redirects
src/domains/usfm/usfm.route.ts
Adds storage availability checks, duplicate request handling, requester-bound access, and signed download redirects.
Pipeline documentation updates
docs/proposals/security-and-export-hardening/*
Updates implementation status and documents the R2-based export pipeline.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

Suggested reviewers: anumonachan, vipinpaul

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
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 60.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title describes the async export storage migration and reliable job handling, which match the main objectives, although the implementation uses R2 instead of Azure Blob.
Linked Issues check ✅ Passed The implementation addresses #196 by enabling retries, shared R2 storage, presigned downloads, streaming uploads, idempotency, ownership binding, and shorter job expiry.
Out of Scope Changes check ✅ Passed The changes support #196 through storage, queue, worker, route, configuration, dependency, test, and documentation updates; no unrelated changes are evident.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/196-async-export-hardening

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between cb105b7 and b0f04b0.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (14)
  • .env.example
  • compose.yaml
  • docs/proposals/security-and-export-hardening/03-async-export-pipeline-hardening.md
  • docs/proposals/security-and-export-hardening/README.md
  • package.json
  • src/domains/usfm/usfm.route.ts
  • src/env.ts
  • src/index.ts
  • src/lib/blob-storage.ts
  • src/lib/file-storage.ts
  • src/lib/queue.ts
  • src/workers/standalone-worker.ts
  • src/workers/usfm-export.worker.test.ts
  • src/workers/usfm-export.worker.ts
💤 Files with no reviewable changes (1)
  • src/lib/file-storage.ts

Comment thread compose.yaml Outdated
Comment thread docs/proposals/security-and-export-hardening/README.md Outdated
Comment thread src/domains/usfm/usfm.route.ts
Comment thread src/lib/blob-storage.ts
Comment thread src/lib/queue.ts
Comment thread src/workers/standalone-worker.ts
Comment thread src/workers/standalone-worker.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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between b0f04b0 and 45ea0d5.

📒 Files selected for processing (6)
  • compose.yaml
  • docs/proposals/security-and-export-hardening/README.md
  • src/domains/usfm/usfm.route.ts
  • src/index.ts
  • src/lib/blob-storage.ts
  • src/lib/queue.ts

Comment thread src/lib/queue.ts Outdated
…-hardening

# Conflicts:
#	.env.example
#	src/env.ts
#	src/lib/queue.ts

@kaseywright kaseywright left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
@henrique221

Copy link
Copy Markdown
Contributor Author

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 (R2_JURISDICTION defaults to eu{account}.eu.r2.cloudflarestorage.com) so data at rest stays in the eu. creating the bucket in that jurisdiction is the one thing code can't do — it's set at bucket-creation time in the dashboard/IaC — so i documented it in .env.example as the infra step (that's the "some settings out of scope" bit you called out). credential var names line up with the audio-record r2 integration. give it another look when you get a sec?

@henrique221
henrique221 requested a review from kaseywright July 21, 2026 16:51
Comment thread src/lib/blob-storage.ts
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
@henrique221
henrique221 requested a review from kaseywright July 23, 2026 19:25

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between 45ea0d5 and 3fc1022.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (11)
  • .env.example
  • compose.yaml
  • docs/proposals/security-and-export-hardening/03-async-export-pipeline-hardening.md
  • package.json
  • src/domains/usfm/usfm.route.ts
  • src/env.ts
  • src/index.ts
  • src/lib/blob-storage.test.ts
  • src/lib/blob-storage.ts
  • src/lib/queue.ts
  • src/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

Comment thread src/lib/blob-storage.ts
Comment thread src/lib/blob-storage.ts Outdated
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
@henrique221
henrique221 enabled auto-merge (squash) July 24, 2026 18:27
@coderabbitai coderabbitai Bot mentioned this pull request Jul 30, 2026
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 kaseywright left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
@henrique221

Copy link
Copy Markdown
Contributor Author

addressed the review:

1 (blocking) — boot blast radius: fixed in e3c15b4. the api entrypoint now calls verifyBlobStorageOnBoot(), which never throws — creds unset keeps the existing warn + 503 path, and a failed bucket check logs a warning and lets boot continue, with failures surfacing per-job in the worker instead. the dedicated export worker keeps fail-fast initializeBlobStorage, since storage is load-bearing there. covered by 3 new unit tests (creds unset / bucket reachable / bucket unreachable → resolves false, no throw).

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.

@henrique221

Copy link
Copy Markdown
Contributor Author

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 kaseywright left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@henrique221
henrique221 merged commit 7b5b0a2 into main Aug 5, 2026
3 checks passed
@github-actions
github-actions Bot deleted the feat/196-async-export-hardening branch August 5, 2026 14:19
henrique221 added a commit that referenced this pull request Aug 6, 2026
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
henrique221 added a commit that referenced this pull request Aug 6, 2026
* 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Async USFM export pipeline hardening (pre-enablement)

2 participants