Files
Pulse/internal/monitoring/lttb.go
rcourtman ee0e89871d fix: reduce metrics memory 86x by reverting buffer and adding LTTB downsampling
The in-memory metrics buffer was changed from 1000 to 86400 points per
metric to support 30-day sparklines, but this pre-allocated ~18 MB per
guest (7 slices × 86400 × 32 bytes). With 50 guests that's 920 MB —
explaining why users needed to double their LXC memory after upgrading
to 5.1.0.

- Revert in-memory buffer to 1000 points / 24h retention
- Remove eager slice pre-allocation (use append growth instead)
- Add LTTB (Largest Triangle Three Buckets) downsampling algorithm
- Chart endpoints now use a two-tier strategy: in-memory for ranges
  ≤ 2h, SQLite persistent store + LTTB for longer ranges
- Reduce frontend ring buffer from 86400 to 2000 points

Related to #1190
2026-02-04 19:49:52 +00:00

87 lines
2.2 KiB
Go

package monitoring
import (
"math"
)
// lttb performs Largest Triangle Three Buckets downsampling on a slice of
// MetricPoints. It reduces data to targetPoints while preserving the visual
// shape of the data — peaks, valleys and trends are retained.
//
// If len(data) <= targetPoints or targetPoints < 3, data is returned as-is.
func lttb(data []MetricPoint, targetPoints int) []MetricPoint {
n := len(data)
if targetPoints >= n || targetPoints < 3 {
return data
}
result := make([]MetricPoint, 0, targetPoints)
// Always keep the first point.
result = append(result, data[0])
bucketSize := float64(n-2) / float64(targetPoints-2)
prevSelected := 0
for i := 0; i < targetPoints-2; i++ {
// Current bucket range.
bucketStart := int(math.Floor(float64(i)*bucketSize)) + 1
bucketEnd := int(math.Floor(float64(i+1)*bucketSize)) + 1
if bucketEnd > n-1 {
bucketEnd = n - 1
}
// Next bucket range — used to compute the "third point" average.
nextStart := bucketEnd
nextEnd := int(math.Floor(float64(i+2)*bucketSize)) + 1
if nextEnd > n-1 {
nextEnd = n - 1
}
if nextStart >= nextEnd {
nextEnd = nextStart + 1
if nextEnd > n {
nextEnd = n
}
}
// Average of next bucket (the "C" vertex of the triangle).
avgTs := float64(0)
avgVal := float64(0)
nextCount := nextEnd - nextStart
for j := nextStart; j < nextEnd; j++ {
avgTs += float64(data[j].Timestamp.UnixMilli())
avgVal += data[j].Value
}
avgTs /= float64(nextCount)
avgVal /= float64(nextCount)
// Previously selected point (the "A" vertex).
aTs := float64(data[prevSelected].Timestamp.UnixMilli())
aVal := data[prevSelected].Value
// Find the point in the current bucket that maximises the triangle area.
maxArea := float64(-1)
bestIdx := bucketStart
for j := bucketStart; j < bucketEnd; j++ {
bTs := float64(data[j].Timestamp.UnixMilli())
bVal := data[j].Value
// Twice the triangle area (sign doesn't matter, we compare magnitudes).
area := math.Abs((aTs-avgTs)*(bVal-aVal) - (aTs-bTs)*(avgVal-aVal))
if area > maxArea {
maxArea = area
bestIdx = j
}
}
result = append(result, data[bestIdx])
prevSelected = bestIdx
}
// Always keep the last point.
result = append(result, data[n-1])
return result
}