186 lines
4.8 KiB
Go
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
|
|
}
|