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