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