fix(uploads): migrate track upload + audio replace to docket (#1331)
the POST /tracks/ and PUT /tracks/{id}/audio handlers used
`fastapi.BackgroundTasks.add_task`, which runs the task within the
same ASGI request lifecycle after the response is sent. consequence:
any request-scoped DB session stays checked out of the pool until the
task finishes (20-100s per upload), and nothing bounds concurrency.
today flo.by uploaded 6 tracks in a single album-create fan-out. six
concurrent uploads held six of the 10 pool slots for over a minute
and starved every other request (/auth/me p95 hit 9.7s, /health 3s).
root cause: this pattern was in place from the very first streaming-
uploads commit (26a48c75, Nov 2025). docket landed a month later and
all post-upload tasks were migrated piecemeal (copyright, embedding,
genre, image moderation, atproto sync, teal, export, pds backfill)
but the upload orchestration itself never was. audio replace (#1311,
Apr 2026) copied the same pattern.
changes:
- uploads.py: add run_track_upload (docket task, primitives only,
rehydrates session, delegates to existing _process_upload_background)
+ schedule_track_upload helper
- audio_replace.py: same trio for replace
- handlers: drop `background_tasks: BackgroundTasks` param, call
await schedule_* instead
- _internal/tasks/__init__.py: register both tasks in the docket list
- test_endpoint.py: patch the scheduler helper, not the orchestrator
- tests/integration/test_album_upload.py: add
test_album_upload_10_tracks_concurrently as regression coverage —
fires 10 concurrent uploads through an album and asserts all complete
- loq.toml: relax limits on uploads.py + audio_replace.py to cover the
new wrapper functions
the existing orchestrators (_process_upload_background,
_process_replace_background) keep the same signature so every pipeline
test that drives them directly continues to pass unchanged.
buys us:
- HTTP handler returns in <1s; request-scoped DB session released on
response instead of 100s later
- per-op DB sessions via db_session() inside the task, not held across
the whole upload
- bounded concurrency via settings.docket.worker_concurrency (default
10/worker x 2 prod machines = 20 concurrent uploads, rest queue in
Redis rather than saturating the pool)
- fresh session rehydration if OAuth refreshed between queue and task
Co-authored-by: Claude Opus 4 (1M context) <noreply@anthropic.com>
authored by