Files
talk/internal/queue/rate.go
T

209 lines
6.8 KiB
Go
Raw Normal View History

package queue
import (
"strings"
"sync"
"time"
"unicode"
)
// Discard reasons in the HTTP contract of POST /speak
// (202 {"id":"","discarded":"…"}).
const (
DiscardRateLimit = "rate_limit" // priority=low over the window budget
DiscardQuietHours = "quiet_hours" // quiet hours active (discard or toast)
DiscardExpired = "expired" // waited longer than queue.max_age (stale)
)
// RateLimiter is the time window of the daemon's anti-spam law: default
// about one announcement per 30 s (queue.rate_limit), 0 = unlimited. Every
// started announcement books the window anew (Reserve), including ones that
// broke out via now or high burst: the window counts playback starts, not
// their legitimacy.
//
// Lock order: RateLimiter.mu is always the inner lock; callers hold the
// queue lock during Allow/Reserve/Next, never the other way round.
type RateLimiter struct {
// Interval is the minimum gap between two announcement starts. 0 or
// negative = unlimited.
Interval time.Duration
mu sync.Mutex
last time.Time // start of the announcement last booked via Reserve
}
// Allow reports whether an announcement could start right now.
func (l *RateLimiter) Allow() bool {
if l == nil || l.Interval <= 0 {
return true
}
l.mu.Lock()
defer l.mu.Unlock()
return time.Since(l.last) >= l.Interval
}
// Reserve books a slot. Call it right after the play decision, before actual
// playback: failed announcements consume the window too, so a broken
// provider cannot turn into a machine gun.
func (l *RateLimiter) Reserve() {
if l == nil || l.Interval <= 0 {
return
}
l.mu.Lock()
defer l.mu.Unlock()
l.last = time.Now()
}
// Next returns when the next slot opens; the zero time when Allow would
// return true right away (unlimited or window elapsed). Basis for parking
// waiting items and for the status endpoint.
func (l *RateLimiter) Next() time.Time {
if l == nil || l.Interval <= 0 {
return time.Time{}
}
l.mu.Lock()
defer l.mu.Unlock()
if l.last.IsZero() {
return time.Time{}
}
return l.last.Add(l.Interval)
}
// Laws are the daemon's anti-spam laws: rate limit with configurable burst
// rights for high, quiet hours, text length cap and maximum queue age. The
// zero value allows everything; the daemon supplies the laws from the
// config (defaults in internal/config.Defaults).
type Laws struct {
// RateLimit is the minimum gap between two announcement starts
// (0 = unlimited).
RateLimit time.Duration
// AllowHighBurst lets priority=high deliberately break the rate limit
// (default true). Without burst rights, high waits like normal. Quiet
// hours apply to high regardless.
AllowHighBurst bool
// Quiet is the quiet window (local time; zero value = disabled).
Quiet QuietHours
// QuietMode is the behaviour during quiet hours: "discard" drops
// silently, "toast" replaces audio with a desktop notification (the
// queue fires the OnToast hook; the daemon wires the notification).
QuietMode string
// MaxText caps announcement length in characters (runes). Longer texts
// are cut at a word boundary on admission and reported as truncated: an
// agent reading out its long answer costs neither minutes nor API
// budget, and the user still hears the beginning. 0 = unlimited.
MaxText int
// MaxAge is the longest an announcement may wait in the queue. Whatever
// parks longer (rate limit, backlog) is stale and gets discarded when it
// comes due (DiscardExpired) instead of spoken. 0 = unlimited. now:true
// never waits long and is exempt.
MaxAge time.Duration
}
// verdict is the laws' decision about a due item.
type verdict int
const (
verdictPlay verdict = iota // play
verdictWait // park until the slot opens
verdictDiscardRate // discard: low over budget
verdictDiscardQuiet // discard: quiet hours (discard)
verdictToast // toast instead of audio (quiet hours)
verdictDiscardExpired // discard: waited longer than MaxAge
)
// decide applies the laws to a due item (pure, no state): slotFree says
// whether the rate-limit slot is open right now. Precedence:
//
// 1. now:true deliberately breaks out: it skips rate limit AND quiet hours
// (the documented escape for urgent matters; it still books a new rate
// window).
// 2. Stale: an item that waited longer than MaxAge is discarded; an
// announcement from five minutes ago is no longer news.
// 3. Quiet hours: discard drops, toast replaces; this applies to high too,
// because burst is a rate right, not a quiet right.
// 4. Rate limit: an open slot plays; high plays with burst rights; low is
// discarded; normal (and high without rights) park until the slot.
func (l Laws) decide(item Item, now time.Time, slotFree bool) verdict {
if item.Now {
return verdictPlay
}
if l.MaxAge > 0 && !item.enqueued.IsZero() && now.Sub(item.enqueued) > l.MaxAge {
return verdictDiscardExpired
}
if l.Quiet.Active(now) {
if l.QuietMode == QuietModeToast {
return verdictToast
}
return verdictDiscardQuiet
}
if slotFree {
return verdictPlay
}
if item.Priority == PriorityHigh && l.AllowHighBurst {
return verdictPlay
}
if item.Priority == PriorityLow {
return verdictDiscardRate
}
return verdictWait
}
// Admission is the result of Enqueue: admitted items carry their ID,
// discarded ones name the reason (the 202 response of POST /speak carries
// it).
type Admission struct {
// ID of the admitted announcement (empty when discarded).
ID string
// Discarded is the discard reason (DiscardRateLimit or
// DiscardQuietHours); empty when the announcement was admitted.
Discarded string
// Toast reports that the announcement was replaced by a notification
// during quiet hours (quiet_mode = "toast").
Toast bool
// Truncated reports that the text was cut to MaxText (the shortened
// version was admitted).
Truncated bool
}
// LawState is the /status view of the laws: the configured rate limit with
// the next open slot, the quiet window, and the length and age limits.
type LawState struct {
RateLimit time.Duration
AllowHighBurst bool
NextSlot time.Time // zero = slot open (or unlimited)
Quiet QuietHours
QuietActive bool
QuietMode string
QuietUntil time.Time // zero = quiet hours inactive
MaxText int // 0 = unlimited
MaxAge time.Duration // 0 = unlimited
}
// truncateText cuts s to at most max runes, preferably at a word boundary
// (unless that lies in the first half), and reports whether it cut.
// max <= 0 = unlimited.
func truncateText(s string, max int) (string, bool) {
if max <= 0 {
return s, false
}
runes := []rune(s)
if len(runes) <= max {
return s, false
}
cut := max
for i := max; i > max/2; i-- {
if unicode.IsSpace(runes[i]) {
cut = i
break
}
}
return strings.TrimRight(string(runes[:cut]), " \t\r\n,;:-–—"), true
}