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:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user