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:
1 parent
e3675536aa
commit
0f3c4a8cec
14 files changed
+893
-264
No files matched your search
@@ -1,6 +1,6 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// sqlc v1.31.0
|
||||
|
||||
package database
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// sqlc v1.31.0
|
||||
|
||||
package database
|
||||
|
||||
@@ -355,6 +355,7 @@ type MediaItems struct {
|
||||
DownloadState string `db:"download_state" json:"download_state"`
|
||||
DownloadError pgtype.Text `db:"download_error" json:"download_error"`
|
||||
DownloadAttempts int32 `db:"download_attempts" json:"download_attempts"`
|
||||
DownloadQueuePos pgtype.Int8 `db:"download_queue_pos" json:"download_queue_pos"`
|
||||
}
|
||||
|
||||
type MediaNotes struct {
|
||||
|
||||
@@ -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
|
||||
|
||||
+249
-162
File diff suppressed because it is too large.
Load diff
@@ -4862,10 +4862,13 @@ SELECT * FROM media_items
|
||||
WHERE id = $1 AND source = 'podcast_episode';
|
||||
|
||||
-- name: SetPodcastEpisodeDownloadQueued :execrows
|
||||
-- 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.
|
||||
UPDATE media_items
|
||||
SET download_state = 'queued',
|
||||
download_queue_pos = nextval('podcast_queue_seq'),
|
||||
download_error = NULL,
|
||||
download_attempts = download_attempts + 1,
|
||||
updated_at = NOW()
|
||||
@@ -4873,10 +4876,9 @@ WHERE id = $1
|
||||
AND source = 'podcast_episode'
|
||||
AND download_state IN ('pending', 'failed');
|
||||
|
||||
-- name: SetPodcastEpisodeDownloading :exec
|
||||
UPDATE media_items
|
||||
SET download_state = 'downloading', updated_at = NOW()
|
||||
WHERE id = $1 AND source = 'podcast_episode';
|
||||
-- SetPodcastEpisodeDownloading RETIRED (§0 item 4): the pair
|
||||
-- GetNextQueuedEpisode + SetPodcastEpisodeDownloading raced under
|
||||
-- concurrency; ClaimQueuedPodcastEpisode does both atomically.
|
||||
|
||||
-- name: MarkPodcastEpisodeDownloaded :one
|
||||
-- SUCCESS-only write (invariant 7): the file is on disk and probed.
|
||||
@@ -4964,12 +4966,26 @@ WHERE s.user_id = sqlc.arg('user_id')
|
||||
OR COALESCE(f.publisher, '') ILIKE '%' || sqlc.arg('search_query')::text || '%')
|
||||
ORDER BY COALESCE(s.display_name, f.title) ASC;
|
||||
|
||||
-- name: GetNextQueuedEpisode :one
|
||||
-- Sequential download worker's claim source (single worker, day one).
|
||||
SELECT * FROM media_items
|
||||
-- name: ListQueuedPodcastEpisodes :many
|
||||
-- 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.
|
||||
SELECT id, source_feed_id FROM media_items
|
||||
WHERE source = 'podcast_episode' AND download_state = 'queued'
|
||||
ORDER BY updated_at ASC
|
||||
LIMIT 1;
|
||||
ORDER BY download_queue_pos ASC NULLS LAST;
|
||||
|
||||
-- name: ClaimQueuedPodcastEpisode :one
|
||||
-- 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.
|
||||
UPDATE media_items
|
||||
SET download_state = 'downloading', updated_at = NOW()
|
||||
WHERE id = $1
|
||||
AND source = 'podcast_episode'
|
||||
AND download_state = 'queued'
|
||||
RETURNING media_items.*;
|
||||
|
||||
-- name: CountQueuedEpisodes :one
|
||||
SELECT COUNT(*) FROM media_items
|
||||
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
// SettingType enumerates the value types stored in system_settings.setting_type.
|
||||
const (
|
||||
SettingTypeInt = "int"
|
||||
SettingTypeFloat = "float"
|
||||
SettingTypeBool = "bool"
|
||||
SettingTypeString = "string"
|
||||
SettingTypeStringList = "string_list"
|
||||
@@ -83,6 +84,8 @@ var SettingDefaults = []SettingDefault{
|
||||
{Key: "worker_pool_size", Value: "3", Type: SettingTypeInt, Min: "1", Max: "100", RequiresRestart: true, Category: "performance", Group: "Worker Pool", Description: "Number of background worker goroutines"},
|
||||
{Key: "worker_queue_cap", Value: "100", Type: SettingTypeInt, Min: "1", Max: "10000", RequiresRestart: true, Category: "performance", Group: "Worker Pool", Description: "Background worker job queue capacity"},
|
||||
{Key: "podcast_poll_interval_seconds", Value: "3600", Type: SettingTypeInt, Min: "300", Max: "604800", Category: "podcasts", Group: "Podcasts", Description: "How often to poll podcast feeds for new episodes (seconds; complete feeds back off)"},
|
||||
{Key: "podcast_download_concurrency", Value: "4", Type: SettingTypeInt, Min: "1", Max: "16", Category: "podcasts", Group: "Podcasts", Description: "Podcast episode downloads in flight server-wide (the connection is the limit; fair-share spreads them across feeds)"},
|
||||
{Key: "podcast_download_speed_mbps", Value: "0", Type: SettingTypeFloat, Min: "0", Max: "10000", Category: "podcasts", Group: "Podcasts", Description: "Shared bandwidth cap for podcast downloads in megabits/second (0 = unlimited)"},
|
||||
}
|
||||
|
||||
// defaultBy indexes SettingDefaults by key for O(1) lookup.
|
||||
@@ -173,6 +176,26 @@ func (r *SettingsRegistry) getInt(key string) int {
|
||||
return n
|
||||
}
|
||||
|
||||
func (r *SettingsRegistry) getFloat(key string) float64 {
|
||||
d := defaultBy[key]
|
||||
v := r.raw(key)
|
||||
f, err := strconv.ParseFloat(v, 64)
|
||||
if err != nil {
|
||||
f, _ = strconv.ParseFloat(d.Value, 64)
|
||||
}
|
||||
if d.Min != "" {
|
||||
if mn, err := strconv.ParseFloat(d.Min, 64); err == nil && f < mn {
|
||||
f = mn
|
||||
}
|
||||
}
|
||||
if d.Max != "" {
|
||||
if mx, err := strconv.ParseFloat(d.Max, 64); err == nil && f > mx {
|
||||
f = mx
|
||||
}
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func (r *SettingsRegistry) getBool(key string) bool {
|
||||
v := r.raw(key)
|
||||
b, err := strconv.ParseBool(v)
|
||||
@@ -189,13 +212,24 @@ func (r *SettingsRegistry) ScanPollInterval() time.Duration {
|
||||
return time.Duration(r.getInt("scan_poll_interval_seconds")) * time.Second
|
||||
}
|
||||
|
||||
// PodcastPollInterval is the feed-poller tick (live setting; the poller
|
||||
// re-reads it every cycle — writes go through the settings API path,
|
||||
// AGENTS.md gotcha #2).
|
||||
// PodcastPollInterval is how often the poller checks feeds, as a
|
||||
// duration (live setting; the poller re-reads it every cycle — writes
|
||||
// go through the settings API path, AGENTS.md gotcha #2).
|
||||
func (r *SettingsRegistry) PodcastPollInterval() time.Duration {
|
||||
return time.Duration(r.getInt("podcast_poll_interval_seconds")) * time.Second
|
||||
}
|
||||
|
||||
// PodcastDownloadConcurrency: episode downloads in flight server-wide
|
||||
// (the connection is the limit; fair-share spreads them across feeds).
|
||||
func (r *SettingsRegistry) PodcastDownloadConcurrency() int {
|
||||
return r.getInt("podcast_download_concurrency")
|
||||
}
|
||||
|
||||
// PodcastDownloadSpeedMbps: shared bandwidth cap (0 = unlimited).
|
||||
func (r *SettingsRegistry) PodcastDownloadSpeedMbps() float64 {
|
||||
return r.getFloat("podcast_download_speed_mbps")
|
||||
}
|
||||
|
||||
// AutoScanEnabled reports whether auto-scanning is on.
|
||||
func (r *SettingsRegistry) AutoScanEnabled() bool { return r.getBool("auto_scan_enabled") }
|
||||
|
||||
|
||||
@@ -178,6 +178,21 @@ func validateSettingValue(def database.SettingDefault, value string) error {
|
||||
if _, err := strconv.ParseBool(value); err != nil {
|
||||
return fmt.Errorf("value must be true or false")
|
||||
}
|
||||
case database.SettingTypeFloat:
|
||||
f, err := strconv.ParseFloat(value, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("value must be a number")
|
||||
}
|
||||
if def.Min != "" {
|
||||
if mn, err := strconv.ParseFloat(def.Min, 64); err == nil && f < mn {
|
||||
return fmt.Errorf("value must be >= %s", def.Min)
|
||||
}
|
||||
}
|
||||
if def.Max != "" {
|
||||
if mx, err := strconv.ParseFloat(def.Max, 64); err == nil && f > mx {
|
||||
return fmt.Errorf("value must be <= %s", def.Max)
|
||||
}
|
||||
}
|
||||
case database.SettingTypeString:
|
||||
if value == "" {
|
||||
return fmt.Errorf("value must not be empty")
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
// Podcast download worker (plan §7 / D6 / D8 / D19): ONE sequential
|
||||
// worker (day one) driving the episode state machine
|
||||
// Podcast download worker (plan §7 / D6 / D8 / D19; pool since §0 item
|
||||
// 4): a download-manager-style pool driving the episode state machine
|
||||
//
|
||||
// pending → queued → downloading → downloaded
|
||||
// ↘ failed (+ download_error)
|
||||
//
|
||||
// queued rows are the DB-backed worklist — the state is persisted so
|
||||
// the episode list ALWAYS renders truth (no in-memory queue to lose).
|
||||
// Each queue ticket (download_queue_pos, a SEQUENCE nextval at
|
||||
// enqueue) makes the enqueue loop's ordering the download order.
|
||||
//
|
||||
// Concurrency: up to podcast_download_concurrency episodes in flight
|
||||
// server-wide (default 4, admin-tunable live 1–16). Fair share: the
|
||||
// claim prefers the feed least-recently-served, so several books
|
||||
// download in parallel while chapters within a book land in ticket
|
||||
// order (start listening while the rest catch up). Bandwidth: an
|
||||
// optional shared token bucket (podcast_download_speed_mbps, 0 =
|
||||
// unlimited) paces all workers together.
|
||||
//
|
||||
// Idempotency: a download request for an episode whose file already
|
||||
// exists and probes cleanly re-marks it downloaded without re-fetching
|
||||
@@ -29,10 +39,10 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -48,25 +58,64 @@ const maxAutoDownloadAttempts = 5
|
||||
|
||||
// PodcastDownloadWorker processes queued episode downloads.
|
||||
type PodcastDownloadWorker struct {
|
||||
db *database.Queries
|
||||
svc *PodcastService
|
||||
wake chan struct{}
|
||||
db *database.Queries
|
||||
svc *PodcastService
|
||||
settings *database.SettingsRegistry
|
||||
wake chan struct{}
|
||||
|
||||
// Fair-share bookkeeping (§0 item 4): feed UUID string → last
|
||||
// time the pool served it. Claim prefers the smallest value;
|
||||
// never-served feeds (missing key) go first. Guarded by fairMu.
|
||||
fairMu sync.Mutex
|
||||
lastServed map[string]time.Time
|
||||
|
||||
// Shared bandwidth bucket across all pool workers (§0 item 4).
|
||||
limiter bandwidthLimiter
|
||||
|
||||
// Live byte-progress slots for in-flight downloads (issue #1).
|
||||
// Keyed by episode UUID string; guarded by progMu.
|
||||
progMu sync.Mutex
|
||||
progMu sync.Mutex
|
||||
progress map[string]*episodeProgress
|
||||
}
|
||||
|
||||
func NewPodcastDownloadWorker(db *database.Queries, svc *PodcastService) *PodcastDownloadWorker {
|
||||
return &PodcastDownloadWorker{
|
||||
db: db,
|
||||
svc: svc,
|
||||
wake: make(chan struct{}, 1),
|
||||
progress: make(map[string]*episodeProgress),
|
||||
db: db,
|
||||
svc: svc,
|
||||
wake: make(chan struct{}, 1),
|
||||
lastServed: make(map[string]time.Time),
|
||||
progress: make(map[string]*episodeProgress),
|
||||
}
|
||||
}
|
||||
|
||||
// SetSettings wires the live settings registry (admin tunables:
|
||||
// concurrency + bandwidth cap). Without it the worker runs on the
|
||||
// registered defaults (4 in flight, unlimited speed) — the test
|
||||
// harness constructs workers without settings.
|
||||
func (w *PodcastDownloadWorker) SetSettings(r *database.SettingsRegistry) {
|
||||
w.settings = r
|
||||
}
|
||||
|
||||
// concurrency re-reads the live setting each drain cycle.
|
||||
func (w *PodcastDownloadWorker) concurrency() int {
|
||||
if w.settings == nil {
|
||||
return 4 // registered default
|
||||
}
|
||||
if n := w.settings.PodcastDownloadConcurrency(); n >= 1 {
|
||||
return n
|
||||
}
|
||||
return 4
|
||||
}
|
||||
|
||||
// refillLimiter re-syncs the shared bucket's rate (bytes/sec) from the
|
||||
// live setting; 0 = unlimited.
|
||||
func (w *PodcastDownloadWorker) refillLimiter() {
|
||||
if w.settings == nil {
|
||||
return
|
||||
}
|
||||
w.limiter.setRate(int64(w.settings.PodcastDownloadSpeedMbps() * 125000)) // Mbps → bytes/sec
|
||||
}
|
||||
|
||||
// Wake nudges the loop (new work queued).
|
||||
func (w *PodcastDownloadWorker) Wake() {
|
||||
select {
|
||||
@@ -93,21 +142,129 @@ func (w *PodcastDownloadWorker) Start(ctx context.Context) {
|
||||
}()
|
||||
}
|
||||
|
||||
// drain processes queued episodes until the queue is empty.
|
||||
// DrainNow runs one bounded pool round synchronously — the test hook
|
||||
// and the future admin "retry now" seam (normally the wake channel
|
||||
// drives rounds).
|
||||
func (w *PodcastDownloadWorker) DrainNow(ctx context.Context) {
|
||||
w.drain(ctx)
|
||||
}
|
||||
|
||||
// drain runs one pool round: up to concurrency() claim-loop goroutines
|
||||
// each claim (fair-share) → download → settle, until the queue is
|
||||
// empty. The cap setting is re-read every round — live-tunable.
|
||||
func (w *PodcastDownloadWorker) drain(ctx context.Context) {
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
acted, err := w.processNext(ctx)
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
w.refillLimiter()
|
||||
limit := w.concurrency()
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < limit; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
ep, ok := w.claimNext(ctx)
|
||||
if !ok {
|
||||
return // queue drained (or nothing claimable)
|
||||
}
|
||||
w.runOne(ctx, ep)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// claimNext atomically claims the next queued episode, preferring the
|
||||
// feed least-recently-served (fair share — books proceed in parallel)
|
||||
// and the earliest queue ticket within a feed (chapters land in
|
||||
// enqueue order). Returns ok=false when nothing is claimable.
|
||||
func (w *PodcastDownloadWorker) claimNext(ctx context.Context) (database.MediaItems, bool) {
|
||||
actx, err := w.svc.adminCtx(ctx)
|
||||
if err != nil {
|
||||
return database.MediaItems{}, false
|
||||
}
|
||||
cands, err := w.db.ListQueuedPodcastEpisodes(actx)
|
||||
if err != nil || len(cands) == 0 {
|
||||
return database.MediaItems{}, false
|
||||
}
|
||||
|
||||
// Group per feed preserving ticket order (the SQL scan is ordered).
|
||||
perFeed := make(map[pgtype.UUID][]pgtype.UUID, 8)
|
||||
for _, c := range cands {
|
||||
perFeed[c.SourceFeedID] = append(perFeed[c.SourceFeedID], c.ID)
|
||||
}
|
||||
|
||||
w.fairMu.Lock()
|
||||
defer w.fairMu.Unlock()
|
||||
pickFeed := pickFeedToServe(perFeed, w.lastServed)
|
||||
if !pickFeed.Valid {
|
||||
return database.MediaItems{}, false
|
||||
}
|
||||
|
||||
// Claim the head ticket of the chosen feed; if another claimant
|
||||
// won it meanwhile (retry raced), fall through the feed's tickets.
|
||||
for _, id := range perFeed[pickFeed] {
|
||||
ep, err := w.db.ClaimQueuedPodcastEpisode(actx, id)
|
||||
if err != nil {
|
||||
log.Printf("[PODCAST-DL] worker step: %v", err)
|
||||
return
|
||||
continue // claimed elsewhere / state moved on
|
||||
}
|
||||
if !acted {
|
||||
return
|
||||
w.lastServed[pickFeed.String()] = time.Now()
|
||||
return ep, true
|
||||
}
|
||||
return database.MediaItems{}, false
|
||||
}
|
||||
|
||||
// pickFeedToServe chooses the feed least-recently-served (never-served
|
||||
// first); among never-served feeds the first encountered wins (any is
|
||||
// equally fair). Unit-testable seam for the fair-share rule.
|
||||
func pickFeedToServe(perFeed map[pgtype.UUID][]pgtype.UUID, lastServed map[string]time.Time) pgtype.UUID {
|
||||
var pick pgtype.UUID
|
||||
var best time.Time
|
||||
haveNever := false
|
||||
for feed := range perFeed {
|
||||
last, seen := lastServed[feed.String()]
|
||||
if !seen {
|
||||
if !haveNever {
|
||||
pick, haveNever = feed, true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if haveNever {
|
||||
continue // a never-served feed already won
|
||||
}
|
||||
if !pick.Valid || last.Before(best) {
|
||||
pick, best = feed, last
|
||||
}
|
||||
}
|
||||
return pick
|
||||
}
|
||||
|
||||
// runOne downloads a claimed episode and settles the row either way
|
||||
// (failed/rejected: state + reason persist; metadata/file facts
|
||||
// untouched — invariant 7, write-after-success only).
|
||||
func (w *PodcastDownloadWorker) runOne(ctx context.Context, ep database.MediaItems) {
|
||||
actx, err := w.svc.adminCtx(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if err := w.runPipeline(actx, ep); err != nil {
|
||||
_ = w.db.MarkPodcastEpisodeFailed(actx, database.MarkPodcastEpisodeFailedParams{
|
||||
ID: ep.ID,
|
||||
DownloadError: pgtypeText(err.Error()),
|
||||
Column3: false, // auto failure: never resets the attempt budget
|
||||
})
|
||||
log.Printf("[PODCAST-DL] episode %s failed: %v", ep.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// AdminCtx exposes the worker's first-admin identity (tests use it for
|
||||
// worker-scope DB statements).
|
||||
func (w *PodcastDownloadWorker) AdminCtx(ctx context.Context) (context.Context, error) {
|
||||
return w.svc.adminCtx(ctx)
|
||||
}
|
||||
|
||||
// QueueEpisode marks one episode queued (manual download / retry path).
|
||||
@@ -228,33 +385,6 @@ func (w *PodcastDownloadWorker) ApplyAutoPolicies(ctx context.Context, feedID pg
|
||||
return queued, nil
|
||||
}
|
||||
|
||||
// processNext claims and processes one queued episode. Returns false
|
||||
// when the queue was empty.
|
||||
func (w *PodcastDownloadWorker) processNext(ctx context.Context) (bool, error) {
|
||||
actx, err := w.svc.adminCtx(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
ep, err := w.db.GetNextQueuedEpisode(actx)
|
||||
if err != nil {
|
||||
return false, nil // empty queue (or read failure — logged upstream)
|
||||
}
|
||||
|
||||
_ = w.db.SetPodcastEpisodeDownloading(actx, ep.ID)
|
||||
|
||||
if err := w.runPipeline(actx, ep); err != nil {
|
||||
// failed/rejected: state + reason persist; metadata/file facts
|
||||
// untouched (invariant 7 — write-after-success only).
|
||||
_ = w.db.MarkPodcastEpisodeFailed(actx, database.MarkPodcastEpisodeFailedParams{
|
||||
ID: ep.ID,
|
||||
DownloadError: pgtypeText(err.Error()),
|
||||
Column3: false, // auto failure: never resets the attempt budget
|
||||
})
|
||||
log.Printf("[PODCAST-DL] episode %s failed: %v", ep.ID, err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// runPipeline: fetch enclosure (SSRF/size guards) → temp file → MIME/
|
||||
// size validation → ffprobe reconcile → move into place → SUCCESS-only
|
||||
// row update.
|
||||
@@ -398,6 +528,42 @@ func (w *PodcastDownloadWorker) EpisodeProgress(episodeID pgtype.UUID) (received
|
||||
return p.Received, p.Total, true
|
||||
}
|
||||
|
||||
// ---- shared bandwidth bucket (§0 item 4) ----
|
||||
// One leaky-bucket-with-reservations limiter across ALL pool workers:
|
||||
// each caller reserves the next slot of bandwidth and sleeps outside
|
||||
// the lock — reservation order = transmission order, and N workers
|
||||
// share one cap instead of N caps.
|
||||
|
||||
type bandwidthLimiter struct {
|
||||
mu sync.Mutex
|
||||
rate int64 // bytes per second; <=0 = unlimited
|
||||
next time.Time // when the currently-reserved budget runs out
|
||||
}
|
||||
|
||||
func (l *bandwidthLimiter) setRate(bytesPerSec int64) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.rate = bytesPerSec
|
||||
}
|
||||
|
||||
// take reserves n bytes of bandwidth and returns how long the caller
|
||||
// must wait before sending them (0 when free flow).
|
||||
func (l *bandwidthLimiter) take(n int64) time.Duration {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if l.rate <= 0 {
|
||||
return 0
|
||||
}
|
||||
now := time.Now()
|
||||
if l.next.Before(now) {
|
||||
l.next = now
|
||||
}
|
||||
d := time.Duration(float64(n) / float64(l.rate) * float64(time.Second))
|
||||
start := l.next // my window begins when earlier reservations end
|
||||
l.next = start.Add(d)
|
||||
return start.Sub(now)
|
||||
}
|
||||
|
||||
// streamEnclosure performs the SSRF-guarded enclosure download straight
|
||||
// to tmpPath while updating the progress slot. Returns bytes received
|
||||
// and the authoritative total (0 when unknown). Scheme and size guards
|
||||
@@ -456,6 +622,15 @@ func (w *PodcastDownloadWorker) streamEnclosure(ctx context.Context, ep database
|
||||
if received > maxEnclosureCapBytes {
|
||||
return received, total, fmt.Errorf("rejected: enclosure exceeds %d byte cap", maxEnclosureCapBytes)
|
||||
}
|
||||
// Shared bandwidth cap: pace against the server-wide
|
||||
// bucket (0 rate = unlimited, take returns 0).
|
||||
if wait := w.limiter.take(int64(n)); wait > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return received, total, fmt.Errorf("download failed: cancelled")
|
||||
case <-time.After(wait):
|
||||
}
|
||||
}
|
||||
w.addProgress(ep.ID, int64(n))
|
||||
if _, werr := out.Write(buf[:n]); werr != nil {
|
||||
return received, total, fmt.Errorf("write: %w", werr)
|
||||
@@ -496,13 +671,13 @@ func (w *PodcastDownloadWorker) markDownloaded(ctx context.Context, ep database.
|
||||
}
|
||||
|
||||
_, err = w.db.MarkPodcastEpisodeDownloaded(ctx, database.MarkPodcastEpisodeDownloadedParams{
|
||||
ID: ep.ID,
|
||||
FilePath: ep.FilePath,
|
||||
FileSize: pgtypeInt8(info.Size()),
|
||||
MimeType: pgtypeText(strings.TrimSpace(pickMimeType(ep, absPath))),
|
||||
ID: ep.ID,
|
||||
FilePath: ep.FilePath,
|
||||
FileSize: pgtypeInt8(info.Size()),
|
||||
MimeType: pgtypeText(strings.TrimSpace(pickMimeType(ep, absPath))),
|
||||
CoverImagePath: coverRel,
|
||||
AudioMetadata: audioPayloadJSONB(payload),
|
||||
ChapterCount: chapterCount,
|
||||
AudioMetadata: audioPayloadJSONB(payload),
|
||||
ChapterCount: chapterCount,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("mark downloaded: %w", err)
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
// Unit tests for the §0 item 4 download-pool mechanics: the shared
|
||||
// bandwidth bucket (reservation math, unlimited passthrough) and the
|
||||
// fair-share feed pick (never-served first, least-recently-served
|
||||
// after). Pure logic — no DB.
|
||||
package services
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func uuidOf(s string) pgtype.UUID {
|
||||
var u pgtype.UUID
|
||||
_ = u.Scan(s)
|
||||
return u
|
||||
}
|
||||
|
||||
func TestBandwidthLimiter(t *testing.T) {
|
||||
t.Run("zero rate is unlimited", func(t *testing.T) {
|
||||
var l bandwidthLimiter
|
||||
l.setRate(0)
|
||||
assert.Zero(t, l.take(1<<20), "unlimited → no wait, ever")
|
||||
assert.Zero(t, l.take(1<<20))
|
||||
})
|
||||
|
||||
t.Run("reservations share one bucket", func(t *testing.T) {
|
||||
var l bandwidthLimiter
|
||||
l.setRate(1_000_000) // 1 MB/s
|
||||
start := time.Now()
|
||||
|
||||
w1 := l.take(1_000_000) // first caller: free flow
|
||||
w2 := l.take(1_000_000) // second caller: waits one full second
|
||||
w3 := l.take(1_000_000) // third caller: waits two seconds
|
||||
|
||||
assert.Less(t, int64(w1), int64(50*time.Millisecond), "first reservation starts immediately")
|
||||
assert.GreaterOrEqual(t, int64(w2), int64(900*time.Millisecond), "second waits ~1s at 1MB/s")
|
||||
assert.GreaterOrEqual(t, int64(w3), int64(1900*time.Millisecond), "third waits ~2s — SHARED bucket, not per-worker")
|
||||
assert.Less(t, time.Since(start), 100*time.Millisecond, "take() only reserves — callers sleep outside the lock")
|
||||
})
|
||||
|
||||
t.Run("idle bucket recovers", func(t *testing.T) {
|
||||
var l bandwidthLimiter
|
||||
l.setRate(1_000_000)
|
||||
_ = l.take(1_000_000)
|
||||
l.next = time.Now().Add(-time.Second) // simulate a second of idle flow
|
||||
assert.Less(t, int64(l.take(500_000)), int64(50*time.Millisecond),
|
||||
"budget accrued while idle → next take flows immediately")
|
||||
})
|
||||
}
|
||||
|
||||
func TestPickFeedToServe(t *testing.T) {
|
||||
a, b, c := uuidOf("00000000-0000-0000-0000-00000000000a"),
|
||||
uuidOf("00000000-0000-0000-0000-00000000000b"),
|
||||
uuidOf("00000000-0000-0000-0000-00000000000c")
|
||||
all := map[pgtype.UUID][]pgtype.UUID{a: {}, b: {}, c: {}}
|
||||
|
||||
t.Run("never-served beats served", func(t *testing.T) {
|
||||
served := map[string]time.Time{
|
||||
a.String(): time.Now().Add(-time.Hour),
|
||||
b.String(): time.Now().Add(-time.Minute),
|
||||
}
|
||||
assert.Equal(t, c, pickFeedToServe(all, served), "c was never served → c wins even though a starved longest")
|
||||
})
|
||||
|
||||
t.Run("least-recently-served wins", func(t *testing.T) {
|
||||
served := map[string]time.Time{
|
||||
a.String(): time.Now().Add(-time.Hour), // starved longest
|
||||
b.String(): time.Now().Add(-time.Minute), // served recently
|
||||
}
|
||||
two := map[pgtype.UUID][]pgtype.UUID{a: {}, b: {}}
|
||||
assert.Equal(t, a, pickFeedToServe(two, served))
|
||||
})
|
||||
|
||||
t.Run("empty queue picks nothing", func(t *testing.T) {
|
||||
assert.False(t, pickFeedToServe(map[pgtype.UUID][]pgtype.UUID{}, nil).Valid)
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user