344 lines
6.7 KiB
Go
344 lines
6.7 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"
|
||
)
|
||
|
||
// Server-Startzeit für Perf/Uptime
|
||
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 startCPUUsageSampler(ctx context.Context) {
|
||
_, _ = 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]
|
||
if math.IsNaN(usage) || math.IsInf(usage, 0) || usage < 0 {
|
||
continue
|
||
}
|
||
if usage > 100 {
|
||
usage = 100
|
||
}
|
||
|
||
setLastCPUUsage(usage)
|
||
}
|
||
}
|
||
}()
|
||
}
|
||
|
||
// ---------- Feste ffmpeg/ffprobe Limiter ----------
|
||
|
||
type FixedLimiter chan struct{}
|
||
|
||
func NewFixedLimiter(n int) FixedLimiter {
|
||
if n < 1 {
|
||
n = 1
|
||
}
|
||
return make(FixedLimiter, n)
|
||
}
|
||
|
||
func (l FixedLimiter) Acquire(ctx context.Context) error {
|
||
if l == nil {
|
||
return nil
|
||
}
|
||
if ctx == nil {
|
||
ctx = context.Background()
|
||
}
|
||
|
||
select {
|
||
case l <- struct{}{}:
|
||
return nil
|
||
case <-ctx.Done():
|
||
return ctx.Err()
|
||
}
|
||
}
|
||
|
||
func (l FixedLimiter) Release() {
|
||
if l == nil {
|
||
return
|
||
}
|
||
select {
|
||
case <-l:
|
||
default:
|
||
}
|
||
}
|
||
|
||
func (l FixedLimiter) Limit() int {
|
||
return cap(l)
|
||
}
|
||
|
||
func (l FixedLimiter) InUse() int {
|
||
return len(l)
|
||
}
|
||
|
||
var (
|
||
previewSem FixedLimiter
|
||
thumbSem FixedLimiter
|
||
genSem FixedLimiter
|
||
durSem FixedLimiter
|
||
)
|
||
|
||
func envInt(name string) (int, bool) {
|
||
v := strings.TrimSpace(os.Getenv(name))
|
||
if v == "" {
|
||
return 0, false
|
||
}
|
||
n, err := strconv.Atoi(v)
|
||
if err != nil {
|
||
return 0, false
|
||
}
|
||
return n, true
|
||
}
|
||
|
||
func initFFmpegLimiters() {
|
||
previewN := 2
|
||
thumbN := 4
|
||
genN := 2
|
||
durN := 8
|
||
|
||
if n, ok := envInt("PREVIEW_WORKERS"); ok && n > 0 {
|
||
previewN = n
|
||
}
|
||
if n, ok := envInt("THUMB_WORKERS"); ok && n > 0 {
|
||
thumbN = n
|
||
}
|
||
if n, ok := envInt("GEN_WORKERS"); ok && n > 0 {
|
||
genN = n
|
||
}
|
||
if n, ok := envInt("DUR_WORKERS"); ok && n > 0 {
|
||
durN = n
|
||
}
|
||
|
||
previewSem = NewFixedLimiter(previewN)
|
||
thumbSem = NewFixedLimiter(thumbN)
|
||
genSem = NewFixedLimiter(genN)
|
||
durSem = NewFixedLimiter(durN)
|
||
|
||
setLastCPUUsage(0)
|
||
startCPUUsageSampler(context.Background())
|
||
}
|
||
|
||
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(),
|
||
"uptimeSec": time.Since(serverStartedAt).Seconds(),
|
||
"cpuPercent": func() float64 {
|
||
v := getLastCPUUsage()
|
||
if math.IsNaN(v) || math.IsInf(v, 0) || v < 0 {
|
||
return 0
|
||
}
|
||
if v > 100 {
|
||
return 100
|
||
}
|
||
return v
|
||
}(),
|
||
"diskPath": diskPath,
|
||
"diskFreeBytes": diskFreeBytes,
|
||
"diskTotalBytes": diskTotalBytes,
|
||
"diskUsedPercent": diskUsedPercent,
|
||
"diskEmergency": atomic.LoadInt32(&diskEmergency) == 1,
|
||
|
||
"diskPauseBelowGB": pauseGB,
|
||
"diskResumeAboveGB": resumeGB,
|
||
|
||
"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 previewSem != nil {
|
||
sem["preview"] = map[string]any{
|
||
"inUse": previewSem.InUse(),
|
||
"limit": previewSem.Limit(),
|
||
}
|
||
}
|
||
if thumbSem != nil {
|
||
sem["thumb"] = map[string]any{
|
||
"inUse": thumbSem.InUse(),
|
||
"limit": thumbSem.Limit(),
|
||
}
|
||
}
|
||
if genSem != nil {
|
||
sem["gen"] = map[string]any{
|
||
"inUse": genSem.InUse(),
|
||
"limit": genSem.Limit(),
|
||
}
|
||
}
|
||
if durSem != nil {
|
||
sem["dur"] = map[string]any{
|
||
"inUse": durSem.InUse(),
|
||
"limit": durSem.Limit(),
|
||
}
|
||
}
|
||
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
|
||
}
|
||
|
||
ms := 5000
|
||
if q := r.URL.Query().Get("ms"); q != "" {
|
||
if v, err := strconv.Atoi(q); err == nil {
|
||
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")
|
||
w.Header().Set("X-Accel-Buffering", "no")
|
||
|
||
ctx := r.Context()
|
||
|
||
send := func() error {
|
||
payload := buildPerfSnapshot()
|
||
|
||
var buf bytes.Buffer
|
||
if err := json.NewEncoder(&buf).Encode(payload); err != nil {
|
||
return err
|
||
}
|
||
|
||
_, _ = io.WriteString(w, "event: perf\n")
|
||
_, _ = io.WriteString(w, "data: ")
|
||
_, _ = w.Write(buf.Bytes())
|
||
_, _ = io.WriteString(w, "\n")
|
||
fl.Flush()
|
||
return nil
|
||
}
|
||
|
||
_ = send()
|
||
|
||
t := time.NewTicker(time.Duration(ms) * time.Millisecond)
|
||
hb := time.NewTicker(15 * time.Second)
|
||
defer t.Stop()
|
||
defer hb.Stop()
|
||
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
return
|
||
case <-t.C:
|
||
_ = send()
|
||
case <-hb.C:
|
||
_, _ = io.WriteString(w, ": keep-alive\n\n")
|
||
fl.Flush()
|
||
}
|
||
}
|
||
}
|