feat(podcasts): concurrent download pool — fair share, tickets, admin tunables

Replaces the sequential worker with a download-manager-style pool
(§0 item 4). Model: the user's connection is the limit; semantics are
fair-share ACROSS feeds, not goroutine-per-book (a global FIFO would
make Book B wait behind Book A's 100 files).

- Atomic claim: GetNextQueuedEpisode + SetPodcastEpisodeDownloading
  was a read-then-write race under concurrency; ClaimQueuedPodcastEpisode
  converts queued→downloading in ONE guarded statement (two claimants,
  one winner — the loser gets ErrNoRows). SetPodcastEpisodeDownloading
  retired.
- Queue tickets: download_queue_pos BIGINT fed by a Postgres SEQUENCE
  stamped at enqueue — the enqueue loop's ordering IS the claim order
  (the foundation §0 item 5 builds on; previously all rows in a batch
  shared one updated_at, so within-feed order was arbitrary).
- Pool: up to podcast_download_concurrency (default 4, live range 1–16)
  claim-loop goroutines per drain round; the setting re-reads every
  round so admin changes take effect without restart.
- Fair share: claim prefers the feed least-recently-served
  (pickFeedToServe; never-served first), earliest ticket within a feed —
  books proceed in parallel while chapters land in order (start
  listening while the rest download).
- Bandwidth: shared token bucket across all workers
  (podcast_download_speed_mbps, default 0 = unlimited); take() reserves
  bandwidth and callers sleep outside the lock.
- Settings: new float setting type end to end (registry defaults +
  clamping, save validation, admin UI number-with-step input) + the two
  schema seeds; worker wires the registry via SetSettings (tests run on
  registered defaults).
- Tests: unit coverage for the bucket reservation math + fair-share
  pick; integration TestPodcastDownloadPool pins ticket order = enqueue
  order, claim exclusivity, and a full 3-episode pool drain.

Dev-DB note: the column/sequence/settings were applied live (idempotent
DDL, same statements the schema runs at next boot) since the running
container predates them.
This commit is contained in:
John O'Keefe committed 2026-10-30 10:12:43 -04:00
1 parent e3675536aa
commit 0f3c4a8cec
14 files changed
+893 -264

No files matched your search

+18 -6
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// sqlc v1.31.0
package database
@@ -32,6 +32,11 @@ type Querier interface {
BulkUpdateFormatGroups(ctx context.Context) error
BulkUpdateProgressFromSync(ctx context.Context, arg BulkUpdateProgressFromSyncParams) ([]interface{}, error)
CheckForProgressConflicts(ctx context.Context, arg CheckForProgressConflictsParams) (int64, error)
// Atomic claim (§0 item 4): converts queued -> downloading in ONE
// statement. Two claimants racing on the same row: exactly one UPDATE
// matches download_state='queued'; the loser gets no rows (ErrNoRows).
// This is the SKIP-LOCKED guarantee without a second round-trip.
ClaimQueuedPodcastEpisode(ctx context.Context, id pgtype.UUID) (MediaItems, error)
// Cleanup expired OPDS tokens
CleanupExpiredOpdsTokens(ctx context.Context) error
CleanupExpiredRefreshTokens(ctx context.Context, dollar_1 float64) error
@@ -446,8 +451,6 @@ type Querier interface {
// GetMediaScanState reads one item's manifest (per-item rescan paths
// and the manifest integration tests).
GetMediaScanState(ctx context.Context, mediaItemID pgtype.UUID) (MediaScanState, error)
// Sequential download worker's claim source (single worker, day one).
GetNextQueuedEpisode(ctx context.Context) (MediaItems, error)
GetNextRetryTime(ctx context.Context) (interface{}, error)
GetNotStartedItems(ctx context.Context, arg GetNotStartedItemsParams) ([]MediaItems, error)
// Get OPDS token
@@ -656,6 +659,11 @@ type Querier interface {
// Item-side projections COALESCE to keep them non-nullable strings;
// the display file path falls back to the issue's own for row-less rows.
ListProcessingIssuesByLibrary(ctx context.Context, libraryID pgtype.UUID) ([]ListProcessingIssuesByLibraryRow, error)
// Fair-share candidate scan (§0 item 4): the download pool claims in
// TICKET order (download_queue_pos = SEQUENCE nextval at enqueue — the
// enqueue loop's ordering IS the download order, item 5). The worker
// groups these per feed and prefers the least-recently-served feed.
ListQueuedPodcastEpisodes(ctx context.Context) ([]ListQueuedPodcastEpisodesRow, error)
ListSyncConflictsByMediaItem(ctx context.Context, arg ListSyncConflictsByMediaItemParams) ([]SyncConflicts, error)
ListSyncConflictsByUser(ctx context.Context, userID pgtype.UUID) ([]ListSyncConflictsByUserRow, error)
// Download All + policy union worklist.
@@ -668,6 +676,9 @@ type Querier interface {
ListUsers(ctx context.Context) ([]ListUsersRow, error)
// First consecutive scan that cannot find the file on disk.
MarkMediaItemMissing(ctx context.Context, id pgtype.UUID) error
// SetPodcastEpisodeDownloading RETIRED (§0 item 4): the pair
// GetNextQueuedEpisode + SetPodcastEpisodeDownloading raced under
// concurrency; ClaimQueuedPodcastEpisode does both atomically.
// SUCCESS-only write (invariant 7): the file is on disk and probed.
// ffprobe facts (real duration via audio_metadata, precise size/mime)
// reconcile the feed's declared values.
@@ -753,10 +764,11 @@ type Querier interface {
SetDeviceSyncEnabled(ctx context.Context, arg SetDeviceSyncEnabledParams) (Devices, error)
// Library Visibility queries
SetLibraryVisibility(ctx context.Context, arg SetLibraryVisibilityParams) (LibraryVisibility, error)
// Worker claim: pending|failed -> queued. Attempts incremented at
// claim time so a crashed worker still counts the try.
// Enqueue: pending|failed -> queued, stamped with the NEXT QUEUE TICKET
// (§0 items 4/5) so claim order = enqueue order (the effective
// listening order the enqueue loop applied). Attempts increment at
// enqueue so a crashed worker still counts the try.
SetPodcastEpisodeDownloadQueued(ctx context.Context, id pgtype.UUID) (int64, error)
SetPodcastEpisodeDownloading(ctx context.Context, id pgtype.UUID) error
// Manual retry (same endpoint as download): reset to pending so the
// worker re-claims it; the reason text clears with the fresh attempt.
SetPodcastEpisodePending(ctx context.Context, id pgtype.UUID) error