1329 lines
42 KiB
Go
1329 lines
42 KiB
Go
package wshub
|
||
|
||
// Disk-backed circular history storage, wire- and file-compatible with the C++
|
||
// StreamHub's HistoryWriter. Long live windows (up to 600 s) hold far more
|
||
// samples than the in-memory zoom rings can, so the samples go to a
|
||
// pre-allocated circular file per signal and are read back on demand.
|
||
//
|
||
// File layout (per signal, little-endian):
|
||
//
|
||
// Offset Size Field
|
||
// 0 4 Magic "SHR1"
|
||
// 4 4 uint32 version (1)
|
||
// 8 4 uint32 capacity (max pairs)
|
||
// 12 4 uint32 head (next write position, wraps)
|
||
// 16 4 uint32 count (valid entries, <= capacity)
|
||
// 20 4 uint32 decimation
|
||
// 24 8 float64 tOldest
|
||
// 32 8 float64 tNewest
|
||
// 40 24 reserved (pad to 64)
|
||
// 64 ... data: capacity × 16 bytes (float64 time, float64 value)
|
||
|
||
import (
|
||
"encoding/binary"
|
||
"encoding/json"
|
||
"fmt"
|
||
"log"
|
||
"math"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"sync"
|
||
"syscall"
|
||
"time"
|
||
|
||
"marte2/common/udpsprotocol"
|
||
)
|
||
|
||
const (
|
||
histHeaderSize = 64
|
||
histPairSize = 16
|
||
histVersion = 1
|
||
// histMinCapacity floors the per-signal file so a slow or unknown-rate
|
||
// signal still keeps a usable amount of history.
|
||
histMinCapacity = 1000
|
||
// histMaxCapacity caps one signal's file at 16 GB of pairs, so a bogus
|
||
// sampling rate in a CONFIG packet cannot ask for an unbounded file.
|
||
histMaxCapacity = 1 << 30
|
||
// histDefaultMaxPoints is the default per-signal file budget, in stored
|
||
// points. 16 Mi points is 256 MB of (time, value) pairs.
|
||
histDefaultMaxPoints = 16 << 20
|
||
// histTypeCodeUint64 is the UDPS type code for uint64; those signals are
|
||
// time references (TimeArray), not data, so they are not archived.
|
||
histTypeCodeUint64 = 6
|
||
)
|
||
|
||
// histHeadroom is how much more than the window a file is sized to hold. The
|
||
// window has to still be on disk when it is read back, and a capture is read
|
||
// back a whole window after its first sample was written — a file sized to
|
||
// exactly the window has overwritten that sample by then.
|
||
const histHeadroom = 1.25
|
||
|
||
var histMagic = [4]byte{'S', 'H', 'R', '1'}
|
||
|
||
// HistoryConfig configures the disk-backed history. A zero Directory disables
|
||
// history entirely.
|
||
type HistoryConfig struct {
|
||
Directory string
|
||
// WindowSec is the timespan one signal's file has to hold — the live or
|
||
// trigger window the clients are displaying, not a fixed retention period.
|
||
// The hub keeps it up to date as clients change what they display, so this
|
||
// field is only the value used until the first client says otherwise.
|
||
WindowSec float64 // default 10 s, the assumed live window
|
||
Decimation int // keep every Nth sample; default 1
|
||
FlushIntervalSec int // header flush period; default 5
|
||
// MinDiskFreeMB pauses writing below this much free space; default 500.
|
||
// A negative value disables the check. Unlike the C++ HistoryWriter, zero
|
||
// cannot mean "disabled" here: it is also the unset value of the field, and
|
||
// silently dropping the guard for a caller who just did not fill it in is
|
||
// the worse failure.
|
||
MinDiskFreeMB int
|
||
// MaxPointsPerSignal caps one signal's file in stored points (16 bytes
|
||
// each); default 16 Mi = 256 MB. The window alone cannot bound the size,
|
||
// because the file is sized from the sample rate: 600 s of a 1 MSps signal
|
||
// is 9.6 GB. The budget is spent on resolution, not on span — a signal too
|
||
// fast to archive whole is stored as a min/max envelope wide enough to fit,
|
||
// so the window is always covered. This is the knob the web UI exposes, in
|
||
// MPts per signal.
|
||
MaxPointsPerSignal int
|
||
}
|
||
|
||
func (c HistoryConfig) withDefaults() HistoryConfig {
|
||
if c.WindowSec <= 0 {
|
||
c.WindowSec = defaultLiveWindowSec
|
||
}
|
||
if c.Decimation <= 0 {
|
||
c.Decimation = 1
|
||
}
|
||
if c.FlushIntervalSec <= 0 {
|
||
c.FlushIntervalSec = 5
|
||
}
|
||
if c.MinDiskFreeMB == 0 {
|
||
c.MinDiskFreeMB = 500
|
||
}
|
||
if c.MaxPointsPerSignal <= 0 {
|
||
c.MaxPointsPerSignal = histDefaultMaxPoints
|
||
}
|
||
return c
|
||
}
|
||
|
||
// histFile is one signal's circular file. Writes come from the hub's Run()
|
||
// goroutine, reads from the WebSocket read goroutines, hence the RWMutex.
|
||
type histFile struct {
|
||
mu sync.RWMutex
|
||
f *os.File
|
||
capacity uint32
|
||
head uint32
|
||
count uint32
|
||
tOldest float64
|
||
tNewest float64
|
||
|
||
decimCount int
|
||
dirty bool
|
||
|
||
// bucket is how many incoming samples collapse into one min/max pair; 1
|
||
// archives every sample verbatim. bMin/bMax accumulate the bucket in
|
||
// progress, which spans as many write() batches as it takes to fill.
|
||
bucket int
|
||
bCount int
|
||
bMinT float64
|
||
bMinV float64
|
||
bMaxT float64
|
||
bMaxV float64
|
||
|
||
// reduction is the header's decimation field: how many source samples one
|
||
// bucket consumes, so a reader knows what the stored resolution is.
|
||
reduction uint32
|
||
|
||
// rateHz is the sampling rate the file was sized from, kept so a later
|
||
// budget change can re-size it without waiting to measure the rate again.
|
||
rateHz float64
|
||
|
||
sourceID string
|
||
signal string
|
||
}
|
||
|
||
// foldBucket accumulates a batch into the min/max bucket in progress and returns
|
||
// the pairs of whichever buckets completed, in time order. Called with hf.mu
|
||
// held by the caller's write path.
|
||
func (hf *histFile) foldBucket(t, v []float64) ([]float64, []float64) {
|
||
outT := make([]float64, 0, 2*(len(t)/hf.bucket+1))
|
||
outV := make([]float64, 0, cap(outT))
|
||
for i := range t {
|
||
if hf.bCount == 0 {
|
||
hf.bMinT, hf.bMinV = t[i], v[i]
|
||
hf.bMaxT, hf.bMaxV = t[i], v[i]
|
||
} else {
|
||
if v[i] < hf.bMinV {
|
||
hf.bMinT, hf.bMinV = t[i], v[i]
|
||
}
|
||
if v[i] > hf.bMaxV {
|
||
hf.bMaxT, hf.bMaxV = t[i], v[i]
|
||
}
|
||
}
|
||
hf.bCount++
|
||
if hf.bCount < hf.bucket {
|
||
continue
|
||
}
|
||
hf.bCount = 0
|
||
switch {
|
||
case hf.bMinT == hf.bMaxT:
|
||
// A bucket whose samples are all equal has one extreme, not two.
|
||
outT = append(outT, hf.bMinT)
|
||
outV = append(outV, hf.bMinV)
|
||
case hf.bMinT < hf.bMaxT:
|
||
outT = append(outT, hf.bMinT, hf.bMaxT)
|
||
outV = append(outV, hf.bMinV, hf.bMaxV)
|
||
default:
|
||
outT = append(outT, hf.bMaxT, hf.bMinT)
|
||
outV = append(outV, hf.bMaxV, hf.bMinV)
|
||
}
|
||
}
|
||
return outT, outV
|
||
}
|
||
|
||
// HistorySignalInfo is the per-signal metadata reported in a historyInfo event.
|
||
type HistorySignalInfo struct {
|
||
T0 float64 `json:"t0"`
|
||
T1 float64 `json:"t1"`
|
||
Count uint32 `json:"count"`
|
||
Capacity uint32 `json:"capacity"`
|
||
// Bucket is how many source samples collapse into one min/max pair; 1 means
|
||
// the signal is archived verbatim. Clients show it so the budget control has
|
||
// a visible effect to tune against.
|
||
Bucket int `json:"bucket"`
|
||
}
|
||
|
||
// pendingHistSignal is a signal whose producer declared no sampling rate, so
|
||
// its file cannot be sized until the rate has been measured on the live stream.
|
||
type pendingHistSignal struct {
|
||
sourceID string
|
||
signal string
|
||
}
|
||
|
||
// historyWriter owns every open signal file.
|
||
type historyWriter struct {
|
||
cfg HistoryConfig
|
||
|
||
mu sync.RWMutex
|
||
files map[string]*histFile // "sourceId:signalName" → file
|
||
pending map[string]pendingHistSignal // awaiting a measured rate
|
||
// windowSec is the timespan the files are sized to hold, kept in step with
|
||
// what the clients display by the hub's retune sweep.
|
||
windowSec float64
|
||
|
||
diskMu sync.RWMutex
|
||
diskLow bool
|
||
|
||
// capMu guards captures, the per-signal copies of the last trigger window.
|
||
capMu sync.RWMutex
|
||
captures map[string]*histFile
|
||
}
|
||
|
||
// newHistoryWriter opens (creating it if needed) the history directory. It
|
||
// returns nil when history is not configured, which every call site treats as
|
||
// "history disabled" so the hub runs unchanged without it.
|
||
func newHistoryWriter(cfg HistoryConfig) (*historyWriter, error) {
|
||
if strings.TrimSpace(cfg.Directory) == "" {
|
||
return nil, nil
|
||
}
|
||
cfg = cfg.withDefaults()
|
||
cfg.Directory = strings.TrimRight(cfg.Directory, "/")
|
||
if err := os.MkdirAll(cfg.Directory, 0o755); err != nil {
|
||
return nil, fmt.Errorf("history directory %q: %w", cfg.Directory, err)
|
||
}
|
||
return &historyWriter{
|
||
cfg: cfg,
|
||
windowSec: cfg.WindowSec,
|
||
files: make(map[string]*histFile),
|
||
pending: make(map[string]pendingHistSignal),
|
||
}, nil
|
||
}
|
||
|
||
func (hw *historyWriter) enabled() bool { return hw != nil }
|
||
|
||
// onSourceConfigured opens a file per data signal of a source, sizing it from
|
||
// the declared sampling rate. Signals already archived under the same key keep
|
||
// their file, so a repeated CONFIG does not discard history.
|
||
func (hw *historyWriter) onSourceConfigured(sourceID string, sigs []udpsprotocol.SignalInfo) {
|
||
if !hw.enabled() {
|
||
return
|
||
}
|
||
for _, sig := range sigs {
|
||
// Time-reference signals are the clock for the others, not data.
|
||
if sig.TypeCode == histTypeCodeUint64 {
|
||
continue
|
||
}
|
||
key := sourceID + ":" + sig.Name
|
||
|
||
hw.mu.RLock()
|
||
_, exists := hw.files[key]
|
||
_, waiting := hw.pending[key]
|
||
hw.mu.RUnlock()
|
||
if exists || waiting {
|
||
continue
|
||
}
|
||
|
||
// The rate the producer declares is what the file has to hold. Many
|
||
// producers (the C++ UDPStreamer among them) declare none, and a fixed
|
||
// guess would be orders of magnitude out at megasample rates — so those
|
||
// signals wait for openPending to size them from the measured rate.
|
||
if sig.SamplingRate <= 0 {
|
||
hw.mu.Lock()
|
||
hw.pending[key] = pendingHistSignal{sourceID: sourceID, signal: sig.Name}
|
||
hw.mu.Unlock()
|
||
continue
|
||
}
|
||
if _, err := hw.open(key, sourceID, sig.Name, sig.SamplingRate); err != nil {
|
||
log.Printf("wshub/history: %s:%s: %v", sourceID, sig.Name, err)
|
||
}
|
||
}
|
||
}
|
||
|
||
// open creates (or reopens) one signal's file and registers it.
|
||
func (hw *historyWriter) open(key, sourceID, signal string, rateHz float64) (*histFile, error) {
|
||
dir := filepath.Join(hw.cfg.Directory, sanitizeHistName(sourceID))
|
||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||
return nil, fmt.Errorf("mkdir %s: %w", dir, err)
|
||
}
|
||
path := filepath.Join(dir, sanitizeHistName(signal)+".shist")
|
||
hf, err := hw.openSignalFile(path, sourceID, signal, rateHz)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
hw.mu.Lock()
|
||
hw.files[key] = hf
|
||
delete(hw.pending, key)
|
||
hw.mu.Unlock()
|
||
return hf, nil
|
||
}
|
||
|
||
// pendingKeys lists the signals still waiting for a measured sampling rate.
|
||
func (hw *historyWriter) pendingKeys() []string {
|
||
if !hw.enabled() {
|
||
return nil
|
||
}
|
||
hw.mu.RLock()
|
||
defer hw.mu.RUnlock()
|
||
if len(hw.pending) == 0 {
|
||
return nil
|
||
}
|
||
keys := make([]string, 0, len(hw.pending))
|
||
for k := range hw.pending {
|
||
keys = append(keys, k)
|
||
}
|
||
return keys
|
||
}
|
||
|
||
// openPending sizes and opens a deferred signal's file from a measured rate.
|
||
func (hw *historyWriter) openPending(key string, rateHz float64) bool {
|
||
if !hw.enabled() || rateHz <= 0 {
|
||
return false
|
||
}
|
||
hw.mu.RLock()
|
||
p, waiting := hw.pending[key]
|
||
hw.mu.RUnlock()
|
||
if !waiting {
|
||
return false
|
||
}
|
||
if _, err := hw.open(key, p.sourceID, p.signal, rateHz); err != nil {
|
||
log.Printf("wshub/history: %s: %v", key, err)
|
||
// Drop it rather than retrying every sweep: the error is a filesystem
|
||
// one, and it would otherwise be logged once a second forever.
|
||
hw.mu.Lock()
|
||
delete(hw.pending, key)
|
||
hw.mu.Unlock()
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
// sanitizeHistName keeps a source or signal name usable as a path component:
|
||
// separators and traversal would otherwise let a producer-supplied name write
|
||
// outside the history directory.
|
||
func sanitizeHistName(s string) string {
|
||
out := strings.Map(func(r rune) rune {
|
||
switch {
|
||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
|
||
return r
|
||
case r == '-', r == '_', r == '.', r == '[', r == ']':
|
||
return r
|
||
default:
|
||
return '_'
|
||
}
|
||
}, s)
|
||
// ".", ".." and the empty string are not usable component names.
|
||
if out == "" || strings.Trim(out, ".") == "" {
|
||
return "_"
|
||
}
|
||
return out
|
||
}
|
||
|
||
// histCapacityFor sizes one signal's file and picks its min/max bucket width.
|
||
//
|
||
// What the file has to hold is the window being displayed, not a retention
|
||
// period: the archive exists so that a zoom or a trigger capture can be served
|
||
// after the in-memory rings have rolled past it, and neither ever asks for more
|
||
// than the window. So the file is `windowSec` of samples long — verbatim while
|
||
// that fits the budget, and otherwise as an envelope of `bucket` samples per
|
||
// min/max pair, the narrowest bucket that does fit. Sizing it for hours instead
|
||
// meant a 1 s live window was archived at a thousandth of its resolution, since
|
||
// the same budget had to stretch over 3600 times as much time.
|
||
//
|
||
// A returned bucket of 1 means every sample is archived verbatim.
|
||
func histCapacityFor(windowSec, rateHz float64, decimation, maxPts int) (uint32, int) {
|
||
need := windowSec * rateHz / float64(decimation)
|
||
if !(need > 0) { // also catches NaN
|
||
return histMinCapacity, 1
|
||
}
|
||
budget := float64(maxPts)
|
||
if maxPts <= 0 || budget > histMaxCapacity {
|
||
budget = histMaxCapacity
|
||
}
|
||
if want := math.Ceil(need * histHeadroom); want <= budget {
|
||
if want < histMinCapacity {
|
||
return histMinCapacity, 1
|
||
}
|
||
return uint32(want), 1
|
||
}
|
||
// Two points per bucket, so `bucket` samples collapse to a min and a max.
|
||
bucket := int(math.Ceil(2 * need * histHeadroom / budget))
|
||
n := math.Ceil(2 * need * histHeadroom / float64(bucket))
|
||
if n > budget {
|
||
n = budget
|
||
}
|
||
if n < histMinCapacity {
|
||
n = histMinCapacity
|
||
}
|
||
return uint32(n), bucket
|
||
}
|
||
|
||
// histCoverageSec is how much time a file of this geometry holds, at the rate it
|
||
// was sized from. A bucket of 2 stores both of its samples, so it reaches no
|
||
// further back than a bucket of 1.
|
||
func histCoverageSec(capacity uint32, bucket, decimation int, rateHz float64) float64 {
|
||
if rateHz <= 0 {
|
||
return 0
|
||
}
|
||
pairs := float64(capacity)
|
||
if bucket > 2 {
|
||
pairs = float64(capacity/2) * float64(bucket)
|
||
}
|
||
return pairs * float64(decimation) / rateHz
|
||
}
|
||
|
||
// window reports the timespan the files are currently sized for.
|
||
func (hw *historyWriter) window() float64 {
|
||
if !hw.enabled() {
|
||
return 0
|
||
}
|
||
hw.mu.RLock()
|
||
defer hw.mu.RUnlock()
|
||
return hw.windowSec
|
||
}
|
||
|
||
// setWindow points the archive at the timespan the clients are looking at, and
|
||
// re-sizes the files that no longer match it. It reports whether any file's
|
||
// geometry changed, which invalidates what clients know about the archive.
|
||
//
|
||
// As in setBudget, the archived samples do not survive a re-size: a file's
|
||
// capacity and bucket width are fixed when it is created. Widening the window
|
||
// therefore costs the history collected so far, which is the price of storing
|
||
// the window at the best resolution the budget can buy rather than storing
|
||
// hours of it at the worst.
|
||
func (hw *historyWriter) setWindow(sec float64) bool {
|
||
if !hw.enabled() || !(sec > 0) {
|
||
return false
|
||
}
|
||
hw.mu.Lock()
|
||
if sec == hw.windowSec {
|
||
hw.mu.Unlock()
|
||
return false
|
||
}
|
||
hw.windowSec = sec
|
||
hw.mu.Unlock()
|
||
return hw.resizeForWindow(sec)
|
||
}
|
||
|
||
// resizeForWindow re-creates every file whose geometry no longer suits `window`.
|
||
func (hw *historyWriter) resizeForWindow(window float64) bool {
|
||
hw.mu.RLock()
|
||
decim := hw.cfg.Decimation
|
||
keys := make([]string, 0, len(hw.files))
|
||
files := make([]*histFile, 0, len(hw.files))
|
||
for k, hf := range hw.files {
|
||
keys = append(keys, k)
|
||
files = append(files, hf)
|
||
}
|
||
hw.mu.RUnlock()
|
||
|
||
changed := false
|
||
for i, hf := range files {
|
||
hf.mu.RLock()
|
||
capacity, bucket, rate := hf.capacity, hf.bucket, hf.rateHz
|
||
hf.mu.RUnlock()
|
||
if rate <= 0 {
|
||
continue
|
||
}
|
||
|
||
cov := histCoverageSec(capacity, bucket, decim, rate)
|
||
newCap, newBucket := histCapacityFor(window, rate, decim, hw.budget())
|
||
switch {
|
||
case cov < window:
|
||
// Too short to answer for the window: it has to grow, and losing what
|
||
// it holds is the price of that.
|
||
case bucket > 1 && newBucket < bucket && cov > 2*window:
|
||
// Longer than it needs to be, and enveloped: a narrower window buys
|
||
// resolution back. Twice the window of slack, as for the rings, so a
|
||
// client nudging its window by a few percent does not cost it the
|
||
// archive.
|
||
default:
|
||
// Longer than the window but already verbatim. Shrinking it would
|
||
// discard history and buy nothing — a trigger arming with a 1 s window
|
||
// must not throw away the seconds it is about to be asked for.
|
||
continue
|
||
}
|
||
// A file at the minimum capacity, or already at the budget ceiling, can
|
||
// come out of the sizer unchanged however far the window moved.
|
||
if newCap == capacity && newBucket == bucket {
|
||
continue
|
||
}
|
||
|
||
// Detach before closing: write() and readRange() look the key up under
|
||
// this lock, so from here on they skip the signal rather than touching a
|
||
// descriptor that is about to go away.
|
||
hw.mu.Lock()
|
||
if hw.files[keys[i]] != hf {
|
||
hw.mu.Unlock()
|
||
continue
|
||
}
|
||
delete(hw.files, keys[i])
|
||
hw.mu.Unlock()
|
||
|
||
hf.mu.Lock()
|
||
if hf.dirty {
|
||
_ = hf.flushHeaderLocked()
|
||
}
|
||
_ = hf.f.Close()
|
||
hf.mu.Unlock()
|
||
|
||
if _, err := hw.open(keys[i], hf.sourceID, hf.signal, rate); err != nil {
|
||
log.Printf("wshub/history: re-size %s: %v", keys[i], err)
|
||
continue
|
||
}
|
||
log.Printf("wshub/history: %s re-sized for a %.1f s window (cap %d→%d, min/max over %d→%d); archive reset",
|
||
keys[i], window, capacity, newCap, bucket, newBucket)
|
||
changed = true
|
||
}
|
||
return changed
|
||
}
|
||
|
||
// budget reports the current per-signal point budget. It is read under the lock
|
||
// because setBudget can change it from a WebSocket goroutine while the hub's Run
|
||
// goroutine is opening a deferred file.
|
||
func (hw *historyWriter) budget() int {
|
||
if !hw.enabled() {
|
||
return 0
|
||
}
|
||
hw.mu.RLock()
|
||
defer hw.mu.RUnlock()
|
||
return hw.cfg.MaxPointsPerSignal
|
||
}
|
||
|
||
// setBudget changes the per-signal point budget and re-creates every open file
|
||
// at the new size, returning the budget actually applied.
|
||
//
|
||
// The archived samples do not survive: a file's geometry — its capacity and its
|
||
// min/max bucket width — is fixed when it is created, and re-bucketing an
|
||
// existing envelope into a different one would be inventing samples. Callers
|
||
// must make that clear to the user before asking for it.
|
||
func (hw *historyWriter) setBudget(maxPts int) int {
|
||
if !hw.enabled() {
|
||
return 0
|
||
}
|
||
if maxPts <= 0 {
|
||
maxPts = histDefaultMaxPoints
|
||
}
|
||
if maxPts > histMaxCapacity {
|
||
maxPts = histMaxCapacity
|
||
}
|
||
|
||
hw.mu.Lock()
|
||
if maxPts == hw.cfg.MaxPointsPerSignal {
|
||
hw.mu.Unlock()
|
||
return maxPts
|
||
}
|
||
hw.cfg.MaxPointsPerSignal = maxPts
|
||
// Detach the old files before reopening: write() and readRange() look the
|
||
// key up under this lock, so from here on they find nothing and skip the
|
||
// signal rather than touching a descriptor that is about to be closed.
|
||
old := hw.files
|
||
hw.files = make(map[string]*histFile, len(old))
|
||
hw.mu.Unlock()
|
||
|
||
for key, hf := range old {
|
||
hf.mu.Lock()
|
||
if hf.dirty {
|
||
_ = hf.flushHeaderLocked()
|
||
}
|
||
_ = hf.f.Close()
|
||
rate := hf.rateHz
|
||
hf.mu.Unlock()
|
||
if _, err := hw.open(key, hf.sourceID, hf.signal, rate); err != nil {
|
||
log.Printf("wshub/history: resize %s: %v", key, err)
|
||
}
|
||
}
|
||
log.Printf("wshub/history: budget set to %.3f MPts per signal, %d file(s) re-sized",
|
||
float64(maxPts)/1e6, len(old))
|
||
return maxPts
|
||
}
|
||
|
||
// openSignalFile reopens an existing file whose capacity still matches, and
|
||
// otherwise creates a fresh pre-allocated one.
|
||
func (hw *historyWriter) openSignalFile(path, sourceID, signal string, rateHz float64) (*histFile, error) {
|
||
capacity, bucket := histCapacityFor(hw.window(), rateHz, hw.cfg.Decimation, hw.budget())
|
||
reduction := uint32(bucket * hw.cfg.Decimation)
|
||
|
||
if f, err := os.OpenFile(path, os.O_RDWR, 0o644); err == nil {
|
||
hdr := make([]byte, histHeaderSize)
|
||
if _, rerr := f.ReadAt(hdr, 0); rerr == nil &&
|
||
string(hdr[0:4]) == string(histMagic[:]) &&
|
||
binary.LittleEndian.Uint32(hdr[4:]) == histVersion &&
|
||
binary.LittleEndian.Uint32(hdr[8:]) == capacity &&
|
||
// Same size but a different resolution would splice two
|
||
// incompatible envelopes into one file.
|
||
binary.LittleEndian.Uint32(hdr[20:]) == reduction {
|
||
hf := &histFile{
|
||
f: f,
|
||
capacity: capacity,
|
||
head: binary.LittleEndian.Uint32(hdr[12:]),
|
||
count: binary.LittleEndian.Uint32(hdr[16:]),
|
||
tOldest: math.Float64frombits(binary.LittleEndian.Uint64(hdr[24:])),
|
||
tNewest: math.Float64frombits(binary.LittleEndian.Uint64(hdr[32:])),
|
||
bucket: bucket,
|
||
reduction: reduction,
|
||
rateHz: rateHz,
|
||
sourceID: sourceID,
|
||
signal: signal,
|
||
}
|
||
if hf.head < capacity && hf.count <= capacity {
|
||
log.Printf("wshub/history: reopened %s (cap=%d, count=%d, bucket=%d)",
|
||
path, capacity, hf.count, bucket)
|
||
return hf, nil
|
||
}
|
||
}
|
||
// Wrong capacity, wrong version, wrong resolution or a corrupt header:
|
||
// start over.
|
||
f.Close()
|
||
}
|
||
|
||
f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o644)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
size := int64(histHeaderSize) + int64(capacity)*histPairSize
|
||
if err := f.Truncate(size); err != nil {
|
||
f.Close()
|
||
return nil, fmt.Errorf("preallocate %d bytes: %w", size, err)
|
||
}
|
||
hf := &histFile{f: f, capacity: capacity, bucket: bucket, reduction: reduction,
|
||
rateHz: rateHz, sourceID: sourceID, signal: signal}
|
||
if err := hf.flushHeaderLocked(); err != nil {
|
||
f.Close()
|
||
return nil, err
|
||
}
|
||
if bucket > 1 {
|
||
log.Printf("wshub/history: created %s (cap=%d, %.1f MB, min/max over %d samples = %.0f Sps stored)",
|
||
path, capacity, float64(size)/(1024*1024), bucket, 2*rateHz/float64(bucket))
|
||
} else {
|
||
log.Printf("wshub/history: created %s (cap=%d, %.1f MB, full resolution)",
|
||
path, capacity, float64(size)/(1024*1024))
|
||
}
|
||
return hf, nil
|
||
}
|
||
|
||
// write archives a batch of full-resolution samples for one signal. Unknown
|
||
// keys and a disabled writer are no-ops, so callers need no guard.
|
||
func (hw *historyWriter) write(key string, t, v []float64) {
|
||
if !hw.enabled() || len(t) == 0 || len(t) != len(v) {
|
||
return
|
||
}
|
||
hw.diskMu.RLock()
|
||
low := hw.diskLow
|
||
hw.diskMu.RUnlock()
|
||
if low {
|
||
return
|
||
}
|
||
hw.mu.RLock()
|
||
hf := hw.files[key]
|
||
hw.mu.RUnlock()
|
||
if hf == nil {
|
||
return
|
||
}
|
||
|
||
if hw.cfg.Decimation > 1 {
|
||
// Decimate into scratch rather than in place: t/v are the same slices
|
||
// the zoom ring and the trigger were handed.
|
||
kt := make([]float64, 0, len(t)/hw.cfg.Decimation+1)
|
||
kv := make([]float64, 0, cap(kt))
|
||
hf.mu.Lock()
|
||
c := hf.decimCount
|
||
for i := range t {
|
||
c++
|
||
if c >= hw.cfg.Decimation {
|
||
kt = append(kt, t[i])
|
||
kv = append(kv, v[i])
|
||
c = 0
|
||
}
|
||
}
|
||
hf.decimCount = c
|
||
hf.mu.Unlock()
|
||
t, v = kt, kv
|
||
if len(t) == 0 {
|
||
return
|
||
}
|
||
}
|
||
|
||
// Too fast to archive sample-for-sample within the budget: store the min/max
|
||
// envelope instead, so the file still covers the configured duration and a
|
||
// spike survives at whatever resolution the budget allows.
|
||
if hf.bucket > 1 {
|
||
hf.mu.Lock()
|
||
t, v = hf.foldBucket(t, v)
|
||
hf.mu.Unlock()
|
||
if len(t) == 0 {
|
||
// The bucket in progress is not complete yet.
|
||
return
|
||
}
|
||
}
|
||
|
||
if err := hf.writePairs(t, v); err != nil {
|
||
log.Printf("wshub/history: write %s: %v", key, err)
|
||
}
|
||
}
|
||
|
||
// captureRange copies [t0, t1] out of every archive file into a capture file of
|
||
// its own, which nothing overwrites until the next trigger. The in-memory rings
|
||
// roll past a captured window within seconds, so this copy is what a zoom into
|
||
// the capture reads from once they have.
|
||
//
|
||
// Protecting the window in place instead — pinning the region and refusing to
|
||
// wrap onto it — was tried and cannot work: the archive is circular and sized in
|
||
// seconds, so a capture held for longer than the file covers stops the archive
|
||
// dead, and the hole then lands exactly where the *next* capture's pre-trigger
|
||
// window belongs. Copying costs one window's worth of disk per signal and leaves
|
||
// the archive rolling untouched.
|
||
func (hw *historyWriter) captureRange(t0, t1 float64) {
|
||
if !hw.enabled() || t1 <= t0 {
|
||
return
|
||
}
|
||
hw.mu.RLock()
|
||
files := make(map[string]*histFile, len(hw.files))
|
||
for k, hf := range hw.files {
|
||
files[k] = hf
|
||
}
|
||
hw.mu.RUnlock()
|
||
|
||
// The copy runs on the hub's goroutine, so how long it takes is how long
|
||
// ingest is held up: report it rather than leaving a stall to be guessed at.
|
||
started := time.Now()
|
||
made := make(map[string]*histFile, len(files))
|
||
for key, hf := range files {
|
||
cf, err := hw.copyRange(hf, t0, t1)
|
||
if err != nil {
|
||
log.Printf("wshub/history: capture %s: %v", key, err)
|
||
continue
|
||
}
|
||
if cf != nil {
|
||
made[key] = cf
|
||
}
|
||
}
|
||
|
||
hw.capMu.Lock()
|
||
old := hw.captures
|
||
hw.captures = made
|
||
hw.capMu.Unlock()
|
||
for _, cf := range old {
|
||
_ = cf.f.Close()
|
||
}
|
||
log.Printf("wshub/history: captured [%.3f, %.3f] for %d/%d signal(s) in %s",
|
||
t0, t1, len(made), len(files), time.Since(started).Round(time.Millisecond))
|
||
}
|
||
|
||
// histCopyChunk is how many pairs one copyRange pass moves. The archive's lock
|
||
// is dropped between passes so a megasample stream keeps being written while a
|
||
// capture of it is copied out.
|
||
const histCopyChunk = 65536
|
||
|
||
// copyRange writes the archived samples in [t0, t1] to the signal's capture
|
||
// file, returning it ready to read. A capture file is laid out as a full,
|
||
// non-wrapping histFile (capacity == count, head == 0), so readRange serves it
|
||
// with no special case. It returns nil when the archive holds nothing in range.
|
||
func (hw *historyWriter) copyRange(hf *histFile, t0, t1 float64) (*histFile, error) {
|
||
path := filepath.Join(hw.cfg.Directory, sanitizeHistName(hf.sourceID),
|
||
sanitizeHistName(hf.signal)+".cap")
|
||
f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o644)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// The copy inherits the archive's resolution: it is the same samples.
|
||
out := &histFile{f: f, sourceID: hf.sourceID, signal: hf.signal,
|
||
bucket: hf.bucket, reduction: hf.reduction}
|
||
// after is the exclusive lower bound of the next pass: resuming by time
|
||
// rather than by index keeps the copy correct even if the archive wrapped
|
||
// between passes.
|
||
after := math.Inf(-1)
|
||
first := true
|
||
for {
|
||
buf, lastT, err := hf.readAfter(after, t0, t1, histCopyChunk)
|
||
if err != nil {
|
||
f.Close()
|
||
return nil, err
|
||
}
|
||
if len(buf) == 0 {
|
||
break
|
||
}
|
||
if _, err := f.WriteAt(buf, int64(histHeaderSize)+int64(out.count)*histPairSize); err != nil {
|
||
f.Close()
|
||
return nil, err
|
||
}
|
||
if first {
|
||
out.tOldest = math.Float64frombits(binary.LittleEndian.Uint64(buf))
|
||
first = false
|
||
}
|
||
out.count += uint32(len(buf) / histPairSize)
|
||
out.tNewest, after = lastT, lastT
|
||
}
|
||
if out.count == 0 {
|
||
f.Close()
|
||
os.Remove(path)
|
||
return nil, nil
|
||
}
|
||
out.capacity = out.count
|
||
if err := out.flushHeaderLocked(); err != nil {
|
||
f.Close()
|
||
return nil, err
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// readAfter returns up to max encoded pairs whose timestamp is in (after, t1]
|
||
// and at or after t0, plus the timestamp of the last one.
|
||
func (hf *histFile) readAfter(after, t0, t1 float64, max int) ([]byte, float64, error) {
|
||
hf.mu.RLock()
|
||
defer hf.mu.RUnlock()
|
||
if hf.count == 0 {
|
||
return nil, 0, nil
|
||
}
|
||
capacity, count := hf.capacity, hf.count
|
||
oldest := (hf.head + capacity - count) % capacity
|
||
timeAt := func(i uint32) float64 {
|
||
var b [8]byte
|
||
if _, err := hf.f.ReadAt(b[:], int64(histHeaderSize)+int64((oldest+i)%capacity)*histPairSize); err != nil {
|
||
return math.NaN()
|
||
}
|
||
return math.Float64frombits(binary.LittleEndian.Uint64(b[:]))
|
||
}
|
||
|
||
lo := histSearch(0, count, func(i uint32) bool {
|
||
t := timeAt(i)
|
||
return t < t0 || t <= after
|
||
})
|
||
hi := histSearch(lo, count, func(i uint32) bool { return timeAt(i) <= t1 })
|
||
n := int(hi - lo)
|
||
if n <= 0 {
|
||
return nil, 0, nil
|
||
}
|
||
if n > max {
|
||
n = max
|
||
}
|
||
|
||
// The run wraps at most once, so it costs at most two reads.
|
||
buf := make([]byte, n*histPairSize)
|
||
start := (oldest + lo) % capacity
|
||
head := min(int(capacity-start)*histPairSize, len(buf))
|
||
if _, err := hf.f.ReadAt(buf[:head], int64(histHeaderSize)+int64(start)*histPairSize); err != nil {
|
||
return nil, 0, err
|
||
}
|
||
if head < len(buf) {
|
||
if _, err := hf.f.ReadAt(buf[head:], histHeaderSize); err != nil {
|
||
return nil, 0, err
|
||
}
|
||
}
|
||
lastT := math.Float64frombits(binary.LittleEndian.Uint64(buf[(n-1)*histPairSize:]))
|
||
return buf, lastT, nil
|
||
}
|
||
|
||
// captureFor returns the capture file to answer a [t0, t1] query, or nil when
|
||
// the archive should answer it. The capture is only preferred when it holds the
|
||
// whole range: a query wider than one trigger window belongs to the archive.
|
||
func (hw *historyWriter) captureFor(key string, t0, t1 float64) *histFile {
|
||
hw.capMu.RLock()
|
||
cf := hw.captures[key]
|
||
hw.capMu.RUnlock()
|
||
if cf == nil || cf.count == 0 {
|
||
return nil
|
||
}
|
||
// The client asks for the window it was given, whose edges are the first and
|
||
// last samples in the capture; float arithmetic on the way there can put the
|
||
// request a sample or two outside it.
|
||
tol := 0.01 * (t1 - t0)
|
||
if cf.tOldest <= t0+tol && cf.tNewest >= t1-tol {
|
||
return cf
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// writePairs appends samples at the head, wrapping. A batch spans at most two
|
||
// contiguous runs, so it costs at most two writes however large it is —
|
||
// per-sample writes would not keep up with a megasample-per-second signal.
|
||
func (hf *histFile) writePairs(t, v []float64) error {
|
||
hf.mu.Lock()
|
||
defer hf.mu.Unlock()
|
||
|
||
n := len(t)
|
||
// Only the last `capacity` samples can survive; writing the rest would
|
||
// just overwrite them within this same call.
|
||
if n > int(hf.capacity) {
|
||
t, v = t[n-int(hf.capacity):], v[n-int(hf.capacity):]
|
||
n = len(t)
|
||
}
|
||
buf := make([]byte, n*histPairSize)
|
||
for i := range t {
|
||
binary.LittleEndian.PutUint64(buf[i*histPairSize:], math.Float64bits(t[i]))
|
||
binary.LittleEndian.PutUint64(buf[i*histPairSize+8:], math.Float64bits(v[i]))
|
||
}
|
||
|
||
first := min(int(hf.capacity-hf.head), n)
|
||
|
||
off := int64(histHeaderSize) + int64(hf.head)*histPairSize
|
||
if _, err := hf.f.WriteAt(buf[:first*histPairSize], off); err != nil {
|
||
return err
|
||
}
|
||
if first < n {
|
||
if _, err := hf.f.WriteAt(buf[first*histPairSize:], histHeaderSize); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
|
||
hf.head = (hf.head + uint32(n)) % hf.capacity
|
||
if hf.count+uint32(n) < hf.capacity {
|
||
hf.count += uint32(n)
|
||
} else {
|
||
hf.count = hf.capacity
|
||
}
|
||
|
||
hf.tNewest = t[n-1]
|
||
if hf.count <= uint32(n) {
|
||
hf.tOldest = t[0]
|
||
} else {
|
||
oldest := (hf.head + hf.capacity - hf.count) % hf.capacity
|
||
var b [8]byte
|
||
if _, err := hf.f.ReadAt(b[:], int64(histHeaderSize)+int64(oldest)*histPairSize); err == nil {
|
||
hf.tOldest = math.Float64frombits(binary.LittleEndian.Uint64(b[:]))
|
||
}
|
||
}
|
||
hf.dirty = true
|
||
return nil
|
||
}
|
||
|
||
// flushHeaders persists the in-memory header of every dirty file and refreshes
|
||
// the disk-space guard. Until this runs, a crash loses the samples written
|
||
// since the last flush — the data region is on disk but the header does not
|
||
// account for it yet.
|
||
func (hw *historyWriter) flushHeaders() {
|
||
if !hw.enabled() {
|
||
return
|
||
}
|
||
low := !hw.hasSufficientDisk()
|
||
hw.diskMu.Lock()
|
||
wasLow := hw.diskLow
|
||
hw.diskLow = low
|
||
hw.diskMu.Unlock()
|
||
if low && !wasLow {
|
||
log.Printf("wshub/history: free space below %d MB, writing paused", hw.cfg.MinDiskFreeMB)
|
||
} else if !low && wasLow {
|
||
log.Printf("wshub/history: free space recovered, writing resumed")
|
||
}
|
||
|
||
hw.mu.RLock()
|
||
files := make([]*histFile, 0, len(hw.files))
|
||
for _, hf := range hw.files {
|
||
files = append(files, hf)
|
||
}
|
||
hw.mu.RUnlock()
|
||
|
||
for _, hf := range files {
|
||
hf.mu.Lock()
|
||
if hf.dirty {
|
||
if err := hf.flushHeaderLocked(); err != nil {
|
||
log.Printf("wshub/history: flush %s:%s: %v", hf.sourceID, hf.signal, err)
|
||
}
|
||
}
|
||
hf.mu.Unlock()
|
||
}
|
||
}
|
||
|
||
func (hf *histFile) flushHeaderLocked() error {
|
||
hdr := make([]byte, histHeaderSize)
|
||
copy(hdr[0:4], histMagic[:])
|
||
binary.LittleEndian.PutUint32(hdr[4:], histVersion)
|
||
binary.LittleEndian.PutUint32(hdr[8:], hf.capacity)
|
||
binary.LittleEndian.PutUint32(hdr[12:], hf.head)
|
||
binary.LittleEndian.PutUint32(hdr[16:], hf.count)
|
||
binary.LittleEndian.PutUint32(hdr[20:], hf.reduction)
|
||
binary.LittleEndian.PutUint64(hdr[24:], math.Float64bits(hf.tOldest))
|
||
binary.LittleEndian.PutUint64(hdr[32:], math.Float64bits(hf.tNewest))
|
||
if _, err := hf.f.WriteAt(hdr, 0); err != nil {
|
||
return err
|
||
}
|
||
hf.dirty = false
|
||
return hf.f.Sync()
|
||
}
|
||
|
||
// close flushes and closes every file.
|
||
func (hw *historyWriter) close() {
|
||
if !hw.enabled() {
|
||
return
|
||
}
|
||
hw.mu.Lock()
|
||
defer hw.mu.Unlock()
|
||
for _, hf := range hw.files {
|
||
hf.mu.Lock()
|
||
if hf.dirty {
|
||
_ = hf.flushHeaderLocked()
|
||
}
|
||
_ = hf.f.Close()
|
||
hf.mu.Unlock()
|
||
}
|
||
hw.files = make(map[string]*histFile)
|
||
|
||
hw.capMu.Lock()
|
||
for _, cf := range hw.captures {
|
||
_ = cf.f.Close()
|
||
}
|
||
hw.captures = nil
|
||
hw.capMu.Unlock()
|
||
}
|
||
|
||
// readRange returns every archived pair in [t0, t1] for one signal, capped at
|
||
// maxOut samples.
|
||
func (hw *historyWriter) readRange(key string, t0, t1 float64, maxOut int) ([]float64, []float64) {
|
||
if !hw.enabled() || t1 < t0 || maxOut <= 0 {
|
||
return nil, nil
|
||
}
|
||
hw.mu.RLock()
|
||
hf := hw.files[key]
|
||
hw.mu.RUnlock()
|
||
// A zoom into a trigger window is served from the capture copy: the archive
|
||
// is circular and has usually rolled past that window by the time anyone
|
||
// zooms into it a second time.
|
||
if cf := hw.captureFor(key, t0, t1); cf != nil {
|
||
hf = cf
|
||
}
|
||
if hf == nil {
|
||
return nil, nil
|
||
}
|
||
|
||
hf.mu.RLock()
|
||
defer hf.mu.RUnlock()
|
||
if hf.count == 0 {
|
||
return nil, nil
|
||
}
|
||
|
||
capacity, count := hf.capacity, hf.count
|
||
oldest := (hf.head + capacity - count) % capacity
|
||
// timeAt reads one timestamp by logical index; the binary searches touch
|
||
// only log2(count) of them, which is what makes a query over a multi-
|
||
// gigabyte file cheap.
|
||
timeAt := func(i uint32) float64 {
|
||
var b [8]byte
|
||
phys := (oldest + i) % capacity
|
||
if _, err := hf.f.ReadAt(b[:], int64(histHeaderSize)+int64(phys)*histPairSize); err != nil {
|
||
return math.NaN()
|
||
}
|
||
return math.Float64frombits(binary.LittleEndian.Uint64(b[:]))
|
||
}
|
||
|
||
lo := histSearch(0, count, func(i uint32) bool { return timeAt(i) < t0 })
|
||
hi := histSearch(lo, count, func(i uint32) bool { return timeAt(i) <= t1 })
|
||
span := int(hi - lo)
|
||
if span <= 0 {
|
||
return nil, nil
|
||
}
|
||
|
||
// A range wider than the cap is thinned out across its whole width. Taking
|
||
// the first maxOut samples instead would answer a 10 s query with its first
|
||
// 2 ms, which reads as an empty plot to a client and sends it back to its
|
||
// own coarse copy of the data.
|
||
stride := 1
|
||
if span > maxOut {
|
||
stride = (span + maxOut - 1) / maxOut
|
||
}
|
||
n := (span + stride - 1) / stride
|
||
|
||
outT := make([]float64, n)
|
||
outV := make([]float64, n)
|
||
if stride > 1 {
|
||
// Reading the whole range to throw most of it away costs stride times
|
||
// the I/O, and at megasample rates that is hundreds of megabytes per
|
||
// zoom, so the wanted samples are read one by one instead.
|
||
var b [histPairSize]byte
|
||
for i := 0; i < n; i++ {
|
||
phys := (oldest + lo + uint32(i*stride)) % capacity
|
||
if _, err := hf.f.ReadAt(b[:], int64(histHeaderSize)+int64(phys)*histPairSize); err != nil {
|
||
return nil, nil
|
||
}
|
||
outT[i] = math.Float64frombits(binary.LittleEndian.Uint64(b[0:]))
|
||
outV[i] = math.Float64frombits(binary.LittleEndian.Uint64(b[8:]))
|
||
}
|
||
return outT, outV
|
||
}
|
||
|
||
// Read in contiguous runs: the range wraps at most once.
|
||
buf := make([]byte, n*histPairSize)
|
||
start := (oldest + lo) % capacity
|
||
first := min(int(capacity-start), n)
|
||
if _, err := hf.f.ReadAt(buf[:first*histPairSize],
|
||
int64(histHeaderSize)+int64(start)*histPairSize); err != nil {
|
||
return nil, nil
|
||
}
|
||
if first < n {
|
||
if _, err := hf.f.ReadAt(buf[first*histPairSize:], histHeaderSize); err != nil {
|
||
return nil, nil
|
||
}
|
||
}
|
||
for i := 0; i < n; i++ {
|
||
outT[i] = math.Float64frombits(binary.LittleEndian.Uint64(buf[i*histPairSize:]))
|
||
outV[i] = math.Float64frombits(binary.LittleEndian.Uint64(buf[i*histPairSize+8:]))
|
||
}
|
||
return outT, outV
|
||
}
|
||
|
||
// histSearch returns the first index in [a, b) for which pred is false; pred
|
||
// must be monotonically decreasing over the range.
|
||
func histSearch(a, b uint32, pred func(uint32) bool) uint32 {
|
||
for a < b {
|
||
mid := a + (b-a)/2
|
||
if pred(mid) {
|
||
a = mid + 1
|
||
} else {
|
||
b = mid
|
||
}
|
||
}
|
||
return a
|
||
}
|
||
|
||
// info snapshots the per-signal metadata for a historyInfo event.
|
||
func (hw *historyWriter) info() map[string]HistorySignalInfo {
|
||
out := make(map[string]HistorySignalInfo)
|
||
if !hw.enabled() {
|
||
return out
|
||
}
|
||
hw.mu.RLock()
|
||
files := make(map[string]*histFile, len(hw.files))
|
||
for k, hf := range hw.files {
|
||
files[k] = hf
|
||
}
|
||
hw.mu.RUnlock()
|
||
for k, hf := range files {
|
||
hf.mu.RLock()
|
||
// Reported even at count==0: the file exists but has not received data
|
||
// yet, and clients need the entry to enable their history UI.
|
||
out[k] = HistorySignalInfo{
|
||
T0: hf.tOldest, T1: hf.tNewest,
|
||
Count: hf.count, Capacity: hf.capacity, Bucket: hf.bucket,
|
||
}
|
||
hf.mu.RUnlock()
|
||
}
|
||
return out
|
||
}
|
||
|
||
/* ─── Hub integration ─────────────────────────────────────────────────────── */
|
||
|
||
// histOpenIntervalSec throttles the deferred-open sweep: the rate can only be
|
||
// measured once data flows, so the sweep repeats until every signal is sized.
|
||
const histOpenIntervalSec = 1.0
|
||
|
||
// histRateMinSpan is the shortest stretch of ring data a rate is measured over.
|
||
// Shorter than this and a burst of packets reads as a far higher rate than the
|
||
// stream really has, which would size the file many times too large.
|
||
const histRateMinSpan = 0.5
|
||
|
||
// openPendingHistoryFiles sizes the history files of signals whose producer
|
||
// declared no sampling rate, using the rate measured on the live stream. The
|
||
// alternative — a fixed guess — is three orders of magnitude out for a 1 MSps
|
||
// stream, leaving a file sized for the window holding a fraction of a second.
|
||
func (h *Hub) openPendingHistoryFiles(nowSec float64) {
|
||
if !h.hist.enabled() || nowSec < h.histOpenAt {
|
||
return
|
||
}
|
||
h.histOpenAt = nowSec + histOpenIntervalSec
|
||
|
||
opened := false
|
||
for _, key := range h.hist.pendingKeys() {
|
||
rb := h.getRing(key)
|
||
if rb == nil {
|
||
continue
|
||
}
|
||
// The rate of the samples arriving, not of the points the ring keeps: a
|
||
// bucketed ring stores one pair per bucket, and sizing the file from that
|
||
// would leave it covering a fraction of the window at the source rate it
|
||
// is actually written at.
|
||
rate := rb.sourceRate()
|
||
if rate <= 0 {
|
||
continue
|
||
}
|
||
// Enough elapsed time to trust the measurement: a burst of packets over a
|
||
// few milliseconds reads as a far higher rate than the stream has.
|
||
if count, span := rb.stats(); count < 2 || span < histRateMinSpan {
|
||
continue
|
||
}
|
||
if h.hist.openPending(key, rate) {
|
||
opened = true
|
||
}
|
||
}
|
||
if opened {
|
||
h.broadcast(h.buildHistoryInfoMsg())
|
||
}
|
||
}
|
||
|
||
// buildHistoryInfoMsg encodes the "historyInfo" event. Clients use it to decide
|
||
// whether a requested window can be served from disk at all.
|
||
func (h *Hub) buildHistoryInfoMsg() []byte {
|
||
m := map[string]any{
|
||
"type": "historyInfo",
|
||
"enabled": h.hist.enabled(),
|
||
"signals": map[string]HistorySignalInfo{},
|
||
}
|
||
if h.hist.enabled() {
|
||
// The span the files are sized for, which is what the clients are
|
||
// displaying — not a retention period.
|
||
m["windowSec"] = h.hist.window()
|
||
m["decimation"] = h.hist.cfg.Decimation
|
||
// In MPts, matching the unit of the UI control and of -history-max-mpts.
|
||
m["maxMPtsPerSignal"] = float64(h.hist.budget()) / 1e6
|
||
m["signals"] = h.hist.info()
|
||
} else {
|
||
m["windowSec"] = 0.0
|
||
m["decimation"] = 1
|
||
m["maxMPtsPerSignal"] = 0.0
|
||
}
|
||
msg, err := json.Marshal(m)
|
||
if err != nil {
|
||
log.Printf("wshub/history: marshal historyInfo: %v", err)
|
||
return nil
|
||
}
|
||
return msg
|
||
}
|
||
|
||
// handleHistoryCommand processes the history WS commands, returning false when
|
||
// the message type is not one of them.
|
||
func (h *Hub) handleHistoryCommand(c *wsClient, t string, env map[string]interface{}) bool {
|
||
switch t {
|
||
case "historyInfo":
|
||
if msg := h.buildHistoryInfoMsg(); msg != nil {
|
||
c.sendText(msg)
|
||
}
|
||
case "historyZoom":
|
||
h.handleHistoryZoom(c, env)
|
||
case "setHistoryBudget":
|
||
h.handleSetHistoryBudget(env)
|
||
default:
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
// handleSetHistoryBudget applies a new per-signal archive budget, in MPts, and
|
||
// tells every client the resulting geometry. The change is broadcast rather than
|
||
// answered to the requester alone because it re-creates the files: every client's
|
||
// view of what history exists has just been invalidated.
|
||
func (h *Hub) handleSetHistoryBudget(env map[string]interface{}) {
|
||
if !h.hist.enabled() {
|
||
return
|
||
}
|
||
mpts, ok := env["maxMPtsPerSignal"].(float64)
|
||
if !ok || math.IsNaN(mpts) || mpts < 0 {
|
||
return
|
||
}
|
||
h.hist.setBudget(int(mpts * 1e6))
|
||
if msg := h.buildHistoryInfoMsg(); msg != nil {
|
||
h.broadcast(msg)
|
||
}
|
||
}
|
||
|
||
// handleHistoryZoom answers a historyZoom request from disk. Same request and
|
||
// reply shape as "zoom", so clients can fall back to it transparently when a
|
||
// window reaches further back than the in-memory rings hold.
|
||
func (h *Hub) handleHistoryZoom(c *wsClient, env map[string]any) {
|
||
if !h.hist.enabled() {
|
||
msg, _ := json.Marshal(map[string]any{
|
||
"type": "historyZoom", "reqId": env["reqId"],
|
||
"error": "history not enabled",
|
||
})
|
||
c.sendText(msg)
|
||
return
|
||
}
|
||
t0, ok0 := env["t0"].(float64)
|
||
t1, ok1 := env["t1"].(float64)
|
||
if !ok0 || !ok1 || t1 <= t0 {
|
||
return
|
||
}
|
||
nF, nOK := env["n"].(float64)
|
||
n := zoomPoints(int(nF), nOK)
|
||
sigCSV, _ := env["signals"].(string)
|
||
|
||
// The envelope can only keep a peak it was shown, so the disk read is
|
||
// oversampled relative to the plot's point budget and thinned afterwards.
|
||
// The cap keeps a request for "no decimation" over a multi-hour window from
|
||
// pulling the whole file into memory.
|
||
readCap := min(n*histReadOversample, histDefaultMaxPoints)
|
||
|
||
signals := make(map[string]sigData)
|
||
for _, k := range strings.Split(sigCSV, ",") {
|
||
k = strings.TrimSpace(k)
|
||
if k == "" {
|
||
continue
|
||
}
|
||
rt, rv := h.hist.readRange(k, t0, t1, readCap)
|
||
if len(rt) == 0 {
|
||
continue
|
||
}
|
||
dt, dv := minMaxDecimate(rt, rv, n)
|
||
signals[k] = sigData{T: dt, V: dv}
|
||
}
|
||
|
||
msg, err := json.Marshal(map[string]any{
|
||
"type": "historyZoom", "reqId": env["reqId"], "signals": signals,
|
||
})
|
||
if err != nil {
|
||
log.Printf("wshub/history: marshal historyZoom: %v", err)
|
||
return
|
||
}
|
||
c.sendText(msg)
|
||
}
|
||
|
||
// histMaxReadPoints bounds one historyZoom read per signal.
|
||
const histMaxReadPoints = 2_000_000
|
||
|
||
// histReadOversample is how many disk samples are read per plotted point. The
|
||
// reads are ascending, so the kernel's readahead still covers them at moderate
|
||
// strides; going much higher turns a zoom into a full scan of the range.
|
||
const histReadOversample = 8
|
||
|
||
// hasSufficientDisk reports whether the history filesystem still has the
|
||
// configured free-space margin.
|
||
func (hw *historyWriter) hasSufficientDisk() bool {
|
||
if hw.cfg.MinDiskFreeMB < 0 {
|
||
return true
|
||
}
|
||
var st syscall.Statfs_t
|
||
if err := syscall.Statfs(hw.cfg.Directory, &st); err != nil {
|
||
return true // cannot tell — do not stop archiving on a guess
|
||
}
|
||
freeMB := int64(st.Bavail) * st.Bsize / (1024 * 1024)
|
||
return freeMB >= int64(hw.cfg.MinDiskFreeMB)
|
||
}
|