Add round-robin posting mode

One story per interval_hours (default 4), cycling through enabled sources
in config order. Empty sources are skipped and the pointer advances to
whichever source actually posted. State persists across restarts.

Duplicate-flagged stories now get a _duplicate sentinel channel so they
stay out of the rotation pool alongside _discarded.
This commit is contained in:
prosolis
2026-05-22 19:58:46 -07:00
parent 69967b25c6
commit 3baec4c8bc
7 changed files with 451 additions and 2 deletions

View File

@@ -0,0 +1,150 @@
// 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.
package scheduler
import (
"context"
"log/slog"
"time"
"pete/internal/config"
"pete/internal/ingestion"
"pete/internal/poster"
"pete/internal/storage"
)
// Enqueuer is the subset of *poster.Queue the scheduler depends on. Defined
// as an interface so tests can substitute a capturing fake without standing
// up a Matrix client.
type Enqueuer interface {
Enqueue(item poster.QueueItem)
}
// RoundRobin paces posts across sources. One tick = at most one Enqueue.
type RoundRobin struct {
sources []config.SourceConfig // only enabled sources, in config 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 {
return nil
}
return &RoundRobin{
sources: enabled,
interval: time.Duration(intervalHours) * time.Hour,
queue: queue,
}
}
// Start runs the scheduler until ctx is cancelled. Honors persisted
// last_tick_at so a restart partway through an interval waits the
// remainder rather than firing immediately.
func (r *RoundRobin) Start(ctx context.Context) {
_, lastTickAt, err := storage.GetRoundRobinState()
if err != nil {
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
} else {
nextTick := time.Unix(lastTickAt, 0).Add(r.interval)
initialDelay = time.Until(nextTick)
if initialDelay < 0 {
initialDelay = 0
}
}
slog.Info("round-robin scheduler started",
"sources", len(r.sources),
"interval", r.interval,
"first_tick_in", initialDelay,
)
timer := time.NewTimer(initialDelay)
defer timer.Stop()
for {
select {
case <-ctx.Done():
slog.Info("round-robin scheduler stopping")
return
case <-timer.C:
r.tick()
timer.Reset(r.interval)
}
}
}
// tick scans sources starting after the last-posted one and enqueues the
// first postable story it finds.
func (r *RoundRobin) tick() {
lastSource, _, 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)
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)
if err != nil {
slog.Error("round-robin: query failed, skipping source",
"source", src.Name, "err", err)
continue
}
if story == nil {
slog.Debug("round-robin: source empty, advancing", "source", src.Name)
continue
}
imageURL := ""
if story.ImageURL != "" && ingestion.ValidateImageURL(story.ImageURL) {
imageURL = story.ImageURL
}
r.queue.Enqueue(poster.QueueItem{
GUID: story.GUID,
Headline: story.Headline,
Lede: story.Lede,
ImageURL: imageURL,
ArticleURL: story.ArticleURL,
Source: story.Source,
Channel: story.Channel,
Platforms: storage.UnmarshalPlatforms(story.Platforms),
})
storage.SetRoundRobinState(src.Name, time.Now().Unix())
slog.Info("round-robin: enqueued",
"source", src.Name, "guid", story.GUID, "channel", story.Channel)
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())
slog.Info("round-robin: no postable stories this tick")
}

View File

@@ -0,0 +1,203 @@
package scheduler
import (
"path/filepath"
"testing"
"pete/internal/config"
"pete/internal/poster"
"pete/internal/storage"
)
type fakeQueue struct {
items []poster.QueueItem
}
func (f *fakeQueue) Enqueue(item poster.QueueItem) {
f.items = append(f.items, item)
}
func setupTestDB(t *testing.T) {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "test.db")
if err := storage.Init(dbPath); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { storage.Close() })
}
func insertPostable(t *testing.T, guid, source, channel string, seenAt int64) {
t.Helper()
if err := storage.InsertStory(&storage.Story{
GUID: guid,
Headline: "Headline for " + guid,
Lede: "Lede for " + guid,
ArticleURL: "https://example.com/" + guid,
Source: source,
FeedHint: "tech",
SeenAt: seenAt,
}); err != nil {
t.Fatalf("InsertStory(%s): %v", guid, err)
}
storage.MarkClassified(guid, channel, "[]")
}
func sources(names ...string) []config.SourceConfig {
out := make([]config.SourceConfig, len(names))
for i, n := range names {
out[i] = config.SourceConfig{
Name: n,
FeedURL: "https://example.com/" + n,
Tier: 1,
PollIntervalMinutes: 20,
Enabled: true,
}
}
return out
}
func TestNew_FiltersDisabledSources(t *testing.T) {
srcs := []config.SourceConfig{
{Name: "A", Enabled: true},
{Name: "B", Enabled: false},
{Name: "C", Enabled: true},
}
rr := New(srcs, 4, &fakeQueue{})
if rr == nil {
t.Fatal("expected non-nil RoundRobin")
}
if len(rr.sources) != 2 || rr.sources[0].Name != "A" || rr.sources[1].Name != "C" {
t.Errorf("expected [A,C], got %v", rr.sources)
}
}
func TestNew_NoEnabledSources_ReturnsNil(t *testing.T) {
srcs := []config.SourceConfig{{Name: "A", Enabled: false}}
if New(srcs, 4, &fakeQueue{}) != nil {
t.Error("expected nil when no sources are enabled")
}
}
func TestTick_PicksNewestPostableFromFirstSource(t *testing.T) {
setupTestDB(t)
insertPostable(t, "a-old", "A", "tech", 100)
insertPostable(t, "a-new", "A", "tech", 200)
insertPostable(t, "b-1", "B", "politics", 150)
fq := &fakeQueue{}
rr := New(sources("A", "B"), 4, fq)
rr.tick()
if len(fq.items) != 1 {
t.Fatalf("expected 1 enqueued, got %d", len(fq.items))
}
if fq.items[0].GUID != "a-new" {
t.Errorf("expected newest from A (a-new), got %s", fq.items[0].GUID)
}
lastSource, _, _ := storage.GetRoundRobinState()
if lastSource != "A" {
t.Errorf("expected last_source=A, got %s", lastSource)
}
}
func TestTick_SkipsEmptyAndAdvancesPointer(t *testing.T) {
setupTestDB(t)
// Pretend we just posted from A; next tick should start at B, which is
// empty, so it should skip to C.
storage.SetRoundRobinState("A", 0)
insertPostable(t, "c-1", "C", "tech", 100)
fq := &fakeQueue{}
rr := New(sources("A", "B", "C"), 4, fq)
rr.tick()
if len(fq.items) != 1 || fq.items[0].GUID != "c-1" {
t.Fatalf("expected c-1 to be enqueued, got %+v", fq.items)
}
lastSource, _, _ := storage.GetRoundRobinState()
if lastSource != "C" {
t.Errorf("expected last_source=C after skipping B, got %s", lastSource)
}
}
func TestTick_NothingPostable_LeavesPointerUnchanged(t *testing.T) {
setupTestDB(t)
storage.SetRoundRobinState("A", 1000)
fq := &fakeQueue{}
rr := New(sources("A", "B"), 4, fq)
rr.tick()
if len(fq.items) != 0 {
t.Fatalf("expected 0 enqueued, got %d", len(fq.items))
}
lastSource, lastTickAt, _ := storage.GetRoundRobinState()
if lastSource != "A" {
t.Errorf("expected last_source unchanged=A, got %s", lastSource)
}
if lastTickAt <= 1000 {
t.Errorf("expected last_tick_at to advance past 1000, got %d", lastTickAt)
}
}
func TestTick_WrapsAroundFromLastSource(t *testing.T) {
setupTestDB(t)
// last_source = C means next tick starts at A (wraparound).
storage.SetRoundRobinState("C", 0)
insertPostable(t, "a-1", "A", "tech", 100)
insertPostable(t, "c-1", "C", "tech", 200) // C is newer, but rotation must pick A
fq := &fakeQueue{}
rr := New(sources("A", "B", "C"), 4, fq)
rr.tick()
if len(fq.items) != 1 || fq.items[0].GUID != "a-1" {
t.Fatalf("expected a-1 (wraparound), got %+v", fq.items)
}
}
func TestTick_DiscardedAndDuplicateExcluded(t *testing.T) {
setupTestDB(t)
insertPostable(t, "a-discard", "A", "_discarded", 200) // sentinel
insertPostable(t, "a-dup", "A", "_duplicate", 150) // sentinel
insertPostable(t, "a-real", "A", "tech", 100) // older but only real one
fq := &fakeQueue{}
rr := New(sources("A"), 4, fq)
rr.tick()
if len(fq.items) != 1 || fq.items[0].GUID != "a-real" {
t.Fatalf("expected a-real (sentinels excluded), got %+v", fq.items)
}
}
func TestTick_AlreadyPostedExcluded(t *testing.T) {
setupTestDB(t)
insertPostable(t, "a-posted", "A", "tech", 200)
insertPostable(t, "a-fresh", "A", "tech", 100)
storage.InsertPostLog("a-posted", "tech", "$evt1", "https://example.com/a-posted")
fq := &fakeQueue{}
rr := New(sources("A"), 4, fq)
rr.tick()
if len(fq.items) != 1 || fq.items[0].GUID != "a-fresh" {
t.Fatalf("expected a-fresh (a-posted already in post_log), got %+v", fq.items)
}
}
func TestTick_UnknownLastSource_StartsAtIndexZero(t *testing.T) {
setupTestDB(t)
// Stale state pointing at a source no longer in config.
storage.SetRoundRobinState("RetiredSource", 0)
insertPostable(t, "a-1", "A", "tech", 100)
insertPostable(t, "b-1", "B", "tech", 200)
fq := &fakeQueue{}
rr := New(sources("A", "B"), 4, fq)
rr.tick()
if len(fq.items) != 1 || fq.items[0].GUID != "a-1" {
t.Fatalf("expected a-1 (start at idx 0 when last_source unknown), got %+v", fq.items)
}
}