Files
meta-core/internal/mounts/stats_poller.go
T
2026-04-27 10:31:11 +02:00

186 lines
4.8 KiB
Go

package mounts
import (
"context"
"log"
"sync"
"time"
)
// StatsInterval drives the IO-stats sampling cadence. 5 s is the usual sweet
// spot — short enough that throughput numbers feel live, long enough that
// /proc reads aren't a load source themselves.
const StatsInterval = 5 * time.Second
// StatsPoller samples cumulative counters from the local rclone RC API and
// exposes computed rates (bytes/sec, transfers/sec) via GetStats.
//
// Post-consolidation note: every mount type executes through rclone, so
// /proc/fs/cifs/Stats and /proc/self/mountstats are no longer relevant —
// rclone is the only counter source. core/stats is daemon-global across all
// active mounts; the same sample is applied to each mount's status. If
// per-mount byte counters become important, the path forward is rclone stats
// groups (out of scope for this consolidation).
type StatsPoller struct {
manager *Manager
mu sync.RWMutex
lastSamples map[string]*StatsSample // mountID -> previous raw sample
latestStats map[string]*MountIOStats
stopChan chan struct{}
running bool
pollInterval time.Duration
}
// NewStatsPoller constructs a StatsPoller backed by the given manager.
func NewStatsPoller(manager *Manager) *StatsPoller {
return &StatsPoller{
manager: manager,
lastSamples: make(map[string]*StatsSample),
latestStats: make(map[string]*MountIOStats),
pollInterval: StatsInterval,
}
}
// Start launches the sampling loop. Idempotent.
func (p *StatsPoller) Start() {
p.mu.Lock()
if p.running {
p.mu.Unlock()
return
}
p.running = true
p.stopChan = make(chan struct{})
stop := p.stopChan
p.mu.Unlock()
log.Printf("[StatsPoller] Starting IO stats sampler (interval: %s)", p.pollInterval)
go p.loop(stop)
}
// Stop terminates the sampling loop. Idempotent.
func (p *StatsPoller) Stop() {
p.mu.Lock()
defer p.mu.Unlock()
if !p.running {
return
}
p.running = false
if p.stopChan != nil {
close(p.stopChan)
p.stopChan = nil
}
}
// GetStats returns the latest computed IO stats for a mount, or nil if no
// stats have been observed yet.
func (p *StatsPoller) GetStats(mountID string) *MountIOStats {
p.mu.RLock()
defer p.mu.RUnlock()
s, ok := p.latestStats[mountID]
if !ok {
return nil
}
// Return a copy so the caller can't mutate our cache.
cp := *s
return &cp
}
func (p *StatsPoller) loop(stop <-chan struct{}) {
// Take the first sample immediately so rates start populating on the
// second tick instead of waiting two intervals.
p.tick()
t := time.NewTicker(p.pollInterval)
defer t.Stop()
for {
select {
case <-stop:
return
case <-t.C:
p.tick()
}
}
}
func (p *StatsPoller) tick() {
mounts, err := p.manager.listMountsWithoutPollingStatus()
if err != nil {
log.Printf("[StatsPoller] listMounts failed: %v", err)
return
}
// Sample rclone once per tick — its RC API gives global stats across all
// active rclone mounts on this host. Both supported mount types (smb,
// rclone) execute through the same rclone daemon, so the same sample is
// applied to every mounted entry.
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
sample, err := SampleRclone(ctx)
if err != nil {
log.Printf("[StatsPoller] rclone sample failed: %v", err)
return
}
if sample == nil {
return
}
for _, m := range mounts {
if !m.Mounted {
continue
}
p.mu.Lock()
prev := p.lastSamples[m.ID]
p.lastSamples[m.ID] = sample
stats := computeStats(prev, sample)
p.latestStats[m.ID] = stats
p.mu.Unlock()
}
}
// computeStats turns a (previous, current) raw-sample pair into the rate-
// based MountIOStats the API exposes. With no previous sample the rates are
// zero — operators will see cumulative counters but Bps/IOPS only on the
// second poll.
func computeStats(prev, cur *StatsSample) *MountIOStats {
out := &MountIOStats{
Source: cur.Source,
BytesRead: cur.BytesRead,
BytesWritten: cur.BytesWritten,
ReadOps: cur.ReadOps,
WriteOps: cur.WriteOps,
LastSampleAt: cur.TakenAt.UnixMilli(),
}
if prev == nil {
return out
}
dt := cur.TakenAt.Sub(prev.TakenAt).Seconds()
if dt <= 0 {
return out
}
out.IntervalMs = int64(dt * 1000)
// Counters can reset to zero (e.g. CIFS module reload, remount). Treat
// negative deltas as "no movement" rather than letting the rate go
// negative or implausibly huge.
if d := cur.BytesRead - prev.BytesRead; d > 0 {
out.ReadBps = float64(d) / dt
}
if d := cur.BytesWritten - prev.BytesWritten; d > 0 {
out.WriteBps = float64(d) / dt
}
if d := cur.ReadOps - prev.ReadOps; d > 0 {
out.ReadIops = float64(d) / dt
}
if d := cur.WriteOps - prev.WriteOps; d > 0 {
out.WriteIops = float64(d) / dt
}
if cur.Source == "rclone" {
if d := cur.Transfers - prev.Transfers; d > 0 {
out.TransfersPerSec = float64(d) / dt
}
}
return out
}