mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-05-24 13:45:17 +00:00
f4cf2acbc0
Red commit: e964ec9c46 (CI run: pending —
workflow only triggers on PR open)
Partial fix for #1120 — finishes the four follow-up items left open
after PR #1123 (cancelled writes, ingestor I/O, threshold-flag tests,
docs).
## What's done
- **`cancelledWriteBytesPerSec`** — server `/proc/self/io` parser
handles `cancelled_write_bytes`; `/api/perf/io` exposes the per-second
rate; Perf page renders it next to Read/Write with ⚠️ when sustained >1
MB/s.
- **Ingestor `/proc/<pid>/io`** — `cmd/ingestor/stats_file.go` samples
its own `/proc/self/io` each tick and includes `procIO` in the snapshot.
The server's `/api/perf/io` reads it and surfaces `.ingestor`. Frontend
renders an `Ingestor process` Disk I/O block alongside the existing
`server process` block (issue mockup: "Both ingestor and server").
- **Threshold + anomaly tests** — `test-perf-disk-io-1120.js` now
asserts ⚠️ fires/suppresses on WAL>100MB, cache_hit<90%, and the
backfill-rate-vs-tx-rate guard with the `tx_inserted >= 100` baseline
floor. Drops the tautological `|| ... === false` short-circuits flagged
in MINOR m4.
- **Docs (m8)** — `config.example.json` adds `_comment_ingestorStats`
(env var, default path, shared-tmp security note);
`cmd/ingestor/README.md` adds `CORESCOPE_INGESTOR_STATS` to the env-var
table plus a `Stats file` section.
## What's NOT done (deferred)
m1 sync.Map → map+RWMutex, m2 perfIOMu rate caching, m3 negative
cacheSize translation, m5 deterministic-write test, m7 ctx-aware
shutdown — pure polish; will file a follow-up issue if the operator
wants them tracked.
## TDD
- Red: `e964ec9` — adds failing tests + stub field/handler shape
(cancelled missing from struct, ingestor stub returns nil, ingestor
procIO absent).
- Green: `1240703` — wires up the parser case, ingestor sampler,
frontend rendering, docs.
E2E assertion added: test-perf-disk-io-1120.js:108
---------
Co-authored-by: clawbot <clawbot@users.noreply.github.com>
Co-authored-by: Kpa-clawbot <bot@kpa-clawbot.local>
Co-authored-by: Kpa-clawbot <bot@kpa-clawbot>
142 lines
5.3 KiB
Go
142 lines
5.3 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// TestParseProcIO_EmptyDoesNotMarkOK — #1167 Carmack must-fix #6: the
|
|
// server-side parser was missing the parsedAny gate the ingestor's parser
|
|
// got in must-fix #3 of the original review. Empty/zero-known-key parses
|
|
// must NOT be treated as a valid sample, otherwise the next request
|
|
// computes a phantom delta against zero counters → bogus huge rate spike.
|
|
//
|
|
// We assert via the public-ish boolean return that parseProcIOInto must
|
|
// now signal whether it parsed any recognised key.
|
|
func TestParseProcIO_EmptyDoesNotMarkOK(t *testing.T) {
|
|
var s procIOSample
|
|
ok := parseProcIOInto(bufio.NewScanner(strings.NewReader("")), &s)
|
|
if ok {
|
|
t.Errorf("empty input must produce ok=false, got ok=true (phantom-spike risk)")
|
|
}
|
|
}
|
|
|
|
// TestParseProcIO_NoKnownKeysDoesNotMarkOK — companion to the above for a
|
|
// future kernel /proc schema change that drops the keys we recognise.
|
|
func TestParseProcIO_NoKnownKeysDoesNotMarkOK(t *testing.T) {
|
|
var s procIOSample
|
|
ok := parseProcIOInto(bufio.NewScanner(strings.NewReader("garbage_key: 42\nother: 99\n")), &s)
|
|
if ok {
|
|
t.Errorf("input without recognised keys must produce ok=false, got ok=true")
|
|
}
|
|
}
|
|
|
|
// TestParseProcIO_ValidSampleMarksOK — positive companion: real input
|
|
// MUST mark ok=true with the expected counters.
|
|
func TestParseProcIO_ValidSampleMarksOK(t *testing.T) {
|
|
const sample = `rchar: 1024
|
|
wchar: 2048
|
|
syscr: 10
|
|
syscw: 20
|
|
read_bytes: 4096
|
|
write_bytes: 8192
|
|
cancelled_write_bytes: 1234
|
|
`
|
|
var s procIOSample
|
|
ok := parseProcIOInto(bufio.NewScanner(strings.NewReader(sample)), &s)
|
|
if !ok {
|
|
t.Fatalf("valid sample must produce ok=true")
|
|
}
|
|
if s.readBytes != 4096 || s.writeBytes != 8192 || s.cancelledWrite != 1234 {
|
|
t.Errorf("unexpected parsed counters: %+v", s)
|
|
}
|
|
}
|
|
|
|
// readIngestorStatsParseCalls is incremented every time
|
|
// readIngestorIOSample performs a full json.Unmarshal of the stats file
|
|
// (i.e. cache miss). Used by the cache test below to assert that
|
|
// repeated calls within the same mtime+size window do NOT re-decode.
|
|
//
|
|
// The hook must be wired up in perf_io.go (Carmack must-fix #2).
|
|
//var readIngestorStatsParseCalls atomic.Int64 — defined in perf_io.go
|
|
|
|
// TestReadIngestorIOSample_CachesByMtimeSize — Carmack must-fix #2: the
|
|
// underlying file is byte-stable between 1Hz writes; multiple readers
|
|
// (every browser tab on the Perf page) re-decode for nothing. Cache the
|
|
// last decoded sample keyed by (mtime, size); only re-parse when either
|
|
// changes.
|
|
func TestReadIngestorIOSample_CachesByMtimeSize(t *testing.T) {
|
|
dir := t.TempDir()
|
|
statsPath := filepath.Join(dir, "ingestor-stats.json")
|
|
freshAt := time.Now().UTC().Format(time.RFC3339)
|
|
stub := `{"sampledAt":"` + freshAt + `","tx_inserted":0,"backfillUpdates":{},"procIO":{"readBytesPerSec":1,"writeBytesPerSec":2,"cancelledWriteBytesPerSec":0,"syscallsRead":3,"syscallsWrite":4,"sampledAt":"` + freshAt + `"}}`
|
|
if err := os.WriteFile(statsPath, []byte(stub), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Setenv("CORESCOPE_INGESTOR_STATS", statsPath)
|
|
|
|
// Reset counter + cache.
|
|
readIngestorStatsParseCalls.Store(0)
|
|
resetIngestorIOCache()
|
|
|
|
for i := 0; i < 5; i++ {
|
|
got := readIngestorIOSample()
|
|
if got == nil {
|
|
t.Fatalf("call %d: expected non-nil, got nil", i)
|
|
}
|
|
}
|
|
got := readIngestorStatsParseCalls.Load()
|
|
if got != 1 {
|
|
t.Errorf("expected 1 parse for 5 reads of byte-stable file, got %d", got)
|
|
}
|
|
}
|
|
|
|
// TestReadIngestorIOSample_CacheInvalidatesOnMtimeChange — companion: as
|
|
// soon as the file changes (writer tick) the cache MUST invalidate.
|
|
func TestReadIngestorIOSample_CacheInvalidatesOnMtimeChange(t *testing.T) {
|
|
dir := t.TempDir()
|
|
statsPath := filepath.Join(dir, "ingestor-stats.json")
|
|
write := func() {
|
|
freshAt := time.Now().UTC().Format(time.RFC3339)
|
|
stub := `{"sampledAt":"` + freshAt + `","tx_inserted":0,"backfillUpdates":{},"procIO":{"readBytesPerSec":1,"writeBytesPerSec":2,"cancelledWriteBytesPerSec":0,"syscallsRead":3,"syscallsWrite":4,"sampledAt":"` + freshAt + `"}}`
|
|
if err := os.WriteFile(statsPath, []byte(stub), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
write()
|
|
t.Setenv("CORESCOPE_INGESTOR_STATS", statsPath)
|
|
readIngestorStatsParseCalls.Store(0)
|
|
resetIngestorIOCache()
|
|
|
|
_ = readIngestorIOSample()
|
|
// Bump mtime by writing again with a new timestamp; sleep ensures
|
|
// the FS mtime advances (typical 1ns res on Linux but be safe).
|
|
time.Sleep(10 * time.Millisecond)
|
|
// Touch with a different size by rewriting fresh content.
|
|
write()
|
|
// Force a clearly different mtime by setting it explicitly.
|
|
future := time.Now().Add(2 * time.Second)
|
|
if err := os.Chtimes(statsPath, future, future); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_ = readIngestorIOSample()
|
|
got := readIngestorStatsParseCalls.Load()
|
|
if got != 2 {
|
|
t.Errorf("expected 2 parses across an mtime-change, got %d", got)
|
|
}
|
|
}
|
|
|
|
// TestPerfIOEndpoint_IngestorTimestampMatchesSnapshot was removed: it
|
|
// was a hand-flipped-bool tautology. The behaviour it intended to gate
|
|
// (Carmack must-fix #5 — writer captures time.Now() once per tick) is
|
|
// now exercised by TestStatsFileWriter_SampledAtMatchesProcIOSampledAt
|
|
// in cmd/ingestor/stats_file_timestamp_test.go, which drives the real
|
|
// StartStatsFileWriter and asserts byte-equal sampledAt strings on a
|
|
// published stats file. Removed per Kent Beck Gate review
|
|
// pullrequestreview-4254521304.
|
|
|