mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-11-12 16:57:48 +00:00
Refs #2148. ## Problem `StoreObs` is the store's most numerous struct: 400k of them on a week of a busy regional network. Its lazy timestamp cache (#1481) is a `time.Time`, a `sync.Once` and a `bool`, together 40 bytes. On current master that makes the struct 200 bytes, which puts every observation in the 208-byte size class. ## Change The cache is now a single `atomic.Int64`: - `0` before the first call; - the parsed time as UTC nanoseconds after it; - or one of two sentinels: one for empty or unparseable strings, and one for times it can't hold exactly (a non-UTC offset, or outside `UnixNano`'s range). Those are re-parsed on each call so their location is preserved. Concurrent first calls may both parse; they store the same value. `StoreObs` is now 160 bytes, in the 160-byte size class: 48 bytes × 400k observations ≈ **19 MB** less heap. The first parse costs the same; cached reads now build the `time.Time` from an int. ## Tests - `TestStoreObsFitsIn160Bytes`: red commit first. - `TestStoreObsParsedTimeMatchesParse`: Z, nanosecond, offset, SQL, empty, garbage and epoch inputs give the same time and location as `time.Parse`, on the first and the cached call. - `TestStoreObsParsedTimeConcurrentFill`. - The existing `storeobs_parsedtime_test.go` still passes. - `go test ./cmd/server` passes, and `-race` passes on the ParsedTime, retransmission and observer analytics tests. ## This PR or #2149 #2149 fixes #2148 by reordering these same three fields. The two conflict, so here is how they compare. Sizes are `unsafe.Sizeof(StoreObs{})` on each branch, with Go's heap size class beside them: | | struct | heap per observation | saved per observation | saved at 400k observations | |---|---|---|---|---| | master | 200 B | 208 B | – | – | | #2149 (field reorder) | 192 B | 192 B | 16 B | ~6.4 MB | | this PR (one 8-byte field) | 160 B | 160 B | 48 B | ~19 MB | - **#2149:** a pure field reorder with no change in behaviour. It is the smallest, safest change and the easiest to review. It saves a third of what this PR does. - **This PR:** three times the saving, but more code. It adds two sentinel values, and re-parses on each call any timestamp with a non-UTC offset. The store writes UTC (`Z`), so that path is rare. It is tested against `time.Parse` on edge cases, concurrently, and under `-race`. Options: 1. **Take this PR.** #2149 then has nothing left to reorder. 2. **Take #2149 now, and this PR after it** as a follow-up for the further 32 bytes per observation. I'll rebase onto #2149 if it lands first. 3. **Take only #2149** if the smaller, simpler change is preferred. No config or UI changes. 🤖 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>
73 lines
2.1 KiB
Go
73 lines
2.1 KiB
Go
package main
|
|
|
|
import (
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
"unsafe"
|
|
)
|
|
|
|
// StoreObs is the store's most numerous struct (400k on a week of a busy
|
|
// network). Its lazy timestamp cache used to be a time.Time, a sync.Once and
|
|
// a bool, 40 bytes that put each observation in the 192-byte size class. It
|
|
// is 8 bytes now, and each observation fits in 160.
|
|
func TestStoreObsFitsIn160Bytes(t *testing.T) {
|
|
if n := unsafe.Sizeof(StoreObs{}); n > 160 {
|
|
t.Fatalf("StoreObs is %d bytes, want at most 160 (one size class down from 192)", n)
|
|
}
|
|
}
|
|
|
|
func parseObsTimestampReference(s string) (time.Time, bool) {
|
|
for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02 15:04:05"} {
|
|
if t, err := time.Parse(layout, s); err == nil {
|
|
return t, true
|
|
}
|
|
}
|
|
return time.Time{}, false
|
|
}
|
|
|
|
// ParsedTime gives the same time, location included, as parsing the string.
|
|
func TestStoreObsParsedTimeMatchesParse(t *testing.T) {
|
|
for _, ts := range []string{
|
|
"2026-10-10T12:34:56.789Z",
|
|
"2026-10-10T12:34:56Z",
|
|
"2026-10-10T13:34:56+01:00",
|
|
"2026-10-10 12:34:56",
|
|
"",
|
|
"not a time",
|
|
"1970-01-01T00:00:00Z",
|
|
} {
|
|
o := &StoreObs{Timestamp: ts}
|
|
want, wantOK := parseObsTimestampReference(ts)
|
|
for i := 0; i < 2; i++ { // first call parses, second reads the cache
|
|
got, ok := o.ParsedTime()
|
|
if ok != wantOK || !got.Equal(want) || got.Location().String() != want.Location().String() {
|
|
t.Fatalf("%q call %d: got %v (%v) ok=%v, want %v (%v) ok=%v", ts, i, got, got.Location(), ok, want, want.Location(), wantOK)
|
|
}
|
|
if wantOK && got.Hour() != want.Hour() {
|
|
t.Fatalf("%q: hour %d, want %d", ts, got.Hour(), want.Hour())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Concurrent readers under the store's read lock may race to fill the cache.
|
|
func TestStoreObsParsedTimeConcurrentFill(t *testing.T) {
|
|
o := &StoreObs{Timestamp: "2026-10-10T12:34:56.789Z"}
|
|
want, _ := parseObsTimestampReference(o.Timestamp)
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < 16; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
for j := 0; j < 100; j++ {
|
|
if got, ok := o.ParsedTime(); !ok || !got.Equal(want) {
|
|
t.Errorf("got %v ok=%v", got, ok)
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
}
|