2026-10-18 17:10:26 +02:00
|
|
|
package queue
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"crypto/rand"
|
|
|
|
|
"encoding/hex"
|
|
|
|
|
"errors"
|
|
|
|
|
"fmt"
|
|
|
|
|
"io"
|
|
|
|
|
"sort"
|
|
|
|
|
"sync"
|
|
|
|
|
"time"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Priority eines Queue-Items — öffentlicher Vertrag der HTTP-API
|
|
|
|
|
// (low | normal | high; leer wird als normal behandelt).
|
|
|
|
|
type Priority string
|
|
|
|
|
|
|
|
|
|
// Erlaubte Prioritäts-Werte (Spec: high > normal > low, FIFO je Level).
|
|
|
|
|
const (
|
|
|
|
|
PriorityLow Priority = "low"
|
|
|
|
|
PriorityNormal Priority = "normal"
|
|
|
|
|
PriorityHigh Priority = "high"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Valid meldet, ob p ein erlaubter Prioritäts-Wert ist.
|
|
|
|
|
func (p Priority) Valid() bool {
|
|
|
|
|
switch p {
|
|
|
|
|
case PriorityLow, PriorityNormal, PriorityHigh:
|
|
|
|
|
return true
|
|
|
|
|
default:
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ErrClosed meldet einen Enqueue-Versuch nach Close (Daemon fährt herunter).
|
|
|
|
|
var ErrClosed = errors.New("queue: bereits geschlossen")
|
|
|
|
|
|
|
|
|
|
// Item ist eine Ansage in der Warteschlange. Alle Felder kommen aus der
|
|
|
|
|
// HTTP-API (POST /speak); ID und Ablauf-Verwaltung liegen bei der Queue.
|
|
|
|
|
type Item struct {
|
|
|
|
|
// ID ist die kurze Ansage-Kennung (Antwort von POST /speak; Grundlage
|
|
|
|
|
// des --wait-Pollings der CLI). Wird von Enqueue vergeben, wenn leer.
|
|
|
|
|
ID string
|
|
|
|
|
|
|
|
|
|
// Text ist der Ansage-Text.
|
|
|
|
|
Text string
|
|
|
|
|
|
|
|
|
|
// Lang ist die Sprach-Pin (de | en); leer = automatische Erkennung
|
|
|
|
|
// durch den Daemon (langdetect).
|
|
|
|
|
Lang string
|
|
|
|
|
|
|
|
|
|
// Provider pinnt die Provider-Kette auf eine Stufe (--provider); leer
|
|
|
|
|
// = konfigurierte Default-Kette.
|
|
|
|
|
Provider string
|
|
|
|
|
|
|
|
|
|
// Voice pinnt eine Voice-ID (--voice); leer = Voice-Mapping/Defaults.
|
|
|
|
|
Voice string
|
|
|
|
|
|
|
|
|
|
// Priority steuert die Abarbeitungs-Reihenfolge (high > normal > low,
|
|
|
|
|
// FIFO je Level).
|
|
|
|
|
Priority Priority
|
|
|
|
|
|
|
|
|
|
// Now lässt die Ansage vor alle wartenden Items springen (auch vor
|
|
|
|
|
// high) — sie unterbricht aber nie die laufende Ansage.
|
|
|
|
|
Now bool
|
|
|
|
|
|
|
|
|
|
seq uint64 // Enqueue-Reihenfolge: FIFO-Tiebrecher innerhalb eines Levels
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// rank liefert die Abarbeitungs-Klasse: now über high über normal über low.
|
|
|
|
|
func (it Item) rank() int {
|
|
|
|
|
if it.Now {
|
|
|
|
|
return 3
|
|
|
|
|
}
|
|
|
|
|
switch it.Priority {
|
|
|
|
|
case PriorityHigh:
|
|
|
|
|
return 2
|
|
|
|
|
case PriorityNormal:
|
|
|
|
|
return 1
|
|
|
|
|
default:
|
|
|
|
|
return 0
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Speaker spielt ein Item ab. Im Daemon: Provider-Kette bauen und Speak
|
|
|
|
|
// aufrufen (Synthese mit Fallback + Wiedergabe über den Audio-Sink — die
|
|
|
|
|
// Kette aus internal/provider wird unverändert wiederverwendet). ctx wird
|
|
|
|
|
// abgebrochen, wenn die Ansage abgebrochen (POST /cancel) oder der Daemon
|
|
|
|
|
// heruntergefahren wird; die Kette bricht ihre Netz- und Prozess-Arbeit
|
|
|
|
|
// dann ab. Der Rückgabe-Fehler wird protokolliert, nie an die API gereicht.
|
|
|
|
|
type Speaker func(ctx context.Context, item Item) error
|
|
|
|
|
|
|
|
|
|
// Queue ist die serialisierte Ansage-Queue des Daemons: genau eine Ansage
|
2026-10-18 17:40:41 +02:00
|
|
|
// gleichzeitig, Reihenfolge now > high > normal > low (FIFO je Level) —
|
|
|
|
|
// seit Task 05 unter dem Anti-Spam-Gesetz (Laws): normal parkt bis zum
|
|
|
|
|
// Rate-Limit-Slot, low wird über dem Budget verworfen, high darf mit
|
|
|
|
|
// Burst-Recht verstoßen, now bricht bewusst aus, und Ruhezeiten verwerfen
|
|
|
|
|
// bzw. toasten statt Audio.
|
2026-10-18 17:10:26 +02:00
|
|
|
//
|
|
|
|
|
// Die Synchronisation ist bewusst simpel und offensichtlich korrekt statt
|
|
|
|
|
// clever: EIN Mutex + EIN Cond schützen alles (Items, aktuelles Item,
|
2026-10-18 17:40:41 +02:00
|
|
|
// Closed-Flag, cancelCur, Slot-Wecker). Der Worker ist der einzige, der
|
|
|
|
|
// Items entnimmt; take und das Setzen von current/cancelCur geschehen unter
|
|
|
|
|
// demselben Lock, damit ein Item nie zwischen „wartet" und „läuft" der
|
|
|
|
|
// Cancelierung entwischen kann. Cancel/Close rufen cancelCur unter dem Lock
|
|
|
|
|
// ab — der Worker bemerkt die Abbrechung in seiner speak-Funktion über ctx.
|
|
|
|
|
// Geparkte Items bleiben in der Warteschlange liegen; ein Slot-Wecker
|
|
|
|
|
// (armWakeLocked) broadcastet zum Fenster-Ablauf, damit der Worker auch ohne
|
|
|
|
|
// neuen Enqueue aufwacht — die Wait-Bedingung prüft jedes Mal die Gesetze
|
|
|
|
|
// neu, verlorene Signale sind dadurch harmlos.
|
2026-10-18 17:10:26 +02:00
|
|
|
type Queue struct {
|
2026-10-18 17:40:41 +02:00
|
|
|
laws Laws
|
|
|
|
|
limit *RateLimiter
|
2026-10-18 17:10:26 +02:00
|
|
|
speak Speaker
|
|
|
|
|
log io.Writer
|
|
|
|
|
|
|
|
|
|
mu sync.Mutex
|
2026-10-18 17:40:41 +02:00
|
|
|
notEmpty *sync.Cond // Signal: neues Item; Broadcast: Close/Slot frei
|
2026-10-18 17:10:26 +02:00
|
|
|
items []Item
|
|
|
|
|
current *Item
|
|
|
|
|
cancelCur context.CancelFunc
|
|
|
|
|
seq uint64
|
|
|
|
|
closed bool
|
2026-10-18 17:40:41 +02:00
|
|
|
wake *time.Timer // weckt den geparkten Worker zum Slot-Termin
|
|
|
|
|
wakeAt time.Time // Termin des laufenden Weckers
|
2026-10-18 17:10:26 +02:00
|
|
|
done chan struct{} // geschlossen, wenn der Worker-Loop endet
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// New startet die Queue mit ihrem Worker-Loop (eine Ansage gleichzeitig).
|
2026-10-18 17:40:41 +02:00
|
|
|
// laws trägt die Anti-Spam-Gesetze (Nullwert = alles erlaubt; der Daemon
|
|
|
|
|
// liefert die Config-Gesetze). log ist optional (nil = still) und empfängt
|
|
|
|
|
// die Lebenslauf-Zeilen der Items. Close beendet den Loop und wartet auf
|
|
|
|
|
// sein Ende.
|
|
|
|
|
func New(laws Laws, speak Speaker, log io.Writer) *Queue {
|
|
|
|
|
q := &Queue{
|
|
|
|
|
laws: laws,
|
|
|
|
|
limit: &RateLimiter{Interval: laws.RateLimit},
|
|
|
|
|
speak: speak,
|
|
|
|
|
log: log,
|
|
|
|
|
done: make(chan struct{}),
|
|
|
|
|
}
|
2026-10-18 17:10:26 +02:00
|
|
|
q.notEmpty = sync.NewCond(&q.mu)
|
|
|
|
|
go q.loop()
|
|
|
|
|
return q
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-18 17:40:41 +02:00
|
|
|
// Enqueue stellt item in die Warteschlange und liefert die Aufnahme-
|
|
|
|
|
// Entscheidung: Aufgenommene tragen ihre ID (bei leerer ID neu vergeben:
|
|
|
|
|
// kurz und zufällig). Ruhezeit-Verwurf/Toast und low über dem Fenster-Budget
|
|
|
|
|
// werden schon beim Eintritt entschieden (die 202-Antwort von POST /speak
|
|
|
|
|
// nennt den Grund); alles andere entscheidet der Worker, wenn das Item
|
|
|
|
|
// fällig ist. Nach Close ist Enqueue ein Fehler.
|
|
|
|
|
func (q *Queue) Enqueue(item Item) (Admission, error) {
|
2026-10-18 17:10:26 +02:00
|
|
|
q.mu.Lock()
|
|
|
|
|
if q.closed {
|
|
|
|
|
q.mu.Unlock()
|
2026-10-18 17:40:41 +02:00
|
|
|
return Admission{}, ErrClosed
|
2026-10-18 17:10:26 +02:00
|
|
|
}
|
|
|
|
|
if item.Priority == "" {
|
|
|
|
|
item.Priority = PriorityNormal
|
|
|
|
|
}
|
2026-10-18 17:40:41 +02:00
|
|
|
switch q.laws.decide(item, time.Now(), q.limit.Allow()) {
|
|
|
|
|
case verdictDiscardQuiet:
|
|
|
|
|
q.mu.Unlock()
|
|
|
|
|
q.logf("queue: ansage (%q, priorität %s) verworfen — ruhezeit %s aktiv (modus discard)",
|
|
|
|
|
item.Text, item.Priority, q.laws.Quiet)
|
|
|
|
|
return Admission{Discarded: DiscardQuietHours}, nil
|
|
|
|
|
case verdictToast:
|
|
|
|
|
q.mu.Unlock()
|
|
|
|
|
q.logf("queue: ansage (%q, priorität %s) durch toast ersetzt — ruhezeit %s aktiv (modus toast)",
|
|
|
|
|
item.Text, item.Priority, q.laws.Quiet)
|
|
|
|
|
return Admission{Discarded: DiscardQuietHours, Toast: true}, nil
|
|
|
|
|
case verdictDiscardRate:
|
|
|
|
|
q.mu.Unlock()
|
|
|
|
|
q.logf("queue: ansage (%q, priorität low) verworfen — rate-limit %s",
|
|
|
|
|
item.Text, q.laws.RateLimit)
|
|
|
|
|
return Admission{Discarded: DiscardRateLimit}, nil
|
|
|
|
|
}
|
2026-10-18 17:10:26 +02:00
|
|
|
q.seq++
|
|
|
|
|
item.seq = q.seq
|
|
|
|
|
if item.ID == "" {
|
|
|
|
|
item.ID = newID()
|
|
|
|
|
}
|
|
|
|
|
q.items = append(q.items, item)
|
|
|
|
|
q.mu.Unlock()
|
|
|
|
|
q.notEmpty.Signal()
|
2026-10-18 17:40:41 +02:00
|
|
|
return Admission{ID: item.ID}, nil
|
2026-10-18 17:10:26 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Pending liefert eine Momentaufnahme der wartenden Items in
|
|
|
|
|
// Abarbeitungs-Reihenfolge (bestes Item zuerst) — Basis für GET /status.
|
|
|
|
|
func (q *Queue) Pending() []Item {
|
|
|
|
|
q.mu.Lock()
|
|
|
|
|
defer q.mu.Unlock()
|
|
|
|
|
out := make([]Item, len(q.items))
|
|
|
|
|
copy(out, q.items)
|
|
|
|
|
sort.SliceStable(out, func(i, j int) bool { return itemBefore(out[i], out[j]) })
|
|
|
|
|
return out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// itemBefore meldet, ob a vor b abgearbeitet wird: höhere Klasse zuerst,
|
|
|
|
|
// innerhalb derselben Klasse die frühere Enqueue-Reihenfolge.
|
|
|
|
|
func itemBefore(a, b Item) bool {
|
|
|
|
|
if a.rank() != b.rank() {
|
|
|
|
|
return a.rank() > b.rank()
|
|
|
|
|
}
|
|
|
|
|
return a.seq < b.seq
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Current liefert das gerade laufende Item (false: Queue ist idle).
|
|
|
|
|
func (q *Queue) Current() (Item, bool) {
|
|
|
|
|
q.mu.Lock()
|
|
|
|
|
defer q.mu.Unlock()
|
|
|
|
|
if q.current == nil {
|
|
|
|
|
return Item{}, false
|
|
|
|
|
}
|
|
|
|
|
return *q.current, true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Cancel bricht die laufende Ansage ab und leert die Queue. Rückgabe ist
|
|
|
|
|
// die Zahl der abgebrochenen Ansagen (laufende + wartende).
|
|
|
|
|
func (q *Queue) Cancel() int {
|
|
|
|
|
q.mu.Lock()
|
|
|
|
|
defer q.mu.Unlock()
|
|
|
|
|
n := len(q.items)
|
|
|
|
|
q.items = nil
|
|
|
|
|
if q.current != nil {
|
|
|
|
|
n++
|
|
|
|
|
if q.cancelCur != nil {
|
|
|
|
|
q.cancelCur()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return n
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Close fährt die Queue herunter: wartende Items werden verworfen, die
|
|
|
|
|
// laufende Ansage abgebrochen, der Worker-Loop beendet. Close wartet, bis
|
|
|
|
|
// der Loop sicher beendet ist (idempotent).
|
|
|
|
|
func (q *Queue) Close() {
|
|
|
|
|
q.mu.Lock()
|
|
|
|
|
if q.closed {
|
|
|
|
|
q.mu.Unlock()
|
|
|
|
|
<-q.done
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
q.closed = true
|
|
|
|
|
q.items = nil
|
|
|
|
|
if q.cancelCur != nil {
|
|
|
|
|
q.cancelCur()
|
|
|
|
|
}
|
2026-10-18 17:40:41 +02:00
|
|
|
if q.wake != nil {
|
|
|
|
|
q.wake.Stop()
|
|
|
|
|
q.wake = nil
|
|
|
|
|
}
|
2026-10-18 17:10:26 +02:00
|
|
|
q.mu.Unlock()
|
|
|
|
|
q.notEmpty.Broadcast()
|
|
|
|
|
<-q.done
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-18 17:40:41 +02:00
|
|
|
// LawState liefert die /status-Sicht auf die Anti-Spam-Gesetze: das
|
|
|
|
|
// konfigurierte Rate-Limit mit dem nächsten freien Slot (Nullzeit = frei
|
|
|
|
|
// oder unbegrenzt) und das Ruhezeit-Fenster mit seinem Ende, wenn es
|
|
|
|
|
// gerade aktiv ist.
|
|
|
|
|
func (q *Queue) LawState() LawState {
|
|
|
|
|
now := time.Now()
|
|
|
|
|
return LawState{
|
|
|
|
|
RateLimit: q.laws.RateLimit,
|
|
|
|
|
AllowHighBurst: q.laws.AllowHighBurst,
|
|
|
|
|
NextSlot: q.limit.Next(),
|
|
|
|
|
Quiet: q.laws.Quiet,
|
|
|
|
|
QuietActive: q.laws.Quiet.Active(now),
|
|
|
|
|
QuietMode: q.laws.QuietMode,
|
|
|
|
|
QuietUntil: q.laws.Quiet.Until(now),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// loop ist der Worker: wartet auf fällige Items und spielt sie einzeln ab.
|
|
|
|
|
// „Fällig" heißt seit Task 05 auch gesetzkonform: Geparkte schlafen bis zum
|
|
|
|
|
// Rate-Limit-Slot (armWakeLocked weckt), low- und Ruhezeit-Verwürfe laufen
|
|
|
|
|
// ohne Audio durchs Protokoll, now und high-burst spielen sofort.
|
|
|
|
|
// Nach Close (oder wenn Close während einer Ansage zuschlägt) beendet er
|
|
|
|
|
// sich, sobald die laufende Ansage zurückgekehrt ist.
|
2026-10-18 17:10:26 +02:00
|
|
|
func (q *Queue) loop() {
|
|
|
|
|
defer close(q.done)
|
|
|
|
|
for {
|
|
|
|
|
q.mu.Lock()
|
2026-10-18 17:40:41 +02:00
|
|
|
for !q.closed && !q.readyLocked() {
|
|
|
|
|
if len(q.items) > 0 {
|
|
|
|
|
// Bestes Item parkt auf den Slot: Wecker zum Termin armieren.
|
|
|
|
|
q.armWakeLocked(q.limit.Next())
|
|
|
|
|
}
|
2026-10-18 17:10:26 +02:00
|
|
|
q.notEmpty.Wait()
|
|
|
|
|
}
|
|
|
|
|
if q.closed {
|
|
|
|
|
q.mu.Unlock()
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-10-18 17:40:41 +02:00
|
|
|
item, v := q.takeLocked()
|
|
|
|
|
if v == verdictWait {
|
|
|
|
|
// Kurz ohne Fortschritt geweckt (z. B. verlorenes Signal):
|
|
|
|
|
// wieder schlafen legen. Kann nach readyLocked eigentlich nie
|
|
|
|
|
// eintreten — sicherheitshalber geparkt statt gedreht.
|
|
|
|
|
q.mu.Unlock()
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if v == verdictPlay {
|
|
|
|
|
// Fenster buchen, BEVOR gesprochen wird: Sobald das Item als
|
|
|
|
|
// laufend gilt, steht der nächste Slot-Termin fest.
|
|
|
|
|
q.limit.Reserve()
|
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
|
q.current = &item
|
|
|
|
|
q.cancelCur = cancel
|
|
|
|
|
q.mu.Unlock()
|
|
|
|
|
|
|
|
|
|
q.logf("queue: ansage %s (%q, Priorität %s, now=%v) startet", item.ID, item.Text, item.Priority, item.Now)
|
|
|
|
|
start := time.Now()
|
|
|
|
|
err := q.speak(ctx, item)
|
|
|
|
|
dur := time.Since(start)
|
2026-10-24 09:50:08 +02:00
|
|
|
// Abbruch VOR dem eigenen cancel() ablesen — danach wäre ctx.Err()
|
|
|
|
|
// immer gesetzt und jede fertige Ansage hieße „abgebrochen".
|
|
|
|
|
cancelled := ctx.Err() != nil
|
2026-10-18 17:40:41 +02:00
|
|
|
cancel()
|
|
|
|
|
|
|
|
|
|
q.mu.Lock()
|
|
|
|
|
q.current = nil
|
|
|
|
|
q.cancelCur = nil
|
|
|
|
|
q.mu.Unlock()
|
|
|
|
|
|
|
|
|
|
switch {
|
2026-10-24 09:50:08 +02:00
|
|
|
case cancelled:
|
2026-10-18 17:40:41 +02:00
|
|
|
q.logf("queue: ansage %s abgebrochen", item.ID)
|
|
|
|
|
case err != nil:
|
|
|
|
|
q.logf("queue: ansage %s fehlgeschlagen nach %s: %v", item.ID, dur.Round(time.Millisecond), err)
|
|
|
|
|
default:
|
|
|
|
|
q.logf("queue: ansage %s fertig nach %s", item.ID, dur.Round(time.Millisecond))
|
|
|
|
|
}
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-10-18 17:10:26 +02:00
|
|
|
q.mu.Unlock()
|
2026-10-18 17:40:41 +02:00
|
|
|
switch v {
|
|
|
|
|
case verdictDiscardRate:
|
|
|
|
|
q.logf("queue: ansage %s (%q, priorität low) verworfen — rate-limit %s",
|
|
|
|
|
item.ID, item.Text, q.laws.RateLimit)
|
|
|
|
|
case verdictDiscardQuiet:
|
|
|
|
|
q.logf("queue: ansage %s (%q) verworfen — ruhezeit %s aktiv (modus discard)",
|
|
|
|
|
item.ID, item.Text, q.laws.Quiet)
|
|
|
|
|
case verdictToast:
|
|
|
|
|
q.logf("queue: ansage %s (%q) durch toast ersetzt — ruhezeit %s aktiv (modus toast)",
|
|
|
|
|
item.ID, item.Text, q.laws.Quiet)
|
2026-10-18 17:10:26 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-18 17:40:41 +02:00
|
|
|
// readyLocked meldet, ob der Worker arbeiten kann (mu muss gehalten
|
|
|
|
|
// werden): geschlossen immer; sonst nur, wenn das beste Item nicht bloß
|
|
|
|
|
// auf seinen Rate-Limit-Slot parkt. Verwürfe zählen als Arbeit — sie
|
|
|
|
|
// laufen ohne Audio, aber mit Protokoll durch den Worker.
|
|
|
|
|
func (q *Queue) readyLocked() bool {
|
|
|
|
|
if q.closed || len(q.items) == 0 {
|
|
|
|
|
return q.closed
|
|
|
|
|
}
|
|
|
|
|
return q.verdictLocked(q.items[q.bestIndexLocked()]) != verdictWait
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// takeLocked entnimmt das als nächstes fällige Item, wenn es spielbar oder
|
|
|
|
|
// entscheidbar ist (mu muss gehalten werden). Bei verdictWait wird NICHT
|
|
|
|
|
// entnommen — das Item bleibt geparkt, bis der Slot frei ist (oder ein
|
|
|
|
|
// besseres Item vorgezogen wird).
|
|
|
|
|
func (q *Queue) takeLocked() (Item, verdict) {
|
|
|
|
|
best := q.bestIndexLocked()
|
|
|
|
|
item := q.items[best]
|
|
|
|
|
v := q.verdictLocked(item)
|
|
|
|
|
if v != verdictWait {
|
|
|
|
|
q.items = append(q.items[:best], q.items[best+1:]...)
|
|
|
|
|
}
|
|
|
|
|
return item, v
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// bestIndexLocked liefert den Index des als nächstes fälligen Items
|
|
|
|
|
// (mu muss gehalten werden).
|
|
|
|
|
func (q *Queue) bestIndexLocked() int {
|
2026-10-18 17:10:26 +02:00
|
|
|
best := 0
|
|
|
|
|
for i := 1; i < len(q.items); i++ {
|
|
|
|
|
if itemBefore(q.items[i], q.items[best]) {
|
|
|
|
|
best = i
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-18 17:40:41 +02:00
|
|
|
return best
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// verdictLocked wendet die Gesetze auf ein Item an (mu muss gehalten
|
|
|
|
|
// werden — die Lock-Ordnung Queue → RateLimiter bleibt gewahrt).
|
|
|
|
|
func (q *Queue) verdictLocked(item Item) verdict {
|
|
|
|
|
return q.laws.decide(item, time.Now(), q.limit.Allow())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// armWakeLocked armiert den Slot-Wecker auf until (mu muss gehalten
|
|
|
|
|
// werden): Er broadcastet die Cond zum Termin, damit der geparkte Worker
|
|
|
|
|
// auch ohne neuen Enqueue aufwacht. Ein bereits laufender Wecker mit
|
|
|
|
|
// früherem (oder gleichem) Termin reicht; sonst wird er ersetzt.
|
|
|
|
|
func (q *Queue) armWakeLocked(until time.Time) {
|
|
|
|
|
if q.wake != nil && !q.wakeAt.After(until) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if q.wake != nil {
|
|
|
|
|
q.wake.Stop()
|
|
|
|
|
}
|
|
|
|
|
q.wakeAt = until
|
|
|
|
|
d := time.Until(until)
|
|
|
|
|
if d < 0 {
|
|
|
|
|
d = 0
|
|
|
|
|
}
|
|
|
|
|
q.wake = time.AfterFunc(d, func() {
|
|
|
|
|
q.mu.Lock()
|
|
|
|
|
q.wake = nil
|
|
|
|
|
q.mu.Unlock()
|
|
|
|
|
q.notEmpty.Broadcast()
|
|
|
|
|
})
|
2026-10-18 17:10:26 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// newID liefert eine kurze, kollisionsarme ID (6 Hex-Zeichen). IDs müssen
|
|
|
|
|
// nur pro Daemon-Lauf eindeutig sein (--wait-Polling); der Fallback ist
|
|
|
|
|
// praktisch unerreichbar, aber deterministisch sicher.
|
|
|
|
|
func newID() string {
|
|
|
|
|
var b [3]byte
|
|
|
|
|
if _, err := rand.Read(b[:]); err == nil {
|
|
|
|
|
return hex.EncodeToString(b[:])
|
|
|
|
|
}
|
|
|
|
|
return fmt.Sprintf("id%d", time.Now().UnixNano())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// logf schreibt eine Protokoll-Zeile, wenn ein Log gesetzt ist. Wird nie
|
|
|
|
|
// unter dem Mutex gerufen (Aufrufer achten darauf).
|
|
|
|
|
func (q *Queue) logf(format string, args ...any) {
|
|
|
|
|
if q.log != nil {
|
|
|
|
|
fmt.Fprintf(q.log, format+"\n", args...)
|
|
|
|
|
}
|
|
|
|
|
}
|