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
@@ -761,6 +761,126 @@ func TestPodcastReverseOrder(t *testing.T) {
|
||||
"position 3 first — a messy-date feed reorders by position, not dates")
|
||||
}
|
||||
|
||||
// ---- §0 item 4: concurrent pool — tickets, exclusivity, drain ----
|
||||
|
||||
func TestPodcastDownloadPool(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
if _, err := exec.LookPath("ffmpeg"); err != nil {
|
||||
if _, err := os.Stat("/usr/bin/ffmpeg"); err != nil {
|
||||
t.Skip("ffmpeg unavailable: worker pipeline test needs runtime-generated audio")
|
||||
}
|
||||
}
|
||||
|
||||
lib, _ := createPodcastsLibrary(t, setup, "test Pool Podcasts")
|
||||
|
||||
mp3Path := filepath.Join(t.TempDir(), "tone.mp3")
|
||||
gen := exec.Command("ffmpeg", "-y", "-v", "error", "-f", "lavfi",
|
||||
"-i", "sine=frequency=440:duration=1", "-b:a", "32k", mp3Path)
|
||||
out, err := gen.CombinedOutput()
|
||||
require.NoError(t, err, "ffmpeg generate: %s", string(out))
|
||||
audio, err := os.ReadFile(mp3Path)
|
||||
require.NoError(t, err)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/audio/", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "audio/mpeg")
|
||||
_, _ = w.Write(audio)
|
||||
})
|
||||
mux.HandleFunc("/feed.xml", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/rss+xml")
|
||||
_, _ = w.Write([]byte(podcastFixture("Pool Feed", map[string]string{
|
||||
"pl-1": "Pool One", "pl-2": "Pool Two", "pl-3": "Pool Three",
|
||||
})))
|
||||
})
|
||||
srv := swapFeedServer(t, mux.ServeHTTP)
|
||||
|
||||
libID := uuid.UUID(lib.ID.Bytes).String()
|
||||
resp, body := podcastAuthed(t, setup, setup.Token, "POST", "/api/podcasts/feeds",
|
||||
fmt.Sprintf(`{"library_id":%q,"url":%q}`, libID, srv.URL+"/feed.xml"))
|
||||
require.Equal(t, http.StatusCreated, resp, string(body))
|
||||
|
||||
adminCtx := adminSeedCtx(t, setup)
|
||||
feeds, err := setup.DB.ListPodcastFeedsForPolling(adminCtx)
|
||||
require.NoError(t, err)
|
||||
var feedRow database.PodcastFeeds
|
||||
for _, f := range feeds {
|
||||
if f.Title == "Pool Feed" {
|
||||
feedRow = f
|
||||
}
|
||||
}
|
||||
require.NotZero(t, feedRow.ID, "feed must exist after add")
|
||||
eps, err := setup.DB.ListPodcastEpisodes(adminCtx, feedRow.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, eps, 3)
|
||||
|
||||
// Point enclosures at the test server (admin-declared write).
|
||||
adminScope := database.NewScopedDB(setup.DBPool)
|
||||
_, err = adminScope.Exec(rls.WithUser(context.Background(), adminCtxValue(t, setup)),
|
||||
`UPDATE media_items SET enclosure_url = $2 WHERE source_feed_id = $1`,
|
||||
feedRow.ID, srv.URL+"/audio/ep.mp3")
|
||||
require.NoError(t, err)
|
||||
|
||||
worker := services.NewPodcastDownloadWorker(setup.DB, services.NewPodcastService(setup.DB))
|
||||
actx, err := worker.AdminCtx(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
// Queue in a KNOWN order: tickets (download_queue_pos SEQUENCE) make
|
||||
// the enqueue loop's ordering the claim order — the §0 item 5
|
||||
// foundation ("getting the last chapter first doesn't help me").
|
||||
ordered := make([]database.MediaItems, 0, len(eps))
|
||||
for _, wantPos := range []int32{1, 2, 3} {
|
||||
for _, ep := range eps {
|
||||
if ep.FeedPosition.Int32 == wantPos {
|
||||
ordered = append(ordered, ep)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, ep := range ordered {
|
||||
_, err := setup.DB.SetPodcastEpisodeDownloadQueued(actx, ep.ID)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
queued, err := setup.DB.ListQueuedPodcastEpisodes(actx)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, queued, 3)
|
||||
for i, ep := range ordered {
|
||||
assert.Equal(t, ep.ID, queued[i].ID,
|
||||
"PIN: ticket order = enqueue order (claim claims in enqueue order)")
|
||||
}
|
||||
|
||||
t.Run("claim_is_exclusive", func(t *testing.T) {
|
||||
first, err := setup.DB.ClaimQueuedPodcastEpisode(actx, queued[0].ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "downloading", first.DownloadState)
|
||||
// A second claimant racing the same row gets NOTHING — the
|
||||
// single-statement guard (queued → downloading) is the
|
||||
// SKIP-LOCKED-equivalent guarantee.
|
||||
_, err = setup.DB.ClaimQueuedPodcastEpisode(actx, queued[0].ID)
|
||||
assert.Error(t, err, "second claim of a downloading row must not match")
|
||||
|
||||
// Hand it back for the drain below (enqueue only takes
|
||||
// pending|failed — a stuck 'downloading' needs the manual reset,
|
||||
// exactly what the crash-recovery path does).
|
||||
require.NoError(t, setup.DB.SetPodcastEpisodePending(actx, queued[0].ID))
|
||||
_, err = setup.DB.SetPodcastEpisodeDownloadQueued(actx, queued[0].ID)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
worker.DrainNow(context.Background())
|
||||
require.Eventually(t, func() bool {
|
||||
rowset, err := setup.DB.ListPodcastEpisodes(adminCtx, feedRow.ID)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, row := range rowset {
|
||||
if row.DownloadState != "downloaded" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return len(rowset) == 3
|
||||
}, 30*time.Second, 250*time.Millisecond, "all 3 episodes must download through the pool")
|
||||
}
|
||||
|
||||
// ---- issue #8: page search hits EPISODE titles/descriptions too ----
|
||||
|
||||
func TestPodcastPageSearchEpisodes(t *testing.T) {
|
||||
|
||||
Reference in new issue
Block a user