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
+223
-15
@@ -67,17 +67,18 @@ func (g *gesteuerteSpielerin) keineWeitere(t *testing.T) {
|
||||
// TestAbarbeitungsReihenfolge: now vor high vor normal vor low, FIFO je
|
||||
// Level. Der erste Blocker besetzt den Worker; danach werden alle Level
|
||||
// gleichzeitig eingereiht — die Abarbeitung muss der Klasse folgen.
|
||||
// (Ohne Gesetze: Null-Laws = unbegrenzt, keine Ruhezeit.)
|
||||
func TestAbarbeitungsReihenfolge(t *testing.T) {
|
||||
g := neueSpielerin()
|
||||
q := New(g.speak, nil)
|
||||
q := New(Laws{}, g.speak, nil)
|
||||
defer q.Close()
|
||||
|
||||
erster, err := q.Enqueue(Item{Text: "blocker", Priority: PriorityNormal})
|
||||
adm, err := q.Enqueue(Item{Text: "blocker", Priority: PriorityNormal})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if erster == "" {
|
||||
t.Fatal("Enqueue vergibt keine ID")
|
||||
if adm.ID == "" || adm.Discarded != "" {
|
||||
t.Fatalf("Enqueue = %+v, want Aufnahme mit ID", adm)
|
||||
}
|
||||
g.warteBis(t, 1) // Blocker läuft, Worker gebunden.
|
||||
|
||||
@@ -111,7 +112,7 @@ func TestAbarbeitungsReihenfolge(t *testing.T) {
|
||||
// TestCurrentUndPending: Status-Sicht auf laufendes Item + Warteschlange.
|
||||
func TestCurrentUndPending(t *testing.T) {
|
||||
g := neueSpielerin()
|
||||
q := New(g.speak, nil)
|
||||
q := New(Laws{}, g.speak, nil)
|
||||
defer q.Close()
|
||||
|
||||
if _, ok := q.Current(); ok {
|
||||
@@ -144,7 +145,7 @@ func TestCurrentUndPending(t *testing.T) {
|
||||
// spielbereit.
|
||||
func TestCancel(t *testing.T) {
|
||||
g := neueSpielerin()
|
||||
q := New(g.speak, nil)
|
||||
q := New(Laws{}, g.speak, nil)
|
||||
defer q.Close()
|
||||
|
||||
q.Enqueue(Item{Text: "blocker"})
|
||||
@@ -165,13 +166,13 @@ func TestCancel(t *testing.T) {
|
||||
}
|
||||
|
||||
// Queue nimmt wieder an und spielt:
|
||||
id, err := q.Enqueue(Item{Text: "danach"})
|
||||
adm, err := q.Enqueue(Item{Text: "danach"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g.step <- struct{}{} // gibt die wartende Ansage im Speaker frei
|
||||
gestartet := g.warteBis(t, 2)
|
||||
if len(gestartet) != 1 || gestartet[0].ID != id {
|
||||
if len(gestartet) != 1 || gestartet[0].ID != adm.ID {
|
||||
t.Errorf("neue Ansage nicht gestartet: %+v", gestartet)
|
||||
}
|
||||
}
|
||||
@@ -181,7 +182,7 @@ func TestCancel(t *testing.T) {
|
||||
// Enqueue ein Fehler (und Close idempotent).
|
||||
func TestClose(t *testing.T) {
|
||||
g := neueSpielerin()
|
||||
q := New(g.speak, nil)
|
||||
q := New(Laws{}, g.speak, nil)
|
||||
|
||||
q.Enqueue(Item{Text: "blocker"})
|
||||
g.warteBis(t, 1)
|
||||
@@ -202,19 +203,19 @@ func TestClose(t *testing.T) {
|
||||
// TestEnqueueVergibtIDs: IDs sind kurz und eindeutig.
|
||||
func TestEnqueueVergibtIDs(t *testing.T) {
|
||||
g := neueSpielerin()
|
||||
q := New(g.speak, nil)
|
||||
q := New(Laws{}, g.speak, nil)
|
||||
defer q.Close()
|
||||
|
||||
seen := map[string]bool{}
|
||||
for i := 0; i < 20; i++ {
|
||||
id, err := q.Enqueue(Item{Text: "x"})
|
||||
adm, err := q.Enqueue(Item{Text: "x"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(id) < 4 || seen[id] {
|
||||
t.Errorf("ID %q zu kurz oder doppelt", id)
|
||||
if len(adm.ID) < 4 || seen[adm.ID] {
|
||||
t.Errorf("ID %q zu kurz oder doppelt", adm.ID)
|
||||
}
|
||||
seen[id] = true
|
||||
seen[adm.ID] = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,7 +280,7 @@ func TestMuteStore(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRateLimiter: Struktur-Semantik (Task 05 verdrahtet sie in den Loop).
|
||||
// TestRateLimiter: Struktur-Semantik (seit Task 05 in den Loop verwired).
|
||||
func TestRateLimiter(t *testing.T) {
|
||||
// Unbegrenzt (Task-04-Default): Interval 0 erlaubt immer.
|
||||
unlimited := &RateLimiter{}
|
||||
@@ -307,3 +308,210 @@ func TestRateLimiter(t *testing.T) {
|
||||
t.Fatal("nach Interval: Allow = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
// fensterUm baut ein "HH:MM-HH:MM"-Ruhefenster, das t sicher einschließt
|
||||
// (eine Minute davor bis eine Minute danach; Minute-Auflösung reicht).
|
||||
func fensterUm(t time.Time) string {
|
||||
return t.Add(-time.Minute).Format("15:04") + "-" + t.Add(time.Minute).Format("15:04")
|
||||
}
|
||||
|
||||
// warteAuf verifies mit Pollen eine Bedingung (Zeit-Gesetze sind real, der
|
||||
// gesteuerte Speaker allein sieht sie nicht).
|
||||
func warteAuf(t *testing.T, timeout time.Duration, cond func() bool, was string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
for !cond() {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("%s innerhalb %s nicht eingetreten", was, timeout)
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRateLimitLowVerworfenNormalParkt: mit kurzem Fenster spielt die
|
||||
// erste Ansage sofort; low über dem Budget wird schon beim Eintritt
|
||||
// verworfen (Admission nennt den Grund), normal parkt und spielt nach
|
||||
// Fenster-Ablauf.
|
||||
func TestRateLimitLowVerworfenNormalParkt(t *testing.T) {
|
||||
g := neueSpielerin()
|
||||
q := New(Laws{RateLimit: 400 * time.Millisecond}, g.speak, nil)
|
||||
defer q.Close()
|
||||
|
||||
if adm, err := q.Enqueue(Item{Text: "erste"}); err != nil || adm.ID == "" || adm.Discarded != "" {
|
||||
t.Fatalf("erste Enqueue = %+v (%v), want Aufnahme", adm, err)
|
||||
}
|
||||
g.warteBis(t, 1) // erste läuft — Fenster ist gebucht.
|
||||
|
||||
adm, err := q.Enqueue(Item{Text: "low-eins", Priority: PriorityLow})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if adm.ID != "" || adm.Discarded != DiscardRateLimit || adm.Toast {
|
||||
t.Errorf("low über Budget = %+v, want Verwurf %q", adm, DiscardRateLimit)
|
||||
}
|
||||
if adm, err = q.Enqueue(Item{Text: "low-zwei", Priority: PriorityLow}); err != nil || adm.Discarded != DiscardRateLimit {
|
||||
t.Errorf("zweiter low-Verwurf = %+v (%v)", adm, err)
|
||||
}
|
||||
|
||||
adm, err = q.Enqueue(Item{Text: "zweite"})
|
||||
if err != nil || adm.ID == "" || adm.Discarded != "" {
|
||||
t.Fatalf("normal = %+v (%v), want Aufnahme (parkt bis zum Slot)", adm, err)
|
||||
}
|
||||
|
||||
// Status-Sicht während der erste läuft: zweite wartet (normal, geparkt).
|
||||
warteAuf(t, time.Second, func() bool {
|
||||
for _, it := range q.Pending() {
|
||||
if it.Text == "zweite" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}, "geparkte normale Ansage in Pending")
|
||||
|
||||
close(g.step) // erste beenden — zweite muss weiter parken …
|
||||
g.keineWeitere(t)
|
||||
// … und nach Fenster-Ablauf starten (Deadline deckt Jitter locker).
|
||||
gestartet := g.warteBis(t, 2)
|
||||
if len(gestartet) != 1 || gestartet[0].Text != "zweite" {
|
||||
t.Errorf("nach Fenster-Ablauf = %+v, will zweite", gestartet)
|
||||
}
|
||||
// low-eins/low-zwei spielen nie: genau 2 Starts insgesamt.
|
||||
g.keineWeitere(t)
|
||||
}
|
||||
|
||||
// TestHighBurstEinUndAus: mit Burst-Recht startet high sofort nach der
|
||||
// laufenden Ansage, obwohl das Fenster voll ist; ohne Recht parkt high
|
||||
// wie normal (kein Verstoß).
|
||||
func TestHighBurstEinUndAus(t *testing.T) {
|
||||
g := neueSpielerin()
|
||||
q := New(Laws{RateLimit: 10 * time.Second, AllowHighBurst: true}, g.speak, nil)
|
||||
|
||||
q.Enqueue(Item{Text: "erste"})
|
||||
g.warteBis(t, 1)
|
||||
if adm, err := q.Enqueue(Item{Text: "dringend", Priority: PriorityHigh}); err != nil || adm.ID == "" {
|
||||
t.Fatalf("high mit Burst-Recht = %+v (%v), want Aufnahme", adm, err)
|
||||
}
|
||||
g.step <- struct{}{} // erste beenden
|
||||
gestartet := g.warteBis(t, 2) // high startet trotz vollem Fenster
|
||||
if len(gestartet) != 1 || gestartet[0].Text != "dringend" {
|
||||
t.Errorf("burst = %+v, will dringend sofort", gestartet)
|
||||
}
|
||||
q.Close()
|
||||
|
||||
g2 := neueSpielerin()
|
||||
q2 := New(Laws{RateLimit: 10 * time.Second, AllowHighBurst: false}, g2.speak, nil)
|
||||
defer q2.Close()
|
||||
|
||||
q2.Enqueue(Item{Text: "erste"})
|
||||
g2.warteBis(t, 1) // erste läuft — Fenster gebucht
|
||||
if adm, err := q2.Enqueue(Item{Text: "dringend", Priority: PriorityHigh}); err != nil || adm.ID == "" {
|
||||
t.Fatalf("high ohne Burst-Recht = %+v (%v), want Aufnahme (parkt)", adm, err)
|
||||
}
|
||||
g2.step <- struct{}{} // erste beenden
|
||||
g2.keineWeitere(t) // high wartet den Slot ab — kein Verstoß ohne Recht
|
||||
}
|
||||
|
||||
// TestNowBypassesRateLimit: now startet trotz vollem Fensters sofort —
|
||||
// der dokumentierte Ausbruch (er startet trotzdem ein neues Fenster).
|
||||
func TestNowBypassesRateLimit(t *testing.T) {
|
||||
g := neueSpielerin()
|
||||
q := New(Laws{RateLimit: 10 * time.Second}, g.speak, nil)
|
||||
defer q.Close()
|
||||
|
||||
q.Enqueue(Item{Text: "erste"})
|
||||
g.warteBis(t, 1) // Fenster gebucht (10 s)
|
||||
|
||||
if adm, err := q.Enqueue(Item{Text: "sofort", Now: true}); err != nil || adm.ID == "" || adm.Discarded != "" {
|
||||
t.Fatalf("now über vollem Fenster = %+v (%v), want Aufnahme", adm, err)
|
||||
}
|
||||
g.step <- struct{}{}
|
||||
gestartet := g.warteBis(t, 2) // lange vor Fenster-Ablauf (10 s) — sonst Timeout
|
||||
if len(gestartet) != 1 || gestartet[0].Text != "sofort" {
|
||||
t.Errorf("now = %+v, will sofort", gestartet)
|
||||
}
|
||||
|
||||
// Der now-Bruch hat ein neues Fenster gebucht: normal parkt jetzt.
|
||||
if adm, err := q.Enqueue(Item{Text: "danach"}); err != nil || adm.ID == "" {
|
||||
t.Fatalf("normal nach now = %+v (%v)", adm, err)
|
||||
}
|
||||
close(g.step)
|
||||
g.keineWeitere(t) // parkt bis zum Slot — nicht sofort
|
||||
}
|
||||
|
||||
// TestRuhezeitDiscardUndToast: aktives Fenster verwirft normal und high
|
||||
// (Burst ist ein Rate-Recht, kein Ruhe-Recht) — toast ersetzt stattdessen;
|
||||
// now bricht bewusst aus.
|
||||
func TestRuhezeitDiscardUndToast(t *testing.T) {
|
||||
fenster := fensterUm(time.Now())
|
||||
|
||||
g := neueSpielerin()
|
||||
q := New(Laws{Quiet: mustQuiet(t, fenster), QuietMode: QuietModeDiscard}, g.speak, nil)
|
||||
defer q.Close()
|
||||
|
||||
adm, err := q.Enqueue(Item{Text: "normal"})
|
||||
if err != nil || adm.ID != "" || adm.Discarded != DiscardQuietHours || adm.Toast {
|
||||
t.Errorf("normal in Ruhezeit = %+v (%v), want stiller Verwurf", adm, err)
|
||||
}
|
||||
if adm, err = q.Enqueue(Item{Text: "dringend", Priority: PriorityHigh}); err != nil || adm.Discarded != DiscardQuietHours {
|
||||
t.Errorf("high in Ruhezeit = %+v (%v), want auch Verwurf", adm, err)
|
||||
}
|
||||
adm, err = q.Enqueue(Item{Text: "sofort", Now: true})
|
||||
if err != nil || adm.ID == "" || adm.Discarded != "" {
|
||||
t.Fatalf("now in Ruhezeit = %+v (%v), want Ausbruch", adm, err)
|
||||
}
|
||||
gestartet := g.warteBis(t, 1)
|
||||
if len(gestartet) != 1 || gestartet[0].Text != "sofort" {
|
||||
t.Errorf("in Ruhezeit = %+v, will nur sofort", gestartet)
|
||||
}
|
||||
g.keineWeitere(t)
|
||||
|
||||
g2 := neueSpielerin()
|
||||
q2 := New(Laws{Quiet: mustQuiet(t, fenster), QuietMode: QuietModeToast}, g2.speak, nil)
|
||||
defer q2.Close()
|
||||
|
||||
adm, err = q2.Enqueue(Item{Text: "normal"})
|
||||
if err != nil || adm.Discarded != DiscardQuietHours || !adm.Toast {
|
||||
t.Errorf("normal in Ruhezeit (toast) = %+v (%v), want Toast-Ersetzung", adm, err)
|
||||
}
|
||||
g2.keineWeitere(t)
|
||||
}
|
||||
|
||||
// mustQuiet parst ein Ruhefenster oder bricht den Test ab.
|
||||
func mustQuiet(t *testing.T, fenster string) QuietHours {
|
||||
t.Helper()
|
||||
q, err := ParseQuietHours(fenster)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
// TestLawState: unbegrenzt liefert freien Slot; nach gebuchtem Fenster
|
||||
// liegt der nächste Slot in der Zukunft; Ruhezeit-Fenster ist sichtbar.
|
||||
func TestLawState(t *testing.T) {
|
||||
g := neueSpielerin()
|
||||
q := New(Laws{}, g.speak, nil)
|
||||
st := q.LawState()
|
||||
if !st.NextSlot.IsZero() || st.RateLimit != 0 {
|
||||
t.Errorf("unbegrenzt = %+v, want Nullzeit-Slot", st)
|
||||
}
|
||||
q.Close()
|
||||
|
||||
fenster := mustQuiet(t, "18:00-08:00")
|
||||
q2 := New(Laws{RateLimit: time.Hour, AllowHighBurst: true, Quiet: fenster, QuietMode: QuietModeToast}, g.speak, nil)
|
||||
defer q2.Close()
|
||||
st2 := q2.LawState()
|
||||
if st2.RateLimit != time.Hour || !st2.AllowHighBurst {
|
||||
t.Errorf("LawState = %+v, want 1h-Fenster mit Burst-Recht", st2)
|
||||
}
|
||||
if st2.Quiet.String() != "18:00-08:00" || st2.QuietMode != QuietModeToast {
|
||||
t.Errorf("Ruhezeit in LawState = %q/%q", st2.Quiet, st2.QuietMode)
|
||||
}
|
||||
|
||||
q2.Enqueue(Item{Text: "erste"})
|
||||
g.warteBis(t, 1)
|
||||
if next := q2.LawState().NextSlot; !next.After(time.Now()) {
|
||||
t.Errorf("nach Start = %v, want Slot in der Zukunft", next)
|
||||
}
|
||||
g.step <- struct{}{}
|
||||
}
|
||||
Reference in new issue
Block a user