Files
bookhoard/internal/services/podcast_worker_test.go
T
John O'Keefe 0f3c4a8cec 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.
2026-10-30 10:12:43 -04:00

81 lines
2.7 KiB
Go

// 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)
})
}