Hard daily cap, no-flood shutdown, ctx-aware poller, double-image fix

Four related fixes after Pete flooded a channel and ignored Ctrl-C:

1. Global daily cap (posting.daily_cap_total, default 5): hard ceiling on
   posts across ALL channels in a rolling 24h window. Checked before the
   per-channel min-interval and burst-cap.

2. Shutdown no longer flushes the queue. Previous drainAll posted every
   remaining item with rate limits disabled — which was literally the
   flood. Replaced with dropOnShutdown that clears queues and logs
   the count.

3. Poller respects ctx mid-loop. pollOnceWithErr now takes ctx and bails
   between items, so Ctrl-C doesn't have to wait for ~30s of network
   per pending story before shutdown can complete.

4. Double-image fix. PostStory now reports imageSent; the queue clears
   ImageURL before retry so a text-send failure after a successful
   image upload doesn't re-post the image.
This commit is contained in:
prosolis
2026-05-22 18:51:33 -07:00
parent 8d1e6ed568
commit c9318d7bb0
7 changed files with 80 additions and 41 deletions

View File

@@ -66,7 +66,9 @@ func (q *Queue) Enqueue(item QueueItem) {
)
}
// Start runs the queue drain ticker. Blocks until ctx is cancelled, then drains remaining items.
// Start runs the queue drain ticker. Blocks until ctx is cancelled, then
// drops any remaining queued items (no flush-posting on shutdown — we'd
// rather lose them than dump a flood into the channel).
func (q *Queue) Start(ctx context.Context) {
defer close(q.done)
ticker := time.NewTicker(10 * time.Second)
@@ -75,8 +77,7 @@ func (q *Queue) Start(ctx context.Context) {
for {
select {
case <-ctx.Done():
// Graceful drain: attempt to post remaining queued items
q.drainAll()
q.dropOnShutdown()
return
case <-ticker.C:
q.drain()
@@ -102,29 +103,23 @@ func (q *Queue) drain() {
}
}
// drainAll posts all remaining items ignoring rate limits (shutdown path).
func (q *Queue) drainAll() {
// dropOnShutdown clears any pending queues, logging the count. We deliberately
// do NOT post these — the daily cap exists for a reason, and a flush on Ctrl-C
// would dump everything pending into the rooms at once.
func (q *Queue) dropOnShutdown() {
q.mu.Lock()
channels := make([]string, 0, len(q.queues))
for ch := range q.queues {
channels = append(channels, ch)
}
q.mu.Unlock()
for _, ch := range channels {
for {
q.mu.Lock()
items := q.queues[ch]
if len(items) == 0 {
q.mu.Unlock()
break
}
item := items[0]
q.queues[ch] = items[1:]
q.mu.Unlock()
q.postItem(item)
defer q.mu.Unlock()
total := 0
for ch, items := range q.queues {
if len(items) > 0 {
slog.Info("dropping queued items on shutdown",
"channel", ch, "count", len(items))
total += len(items)
}
q.queues[ch] = nil
}
if total > 0 {
slog.Info("shutdown: queue drop complete", "total_dropped", total)
}
}
@@ -141,13 +136,25 @@ func (q *Queue) drainChannel(channel string) {
minInterval := int64(q.config.MinIntervalSeconds)
burstWindow := int64(q.config.BurstCapWindowSeconds)
// Check minimum interval since last post
// Global daily cap: hard ceiling across ALL channels (rolling 24h).
// Checked first so it short-circuits per-channel logic.
if q.config.DailyCapTotal > 0 {
dayStart := now - 24*3600
todays := storage.CountAllPostsInWindow(dayStart)
if todays >= q.config.DailyCapTotal {
slog.Debug("global daily cap reached, holding queue",
"channel", channel, "posts_24h", todays, "cap", q.config.DailyCapTotal)
return
}
}
// Check minimum interval since last post (per channel)
lastPost := storage.GetLastPostTime(channel)
if now-lastPost < minInterval {
return
}
// Check burst cap
// Check burst cap (per channel)
windowStart := now - burstWindow
postsInWindow := storage.CountPostsInWindow(channel, windowStart)
if postsInWindow >= q.config.BurstCapCount {
@@ -194,7 +201,7 @@ func (q *Queue) postItem(item QueueItem) {
Platforms: item.Platforms,
}
eventID, err := q.mx.PostStory(item.Channel, story)
eventID, imageSent, err := q.mx.PostStory(item.Channel, story)
if err != nil {
item.retries++
if item.retries >= maxRetries {
@@ -211,6 +218,11 @@ func (q *Queue) postItem(item QueueItem) {
"attempt", item.retries,
"err", err,
)
// If the image already went up before the text failed, clear it on the
// retry so we don't send the m.image event a second time.
if imageSent {
item.ImageURL = ""
}
// Re-queue at front for retry
q.mu.Lock()
q.queues[item.Channel] = append([]QueueItem{item}, q.queues[item.Channel]...)