Files
pulse/internal/monitoring/ratetracker.go
T
2026-07-24 10:26:50 +01:00

164 lines
5.1 KiB
Go

package monitoring
import (
"sync"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
)
// IOMetrics is an alias for models.IOMetrics.
type IOMetrics = models.IOMetrics
type counterBaseline struct {
value int64
observedAt time.Time
initialized bool
}
type counterHistory struct {
diskRead counterBaseline
diskWrite counterBaseline
diskBusy counterBaseline
networkIn counterBaseline
networkOut counterBaseline
lastObservedAt time.Time
sourceUptime uint64
}
// RateTracker converts cumulative byte counters into adjacent-sample rates.
// Each counter has an independent baseline because Proxmox may omit only part
// of an otherwise valid status payload.
type RateTracker struct {
mu sync.RWMutex
history map[string]*counterHistory
}
// NewRateTracker creates a new rate tracker.
func NewRateTracker() *RateTracker {
return &RateTracker{history: make(map[string]*counterHistory)}
}
// CalculateRates calculates disk and network rates in bytes per second.
// A negative result means the upstream counter was absent, the sample was
// out-of-order, or no earlier observation exists. A returned zero is a valid
// observed idle/reset interval.
func (rt *RateTracker) CalculateRates(guestID string, current IOMetrics) (diskReadRate, diskWriteRate, netInRate, netOutRate float64) {
diskReadRate, diskWriteRate, _, netInRate, netOutRate = rt.calculateRates(guestID, current)
return
}
// CalculateRatesWithBusy also calculates disk busy percent from a cumulative
// millisecond counter.
func (rt *RateTracker) CalculateRatesWithBusy(guestID string, current IOMetrics) (diskReadRate, diskWriteRate, diskBusyPct, netInRate, netOutRate float64) {
return rt.calculateRates(guestID, current)
}
func (rt *RateTracker) calculateRates(guestID string, current IOMetrics) (diskReadRate, diskWriteRate, diskBusyPct, netInRate, netOutRate float64) {
rt.mu.Lock()
defer rt.mu.Unlock()
if current.Timestamp.IsZero() {
return -1, -1, -1, -1, -1
}
history := rt.history[guestID]
if history == nil {
history = &counterHistory{}
rt.history[guestID] = history
}
if current.SourceUptime > 0 {
if history.sourceUptime > 0 && current.SourceUptime < history.sourceUptime {
history.resetCounterEpoch()
}
history.sourceUptime = current.SourceUptime
}
presence := current.Presence.Effective()
diskReadRate = calculateCounterRate(&history.diskRead, current.DiskRead, observationTime(current.ObservedAt.DiskRead, current.Timestamp), presence.DiskRead)
diskWriteRate = calculateCounterRate(&history.diskWrite, current.DiskWrite, observationTime(current.ObservedAt.DiskWrite, current.Timestamp), presence.DiskWrite)
diskBusyRate := calculateCounterRate(&history.diskBusy, current.DiskBusy, observationTime(current.ObservedAt.DiskBusy, current.Timestamp), presence.DiskBusy)
netInRate = calculateCounterRate(&history.networkIn, current.NetworkIn, observationTime(current.ObservedAt.NetworkIn, current.Timestamp), presence.NetworkIn)
netOutRate = calculateCounterRate(&history.networkOut, current.NetworkOut, observationTime(current.ObservedAt.NetworkOut, current.Timestamp), presence.NetworkOut)
if diskBusyRate < 0 {
diskBusyPct = -1
} else {
// DiskBusy is cumulative busy milliseconds, so ms/s divided by ten is
// the percentage of wall time spent busy.
diskBusyPct = diskBusyRate / 10
if diskBusyPct > 100 {
diskBusyPct = 100
}
}
if current.Timestamp.After(history.lastObservedAt) {
history.lastObservedAt = current.Timestamp
}
return
}
func (history *counterHistory) resetCounterEpoch() {
history.diskRead = counterBaseline{}
history.diskWrite = counterBaseline{}
history.diskBusy = counterBaseline{}
history.networkIn = counterBaseline{}
history.networkOut = counterBaseline{}
}
func observationTime(counterTime, fallback time.Time) time.Time {
if counterTime.IsZero() {
return fallback
}
return counterTime
}
func calculateCounterRate(baseline *counterBaseline, value int64, observedAt time.Time, present bool) float64 {
if !present {
return -1
}
if !baseline.initialized {
baseline.value = value
baseline.observedAt = observedAt
baseline.initialized = true
return -1
}
if !observedAt.After(baseline.observedAt) {
return -1
}
elapsed := observedAt.Sub(baseline.observedAt).Seconds()
previous := baseline.value
baseline.value = value
baseline.observedAt = observedAt
if value < previous {
// A guest restart, migration reconnect, or counter wrap starts a new
// cumulative epoch. Rebase without inventing a negative or huge rate.
return 0
}
return float64(value-previous) / elapsed
}
// Clear removes all tracked data.
func (rt *RateTracker) Clear() {
rt.mu.Lock()
defer rt.mu.Unlock()
rt.history = make(map[string]*counterHistory)
}
// Cleanup removes resources that have not supplied any newer sample since the
// cutoff. Idle and partial samples still refresh resource observation time.
func (rt *RateTracker) Cleanup(cutoff time.Time) (removed int) {
rt.mu.Lock()
defer rt.mu.Unlock()
for guestID, history := range rt.history {
if history.lastObservedAt.Before(cutoff) {
delete(rt.history, guestID)
removed++
}
}
return removed
}