Files
meshcore-analyzer/cmd/server/observer_strings_intern_test.go
T
Alex BandClaude Opus 5.5 e53c055d6e perf(store): share one copy of each observer string across observations (#2183)
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>
2026-10-13 08:37:54 +02:00

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)
}