Round-robin: rotate by channel instead of by source

Source-keyed rotation skewed toward whichever channel had the most
feeds (4 of 7 sources routed to politics, so politics dominated the
rotation). Channel-keyed rotation guarantees variety regardless of
feed counts.

Schema: round_robin_state.last_source -> last_channel, added via
addColumnIfMissing so existing DBs migrate in place.
This commit is contained in:
prosolis
2026-05-24 19:31:27 -07:00
parent afe2ef996b
commit 87906719fa
7 changed files with 121 additions and 137 deletions

View File

@@ -1,15 +1,15 @@
// Package scheduler provides the round-robin posting scheduler: when
// enabled, Pete posts one classified story per interval, cycling through
// sources in config order. Empty sources are skipped and the rotation
// pointer advances to the source that actually posted.
// channels in sorted order. Empty channels are skipped and the rotation
// pointer advances to the channel that actually posted.
package scheduler
import (
"context"
"log/slog"
"sort"
"time"
"pete/internal/config"
"pete/internal/ingestion"
"pete/internal/poster"
"pete/internal/storage"
@@ -22,27 +22,26 @@ type Enqueuer interface {
Enqueue(item poster.QueueItem)
}
// RoundRobin paces posts across sources. One tick = at most one Enqueue.
// RoundRobin paces posts across channels. One tick = at most one Enqueue.
type RoundRobin struct {
sources []config.SourceConfig // only enabled sources, in config order
channels []string // routable channel names, in stable (sorted) order
interval time.Duration
queue Enqueuer
}
// New filters to enabled sources and returns nil if the rotation has nothing
// to work with (callers should fall back to immediate posting in that case).
func New(sources []config.SourceConfig, intervalHours int, queue Enqueuer) *RoundRobin {
var enabled []config.SourceConfig
for _, s := range sources {
if s.Enabled {
enabled = append(enabled, s)
}
}
if len(enabled) == 0 {
// New returns a scheduler that rotates across the given channel names.
// Returns nil if the channel list is empty (callers should fall back to
// immediate posting in that case). Channel order is normalized so behavior
// is independent of map iteration order.
func New(channels []string, intervalHours int, queue Enqueuer) *RoundRobin {
if len(channels) == 0 {
return nil
}
sorted := make([]string, len(channels))
copy(sorted, channels)
sort.Strings(sorted)
return &RoundRobin{
sources: enabled,
channels: sorted,
interval: time.Duration(intervalHours) * time.Hour,
queue: queue,
}
@@ -57,7 +56,6 @@ func (r *RoundRobin) Start(ctx context.Context) {
slog.Error("round-robin: failed to load state, starting fresh", "err", err)
}
// Compute time until the next scheduled tick.
var initialDelay time.Duration
if lastTickAt == 0 {
initialDelay = r.interval
@@ -69,7 +67,7 @@ func (r *RoundRobin) Start(ctx context.Context) {
}
}
slog.Info("round-robin scheduler started",
"sources", len(r.sources),
"channels", len(r.channels),
"interval", r.interval,
"first_tick_in", initialDelay,
)
@@ -89,36 +87,35 @@ func (r *RoundRobin) Start(ctx context.Context) {
}
}
// tick scans sources starting after the last-posted one and enqueues the
// first postable story it finds.
// tick scans channels starting after the last-posted one and enqueues the
// newest unposted story routed to the first channel with anything available.
func (r *RoundRobin) tick() {
lastSource, _, err := storage.GetRoundRobinState()
lastChannel, _, err := storage.GetRoundRobinState()
if err != nil {
slog.Error("round-robin: failed to load state", "err", err)
// continue with empty lastSource — start from index 0
}
startIdx := 0
if lastSource != "" {
for i, s := range r.sources {
if s.Name == lastSource {
startIdx = (i + 1) % len(r.sources)
if lastChannel != "" {
for i, ch := range r.channels {
if ch == lastChannel {
startIdx = (i + 1) % len(r.channels)
break
}
}
}
for offset := 0; offset < len(r.sources); offset++ {
idx := (startIdx + offset) % len(r.sources)
src := r.sources[idx]
story, err := storage.GetNewestPostableStory(src.Name)
for offset := 0; offset < len(r.channels); offset++ {
idx := (startIdx + offset) % len(r.channels)
ch := r.channels[idx]
story, err := storage.GetNewestPostableStoryByChannel(ch)
if err != nil {
slog.Error("round-robin: query failed, skipping source",
"source", src.Name, "err", err)
slog.Error("round-robin: query failed, skipping channel",
"channel", ch, "err", err)
continue
}
if story == nil {
slog.Debug("round-robin: source empty, advancing", "source", src.Name)
slog.Debug("round-robin: channel empty, advancing", "channel", ch)
continue
}
@@ -137,14 +134,14 @@ func (r *RoundRobin) tick() {
Channel: story.Channel,
Platforms: storage.UnmarshalPlatforms(story.Platforms),
})
storage.SetRoundRobinState(src.Name, time.Now().Unix())
storage.SetRoundRobinState(ch, time.Now().Unix())
slog.Info("round-robin: enqueued",
"source", src.Name, "guid", story.GUID, "channel", story.Channel)
"channel", ch, "guid", story.GUID, "source", story.Source)
return
}
// Nothing to post this tick — record the tick timestamp but leave
// last_source untouched so the next tick resumes from the same offset.
storage.SetRoundRobinState(lastSource, time.Now().Unix())
// last_channel untouched so the next tick resumes from the same offset.
storage.SetRoundRobinState(lastChannel, time.Now().Unix())
slog.Info("round-robin: no postable stories this tick")
}