mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-25 22:23:50 +00:00
Swaps the SQLite driver from `modernc.org/sqlite` (pure Go, SQLite
3.46.0) to `github.com/mattn/go-sqlite3` (cgo, bundled SQLite 3.53.4),
and pays the resulting cross-compilation cost with `zig cc`.
Draft because the riskiest part of this deletes rows — see [Please
review this part first](#please-review-this-part-first) — and because
three things remain unverified at the bottom.
`modernc.org/sqlite` is a transpilation of the C amalgamation. This repo
is read-heavy: `cmd/server` chunk-loads a graph at startup and fans out
neighbour/topology/analytics queries per request, and it pays for that
transpilation on exactly those paths. Head-to-head on the same
120k-transmission / 240k-observation database, running our own hot-path
SQL under both drivers (Apple M4, `-count=5`, medians):
| workload | modernc | mattn | |
|---|---:|---:|---|
| chunk load (`chunked_load.go` v3 join, 20k tx) | 449ms | 196ms |
**2.3×** |
| aggregate scan (240k-row join + `GROUP BY`) | 276ms | 137ms | **2.0×**
|
| 1500 prepared-statement lookups | 512ms | 403ms | **1.3×** |
Allocations fall with it: 1.12M vs 1.64M allocs and 21MB vs 30MB on the
chunk load.
**Superseded by a production run.** @efiten measured both drivers on a
real instance — 11,077,038 observations, 9.7GB database, 4-core arm64 —
as server-only containers against the same live volume, one at a time,
with round 2 reversing the order so the page cache favours the old
driver:
| | audit 7d | audit 24h | background fill (13 chunks) | start →
/api/health |
|---|---:|---:|---:|---:|
| modernc, round 1 | 16.67s | 2.27s | 130.2s | 16.6s |
| mattn, round 1 | 7.87s | 1.34s | 93.8s | 13.5s |
| mattn, round 2 | 8.15s | 1.35s | 96.4s | 13.0s |
| modernc, round 2 | 13.46s | 2.29s | 137.8s | 15.5s |
Warm, the old driver improves to 13.46s on the 7d audit and still loses
by ~1.8×. Chunk load is ~1.4×. `/api/nodes?limit=500` is 0.039s against
0.037s — nothing.
**So the real gain is ~1.4–1.8× on the paths that matter, not 2–2.3×.**
The shape the harness predicted holds — scans and joins gain, small
lookups do not — which is more reassuring than the magnitude would have
been. Quote these numbers.
**The counterweight**, cold and native on that machine: a build goes
from **52s to 163s**. An instance that builds its own image pays that
per deploy.
## The build is cgo now, and one thing about that is a trap
**`CGO_ENABLED=0` still builds.** mattn links a stub, and the binary
dies on its first query with `go-sqlite3 requires cgo to work. This is a
stub`. A green build is not evidence of anything here, which is why
`AGENTS.md` now says so explicitly. `GOOS=linux go build` genuinely
cannot cross-compile any more.
A new root `Makefile` is the entry point. `make crossbuild` uses `zig cc
-target {x86_64,aarch64}-linux-musl` and links static, so each artifact
stays a single self-contained file and the `alpine:3.20` runtime no
longer depends on the base image's libc at all.
`-Wl,-s` is load-bearing: Go's own `-s -w` does not reach the musl
objects zig links in, and without it the server binary is 19.8MB instead
of 12.1MB.
The Dockerfile keeps its single `$BUILDPLATFORM` builder — still no QEMU
for compilation — and gains a checksum-pinned zig plus BuildKit cache
mounts. The mounts are not a nicety: without them an image build
recompiles the amalgamation from cold and takes over half an hour.
## Please review this part first
`internal/dbschema/dedup_index.go` **deletes observation rows**. It is
the one part of this change that can lose data, and it exists because
the migration exposed a real bug rather than causing one.
`stmtInsertObservation` resolves its `ON CONFLICT` against
`idx_observations_dedup`, which `cmd/ingestor/db.go` only ever created
inside the branch that creates the `observations` table for the first
time. Any database whose table predates that branch never got one, so
the UPSERT had no conflict target. modernc failed on the first insert;
mattn fails at `OpenStore`. Same bug, found earlier.
Creating the index unconditionally repairs it — but the index is what
was supposed to prevent duplicates, so a database that never had it can
already hold rows violating it. **`test-fixtures/e2e-fixture.db` in this
repo holds one.** So duplicates are collapsed first. Refusing is not the
safer option: without the index the ingestor cannot prepare its UPSERT,
so it cannot start at all.
Replaying that UPSERT faithfully is subtler than it looks, and a first
cut of this got it wrong twice:
- `COALESCE(excluded.x, x)` means the **incoming** value wins, so down a
group in id order the survivor keeps the **last** non-NULL value. Taking
the first silently discarded newer readings.
- The UPSERT names exactly five columns (`snr`, `rssi`, `score`,
`raw_hex`, `resolved_path`). Every other column must keep the surviving
row's own value; merging those too invents history the ingestor would
never have written.
Merge, delete and `CREATE UNIQUE INDEX` now share one transaction. Split
apart, a writer inserting a duplicate in the gap fails the index
creation while leaving the deletions committed — rows destroyed and no
index to show for it.
Cost, measured on 2.4M synthetic rows holding 5 duplicates: **4.1s**,
holding the write lock throughout, once, at ingestor startup before MQTT
subscribe. Materialising the duplicate-group scan once rather than per
column took that from 9.7s; the pathological case (400k of 600k rows
duplicated) is 5.7s, slightly worse than the 4.2s it was before that
change.
## Four more behavioural differences
Full detail in `docs/sqlite-driver-migration.md`. Briefly:
**Statement preparation is eager.** modernc's `newStmt` stored the SQL
and compiled lazily; mattn calls `sqlite3_prepare_v2` inside `Prepare`,
so SQL naming a missing table fails at *open*. 59 server tests failed on
this alone, all fixtures with partial schemas. `OpenDB` keeps failing
loudly (#1901; `main.go` gates on `dbschema.AssertReady` anyway) and the
fixtures now declare what they are prepared against via
`ensurePreparable`. This also exposed nine `nodes(pubkey …)`
declarations across seven files, where production has only ever had
`public_key` — lazy compilation had hidden the mismatch for as long as
it existed.
**`synchronous` silently dropped FULL → NORMAL.** mattn defaults it to
NORMAL and executes the pragma unconditionally, where SQLite's own
default (what modernc left alone) is FULL. In WAL mode that weakens
durability under power loss. Pinned in `dbschema.WriterDSN`, which both
writers now share — `cmd/migrate` kept a bare path at first and so
quietly wrote at NORMAL, which is what a second copy of a DSN buys you.
**The DSN dialects are mutually invisible.** modernc understood only
`_pragma=name(value)`, mattn only `_`-prefixed parameters, and neither
errors on the other's form — a driver-only rename would have dropped
every pragma in silence. `_journal_mode=WAL` is also gone from the
server's read handle: modernc ignored it, mattn honours it, and setting
`journal_mode` on a read-only connection is a write. Dropping
`_busy_timeout` with it costs nothing, since mattn already defaults to
5000ms — which means the read handle finally *gets* the busy timeout it
had silently lacked.
**`mode=ro` survives for a non-obvious reason.** mattn always passes
`READWRITE|CREATE` and its amalgamation has `SQLITE_USE_URI=0`; what
makes the URI work is its C wrapper ORing `SQLITE_OPEN_URI` in. So the
#1283/#1289 invariant holds with no build flags — but it depends on the
`file:` prefix. `cmd/decrypt` had been building its DSN without one, so
its `mode=ro` had never applied and a missing path was created
read-write. Fixed in passing; never a migration regression.
## What did not change
No modernc-specific API was in use: no `RegisterFunction`, no
`*sqlite.Conn`, no `sqlite/lib` error constants, no `sql.Register`. No
`time.Time` is ever bound as a query argument, so driver time handling
is not in play. Both drivers convert declared
`DATE`/`DATETIME`/`TIMESTAMP` columns to `time.Time`, so
`/api/dropped-packets` keeps emitting `dropped_at` as RFC3339 — an
earlier draft "fixed" that with a `CAST` and would have been the
regression.
## Tests and CI
New regression tests, each written because something got through without
it:
- `TestEnsureObservationsDedupIndexKeepsLatestValues` — the merge
ordering. The original test used complementary NULLs, which passes
whichever direction you pick, which is why the bug survived it.
- `TestCollapseDuplicatesAndIndexIsAtomic` — a failed index creation
must roll the deletions back.
- `TestOpenStorePragmas` / `TestWriterDSNPragmas` — every writer pragma,
read back through the store's own connection. A separate `sqlite3`
session or the startup log line would prove nothing.
- `TestOpenDBRefusesMissingDatabase` — the read-only invariant, which
now rests on a detail of the driver's C wrapper.
- `TestEnsurePreparableMatchesPrepareStatements` — fails when a new
prepared statement outgrows the fixture helper.
CI gains test execution for `cmd/migrate` and `internal/dbschema`, which
had none and both open the database. A PR-time two-arch build plus an
arm64 QEMU smoke gate is new: the GHCR push is push/tag-only, so without
it nothing on a PR would exercise zig, static musl linking or arm64, and
the first signal would arrive on master. `cache-dependency-path` widens
from 2 of the 5 tracked `go.sum` files to all of them.
`make test` passes across all 14 modules, `cmd/server` also under `-race
-count=2` with no failures and no races. `gofmt` and `go vet` clean.
Release-routing and Dockerfile COPY-invariant gates pass.
## Verified by running
- All 8 cross-builds static and correct-architecture; both arches of the
container image built, exported and run under QEMU, serving
`/api/health` and `/api/nodes` against a 2.9M-observation production
snapshot.
- The `migrate` binary repairing that snapshot's duplicate on bare
Alpine.
- `CGO_ENABLED=0` producing a binary that builds and then fails on first
query.
## Not verified
- ~~The 2–2.3× figures come from a standalone harness, not this load
under the old driver.~~ **Closed** by @efiten's production run above,
which also corrected the multiplier.
- SQLite 3.46.0 → 3.53.4 query-planner differences on queries with no
total `ORDER BY`.
- Sustained live ingest through the new writer DSN, and the duplicate
collapse against a database an ingestor is actively writing to. Verified
against a static snapshot only, and the collapse is measured at 4.1s on
2.4M synthetic rows with 5 duplicates — well short of an 11M-row
instance. @efiten has offered a staging instance taking real MQTT
traffic; **this is the item to close before the PR leaves draft.**
An earlier revision of this branch shipped the dedup merge in the wrong
direction with a green test suite, and review then found three more
things in the same file: the repair gated on an error string, a
non-atomic TEMP table drop aimed at the wrong connection, and a deletion
whose only record was a row count. All fixed in ac7e8d38. Passing tests
did not establish safety here, which is why the deletion path wanted a
second pair of eyes rather than a rubber stamp.
496 lines
18 KiB
Go
496 lines
18 KiB
Go
package main
|
||
|
||
import (
|
||
"bufio"
|
||
"encoding/json"
|
||
"net/http"
|
||
"os"
|
||
"sync"
|
||
"sync/atomic"
|
||
"time"
|
||
|
||
"github.com/meshcore-analyzer/perfio"
|
||
)
|
||
|
||
// PerfIOResponse holds per-process disk I/O metrics derived from /proc/self/io.
|
||
//
|
||
// `Ingestor` is the same shape as the top-level fields, sourced from the
|
||
// ingestor's own /proc/self/io snapshot (published via the ingestor stats file).
|
||
// Issue #1120 calls for "Both ingestor and server" — this is the ingestor half.
|
||
//
|
||
// `CancelledWriteBytesPerSec` surfaces `cancelled_write_bytes` from
|
||
// /proc/self/io — bytes the kernel discarded before they hit disk (e.g. file
|
||
// truncated/unlinked while dirty). Useful signal when chasing
|
||
// write-amplification anomalies (cf. the BackfillPathJSON loop in #1119).
|
||
type PerfIOResponse struct {
|
||
ReadBytesPerSec float64 `json:"readBytesPerSec"`
|
||
WriteBytesPerSec float64 `json:"writeBytesPerSec"`
|
||
CancelledWriteBytesPerSec float64 `json:"cancelledWriteBytesPerSec"`
|
||
SyscallsRead float64 `json:"syscallsRead"`
|
||
SyscallsWrite float64 `json:"syscallsWrite"`
|
||
Ingestor *PerfIOSample `json:"ingestor,omitempty"`
|
||
}
|
||
|
||
// PerfIOSample is the canonical per-process I/O rate sample, shared with the
|
||
// ingestor via internal/perfio. Sharing the type prevents silent JSON contract
|
||
// drift between the publisher (ingestor) and the consumer (server) (#1167).
|
||
type PerfIOSample = perfio.Sample
|
||
|
||
// PerfSqliteResponse holds SQLite-specific perf metrics.
|
||
type PerfSqliteResponse struct {
|
||
WalSizeMB float64 `json:"walSizeMB"`
|
||
WalSize int64 `json:"walSize"`
|
||
PageCount int64 `json:"pageCount"`
|
||
PageSize int64 `json:"pageSize"`
|
||
CacheSize int64 `json:"cacheSize"`
|
||
CacheHitRate float64 `json:"cacheHitRate"`
|
||
}
|
||
|
||
// procIOSample is a snapshot of /proc/self/io counters.
|
||
type procIOSample struct {
|
||
at time.Time
|
||
readBytes int64
|
||
writeBytes int64
|
||
cancelledWrite int64
|
||
syscR int64
|
||
syscW int64
|
||
}
|
||
|
||
// perfIOTracker keeps the previous sample so handlePerfIO can compute deltas.
|
||
var (
|
||
perfIOMu sync.Mutex
|
||
perfIOLastSample procIOSample
|
||
)
|
||
|
||
// readIngestorStatsParseCalls counts full json.Unmarshal calls performed by
|
||
// readIngestorIOSample (cache miss path). Exported (lowercase + same-package
|
||
// access) for tests asserting the cache eliminates redundant decodes.
|
||
// Carmack must-fix #2.
|
||
var readIngestorStatsParseCalls atomic.Int64
|
||
|
||
// resetIngestorIOCache wipes the cached snapshot. Test-only helper.
|
||
func resetIngestorIOCache() {
|
||
ingestorIOCache.Lock()
|
||
ingestorIOCache.mtimeUnixNano = 0
|
||
ingestorIOCache.size = 0
|
||
ingestorIOCache.sample = nil
|
||
ingestorIOCache.Unlock()
|
||
}
|
||
|
||
// ingestorIOCache is the byte-stable snapshot cache for readIngestorIOSample
|
||
// (Carmack must-fix #2). Keyed by (file mtime nanoseconds, size); on hit we
|
||
// return the previously decoded sample without re-opening the file.
|
||
var ingestorIOCache struct {
|
||
sync.Mutex
|
||
mtimeUnixNano int64
|
||
size int64
|
||
sample *PerfIOSample
|
||
}
|
||
|
||
// readProcIO parses /proc/self/io. Returns a zero-time sample (at.IsZero())
|
||
// on non-Linux, read failure, or when no recognised keys were parsed
|
||
// (Carmack must-fix #6 — never publish a phantom-zero counter set, the
|
||
// next tick would treat the real counters as a giant delta).
|
||
func readProcIO() procIOSample {
|
||
s := procIOSample{at: time.Now()}
|
||
f, err := os.Open("/proc/self/io")
|
||
if err != nil {
|
||
return procIOSample{}
|
||
}
|
||
defer f.Close()
|
||
if !parseProcIOInto(bufio.NewScanner(f), &s) {
|
||
return procIOSample{}
|
||
}
|
||
return s
|
||
}
|
||
|
||
// parseProcIOInto reads /proc/self/io-shaped key:value lines from sc and
|
||
// populates the byte/syscall fields on s. Returns true iff at least one
|
||
// recognised key was successfully parsed (Carmack must-fix #6).
|
||
//
|
||
// Implementation delegates to perfio.ParseProcIO — single source of truth
|
||
// shared with the ingestor (Carmack must-fix #7; previously two divergent
|
||
// copies, which is how the empty-key gate was missing on this side).
|
||
func parseProcIOInto(sc *bufio.Scanner, s *procIOSample) bool {
|
||
var c perfio.Counters
|
||
ok := perfio.ParseProcIO(sc, &c)
|
||
s.readBytes = c.ReadBytes
|
||
s.writeBytes = c.WriteBytes
|
||
s.cancelledWrite = c.CancelledWriteBytes
|
||
s.syscR = c.SyscR
|
||
s.syscW = c.SyscW
|
||
return ok
|
||
}
|
||
|
||
// handlePerfIO returns delta-rate disk I/O for the server process (per-second).
|
||
// On the first call (no prior sample), rates are zero; subsequent calls
|
||
// report the delta divided by elapsed seconds.
|
||
func (s *Server) handlePerfIO(w http.ResponseWriter, r *http.Request) {
|
||
cur := readProcIO()
|
||
resp := PerfIOResponse{}
|
||
|
||
perfIOMu.Lock()
|
||
prev := perfIOLastSample
|
||
perfIOLastSample = cur
|
||
perfIOMu.Unlock()
|
||
|
||
if !prev.at.IsZero() {
|
||
dt := cur.at.Sub(prev.at).Seconds()
|
||
if dt < 0.001 {
|
||
dt = 0.001
|
||
}
|
||
resp.ReadBytesPerSec = float64(cur.readBytes-prev.readBytes) / dt
|
||
resp.WriteBytesPerSec = float64(cur.writeBytes-prev.writeBytes) / dt
|
||
resp.CancelledWriteBytesPerSec = float64(cur.cancelledWrite-prev.cancelledWrite) / dt
|
||
resp.SyscallsRead = float64(cur.syscR-prev.syscR) / dt
|
||
resp.SyscallsWrite = float64(cur.syscW-prev.syscW) / dt
|
||
}
|
||
// Ingestor block: GREEN commit replaces stub readIngestorIOSample with
|
||
// real parsing of the ingestor stats file's procIO section (#1120
|
||
// follow-up — "Both ingestor and server").
|
||
if ing := readIngestorIOSample(); ing != nil {
|
||
resp.Ingestor = ing
|
||
}
|
||
writeJSON(w, resp)
|
||
}
|
||
|
||
// IngestorStatsStaleThreshold is the maximum age (sampledAt → now) of an
|
||
// ingestor stats snapshot before it is treated as dead and dropped from the
|
||
// /api/perf/io response. Default writer interval is ~1s; 5× that catches a
|
||
// wedged writer goroutine without flapping on a brief tick miss.
|
||
//
|
||
// #1167 must-fix #1: serving stale procIO as live disguises a dead ingestor.
|
||
const IngestorStatsStaleThreshold = 5 * time.Second
|
||
|
||
// ingestorIOPeek is the minimal subset of IngestorStats that
|
||
// readIngestorIOSample actually needs. Decoding into this instead of the
|
||
// full IngestorStats avoids allocating BackfillUpdates (a map) and the
|
||
// ~10 unused counter fields on every /api/perf/io request (Carmack
|
||
// must-fix #1).
|
||
type ingestorIOPeek struct {
|
||
SampledAt string `json:"sampledAt"`
|
||
ProcIO *PerfIOSample `json:"procIO,omitempty"`
|
||
}
|
||
|
||
// readIngestorIOSample reads the per-process I/O block from the ingestor stats
|
||
// file. Returns nil if the file is missing, malformed, carries no proc-IO
|
||
// block (older ingestor builds), OR the snapshot is older than
|
||
// IngestorStatsStaleThreshold (#1167 must-fix #1 — operators must not see
|
||
// stale numbers under .ingestor when the ingestor is down). Never errors —
|
||
// diagnostics only.
|
||
//
|
||
// Cached by (file mtime nanoseconds, size): the underlying file is byte-stable
|
||
// between 1Hz writer ticks, so polling the endpoint at 1Hz from N tabs MUST
|
||
// NOT cause N file-opens + N json.Unmarshal per second on identical bytes
|
||
// (Carmack must-fix #2). The cache invalidates as soon as either mtime or
|
||
// size differs from the cached entry.
|
||
func readIngestorIOSample() *PerfIOSample {
|
||
path := IngestorStatsPath()
|
||
info, statErr := os.Stat(path)
|
||
if statErr != nil {
|
||
return nil
|
||
}
|
||
mtimeNs := info.ModTime().UnixNano()
|
||
size := info.Size()
|
||
|
||
ingestorIOCache.Lock()
|
||
if ingestorIOCache.mtimeUnixNano == mtimeNs && ingestorIOCache.size == size && ingestorIOCache.sample != nil {
|
||
s := ingestorIOCache.sample
|
||
ingestorIOCache.Unlock()
|
||
// Re-validate freshness on cache hit too: a stale-but-byte-stable
|
||
// file (writer wedged) MUST still drop after the threshold.
|
||
if s.SampledAt != "" {
|
||
if ts, err := time.Parse(time.RFC3339, s.SampledAt); err == nil {
|
||
if time.Since(ts) > IngestorStatsStaleThreshold {
|
||
return nil
|
||
}
|
||
}
|
||
}
|
||
return s
|
||
}
|
||
ingestorIOCache.Unlock()
|
||
|
||
data, err := os.ReadFile(path)
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
readIngestorStatsParseCalls.Add(1)
|
||
var st ingestorIOPeek
|
||
if err := json.Unmarshal(data, &st); err != nil {
|
||
return nil
|
||
}
|
||
if st.ProcIO == nil {
|
||
return nil
|
||
}
|
||
stamp := st.SampledAt
|
||
if stamp == "" {
|
||
stamp = st.ProcIO.SampledAt
|
||
}
|
||
if stamp == "" {
|
||
return nil
|
||
}
|
||
ts, err := time.Parse(time.RFC3339, stamp)
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
if time.Since(ts) > IngestorStatsStaleThreshold {
|
||
return nil
|
||
}
|
||
|
||
ingestorIOCache.Lock()
|
||
ingestorIOCache.mtimeUnixNano = mtimeNs
|
||
ingestorIOCache.size = size
|
||
ingestorIOCache.sample = st.ProcIO
|
||
ingestorIOCache.Unlock()
|
||
|
||
return st.ProcIO
|
||
}
|
||
|
||
// handlePerfSqlite returns SQLite WAL size + cache hit-rate stats.
|
||
func (s *Server) handlePerfSqlite(w http.ResponseWriter, r *http.Request) {
|
||
resp := PerfSqliteResponse{}
|
||
if s.db != nil && s.db.conn != nil {
|
||
var pageCount, pageSize int64
|
||
_ = s.db.conn.QueryRow("PRAGMA page_count").Scan(&pageCount)
|
||
_ = s.db.conn.QueryRow("PRAGMA page_size").Scan(&pageSize)
|
||
var cacheSize int64
|
||
_ = s.db.conn.QueryRow("PRAGMA cache_size").Scan(&cacheSize)
|
||
resp.PageCount = pageCount
|
||
resp.PageSize = pageSize
|
||
resp.CacheSize = cacheSize
|
||
|
||
// Cache hit rate: derived from PacketStore cache (rw_cache). We don't
|
||
// have a direct SQLite cache counter through the driver, so we
|
||
// surface the closest available proxy — the in-process row cache.
|
||
if s.store != nil {
|
||
cs := s.store.GetCacheStatsTyped()
|
||
total := cs.Hits + cs.Misses
|
||
if total > 0 {
|
||
resp.CacheHitRate = float64(cs.Hits) / float64(total)
|
||
}
|
||
}
|
||
|
||
if s.db.path != "" && s.db.path != ":memory:" {
|
||
if info, err := os.Stat(s.db.path + "-wal"); err == nil {
|
||
resp.WalSize = info.Size()
|
||
resp.WalSizeMB = float64(info.Size()) / 1048576
|
||
}
|
||
}
|
||
}
|
||
writeJSON(w, resp)
|
||
}
|
||
|
||
// IngestorStats is the on-disk JSON shape the ingestor writes periodically
|
||
// for the server to expose via /api/perf/write-sources.
|
||
type IngestorStats struct {
|
||
SampledAt string `json:"sampledAt"`
|
||
TxInserted int64 `json:"tx_inserted"`
|
||
ObsInserted int64 `json:"obs_inserted"`
|
||
DuplicateTx int64 `json:"tx_dupes"`
|
||
NodeUpserts int64 `json:"node_upserts"`
|
||
ObserverUpserts int64 `json:"observer_upserts"`
|
||
WriteErrors int64 `json:"write_errors"`
|
||
SignatureDrops int64 `json:"sig_drops"`
|
||
WALCommits int64 `json:"walCommits"`
|
||
GroupCommitFlushes int64 `json:"groupCommitFlushes"`
|
||
BackfillUpdates map[string]int64 `json:"backfillUpdates"`
|
||
// ProcIO is the ingestor's own /proc/self/io rates (since its previous
|
||
// sample). Optional — older ingestor builds don't publish this. See #1120.
|
||
ProcIO *PerfIOSample `json:"procIO,omitempty"`
|
||
// WriterPerf is the per-component SQLite writer-lock latency
|
||
// snapshot (#1340). Optional — older ingestor builds don't
|
||
// publish this. Surfaced under .writer_perf by
|
||
// handlePerfWriteSources.
|
||
WriterPerf map[string]WriterStatsSnapshot `json:"writer_perf,omitempty"`
|
||
// SourceLiveness (PR #1609 M1) is the per-MQTT-source two-clock
|
||
// snapshot: lastReceiptUnix (broker liveness, stamped at receipt)
|
||
// vs lastMessageUnix (write-path liveness, stamped post-write).
|
||
// Surfaced by /api/healthz under .ingest_liveness so operators can
|
||
// distinguish "broker alive, write path stuck" from "everything
|
||
// stalled". Optional — older ingestor builds don't publish this.
|
||
SourceLiveness map[string]SourceLivenessSnapshot `json:"source_liveness,omitempty"`
|
||
}
|
||
|
||
// SourceLivenessSnapshot mirrors the ingestor's per-MQTT-source liveness
|
||
// pair (PR #1609 M1). Both fields are unix seconds; 0 means "never".
|
||
type SourceLivenessSnapshot struct {
|
||
LastReceiptUnix int64 `json:"lastReceiptUnix"`
|
||
LastMessageUnix int64 `json:"lastMessageUnix"`
|
||
}
|
||
|
||
// WriterStatsSnapshot mirrors the ingestor's per-component writer-lock
|
||
// latency snapshot (#1340). Times are milliseconds. Server-side decode
|
||
// uses this type to keep the JSON contract stable across processes.
|
||
type WriterStatsSnapshot struct {
|
||
Count int64 `json:"count"`
|
||
ContentionTotal int64 `json:"contention_total"`
|
||
WaitMsP50 float64 `json:"wait_ms_p50"`
|
||
WaitMsP95 float64 `json:"wait_ms_p95"`
|
||
WaitMsP99 float64 `json:"wait_ms_p99"`
|
||
WaitMsMax float64 `json:"wait_ms_max"`
|
||
HoldMsP50 float64 `json:"hold_ms_p50"`
|
||
HoldMsP95 float64 `json:"hold_ms_p95"`
|
||
HoldMsP99 float64 `json:"hold_ms_p99"`
|
||
HoldMsMax float64 `json:"hold_ms_max"`
|
||
}
|
||
|
||
// IngestorStatsPath is the well-known location where the ingestor writes its
|
||
// rolling stats snapshot. Overridable by env CORESCOPE_INGESTOR_STATS for tests.
|
||
func IngestorStatsPath() string {
|
||
if p := os.Getenv("CORESCOPE_INGESTOR_STATS"); p != "" {
|
||
return p
|
||
}
|
||
return "/tmp/corescope-ingestor-stats.json"
|
||
}
|
||
|
||
// readIngestorSourceLiveness returns the per-source receipt/write-path
|
||
// liveness map from the ingestor stats file, or nil on any error / older
|
||
// ingestor that doesn't publish the field. PR #1609 M1 — surfaced by
|
||
// /api/healthz under .ingest_liveness so operators can spot "broker
|
||
// alive, write path stuck".
|
||
//
|
||
// /healthz is a hot path (LB / k8s / uptime monitors), so the result
|
||
// is memoized with a short TTL (sourceLivenessCacheTTL) and refreshed
|
||
// whenever the underlying file mtime changes (PR #1623 round-1
|
||
// finding 4). The lock is held briefly; the costly Unmarshal happens
|
||
// at most once per refresh window.
|
||
func readIngestorSourceLiveness() map[string]SourceLivenessSnapshot {
|
||
path := IngestorStatsPath()
|
||
now := time.Now()
|
||
|
||
sourceLivenessCache.mu.RLock()
|
||
if sourceLivenessCache.path == path &&
|
||
now.Sub(sourceLivenessCache.cachedAt) < sourceLivenessCacheTTL {
|
||
// Cheap mtime probe: if the file moved since we cached, fall
|
||
// through to the refresh path. Stat is cheap relative to
|
||
// ReadFile+Unmarshal.
|
||
info, err := os.Stat(path)
|
||
fresh := err == nil && info.ModTime().Equal(sourceLivenessCache.mtime)
|
||
if fresh || (err != nil && sourceLivenessCache.mtime.IsZero()) {
|
||
out := sourceLivenessCache.value
|
||
sourceLivenessCache.mu.RUnlock()
|
||
return out
|
||
}
|
||
}
|
||
sourceLivenessCache.mu.RUnlock()
|
||
|
||
sourceLivenessCache.mu.Lock()
|
||
defer sourceLivenessCache.mu.Unlock()
|
||
// Re-check under the write lock — another goroutine may have just
|
||
// refreshed.
|
||
if sourceLivenessCache.path == path &&
|
||
time.Since(sourceLivenessCache.cachedAt) < sourceLivenessCacheTTL {
|
||
info, err := os.Stat(path)
|
||
fresh := err == nil && info.ModTime().Equal(sourceLivenessCache.mtime)
|
||
if fresh || (err != nil && sourceLivenessCache.mtime.IsZero()) {
|
||
return sourceLivenessCache.value
|
||
}
|
||
}
|
||
|
||
data, err := sourceLivenessReadFile(path)
|
||
if err != nil {
|
||
// Cache the negative result too, so a missing file doesn't
|
||
// hammer the disk under /healthz pressure.
|
||
sourceLivenessCache.path = path
|
||
sourceLivenessCache.value = nil
|
||
sourceLivenessCache.cachedAt = now
|
||
sourceLivenessCache.mtime = time.Time{}
|
||
return nil
|
||
}
|
||
var st IngestorStats
|
||
if err := json.Unmarshal(data, &st); err != nil {
|
||
sourceLivenessCache.path = path
|
||
sourceLivenessCache.value = nil
|
||
sourceLivenessCache.cachedAt = now
|
||
sourceLivenessCache.mtime = time.Time{}
|
||
return nil
|
||
}
|
||
sourceLivenessCache.path = path
|
||
sourceLivenessCache.value = st.SourceLiveness
|
||
sourceLivenessCache.cachedAt = now
|
||
if info, err := os.Stat(path); err == nil {
|
||
sourceLivenessCache.mtime = info.ModTime()
|
||
} else {
|
||
sourceLivenessCache.mtime = time.Time{}
|
||
}
|
||
return st.SourceLiveness
|
||
}
|
||
|
||
// sourceLivenessReadFile is the file-reader used by
|
||
// readIngestorSourceLiveness. Swappable for tests so call counts can
|
||
// be asserted (PR #1623 round-1 finding 4 TTL cache test).
|
||
var sourceLivenessReadFile = os.ReadFile
|
||
|
||
// sourceLivenessCacheTTL caps how long a parsed liveness map is reused
|
||
// across /healthz probes. 1s is short enough that operators see stale
|
||
// data only briefly during incidents, but long enough to coalesce
|
||
// hundreds of probes/sec from LBs.
|
||
var sourceLivenessCacheTTL = time.Second
|
||
|
||
// sourceLivenessCache memoizes the parsed liveness map keyed by file
|
||
// path + mtime. See readIngestorSourceLiveness.
|
||
var sourceLivenessCache struct {
|
||
mu sync.RWMutex
|
||
path string
|
||
value map[string]SourceLivenessSnapshot
|
||
cachedAt time.Time
|
||
mtime time.Time
|
||
}
|
||
|
||
// resetSourceLivenessCache clears the memo. Test-only helper; callable
|
||
// from production code is harmless (next call just re-reads).
|
||
func resetSourceLivenessCache() {
|
||
sourceLivenessCache.mu.Lock()
|
||
defer sourceLivenessCache.mu.Unlock()
|
||
sourceLivenessCache.path = ""
|
||
sourceLivenessCache.value = nil
|
||
sourceLivenessCache.cachedAt = time.Time{}
|
||
sourceLivenessCache.mtime = time.Time{}
|
||
}
|
||
|
||
// handlePerfWriteSources reads the ingestor's stats file and returns a flat
|
||
// map of source-name -> counter, plus the sample timestamp.
|
||
func (s *Server) handlePerfWriteSources(w http.ResponseWriter, r *http.Request) {
|
||
out := map[string]interface{}{
|
||
"sources": map[string]int64{},
|
||
"sampleAt": "",
|
||
}
|
||
|
||
data, err := os.ReadFile(IngestorStatsPath())
|
||
if err != nil {
|
||
writeJSON(w, out)
|
||
return
|
||
}
|
||
var st IngestorStats
|
||
if err := json.Unmarshal(data, &st); err != nil {
|
||
writeJSON(w, out)
|
||
return
|
||
}
|
||
sources := map[string]int64{
|
||
"tx_inserted": st.TxInserted,
|
||
"tx_dupes": st.DuplicateTx,
|
||
"obs_inserted": st.ObsInserted,
|
||
"node_upserts": st.NodeUpserts,
|
||
"observer_upserts": st.ObserverUpserts,
|
||
"write_errors": st.WriteErrors,
|
||
"sig_drops": st.SignatureDrops,
|
||
"walCommits": st.WALCommits,
|
||
"groupCommitFlushes": st.GroupCommitFlushes,
|
||
}
|
||
for name, v := range st.BackfillUpdates {
|
||
sources["backfill_"+name] = v
|
||
}
|
||
out["sources"] = sources
|
||
out["sampleAt"] = st.SampledAt
|
||
// Surface per-component SQLite writer-lock latency histograms
|
||
// (#1340) under .writer_perf so operators can see when a
|
||
// component (e.g. neighbor_builder) is starving the writer.
|
||
// Empty map when the ingestor is too old to publish this field.
|
||
if len(st.WriterPerf) > 0 {
|
||
out["writer_perf"] = st.WriterPerf
|
||
} else {
|
||
out["writer_perf"] = map[string]WriterStatsSnapshot{}
|
||
}
|
||
writeJSON(w, out)
|
||
}
|