perf: make txToMap observations lazy via ExpandObservations flag (#595)

## Summary

`txToMap()` previously always allocated observation sub-maps for every
packet, even though the `/api/packets` handler immediately stripped them
via `delete(p, "observations")` unless `expand=observations` was
requested. A typical page of 50 packets with ~5 observations each caused
300+ unnecessary map allocations per request.

## Changes

- **`txToMap`**: Add variadic `includeObservations bool` parameter.
Observations are only built when `true` is passed, eliminating
allocations when they'd just be discarded.
- **`PacketQuery`**: Add `ExpandObservations bool` field to thread the
caller's intent through the query pipeline.
- **`routes.go`**: Set `ExpandObservations` based on
`expand=observations` query param. Removed the post-hoc `delete(p,
"observations")` loop — observations are simply never created when not
requested.
- **Single-packet lookups** (`GetPacketByID`, `GetPacketByHash`): Always
pass `true` since detail views need observations.
- **Multi-node/analytics queries**: Default (no flag) = no observations,
matching prior behavior.

## Testing

- Added `TestTxToMapLazyObservations` covering all three cases: no flag,
`false`, and `true`.
- All existing tests pass (`go test ./...`).

## Perf Impact

Eliminates ~250 observation map allocations per /api/packets request (at
default page size of 50 with ~5 observations each). This is a
constant-factor improvement per request — no algorithmic complexity
change.

Fixes #374

Co-authored-by: you <you@example.com>
This commit is contained in:
Kpa-clawbot
2026-04-04 10:39:30 -07:00
committed by GitHub
co-authored by you
parent 6e2f79c0ad
commit 6ae62ce535
4 changed files with 70 additions and 31 deletions
+42
View File
@@ -2034,6 +2034,48 @@ func TestTxToMap(t *testing.T) {
}
}
func TestTxToMapLazyObservations(t *testing.T) {
snr := 10.5
rssi := -90.0
tx := &StoreTx{
ID: 1,
Hash: "abc",
Observations: []*StoreObs{
{ID: 10, ObserverID: "obs1", ObserverName: "O1", SNR: &snr, RSSI: &rssi, Timestamp: "2025-01-01"},
{ID: 11, ObserverID: "obs2", ObserverName: "O2", SNR: &snr, RSSI: &rssi, Timestamp: "2025-01-02"},
},
}
// Without flag: no observations key
m := txToMap(tx)
if _, ok := m["observations"]; ok {
t.Error("txToMap without includeObservations should not include observations key")
}
// With false: no observations key
m = txToMap(tx, false)
if _, ok := m["observations"]; ok {
t.Error("txToMap(tx, false) should not include observations key")
}
// With true: observations included
m = txToMap(tx, true)
obs, ok := m["observations"]
if !ok {
t.Fatal("txToMap(tx, true) should include observations key")
}
obsList, ok := obs.([]map[string]interface{})
if !ok {
t.Fatal("observations should be []map[string]interface{}")
}
if len(obsList) != 2 {
t.Errorf("expected 2 observations, got %d", len(obsList))
}
if obsList[0]["observer_id"] != "obs1" {
t.Errorf("expected observer_id obs1, got %v", obsList[0]["observer_id"])
}
}
// --- filterTxSlice ---
func TestFilterTxSlice(t *testing.T) {
+2 -1
View File
@@ -377,7 +377,8 @@ type PacketQuery struct {
Until string
Region string
Node string
Order string // ASC or DESC
Order string // ASC or DESC
ExpandObservations bool // when true, include observation sub-maps in txToMap output
}
// PacketResult wraps paginated packet list.
+2 -8
View File
@@ -720,7 +720,8 @@ func (s *Server) handlePackets(w http.ResponseWriter, r *http.Request) {
Until: r.URL.Query().Get("until"),
Region: r.URL.Query().Get("region"),
Node: r.URL.Query().Get("node"),
Order: "DESC",
Order: "DESC",
ExpandObservations: r.URL.Query().Get("expand") == "observations",
}
if r.URL.Query().Get("order") == "asc" {
q.Order = "ASC"
@@ -762,13 +763,6 @@ func (s *Server) handlePackets(w http.ResponseWriter, r *http.Request) {
return
}
// Strip observations from default response
if r.URL.Query().Get("expand") != "observations" {
for _, p := range result.Packets {
delete(p, "observations")
}
}
writeJSON(w, result)
}
+24 -22
View File
@@ -534,7 +534,7 @@ func (s *PacketStore) QueryPackets(q PacketQuery) *PacketResult {
packets := make([]map[string]interface{}, 0, pageSize)
if q.Order == "ASC" {
for _, tx := range results[start : start+pageSize] {
packets = append(packets, txToMap(tx))
packets = append(packets, txToMap(tx, q.ExpandObservations))
}
} else {
// DESC: newest items are at the tail; page 0 = last pageSize items reversed
@@ -544,7 +544,7 @@ func (s *PacketStore) QueryPackets(q PacketQuery) *PacketResult {
startIdx = 0
}
for i := endIdx - 1; i >= startIdx; i-- {
packets = append(packets, txToMap(results[i]))
packets = append(packets, txToMap(results[i], q.ExpandObservations))
}
}
return &PacketResult{Packets: packets, Total: total}
@@ -928,7 +928,7 @@ func (s *PacketStore) GetTransmissionByID(id int) map[string]interface{} {
if tx == nil {
return nil
}
return txToMap(tx)
return txToMap(tx, true)
}
// GetPacketByHash returns a transmission by content hash.
@@ -940,7 +940,7 @@ func (s *PacketStore) GetPacketByHash(hash string) map[string]interface{} {
if tx == nil {
return nil
}
return txToMap(tx)
return txToMap(tx, true)
}
// GetPacketByID returns an observation (enriched with transmission fields) by observation ID.
@@ -1967,7 +1967,7 @@ func (s *PacketStore) enrichObs(obs *StoreObs) map[string]interface{} {
// --- Conversion helpers ---
// txToMap converts a StoreTx to the map shape matching scanTransmissionRow output.
func txToMap(tx *StoreTx) map[string]interface{} {
func txToMap(tx *StoreTx, includeObservations ...bool) map[string]interface{} {
m := map[string]interface{}{
"id": tx.ID,
"raw_hex": strOrNil(tx.RawHex),
@@ -1994,25 +1994,27 @@ func txToMap(tx *StoreTx) map[string]interface{} {
} else {
m["_parsedPath"] = nil
}
// Include observations for expand=observations support (stripped by handler when not requested)
obs := make([]map[string]interface{}, 0, len(tx.Observations))
for _, o := range tx.Observations {
om := map[string]interface{}{
"id": o.ID,
"observer_id": strOrNil(o.ObserverID),
"observer_name": strOrNil(o.ObserverName),
"snr": floatPtrOrNil(o.SNR),
"rssi": floatPtrOrNil(o.RSSI),
"path_json": strOrNil(o.PathJSON),
"timestamp": strOrNil(o.Timestamp),
"direction": strOrNil(o.Direction),
// Only build observation sub-maps when caller requests them (avoids allocations that get stripped)
if len(includeObservations) > 0 && includeObservations[0] {
obs := make([]map[string]interface{}, 0, len(tx.Observations))
for _, o := range tx.Observations {
om := map[string]interface{}{
"id": o.ID,
"observer_id": strOrNil(o.ObserverID),
"observer_name": strOrNil(o.ObserverName),
"snr": floatPtrOrNil(o.SNR),
"rssi": floatPtrOrNil(o.RSSI),
"path_json": strOrNil(o.PathJSON),
"timestamp": strOrNil(o.Timestamp),
"direction": strOrNil(o.Direction),
}
if o.ResolvedPath != nil {
om["resolved_path"] = o.ResolvedPath
}
obs = append(obs, om)
}
if o.ResolvedPath != nil {
om["resolved_path"] = o.ResolvedPath
}
obs = append(obs, om)
m["observations"] = obs
}
m["observations"] = obs
return m
}