sweep capture and mavpoll for duplication and magic values (V-581)

capture: one Session.discard for the reaper and Abort, which both closed
the spool and deleted it by hand. One abandon closure for the two failed
Start paths. bytesPerSample and bytesPerSecond replace three copies of
the byte-rate arithmetic.

mavpoll: unchanged and writeFact hold the read-compare and the row shape,
so the three fact writers keep only what differs between them, their log
line. The wg key and source are named constants. Named coreDialWait,
zenTimeoutFactor, maxBodyBytes and factConfidence. run lost the zenmoney
setup to newZenClient and the ticker to loop.

Comments only where they were stale: the package doc counted two sources
and there are four.
This commit is contained in:
2026-08-06 01:54:38 +04:00
parent 59b98d5c4d
commit db8cbdc20a
2 changed files with 144 additions and 84 deletions
+106 -58
View File
@@ -6,10 +6,11 @@
// restart-free, fail-independent — a crashing poller can't touch the store key // restart-free, fail-independent — a crashing poller can't touch the store key
// (it never had it), worst case a stale env fact until the next tick. // (it never had it), worst case a stale env fact until the next tick.
// //
// Two sources, each its own provenance (the loop's rules trust source): // Four sources, each its own provenance (the loop's rules trust source):
// - netdata → poll:netdata resource alarms (disk/mem/cert/temp) // - netdata → poll:netdata resource alarms (disk/mem/cert/temp)
// - kuma → poll:uptimekuma service up/down (the source of truth for it) // - kuma → poll:uptimekuma service up/down (the source of truth for it)
// - zenmoney → poll:zenmoney spending/income totals (Vikunja #125) // - zenmoney → poll:zenmoney spending/income totals (Vikunja #125)
// - wireguard → infer:wg latest handshake, the presence signal
// //
// The zenmoney source is why the token lives HERE and not in core: the poller // The zenmoney source is why the token lives HERE and not in core: the poller
// already owns every other third-party credential, it holds no store key, and // already owns every other third-party credential, it holds no store key, and
@@ -77,26 +78,15 @@ func run(args []string) error {
return fmt.Errorf("nothing to poll: set -netdata, -kuma, -wg and/or -zenmoney-token-file") return fmt.Errorf("nothing to poll: set -netdata, -kuma, -wg and/or -zenmoney-token-file")
} }
// The token is read from a file, never taken as a flag value: an argv token zen, err := newZenClient(*zenTokenFile, *zenURL, *timeout)
// is visible in `ps` to every user on the box and lands in the compose file if err != nil {
// and the shell history. Read once at start — a rotated token means a return err
// restart, which is cheaper than re-reading his credential every hour.
var zen *zenmoney.Client
if *zenTokenFile != "" {
raw, err := os.ReadFile(*zenTokenFile)
if err != nil {
return fmt.Errorf("read zenmoney token: %w", err)
}
zen, err = zenmoney.New(strings.TrimSpace(string(raw)), *zenURL, *timeout*3)
if err != nil {
return err
}
} }
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop() defer stop()
core, err := ipc.DialWait(*socket, 60*time.Second) core, err := ipc.DialWait(*socket, coreDialWait)
if err != nil { if err != nil {
return err return err
} }
@@ -117,14 +107,46 @@ func run(args []string) error {
// The token is never logged, not even its length. // The token is never logged, not even its length.
log.Printf("mavpoll: polling every %s (netdata=%q kuma=%q wg=%q zenmoney=%v every %s)", log.Printf("mavpoll: polling every %s (netdata=%q kuma=%q wg=%q zenmoney=%v every %s)",
*interval, *netdataURL, *kumaURL, *wgIface, zen != nil, *zenInterval) *interval, *netdataURL, *kumaURL, *wgIface, zen != nil, *zenInterval)
p.loop(ctx, *interval)
return nil
}
// coreDialWait — how long to wait for core's socket at start. The poller and
// core come up together under compose, so a cold start is a wait, not a failure.
const coreDialWait = 60 * time.Second
// zenTimeoutFactor — the zenmoney client gets a longer deadline than the other
// sources. A diff call walks his whole transaction history, where netdata and
// kuma answer from memory.
const zenTimeoutFactor = 3
// newZenClient builds the money client, or nil when no token file was given.
//
// The token is read from a file, never taken as a flag value: an argv token is
// visible in `ps` to every user on the box and lands in the compose file and
// the shell history. Read once at start — a rotated token means a restart,
// which is cheaper than re-reading his credential every hour.
func newZenClient(tokenFile, baseURL string, timeout time.Duration) (*zenmoney.Client, error) {
if tokenFile == "" {
return nil, nil
}
raw, err := os.ReadFile(tokenFile)
if err != nil {
return nil, fmt.Errorf("read zenmoney token: %w", err)
}
return zenmoney.New(strings.TrimSpace(string(raw)), baseURL, timeout*zenTimeoutFactor)
}
// loop polls until the context is cancelled.
func (p *poller) loop(ctx context.Context, interval time.Duration) {
p.pollOnce(ctx) // fire immediately; don't idle a full interval on start p.pollOnce(ctx) // fire immediately; don't idle a full interval on start
t := time.NewTicker(*interval) t := time.NewTicker(interval)
defer t.Stop() defer t.Stop()
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
log.Printf("mavpoll: bye") log.Printf("mavpoll: bye")
return nil return
case <-t.C: case <-t.C:
p.pollOnce(ctx) p.pollOnce(ctx)
} }
@@ -151,8 +173,8 @@ type poller struct {
zenLast time.Time zenLast time.Time
} }
// pollOnce — one sweep of both sources. A failure in one source logs and does // pollOnce — one sweep of every configured source. A failure in one logs and
// NOT abort the other: netdata being down shouldn't blind kuma and vice versa. // does NOT abort the rest: netdata being down shouldn't blind kuma.
func (p *poller) pollOnce(ctx context.Context) { func (p *poller) pollOnce(ctx context.Context) {
now := time.Now() now := time.Now()
if p.netdataURL != "" { if p.netdataURL != "" {
@@ -244,6 +266,14 @@ func (p *poller) pollZenmoney(ctx context.Context, now time.Time) error {
// ---- wireguard: latest handshake → presence signal ------------------------- // ---- wireguard: latest handshake → presence signal -------------------------
const (
// wgFactKey / wgSource — the presence signal, read by the decay in core.
// The source says infer because a handshake is evidence he is home, not a
// reading of where he is.
wgFactKey = "wg_handshake"
wgSource = "infer:wg"
)
// pollWg reads `wg show <iface> latest-handshakes` and writes a wg_handshake // pollWg reads `wg show <iface> latest-handshakes` and writes a wg_handshake
// fact (source=infer:wg) stamped with the MOST RECENT peer handshake time — not // fact (source=infer:wg) stamped with the MOST RECENT peer handshake time — not
// now(). Presence decays from the real handshake instant, so the fact's ts must // now(). Presence decays from the real handshake instant, so the fact's ts must
@@ -263,20 +293,19 @@ func (p *poller) pollWg(ctx context.Context) error {
return nil // no peer has ever handshaked → drop out of presence return nil // no peer has ever handshaked → drop out of presence
} }
hs := time.Unix(maxTs, 0) hs := time.Unix(maxTs, 0)
prev, err := p.core.LatestFactBySource(ctx, "wg_handshake", "infer:wg") prev, err := p.core.LatestFactBySource(ctx, wgFactKey, wgSource)
if err == nil && !hs.After(prev.Ts) { if err == nil && !hs.After(prev.Ts) {
return nil // not newer → no churn return nil // not newer → no churn
} }
if err != nil && err != ipc.ErrNoFact && !isNoFact(err) { if err != nil && !isNoFact(err) {
return fmt.Errorf("read wg_handshake: %w", err) return fmt.Errorf("read %s: %w", wgFactKey, err)
} }
if _, err := p.core.WriteFact(ctx, ipc.WriteFactReq{ // The ts is the handshake instant, not now(): presence decays from when he
Ts: hs, Kind: "env", Key: "wg_handshake", Value: `"up"`, // was last seen.
Source: "infer:wg", Confidence: 1.0, if err := p.writeFact(ctx, wgFactKey, wgSource, `"up"`, hs); err != nil {
}); err != nil { return err
return fmt.Errorf("write wg_handshake: %w", err)
} }
log.Printf("mavpoll: wg_handshake @ %s (infer:wg)", hs.Format(time.RFC3339)) log.Printf("mavpoll: %s @ %s (%s)", wgFactKey, hs.Format(time.RFC3339), wgSource)
return nil return nil
} }
@@ -431,29 +460,53 @@ func kumaState(v float64) string {
// ---- helpers --------------------------------------------------------------- // ---- helpers ---------------------------------------------------------------
// writeIfChanged writes a `facts(kind=env)` row only when val differs from the // factConfidence — every poll is a direct reading of another service, never an
// latest fact for (key, source). Values are stored JSON-encoded (the store's // inference, so the fact goes in at full confidence.
// convention: `"down"`, `"critical"`), matching how rules compare f.Value. const factConfidence = 1.0
func (p *poller) writeIfChanged(ctx context.Context, key, source, val string, now time.Time) error {
jv, _ := json.Marshal(val) // string never fails to marshal // unchanged reports whether the latest fact for (key, source) already holds
// jsonVal. A missing fact is not an error here, it is the first write.
func (p *poller) unchanged(ctx context.Context, key, source, jsonVal string) (bool, error) {
prev, err := p.core.LatestFactBySource(ctx, key, source) prev, err := p.core.LatestFactBySource(ctx, key, source)
switch { switch {
case err == nil && prev.Value == string(jv): case err == nil:
return nil // unchanged → no churn return prev.Value == jsonVal, nil
case err != nil && err != ipc.ErrNoFact && !isNoFact(err): case isNoFact(err):
return fmt.Errorf("read %s: %w", key, err) return false, nil
default:
return false, fmt.Errorf("read %s: %w", key, err)
} }
_, err = p.core.WriteFact(ctx, ipc.WriteFactReq{ }
// writeFact writes one `facts(kind=env)` row. Every poll in this file lands
// here, so the row shape is written once.
func (p *poller) writeFact(ctx context.Context, key, source, jsonVal string, now time.Time) error {
_, err := p.core.WriteFact(ctx, ipc.WriteFactReq{
Ts: now, Ts: now,
Kind: "env", Kind: "env",
Key: key, Key: key,
Value: string(jv), Value: jsonVal,
Source: source, Source: source,
Confidence: 1.0, // a direct reading, not an inference Confidence: factConfidence,
}) })
if err != nil { if err != nil {
return fmt.Errorf("write %s: %w", key, err) return fmt.Errorf("write %s: %w", key, err)
} }
return nil
}
// writeIfChanged writes only when val differs from the latest fact for
// (key, source). Values are stored JSON-encoded (the store's convention:
// `"down"`, `"critical"`), matching how rules compare f.Value.
func (p *poller) writeIfChanged(ctx context.Context, key, source, val string, now time.Time) error {
jv, _ := json.Marshal(val) // a string never fails to marshal
same, err := p.unchanged(ctx, key, source, string(jv))
if err != nil || same {
return err // unchanged → no churn
}
if err := p.writeFact(ctx, key, source, string(jv), now); err != nil {
return err
}
log.Printf("mavpoll: %s=%s (%s)", key, val, source) log.Printf("mavpoll: %s=%s (%s)", key, val, source)
return nil return nil
} }
@@ -466,18 +519,12 @@ func (p *poller) writeIfChanged(ctx context.Context, key, source, val string, no
// The log line names the key and the source, never the figures: mavpoll's log // The log line names the key and the source, never the figures: mavpoll's log
// is not the place his spending ends up. // is not the place his spending ends up.
func (p *poller) writeIfChangedRaw(ctx context.Context, key, source, jsonVal string, now time.Time) error { func (p *poller) writeIfChangedRaw(ctx context.Context, key, source, jsonVal string, now time.Time) error {
prev, err := p.core.LatestFactBySource(ctx, key, source) same, err := p.unchanged(ctx, key, source, jsonVal)
switch { if err != nil || same {
case err == nil && prev.Value == jsonVal: return err
return nil
case err != nil && err != ipc.ErrNoFact && !isNoFact(err):
return fmt.Errorf("read %s: %w", key, err)
} }
if _, err := p.core.WriteFact(ctx, ipc.WriteFactReq{ if err := p.writeFact(ctx, key, source, jsonVal, now); err != nil {
Ts: now, Kind: "env", Key: key, Value: jsonVal, return err
Source: source, Confidence: 1.0,
}); err != nil {
return fmt.Errorf("write %s: %w", key, err)
} }
log.Printf("mavpoll: %s updated (%s)", key, source) log.Printf("mavpoll: %s updated (%s)", key, source)
return nil return nil
@@ -490,11 +537,8 @@ func (p *poller) writeIfChangedRaw(ctx context.Context, key, source, jsonVal str
// The log line names the key only, never the figures: mavpoll's log is not the // The log line names the key only, never the figures: mavpoll's log is not the
// place his spending ends up. // place his spending ends up.
func (p *poller) writeMoneyFact(ctx context.Context, key, jsonVal string, now time.Time) error { func (p *poller) writeMoneyFact(ctx context.Context, key, jsonVal string, now time.Time) error {
if _, err := p.core.WriteFact(ctx, ipc.WriteFactReq{ if err := p.writeFact(ctx, key, zenmoney.Source, jsonVal, now); err != nil {
Ts: now, Kind: "env", Key: key, Value: jsonVal, return err
Source: zenmoney.Source, Confidence: 1.0,
}); err != nil {
return fmt.Errorf("write %s: %w", key, err)
} }
log.Printf("mavpoll: %s read (%s)", key, zenmoney.Source) log.Printf("mavpoll: %s read (%s)", key, zenmoney.Source)
return nil return nil
@@ -506,6 +550,10 @@ func isNoFact(err error) bool {
return errors.Is(err, ipc.ErrNoFact) return errors.Is(err, ipc.ErrNoFact)
} }
// maxBodyBytes caps what a source can make the poller hold. Kuma's whole
// metrics page is a few hundred kilobytes, so 4 MiB is slack, not a budget.
const maxBodyBytes = 4 << 20
func (p *poller) get(ctx context.Context, url, basicUser string) ([]byte, error) { func (p *poller) get(ctx context.Context, url, basicUser string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil { if err != nil {
@@ -519,7 +567,7 @@ func (p *poller) get(ctx context.Context, url, basicUser string) ([]byte, error)
return nil, err return nil, err
} }
defer resp.Body.Close() defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) body, err := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes))
if err != nil { if err != nil {
return nil, err return nil, err
} }
+38 -26
View File
@@ -161,6 +161,18 @@ func (s *Session) finish() error {
return s.spool.Sync() return s.spool.Sync()
} }
// discard closes the spool and deletes it, leaving nothing behind. Used by the
// reaper and by Abort, which throw a recording away rather than harvest it.
func (s *Session) discard() {
s.mu.Lock()
_ = s.finish()
path := s.path
s.mu.Unlock()
if path != "" {
_ = os.Remove(path)
}
}
// Duration is how much audio has been collected, from the bytes rather than the // Duration is how much audio has been collected, from the bytes rather than the
// wall clock: a stream that dropped frames should report the audio that exists, // wall clock: a stream that dropped frames should report the audio that exists,
// not the time that passed. // not the time that passed.
@@ -174,9 +186,16 @@ func (s *Session) duration() time.Duration {
return pcmDuration(s.format, s.n) return pcmDuration(s.format, s.n)
} }
// bytesPerSample is one sample across all channels. Cutting a buffer anywhere
// that is not a multiple of it shifts every following sample by a byte.
func bytesPerSample(f audio.Format) int64 { return int64(f.SampleBits / 8 * f.Channels) }
// bytesPerSecond is the format's byte rate, 32000 for the canonical 16 kHz mono.
func bytesPerSecond(f audio.Format) int64 { return int64(f.SampleRate) * bytesPerSample(f) }
// pcmDuration is how long n bytes of PCM lasts in the given format. // pcmDuration is how long n bytes of PCM lasts in the given format.
func pcmDuration(f audio.Format, n int64) time.Duration { func pcmDuration(f audio.Format, n int64) time.Duration {
per := int64(f.SampleRate) * int64(f.Channels) * int64(f.SampleBits) / 8 per := bytesPerSecond(f)
if per <= 0 { if per <= 0 {
return 0 return 0
} }
@@ -279,19 +298,21 @@ func (r *Recorder) Start(label string) (*Session, error) {
f.Close() f.Close()
return nil, err return nil, err
} }
// A session that never opened leaves no spool file behind.
abandon := func(err error) (*Session, error) {
f.Close()
_ = os.Remove(f.Name())
return nil, err
}
// The header is written first and rewritten at Stop with the real length, // The header is written first and rewritten at Stop with the real length,
// so the spool file is a playable WAV rather than headerless PCM that has // so the spool file is a playable WAV rather than headerless PCM that has
// to be copied to gain 44 bytes. // to be copied to gain 44 bytes.
if _, err := f.Write(hdr); err != nil { if _, err := f.Write(hdr); err != nil {
f.Close() return abandon(fmt.Errorf("capture: spool header: %w", err))
_ = os.Remove(f.Name())
return nil, fmt.Errorf("capture: spool header: %w", err)
} }
token, err := newToken() token, err := newToken()
if err != nil { if err != nil {
f.Close() return abandon(err)
_ = os.Remove(f.Name())
return nil, err
} }
s := &Session{ s := &Session{
Label: strings.TrimSpace(label), Label: strings.TrimSpace(label),
@@ -330,15 +351,11 @@ func (r *Recorder) reapLocked() {
} }
s.mu.Lock() s.mu.Lock()
s.expired = true s.expired = true
_ = s.finish()
path := s.path
s.mu.Unlock() s.mu.Unlock()
if path != "" { // The audio goes with it. A recording nobody stopped is one nobody is
// The audio goes with it. A recording nobody stopped is one nobody is // waiting for, and keeping it would mean storing a meeting on the strength
// waiting for, and keeping it would mean storing a meeting on the // of a dropped connection.
// strength of a dropped connection. s.discard()
_ = os.Remove(path)
}
r.current = nil r.current = nil
} }
@@ -536,13 +553,7 @@ func (r *Recorder) Abort(token string) bool {
return false return false
} }
r.current = nil r.current = nil
s.mu.Lock() s.discard()
_ = s.finish()
path := s.path
s.mu.Unlock()
if path != "" {
_ = os.Remove(path)
}
return true return true
} }
@@ -576,7 +587,7 @@ func (r *Recorder) transcribeFile(ctx context.Context, path string, format audio
} }
// Never cut mid-sample: a split inside an int16 shifts every following // Never cut mid-sample: a split inside an int16 shifts every following
// sample by a byte and turns the tail of the window into noise. // sample by a byte and turns the tail of the window into noise.
if bps := int64(format.SampleBits / 8 * format.Channels); bps > 0 { if bps := bytesPerSample(format); bps > 0 {
size -= size % bps size -= size % bps
} }
if size <= 0 { if size <= 0 {
@@ -608,12 +619,13 @@ func (r *Recorder) transcribeFile(ctx context.Context, path string, format audio
// is read by him in a note next to the words around it. // is read by him in a note next to the words around it.
const gapMarker = "[…не разобрала…]" const gapMarker = "[…не разобрала…]"
// windowBytes is how many PCM bytes one STT window holds. // windowBytes is how many PCM bytes one STT window holds, rounded down to a
// whole sample.
func windowBytes(f audio.Format, window time.Duration) int64 { func windowBytes(f audio.Format, window time.Duration) int64 {
bps := int64(f.SampleBits / 8 * f.Channels) bps := bytesPerSample(f)
if bps <= 0 || f.SampleRate <= 0 || window <= 0 { if bps <= 0 || f.SampleRate <= 0 || window <= 0 {
return 0 return 0
} }
per := int64(window.Seconds()) * int64(f.SampleRate) * bps per := int64(window.Seconds()) * bytesPerSecond(f)
return per - per%bps return per - per%bps
} }