mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-08-29 07:58:23 +00:00
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:
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user