0a941f3ba6
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
160 lines
4.9 KiB
Go
160 lines
4.9 KiB
Go
package dispatch
|
|
|
|
import (
|
|
"regexp"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/dbledeez/panel/agent/internal/runtime"
|
|
)
|
|
|
|
// logCoalescer throttles repetitive progress-style log lines while letting
|
|
// genuine content through unchanged.
|
|
//
|
|
// Why this exists: containers under install/update pressure (SteamCMD
|
|
// download ticks, preallocation, LGSM step banners) can emit 40+ lines per
|
|
// second. Forwarding every one to the controller saturates the gRPC send
|
|
// window, fills the event bus's per-subscriber buffer (which silently drops
|
|
// on overflow), and burns CPU re-rendering DOM. The browser only needs to
|
|
// see the *latest* progress value, not every interstitial frame.
|
|
//
|
|
// Behaviour:
|
|
// - Lines with progressKey() == "": emit immediately.
|
|
// - Lines with a key: if it's been less than `interval` since the last
|
|
// emit of that key, replace the currently-buffered line for that key.
|
|
// A single background timer flushes any pending lines every `interval`.
|
|
type logCoalescer struct {
|
|
interval time.Duration
|
|
emit func(stream runtime.LogStream, line string, at time.Time)
|
|
|
|
mu sync.Mutex
|
|
pending map[string]*pendingLine
|
|
lastAt map[string]time.Time
|
|
timer *time.Timer
|
|
stopped bool
|
|
}
|
|
|
|
type pendingLine struct {
|
|
stream runtime.LogStream
|
|
line string
|
|
at time.Time
|
|
}
|
|
|
|
func newLogCoalescer(interval time.Duration, emit func(stream runtime.LogStream, line string, at time.Time)) *logCoalescer {
|
|
c := &logCoalescer{
|
|
interval: interval,
|
|
emit: emit,
|
|
pending: map[string]*pendingLine{},
|
|
lastAt: map[string]time.Time{},
|
|
}
|
|
return c
|
|
}
|
|
|
|
// offer delivers a line to the coalescer. Safe to call concurrently, though
|
|
// in practice the caller is the single log-scan goroutine.
|
|
func (c *logCoalescer) offer(stream runtime.LogStream, line string, at time.Time) {
|
|
key := progressKey(line)
|
|
if key == "" {
|
|
// Non-progress — emit immediately, but first flush any buffered
|
|
// progress lines so they don't appear AFTER a later non-progress line.
|
|
c.mu.Lock()
|
|
c.flushLocked()
|
|
c.mu.Unlock()
|
|
c.emit(stream, line, at)
|
|
return
|
|
}
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if c.stopped {
|
|
return
|
|
}
|
|
last := c.lastAt[key]
|
|
if last.IsZero() || at.Sub(last) >= c.interval {
|
|
// Enough quiet time — emit right away.
|
|
c.emit(stream, line, at)
|
|
c.lastAt[key] = at
|
|
delete(c.pending, key)
|
|
return
|
|
}
|
|
// Replace the buffered line for this key (we only care about the newest).
|
|
c.pending[key] = &pendingLine{stream: stream, line: line, at: at}
|
|
// Make sure the timer is armed. Single timer drains all pending keys.
|
|
if c.timer == nil {
|
|
c.timer = time.AfterFunc(c.interval, c.tick)
|
|
}
|
|
}
|
|
|
|
func (c *logCoalescer) tick() {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.flushLocked()
|
|
// If more pending arrived during flush (or we expect more), re-arm the timer.
|
|
if len(c.pending) > 0 && !c.stopped {
|
|
c.timer = time.AfterFunc(c.interval, c.tick)
|
|
} else {
|
|
c.timer = nil
|
|
}
|
|
}
|
|
|
|
func (c *logCoalescer) flushLocked() {
|
|
now := time.Now()
|
|
for key, p := range c.pending {
|
|
c.emit(p.stream, p.line, p.at)
|
|
c.lastAt[key] = now
|
|
delete(c.pending, key)
|
|
}
|
|
}
|
|
|
|
// stop flushes any pending lines and prevents further work. Call from the
|
|
// streamLogs goroutine's defer so a stopping instance doesn't leave buffered
|
|
// progress frames undelivered.
|
|
func (c *logCoalescer) stop() {
|
|
c.mu.Lock()
|
|
c.stopped = true
|
|
if c.timer != nil {
|
|
c.timer.Stop()
|
|
c.timer = nil
|
|
}
|
|
c.flushLocked()
|
|
c.mu.Unlock()
|
|
}
|
|
|
|
// Regexes for lines we treat as part of a repeating progress stream.
|
|
// Each group returns a stable key — same key = same progress counter.
|
|
var (
|
|
// SteamCMD: "Update state (0x61) downloading, progress: 47.95 (...)"
|
|
// Also covers "verifying", "preallocating", "committing" stages.
|
|
reSteamcmdProgress = regexp.MustCompile(`Update state \([^)]+\) (\w+), progress:`)
|
|
// Generic "Progress: 64%" or "[ 64%] Installing ...".
|
|
reGenericProgress = regexp.MustCompile(`^\s*(?:Progress:|\[\s*\d+%\s*\])`)
|
|
// LGSM step banner: "Starting sdtdserver: <step>". vinanrra emits
|
|
// several per step as the banner animates ([ .... ] → [ INFO ] → [ OK ]).
|
|
reLGSMStarting = regexp.MustCompile(`^\s*Starting sdtdserver:`)
|
|
reLGSMStopping = regexp.MustCompile(`^\s*Stopping sdtdserver:`)
|
|
// Docker pull: "Downloading [=>] 12.3MB / 100MB"
|
|
reDockerPull = regexp.MustCompile(`^\s*(?:Downloading|Extracting|Pulling)\s+[\[(]`)
|
|
)
|
|
|
|
// progressKey returns a stable identifier for a progress-style line, or ""
|
|
// if the line should be forwarded unchanged. The returned key collapses
|
|
// every frame of the same counter onto a single slot so we keep only the
|
|
// latest within each coalesce window.
|
|
func progressKey(text string) string {
|
|
if m := reSteamcmdProgress.FindStringSubmatch(text); m != nil {
|
|
return "steamcmd:" + m[1]
|
|
}
|
|
if reGenericProgress.MatchString(text) {
|
|
return "progress"
|
|
}
|
|
if reLGSMStarting.MatchString(text) {
|
|
return "lgsm-starting"
|
|
}
|
|
if reLGSMStopping.MatchString(text) {
|
|
return "lgsm-stopping"
|
|
}
|
|
if reDockerPull.MatchString(text) {
|
|
return "docker-pull"
|
|
}
|
|
return ""
|
|
}
|