262 lines
6.4 KiB
Go
262 lines
6.4 KiB
Go
// backend/performance.go
|
||
package main
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"io"
|
||
"math"
|
||
"net/http"
|
||
"os"
|
||
"runtime"
|
||
"strconv"
|
||
"strings"
|
||
"sync/atomic"
|
||
"time"
|
||
|
||
gocpu "github.com/shirou/gopsutil/v3/cpu"
|
||
godisk "github.com/shirou/gopsutil/v3/disk"
|
||
)
|
||
|
||
var serverStartedAt = time.Now()
|
||
|
||
var lastCPUUsageBits uint64 // atomic float64 bits
|
||
|
||
func setLastCPUUsage(v float64) { atomic.StoreUint64(&lastCPUUsageBits, math.Float64bits(v)) }
|
||
func getLastCPUUsage() float64 { return math.Float64frombits(atomic.LoadUint64(&lastCPUUsageBits)) }
|
||
|
||
func buildPerfSnapshot() map[string]any {
|
||
var ms runtime.MemStats
|
||
runtime.ReadMemStats(&ms)
|
||
|
||
s := getSettings()
|
||
recordDir, _ := resolvePathRelativeToApp(s.RecordDir)
|
||
|
||
var diskFreeBytes uint64
|
||
var diskTotalBytes uint64
|
||
var diskUsedPercent float64
|
||
diskPath := recordDir
|
||
|
||
if recordDir != "" {
|
||
if u, err := godisk.Usage(recordDir); err == nil && u != nil {
|
||
diskFreeBytes = u.Free
|
||
diskTotalBytes = u.Total
|
||
diskUsedPercent = u.UsedPercent
|
||
}
|
||
}
|
||
|
||
// ✅ Dynamische Disk-Schwellen (2× inFlight, Resume = +3GB)
|
||
pauseGB, resumeGB, inFlight, pauseNeed, resumeNeed := computeDiskThresholds()
|
||
|
||
resp := map[string]any{
|
||
"ts": time.Now().UTC().Format(time.RFC3339Nano),
|
||
"serverMs": time.Now().UTC().UnixMilli(), // ✅ für "Ping" im Frontend (Approx)
|
||
"uptimeSec": time.Since(serverStartedAt).Seconds(),
|
||
"cpuPercent": func() float64 {
|
||
v := getLastCPUUsage()
|
||
if math.IsNaN(v) || math.IsInf(v, 0) || v < 0 {
|
||
return 0
|
||
}
|
||
if v == 0 {
|
||
// Fallback: direkt messen, falls Controller nicht läuft / noch nicht gemessen hat
|
||
if p, err := gocpu.Percent(0, false); err == nil && len(p) > 0 {
|
||
return p[0]
|
||
}
|
||
}
|
||
return v
|
||
}(),
|
||
"diskPath": diskPath,
|
||
"diskFreeBytes": diskFreeBytes,
|
||
"diskTotalBytes": diskTotalBytes,
|
||
"diskUsedPercent": diskUsedPercent,
|
||
"diskEmergency": atomic.LoadInt32(&diskEmergency) == 1,
|
||
|
||
// ✅ statt LowDiskPauseBelowGB aus Settings
|
||
"diskPauseBelowGB": pauseGB,
|
||
"diskResumeAboveGB": resumeGB,
|
||
|
||
// ✅ optional, aber sehr hilfreich (Debug/UI)
|
||
"diskInFlightBytes": inFlight,
|
||
"diskInFlightHuman": formatBytesSI(u64ToI64(inFlight)),
|
||
"diskPauseNeedBytes": pauseNeed,
|
||
"diskPauseNeedHuman": formatBytesSI(u64ToI64(pauseNeed)),
|
||
"diskResumeNeedBytes": resumeNeed,
|
||
"diskResumeNeedHuman": formatBytesSI(u64ToI64(resumeNeed)),
|
||
|
||
"goroutines": runtime.NumGoroutine(),
|
||
"mem": map[string]any{
|
||
"alloc": ms.Alloc,
|
||
"heapAlloc": ms.HeapAlloc,
|
||
"heapInuse": ms.HeapInuse,
|
||
"sys": ms.Sys,
|
||
"numGC": ms.NumGC,
|
||
},
|
||
}
|
||
|
||
sem := map[string]any{}
|
||
if genSem != nil {
|
||
sem["gen"] = map[string]any{"inUse": genSem.InUse(), "cap": genSem.Cap(), "max": genSem.Max()}
|
||
}
|
||
if previewSem != nil {
|
||
sem["preview"] = map[string]any{"inUse": previewSem.InUse(), "cap": previewSem.Cap(), "max": previewSem.Max()}
|
||
}
|
||
if thumbSem != nil {
|
||
sem["thumb"] = map[string]any{"inUse": thumbSem.InUse(), "cap": thumbSem.Cap(), "max": thumbSem.Max()}
|
||
}
|
||
if durSem != nil {
|
||
sem["dur"] = map[string]any{"inUse": durSem.InUse(), "cap": durSem.Cap(), "max": durSem.Max()}
|
||
}
|
||
if len(sem) > 0 {
|
||
resp["sem"] = sem
|
||
}
|
||
|
||
return resp
|
||
}
|
||
|
||
func pingHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||
http.Error(w, "Nur GET/HEAD erlaubt", http.StatusMethodNotAllowed)
|
||
return
|
||
}
|
||
w.Header().Set("Cache-Control", "no-store")
|
||
w.WriteHeader(http.StatusNoContent)
|
||
}
|
||
|
||
func perfHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodGet {
|
||
http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed)
|
||
return
|
||
}
|
||
|
||
resp := buildPerfSnapshot()
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.Header().Set("Cache-Control", "no-store")
|
||
_ = json.NewEncoder(w).Encode(resp)
|
||
}
|
||
|
||
func perfStreamHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodGet {
|
||
http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed)
|
||
return
|
||
}
|
||
|
||
fl, ok := w.(http.Flusher)
|
||
if !ok {
|
||
http.Error(w, "Streaming nicht unterstützt", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Optional: client kann Intervall mitgeben: /api/stream?ms=5000
|
||
ms := 5000
|
||
if q := r.URL.Query().Get("ms"); q != "" {
|
||
if v, err := strconv.Atoi(q); err == nil {
|
||
// clamp: 1000..30000
|
||
if v < 1000 {
|
||
v = 1000
|
||
}
|
||
if v > 30000 {
|
||
v = 30000
|
||
}
|
||
ms = v
|
||
}
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
|
||
w.Header().Set("Cache-Control", "no-store")
|
||
w.Header().Set("Connection", "keep-alive")
|
||
// hilfreich hinter nginx/proxies:
|
||
w.Header().Set("X-Accel-Buffering", "no")
|
||
|
||
ctx := r.Context()
|
||
|
||
// sofort erstes Event schicken
|
||
send := func() error {
|
||
payload := buildPerfSnapshot()
|
||
|
||
var buf bytes.Buffer
|
||
if err := json.NewEncoder(&buf).Encode(payload); err != nil {
|
||
return err
|
||
}
|
||
|
||
// event: perf
|
||
_, _ = io.WriteString(w, "event: perf\n")
|
||
_, _ = io.WriteString(w, "data: ")
|
||
_, _ = w.Write(buf.Bytes())
|
||
_, _ = io.WriteString(w, "\n")
|
||
fl.Flush()
|
||
return nil
|
||
}
|
||
|
||
// initial
|
||
_ = send()
|
||
|
||
t := time.NewTicker(time.Duration(ms) * time.Millisecond)
|
||
hb := time.NewTicker(15 * time.Second) // heartbeat gegen Proxy timeouts
|
||
defer t.Stop()
|
||
defer hb.Stop()
|
||
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
return
|
||
case <-t.C:
|
||
_ = send()
|
||
case <-hb.C:
|
||
// SSE Kommentar als Heartbeat
|
||
_, _ = io.WriteString(w, ": keep-alive\n\n")
|
||
fl.Flush()
|
||
}
|
||
}
|
||
}
|
||
|
||
func startAdaptiveSemController(ctx context.Context) {
|
||
targetHi := 85.0
|
||
targetLo := 65.0
|
||
|
||
if v := strings.TrimSpace(os.Getenv("CPU_TARGET_HI")); v != "" {
|
||
if f, err := strconv.ParseFloat(v, 64); err == nil {
|
||
targetHi = f
|
||
}
|
||
}
|
||
if v := strings.TrimSpace(os.Getenv("CPU_TARGET_LO")); v != "" {
|
||
if f, err := strconv.ParseFloat(v, 64); err == nil {
|
||
targetLo = f
|
||
}
|
||
}
|
||
|
||
// Warmup (erste Messung kann 0 sein)
|
||
_, _ = gocpu.Percent(200*time.Millisecond, false)
|
||
|
||
t := time.NewTicker(2 * time.Second)
|
||
go func() {
|
||
defer t.Stop()
|
||
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
return
|
||
case <-t.C:
|
||
p, err := gocpu.Percent(0, false)
|
||
if err != nil || len(p) == 0 {
|
||
continue
|
||
}
|
||
usage := p[0]
|
||
setLastCPUUsage(usage)
|
||
|
||
// Preview ist am teuersten → konservativ
|
||
if usage > targetHi {
|
||
previewSem.SetMax(previewSem.Max() - 1)
|
||
genSem.SetMax(genSem.Max() - 1)
|
||
thumbSem.SetMax(thumbSem.Max() - 1)
|
||
} else if usage < targetLo {
|
||
previewSem.SetMax(previewSem.Max() + 1)
|
||
genSem.SetMax(genSem.Max() + 1)
|
||
thumbSem.SetMax(thumbSem.Max() + 1)
|
||
}
|
||
}
|
||
}
|
||
}()
|
||
}
|