feat(queue): Rate-Limit und Ruhezeiten als Gesetz im Worker-Loop
Das Anti-Spam-Gesetz wird an den Loop verwired (die Rate-Limiter-Struktur aus Task 04): Jede gestartete Ansage bucht das Fenster — low über dem Budget verfällt (beim Eintritt, Admission nennt den Grund für die 202-Antwort), normal parkt bis zum Slot (Slot-Wecker broadcastet die Cond, geparkte bleiben in der Queue und in /status sichtbar), high darf mit Burst-Recht verstoßen, now bricht bewusst aus Rate-Limit UND Ruhezeit aus und bucht trotzdem ein neues Fenster. Ruhezeiten (quiet.go: lokales Fenster HH:MM-HH:MM über Mitternacht) verwerfen still oder ersetzen durch Toast — gilt auch für high. Rangordnung: mute (API) > now > Ruhezeit > Rate-Limit. Enqueue liefert Admission statt nur ID; LawState für /status. Lock-Ordnung Queue → RateLimiter bleibt gewahrt.
This commit is contained in:
1 parent
b20e6dd319
commit
5385a4b87d
6 files changed
+785
-81
No files matched your search
+121
-15
@@ -5,19 +5,24 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// RateLimiter ist die Struktur für das Daemon-Gesetz gegen Ansage-Spam
|
||||
// (Spec: Default ca. 1 Ansage / 30 s, konfigurierbar).
|
||||
// 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.
|
||||
//
|
||||
// SEAM FÜR TASK 05: Struktur und Semantik stehen und sind getestet, sind
|
||||
// aber noch NICHT an den Worker-Loop angebunden — Task 04 erzeugt keinen
|
||||
// Limiter (unbegrenzt, so verlangt es der Task-04-Brief: „Default unendlich
|
||||
// in T04-Tests, dokumentiert"). Task 05 verdrahtet den Limiter (config
|
||||
// queue.rate_limit) in den Loop und ergänzt die prioritätsbewusste
|
||||
// Entscheidung: low verwerfen, normal parken, high konfigurierbar verstoßen
|
||||
// (queue.allow_high_burst) — dafür ist Next() bereits Teil der Struktur.
|
||||
// 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-Starts.
|
||||
// 0 oder negativ = unbegrenzt (Task-04-Default).
|
||||
// Interval ist der Mindestabstand zwischen zwei Ansage-Starten.
|
||||
// 0 oder negativ = unbegrenzt.
|
||||
Interval time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
@@ -34,7 +39,10 @@ func (l *RateLimiter) Allow() bool {
|
||||
return time.Since(l.last) >= l.Interval
|
||||
}
|
||||
|
||||
// Reserve bucht einen Ansage-Slot (direkt nach Allow aufrufen).
|
||||
// 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
|
||||
@@ -44,14 +52,112 @@ func (l *RateLimiter) Reserve() {
|
||||
l.last = time.Now()
|
||||
}
|
||||
|
||||
// Next liefert, wann der nächste Ansage-Slot frei wird (Nullzeit, wenn
|
||||
// Allow sofort true liefern würde) — Basis für das Parken wartender Items
|
||||
// in Task 05.
|
||||
// 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
|
||||
}
|
||||
Reference in new issue
Block a user