Files
talk/internal/queue/rate.go
T

163 lines
5.4 KiB
Go
Raw Normal View History

package queue
import (
"sync"
"time"
)
// Verwurfs-Gründe im HTTP-Vertrag von POST /speak
// (202 {"id":"","discarded":"…"}).
const (
DiscardRateLimit = "rate_limit" // priority=low über dem Fenster-Budget
DiscardQuietHours = "quiet_hours" // Ruhezeit aktiv (discard oder toast)
)
// RateLimiter ist das Zeitfenster des Daemon-Gesetzes gegen Ansage-Spam:
// Spec-Default ca. 1 Ansage / 30 s (queue.rate_limit), 0 = unbegrenzt.
// Jede gestartete Ansage bucht das Fenster neu (Reserve) — auch eine per
// now oder high-burst ausgebrochene: Das Fenster zählt Abspiel-Starts,
// nicht deren Legitimität.
//
// Lock-Ordnung: RateLimiter.mu ist immer die innere Sperre — Aufrufer
// halten beim Allow/Reserve/Next die Queue-sperre (niemals umgekehrt).
type RateLimiter struct {
// Interval ist der Mindestabstand zwischen zwei Ansage-Starten.
// 0 oder negativ = unbegrenzt.
Interval time.Duration
mu sync.Mutex
last time.Time // Start der zuletzt per Reserve gebuchten Ansage
}
// Allow meldet, ob sofort eine Ansage starten dürfte.
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 bucht einen Ansage-Slot (direkt nach der Play-Entscheidung
// aufrufen, vor dem eigentlichen Abspielen — auch fehlgeschlagene Ansagen
// verbrauchen das Fenster: Ein kaputter Provider darf nicht zum Maschinengewehr
// werden).
func (l *RateLimiter) Reserve() {
if l == nil || l.Interval <= 0 {
return
}
l.mu.Lock()
defer l.mu.Unlock()
l.last = time.Now()
}
// Next liefert, wann der nächste Ansage-Slot frei wird — Nullzeit, wenn
// Allow sofort true liefern würde (unbegrenzt oder Fenster abgelaufen).
// Basis für das Parken wartender Items und den 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 sind die Anti-Spam-Gesetze des Daemons (Task 05): Rate-Limit mit
// verkonfigurierbarem Verstoß-Recht für high, plus Ruhezeiten. Der Nullwert
// erlaubt alles (unbegrenzt, kein Ruhefenster) — der Daemon liefert die
// Gesetze aus der Config; Default siehe internal/config.Defaults.
type Laws struct {
// RateLimit ist der Mindestabstand zwischen zwei Ansage-Starten
// (0 = unbegrenzt).
RateLimit time.Duration
// AllowHighBurst erlaubt priority=high, das Rate-Limit bewusst zu
// verstoßen (Default true). Ohne Burst-Recht wartet high wie normal.
// Die Ruhezeit gilt davon unberührt auch für high.
AllowHighBurst bool
// Quiet ist das Ruhezeit-Fenster (lokale Zeit; Nullwert = deaktiviert).
Quiet QuietHours
// QuietMode bestimmt das Verhalten in der Ruhezeit: "discard" verwirft
// still, "toast" ersetzt Audio durch eine Benachrichtigung — aktuell
// eine Protokoll-Zeile über den Notification-Pfad des Loggers; der
// echte OS-Toast folgt mit der Notification-Anbindung (dokumentierte
// Einschränkung, siehe Task-05-Report).
QuietMode string
}
// verdict ist die Gesetzes-Entscheidung über ein fälliges Item.
type verdict int
const (
verdictPlay verdict = iota // abspielen
verdictWait // parken bis zum freien Slot
verdictDiscardRate // verwerfen: low über dem Budget
verdictDiscardQuiet // verwerfen: Ruhezeit (discard)
verdictToast // Toast statt Audio (Ruhezeit)
)
// decide wendet die Gesetze auf ein fälliges Item an (rein, ohne Zustand):
// slotFree sagt, ob der Rate-Limit-Slot gerade frei ist. Rangordnung:
//
// 1. now:true bricht bewusst aus — überspringt Rate-Limit UND Ruhezeit
// (dokumentierter Ausbruch für Dringendes; startet trotzdem ein neues
// Rate-Fenster).
// 2. Ruhezeit: discard verwirft, toast ersetzt — gilt auch für high,
// denn Burst ist ein Rate-Recht, kein Ruhe-Recht.
// 3. Rate-Limit: freier Slot spielt; high spielt mit Burst-Recht; low
// wird verworfen; normal (und high ohne Recht) parken bis zum Slot.
func (l Laws) decide(item Item, now time.Time, slotFree bool) verdict {
if item.Now {
return verdictPlay
}
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 ist das Ergebnis von Enqueue: Aufgenommene tragen ihre ID,
// verworfene nennen den Grund (die 202-Antwort von POST /speak führt ihn).
type Admission struct {
// ID der aufgenommenen Ansage (leer, wenn verworfen).
ID string
// Discarded ist der Verwurfs-Grund (DiscardRateLimit bzw.
// DiscardQuietHours); leer, wenn die Ansage aufgenommen wurde.
Discarded string
// Toast meldet, dass die Ansage in der Ruhezeit durch eine
// Benachrichtigung ersetzt wurde (quiet_mode = "toast").
Toast bool
}
// LawState ist die /status-Sicht auf die Anti-Spam-Gesetze: konfiguriertes
// Rate-Limit mit dem nächsten freien Slot sowie das Ruhezeit-Fenster.
type LawState struct {
RateLimit time.Duration
AllowHighBurst bool
NextSlot time.Time // Nullzeit = Slot frei (oder unbegrenzt)
Quiet QuietHours
QuietActive bool
QuietMode string
QuietUntil time.Time // Nullzeit = Ruhezeit inaktiv
}