feat(runner): process candidates newest-first within each pass (ADR-018)

Restructures RunOnce from per-channel inline processing to collect-sort-process:

Phase 1 — discover, persist (UpsertVideo), apply pre-filters (seen/manual/backoff)
           and collect surviving candidates with their discovery position.
Phase 2 — sort candidates by published_at DESC, NULLS LAST, pos ASC tiebreak
           so videos with no publish date never jump ahead of dated content.
Phase 3 — process in sorted order through the unchanged globalFetchGate.

Before (per-channel): chanA=[v-old, v-mid], chanB=[v-new, v-null]
                    → [v-old, v-mid, v-new, v-null]
After  (newest-first): [v-new, v-mid, v-old, v-null]

Same set of videos processed; only the order changes within a pass. All existing
behaviour is preserved: failure isolation, backoff skip, manual mode,
channel-unavailable, stats. In-memory sort; no new table or persisted queue.

The ordering is onboarding prioritisation — new users get summaries of their most
recent, relevant videos first; the back-catalogue fills in behind across subsequent
passes. Both this background batch and the foreground 'Try now' button honour the
shared globalFetchGate: rate limiting is respected, not evaded.
This commit is contained in:
2026-06-06 21:29:28 +02:00
parent 8403e8e524
commit 0c0225f9c6
2 changed files with 210 additions and 61 deletions
+92
View File
@@ -123,6 +123,12 @@ func vid(provID, title string) domain.Video {
return domain.Video{UserID: testUser, Provider: domain.ProviderYouTube, ProviderVideoID: provID, Title: title}
}
func vidAt(provID, title string, publishedAt time.Time) domain.Video {
v := vid(provID, title)
v.PublishedAt = publishedAt
return v
}
func quietLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
@@ -298,3 +304,89 @@ func TestRunOnce_UpsertsEveryCandidate(t *testing.T) {
require.NoError(t, err)
require.Len(t, st.upserted, 2, "every candidate is upserted, including seen ones")
}
// TestRunOnce_NewestFirstOrdering asserts that within a pass, candidates are
// processed newest-first (published_at DESC, NULLS LAST) across all channels,
// and that the set of processed videos is identical to what per-channel inline
// processing would produce (only the order differs).
//
// Fixture: two channels, four videos with mixed published_at (one NULL).
//
// Per-channel (before): chanA=[v-old, v-mid], chanB=[v-new, v-null]
// → [v-old, v-mid, v-new, v-null]
// Newest-first (after): [v-new, v-mid, v-old, v-null]
func TestRunOnce_NewestFirstOrdering(t *testing.T) {
old := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
mid := time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC)
newt := time.Date(2024, 12, 1, 0, 0, 0, 0, time.UTC)
// zero time = NULL published_at (schema 001: nullable)
src := &fakeSource{
subs: []domain.Subscription{
sub("chanA", "Channel A"),
sub("chanB", "Channel B"),
},
videos: map[string][]domain.Video{
"chanA": {
vidAt("v-old", "Old Video", old),
vidAt("v-mid", "Mid Video", mid),
},
"chanB": {
vidAt("v-new", "New Video", newt),
vidAt("v-null", "No Date Video", time.Time{}), // NULL
},
},
}
st := &fakeStore{seen: map[string]bool{}, auto: true}
sink := &recordingSink{}
eng := usecase.NewEngine(src, fakeSummarizer{}, sink)
r := runner.New(src, st, eng, testUser, quietLogger())
stats, err := r.RunOnce(context.Background())
require.NoError(t, err)
// Same set: all 4 candidates processed regardless of order.
require.Equal(t, 4, stats.Candidates)
require.Equal(t, 4, stats.Summarized, "same set of videos processed as per-channel order")
require.Len(t, sink.delivered, 4)
// Build video-id → delivery-position map.
order := make(map[string]int, len(sink.delivered))
for i, s := range sink.delivered {
order[s.VideoID] = i
t.Logf("position %d: %s", i, s.VideoID)
}
require.Less(t, order["id-v-new"], order["id-v-mid"], "newest (Dec) before mid (Jun)")
require.Less(t, order["id-v-mid"], order["id-v-old"], "mid (Jun) before old (Jan)")
require.Less(t, order["id-v-old"], order["id-v-null"], "dated before NULL (NULLS LAST)")
}
// TestRunOnce_NewestFirstNullsOnly asserts that when all candidates have NULL
// published_at, discovery order (stable) is preserved as the tiebreak.
func TestRunOnce_NewestFirstNullsOnly(t *testing.T) {
src := &fakeSource{
subs: []domain.Subscription{
sub("chanA", "Channel A"),
sub("chanB", "Channel B"),
},
videos: map[string][]domain.Video{
"chanA": {vidAt("v1", "V1", time.Time{}), vidAt("v2", "V2", time.Time{})},
"chanB": {vidAt("v3", "V3", time.Time{})},
},
}
st := &fakeStore{seen: map[string]bool{}, auto: true}
sink := &recordingSink{}
eng := usecase.NewEngine(src, fakeSummarizer{}, sink)
r := runner.New(src, st, eng, testUser, quietLogger())
stats, err := r.RunOnce(context.Background())
require.NoError(t, err)
require.Equal(t, 3, stats.Summarized, "all null-date videos processed")
// Discovery order: chanA[v1, v2], chanB[v3] → [v1, v2, v3].
// All have NULL published_at so the sort is stable; discovery order must hold.
require.Equal(t, "id-v1", sink.delivered[0].VideoID)
require.Equal(t, "id-v2", sink.delivered[1].VideoID)
require.Equal(t, "id-v3", sink.delivered[2].VideoID)
}