mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-15 18:50:17 +00:00
Part of #1870: this interns the three fields the issue names (`ObserverID`, `ObserverName`, `ObserverIATA`), plus `Direction`. ## Problem Each `StoreObs` kept the row scan's own copy of its observer ID (64 hex characters for most observers), observer name, IATA code and direction. A busy regional network's 7-day window holds about 400k observations from a few hundred observers, so a few hundred distinct values were stored hundreds of thousands of times. ## Change - `internStr` returns the canonical copy of each value from a map guarded by an `RWMutex`, so every observation shares one copy. - The copies are held for the life of the process. That is what makes the sharing survive garbage collections. An earlier version of this PR used `unique.Make`, which holds its copies weakly, so a value interned again after a GC came back as a second copy (thanks to the review for catching that). - Observer strings number in the hundreds. The map is capped at 65,536 entries as a guard (AGENTS.md: no unbounded structures); past the cap a string is returned as it came, so nothing breaks. - A hit takes only the read lock and allocates nothing. A new value is cloned, so it doesn't pin a larger buffer it may be a slice of. - All five places that build a `StoreObs` use it: `Load`, `loadChunk`, `scanAndMergeChunk`, and both ingest paths. - The ingest paths intern while scanning rows, **before** taking `s.mu`, so interning adds nothing to the time API readers wait on the store. - `cmd/server/go.mod` stays at `go 1.22`. The earlier 1.27 bump was only there for `unique`, and is reverted here; the module upgrade belongs in its own PR. ## Scaling `BenchmarkInternStr` (a hit, the steady state): | | alone | 12 cores (per call) | allocs | |---|---|---|---| | plain `Mutex` map (the first version of this PR) | 20 ns | 57 ns | 0 | | `unique` (the second version, withdrawn) | 48 ns | 8 ns | 0 | | **`RWMutex` map (this PR)** | 17 ns | 26 ns | 0 | ## Measured on a copy of that window (`GOMEMLIMIT=750MiB`) The same database and harness, one run each, the `unique` version against this one: | | `unique` version | this PR | |---|---|---| | live heap at the last GC after load | 581 MB | **535 MB** | | string bytes allocated by row scans still live (`bytealg.MakeNoZero`) | 106 MB | **80 MB** | | peak RSS | 777 MB | 781 MB | | load to ready | 15 s | 15 s | Peak RSS is set by the load itself, not by these strings. Against master, the first measurement of this PR found the strings retained from row scans went from 84 MB to 58 MB. An earlier version of this PR (the `unique` one) also ran on a staging instance (live MQTT ingest, `GOMEMLIMIT=1GiB`) through five back-to-back restarts and a two-hour soak, with no OOM kills and every request succeeding. This version has not had a staging soak yet. ## Tests - `observer_strings_intern_test.go`: - one copy of each value across 50 observations, through `Load`, `LoadChunked` and `loadChunk`; - **a value interned again after `runtime.GC()` is the same copy** (new: red against the `unique` version, green now); - 16 goroutines interning concurrently get the same copy; - a hit allocates nothing; - `BenchmarkInternStr` (serial and parallel). - Red commits first, then green. - `cd cmd/server && go test ./...` passes at `go 1.22`, and `-race` passes on the intern tests. Backend only: no UI, API shape or config change. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01KaR4xu8gVykQBkH8yG8m6H --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
182 lines
5.1 KiB
Go
182 lines
5.1 KiB
Go
package main
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
"unsafe"
|
|
)
|
|
|
|
// Every observation carries its observer's ID, name, IATA code and the
|
|
// direction. A store holds hundreds of thousands of observations from a few
|
|
// hundred observers, so each loader shares one copy of each of those strings
|
|
// instead of keeping the copy every row scan makes.
|
|
|
|
func assertObserverStringsShared(t *testing.T, store *PacketStore, wantObs int) {
|
|
t.Helper()
|
|
store.mu.RLock()
|
|
defer store.mu.RUnlock()
|
|
var all []*StoreObs
|
|
for _, tx := range store.packets {
|
|
all = append(all, tx.Observations...)
|
|
}
|
|
if len(all) < wantObs {
|
|
t.Fatalf("loaded %d observations, want at least %d", len(all), wantObs)
|
|
}
|
|
first := all[0]
|
|
for _, f := range []struct {
|
|
name string
|
|
get func(*StoreObs) string
|
|
}{
|
|
{"ObserverID", func(o *StoreObs) string { return o.ObserverID }},
|
|
{"ObserverName", func(o *StoreObs) string { return o.ObserverName }},
|
|
{"ObserverIATA", func(o *StoreObs) string { return o.ObserverIATA }},
|
|
{"Direction", func(o *StoreObs) string { return o.Direction }},
|
|
} {
|
|
want := f.get(first)
|
|
if want == "" {
|
|
t.Fatalf("fixture: %s is empty", f.name)
|
|
}
|
|
copies := map[*byte]bool{}
|
|
for _, o := range all {
|
|
if f.get(o) == want {
|
|
copies[unsafe.StringData(f.get(o))] = true
|
|
}
|
|
}
|
|
if len(copies) != 1 {
|
|
t.Errorf("%s %q is held in %d copies across %d observations, want 1", f.name, want, len(copies), len(all))
|
|
}
|
|
}
|
|
}
|
|
|
|
func openInternTestStore(t *testing.T, numTx int) *PacketStore {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
dbPath := filepath.Join(dir, "intern.db")
|
|
createTestDBAt(t, dbPath, numTx)
|
|
t.Cleanup(func() { os.RemoveAll(dir) })
|
|
// The shared fixture leaves observers empty; give each an IATA code so
|
|
// the loaders read one for every observation.
|
|
conn, err := sql.Open("sqlite3", dbPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := conn.Exec(`INSERT INTO observers (id, name, iata) SELECT DISTINCT observer_id, observer_name, 'EDI' FROM observations`); err != nil {
|
|
t.Fatalf("fixture observers: %v", err)
|
|
}
|
|
conn.Close()
|
|
db, err := OpenDB(dbPath)
|
|
if err != nil {
|
|
t.Fatalf("OpenDB: %v", err)
|
|
}
|
|
t.Cleanup(func() { db.Close() })
|
|
return NewPacketStore(db, &PacketStoreConfig{})
|
|
}
|
|
|
|
func TestObserverStringsShared_Load(t *testing.T) {
|
|
store := openInternTestStore(t, 50)
|
|
if err := store.Load(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
assertObserverStringsShared(t, store, 50)
|
|
}
|
|
|
|
func TestObserverStringsShared_LoadChunked(t *testing.T) {
|
|
store := openInternTestStore(t, 50)
|
|
if err := store.LoadChunked(10); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
assertObserverStringsShared(t, store, 50)
|
|
}
|
|
|
|
func TestObserverStringsShared_LoadChunk(t *testing.T) {
|
|
store := openInternTestStore(t, 50)
|
|
from := time.Date(2025, 12, 31, 0, 0, 0, 0, time.UTC)
|
|
if err := store.loadChunk(from, from.Add(72*time.Hour)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
assertObserverStringsShared(t, store, 50)
|
|
}
|
|
|
|
// Many goroutines interning the same values all get the one shared copy.
|
|
func TestInternStrConcurrent(t *testing.T) {
|
|
keys := make([]string, 50)
|
|
for i := range keys {
|
|
keys[i] = fmt.Sprintf("intern-concurrent-%062d", i)
|
|
}
|
|
var wg sync.WaitGroup
|
|
got := make([][]string, 16)
|
|
for g := range got {
|
|
wg.Add(1)
|
|
go func(g int) {
|
|
defer wg.Done()
|
|
for _, k := range keys {
|
|
got[g] = append(got[g], internStr(strings.Clone(k)))
|
|
}
|
|
}(g)
|
|
}
|
|
wg.Wait()
|
|
for i := range keys {
|
|
want := unsafe.StringData(got[0][i])
|
|
for g := range got {
|
|
if got[g][i] != keys[i] || unsafe.StringData(got[g][i]) != want {
|
|
t.Fatalf("goroutine %d got a different copy of %q", g, keys[i])
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// BenchmarkInternStr measures a hit (the steady state: a few hundred
|
|
// observers) alone and from every core. Loaders and ingest intern in
|
|
// parallel, so hits must not contend.
|
|
//
|
|
// go test -run '^$' -bench BenchmarkInternStr -cpu 1,12 ./cmd/server
|
|
func BenchmarkInternStr(b *testing.B) {
|
|
keys := make([]string, 300)
|
|
for i := range keys {
|
|
keys[i] = internStr(fmt.Sprintf("bench-observer-%049d", i))
|
|
}
|
|
b.Run("serial", func(b *testing.B) {
|
|
for i := 0; i < b.N; i++ {
|
|
_ = internStr(keys[i%len(keys)])
|
|
}
|
|
})
|
|
b.Run("parallel", func(b *testing.B) {
|
|
b.RunParallel(func(pb *testing.PB) {
|
|
i := 0
|
|
for pb.Next() {
|
|
_ = internStr(keys[i%len(keys)])
|
|
i++
|
|
}
|
|
})
|
|
})
|
|
}
|
|
|
|
// A hit, the steady state, allocates nothing.
|
|
func TestInternStrHitDoesNotAllocate(t *testing.T) {
|
|
k := internStr("hit-probe-0123456789abcdef0123456789abcdef0123456789abcdef01234567")
|
|
if n := testing.AllocsPerRun(1000, func() { _ = internStr(k) }); n != 0 {
|
|
t.Fatalf("internStr allocates %.0f times on a hit", n)
|
|
}
|
|
}
|
|
|
|
// A canonical copy outlives a garbage collection: interning the same value
|
|
// after a GC returns the copy observations already hold, not a new one, so a
|
|
// long load or a long-running server keeps one copy per value in total.
|
|
func TestInternStrSurvivesGC(t *testing.T) {
|
|
a := internStr(strings.Repeat("x", 3) + "gc-obs")
|
|
runtime.GC()
|
|
runtime.GC()
|
|
b := internStr(strings.Repeat("x", 3) + "gc-obs")
|
|
if unsafe.StringData(a) != unsafe.StringData(b) {
|
|
t.Fatal("interning after a GC made a second copy of a value still in use")
|
|
}
|
|
runtime.KeepAlive(a)
|
|
}
|