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.
81 lines
2.7 KiB
Go
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)
|
|
})
|
|
}
|