Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
f897ce1
fix: use runtime heap stats for memory-based eviction (#564)
efiten Apr 4, 2026
c670742
feat: add byte-size filter to map page (#565) (#568)
Kpa-clawbot Apr 4, 2026
588fba2
perf: track max transmission/observation IDs incrementally (#569)
Kpa-clawbot Apr 4, 2026
aac038a
fix: filter inconsistent hash sizes by role and add 7-day time window…
Kpa-clawbot Apr 4, 2026
cb8a2e1
perf: index node path lookups instead of scanning all packets (#572)
Kpa-clawbot Apr 4, 2026
37300bf
fix: cap prefix map at 8 chars to cut memory ~10x (#570)
Kpa-clawbot Apr 4, 2026
d4f2c3a
perf: index subpath detail lookups instead of scanning all packets (#…
Kpa-clawbot Apr 4, 2026
b35b473
perf(nodes): extract shared fetchNodeDetail() to deduplicate API call…
Kpa-clawbot Apr 4, 2026
67511ed
perf: combine GetStoreStats into 2 concurrent queries instead of 5 se…
Kpa-clawbot Apr 4, 2026
ef30031
perf: cache resolveRegionObservers with 30s TTL (#575)
Kpa-clawbot Apr 4, 2026
02004c5
perf: incremental distance index update on path changes (#576)
Kpa-clawbot Apr 4, 2026
f3d5d1e
perf: resolve hops from in-memory prefix map instead of N+1 DB querie…
Kpa-clawbot Apr 4, 2026
f68e98c
perf(live): skip updateTimeline() when tab is hidden (#578)
Kpa-clawbot Apr 4, 2026
45d8116
perf: query only matching node locations in handleObservers (#579)
Kpa-clawbot Apr 4, 2026
b37e8e2
perf(packets): replace N+1 API calls with single expand=observations …
Kpa-clawbot Apr 4, 2026
d2d4c50
perf(live): parallelize replayRecent() observation fetches (#581)
Kpa-clawbot Apr 4, 2026
26de38f
perf(map): reposition markers on zoom/resize instead of full rebuild …
Kpa-clawbot Apr 4, 2026
87ac617
perf(analytics): compute network status client-side, eliminate redund…
Kpa-clawbot Apr 4, 2026
493849f
perf(frontend): compress og-image.png from 1.1MB to 235KB (#584)
Kpa-clawbot Apr 4, 2026
7ff89d8
perf(packets): coalesce WS-triggered renders with requestAnimationFra…
Kpa-clawbot Apr 4, 2026
cd470df
perf: batch observation fetching to eliminate N+1 API calls on sort c…
Kpa-clawbot Apr 4, 2026
790a713
perf: combine 4 subpath API calls into single bulk endpoint (#587)
Kpa-clawbot Apr 4, 2026
321d1cf
perf: apply time filter early in GetNodeAnalytics to avoid full packe…
Kpa-clawbot Apr 4, 2026
56115ee
perf: use byNode index in QueryMultiNodePackets instead of full scan …
Kpa-clawbot Apr 4, 2026
6f8378a
perf: batch-remove from secondary indexes in EvictStale (#590)
Kpa-clawbot Apr 4, 2026
76c4255
perf: sort snrVals/rssiVals once in computeAnalyticsRF (#591)
Kpa-clawbot Apr 4, 2026
45991ec
perf: combine chained filterPackets passes into single scan (#592)
Kpa-clawbot Apr 4, 2026
b0862f7
fix: replace time.Tick with NewTicker in prune goroutine for graceful…
Kpa-clawbot Apr 4, 2026
6e2f79c
perf: optimize QueryGroupedPackets — cache observer count, defer map …
Kpa-clawbot Apr 4, 2026
6ae62ce
perf: make txToMap observations lazy via ExpandObservations flag (#595)
Kpa-clawbot Apr 4, 2026
2848fbe
perf: incremental DOM diff in renderVisibleRows (#414)
efiten Apr 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
420 changes: 402 additions & 18 deletions cmd/server/coverage_test.go

Large diffs are not rendered by default.

36 changes: 35 additions & 1 deletion cmd/server/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -1497,6 +1498,39 @@ func (db *DB) GetNodeLocations() map[string]map[string]interface{} {
return result
}

// GetNodeLocationsByKeys returns location data only for the given public keys.
// This avoids fetching ALL nodes when only a few keys need to be matched.
func (db *DB) GetNodeLocationsByKeys(keys []string) map[string]map[string]interface{} {
result := make(map[string]map[string]interface{})
if len(keys) == 0 {
return result
}
placeholders := make([]string, len(keys))
args := make([]interface{}, len(keys))
for i, k := range keys {
placeholders[i] = "?"
args[i] = strings.ToLower(k)
}
query := "SELECT public_key, lat, lon, role FROM nodes WHERE LOWER(public_key) IN (" + strings.Join(placeholders, ",") + ")"
rows, err := db.conn.Query(query, args...)
if err != nil {
return result
}
defer rows.Close()
for rows.Next() {
var pk string
var role sql.NullString
var lat, lon sql.NullFloat64
rows.Scan(&pk, &lat, &lon, &role)
result[strings.ToLower(pk)] = map[string]interface{}{
"lat": nullFloat(lat),
"lon": nullFloat(lon),
"role": nullStr(role),
}
}
return result
}

// QueryMultiNodePackets returns transmissions referencing any of the given pubkeys.
func (db *DB) QueryMultiNodePackets(pubkeys []string, limit, offset int, order, since, until string) (*PacketResult, error) {
if len(pubkeys) == 0 {
Expand Down
38 changes: 32 additions & 6 deletions cmd/server/eviction_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,24 +162,50 @@ func TestEvictStale_NoEvictionWhenDisabled(t *testing.T) {

func TestEvictStale_MemoryBasedEviction(t *testing.T) {
now := time.Now().UTC()
// Create enough packets to exceed a small memory limit
// 1000 packets * 5KB + 2000 obs * 500B ≈ 6MB
store := makeTestStore(1000, now.Add(-1*time.Hour), 0)
// All packets are recent (1h old) so time-based won't trigger
// All packets are recent (1h old) so time-based won't trigger.
store.retentionHours = 24
store.maxMemoryMB = 3 // ~3MB limit, should evict roughly half
store.maxMemoryMB = 3
// Inject deterministic estimator: simulates 6MB (over 3MB limit).
// Uses packet count so it scales correctly after eviction.
store.memoryEstimator = func() float64 {
return float64(len(store.packets)*5120+store.totalObs*500) / 1048576.0
}

evicted := store.EvictStale()
if evicted == 0 {
t.Fatal("expected some evictions for memory cap")
}
// After eviction, estimated memory should be <= 3MB
estMB := store.estimatedMemoryMB()
if estMB > 3.5 { // small tolerance
if estMB > 3.5 {
t.Fatalf("expected <=3.5MB after eviction, got %.1fMB", estMB)
}
}

// TestEvictStale_MemoryBasedEviction_UnderestimatedHeap verifies that eviction
// fires correctly when actual heap is much larger than a formula-based estimate
// would report — the scenario that caused OOM kills in production.
func TestEvictStale_MemoryBasedEviction_UnderestimatedHeap(t *testing.T) {
now := time.Now().UTC()
store := makeTestStore(1000, now.Add(-1*time.Hour), 0)
store.retentionHours = 24
store.maxMemoryMB = 500
// Simulate actual heap 5x over budget (like production: ~5GB actual vs ~1GB limit).
store.memoryEstimator = func() float64 {
return 2500.0 // 2500MB actual vs 500MB limit
}

evicted := store.EvictStale()
if evicted == 0 {
t.Fatal("expected evictions when heap is 5x over limit")
}
// Should keep roughly 500/2500 * 0.9 = 18% of packets → ~180 of 1000.
remaining := len(store.packets)
if remaining > 250 {
t.Fatalf("expected most packets evicted (heap 5x over), but %d of 1000 remain", remaining)
}
}

func TestEvictStale_CleansNodeIndexes(t *testing.T) {
now := time.Now().UTC()
store := makeTestStore(10, now.Add(-48*time.Hour), 0)
Expand Down
27 changes: 22 additions & 5 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -224,20 +224,32 @@ func main() {
defer stopEviction()

// Auto-prune old packets if retention.packetDays is configured
var stopPrune func()
if cfg.Retention != nil && cfg.Retention.PacketDays > 0 {
days := cfg.Retention.PacketDays
pruneTicker := time.NewTicker(24 * time.Hour)
pruneDone := make(chan struct{})
stopPrune = func() {
pruneTicker.Stop()
close(pruneDone)
}
go func() {
time.Sleep(1 * time.Minute)
if n, err := database.PruneOldPackets(days); err != nil {
log.Printf("[prune] error: %v", err)
} else {
log.Printf("[prune] deleted %d transmissions older than %d days", n, days)
}
for range time.Tick(24 * time.Hour) {
if n, err := database.PruneOldPackets(days); err != nil {
log.Printf("[prune] error: %v", err)
} else {
log.Printf("[prune] deleted %d transmissions older than %d days", n, days)
for {
select {
case <-pruneTicker.C:
if n, err := database.PruneOldPackets(days); err != nil {
log.Printf("[prune] error: %v", err)
} else {
log.Printf("[prune] deleted %d transmissions older than %d days", n, days)
}
case <-pruneDone:
return
}
}
}()
Expand All @@ -262,6 +274,11 @@ func main() {
// 1. Stop accepting new WebSocket/poll data
poller.Stop()

// 1b. Stop auto-prune ticker
if stopPrune != nil {
stopPrune()
}

// 2. Gracefully drain HTTP connections (up to 15s)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
Expand Down
8 changes: 6 additions & 2 deletions cmd/server/resolve_context_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ func TestResolveHopsAPI_UniquePrefix(t *testing.T) {
// Insert a unique node
srv.db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, lat, lon) VALUES (?, ?, ?, ?)",
"ff11223344", "UniqueNode", 37.0, -122.0)
srv.store.InvalidateNodeCache()

req := httptest.NewRequest("GET", "/api/resolve-hops?hops=ff11223344", nil)
rr := httptest.NewRecorder()
Expand All @@ -192,6 +193,7 @@ func TestResolveHopsAPI_AmbiguousNoContext(t *testing.T) {
"ee1aaaaaaa", "Node-E1", 37.0, -122.0)
srv.db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, lat, lon) VALUES (?, ?, ?, ?)",
"ee1bbbbbbb", "Node-E2", 38.0, -121.0)
srv.store.InvalidateNodeCache()

req := httptest.NewRequest("GET", "/api/resolve-hops?hops=ee1", nil)
rr := httptest.NewRecorder()
Expand All @@ -204,8 +206,10 @@ func TestResolveHopsAPI_AmbiguousNoContext(t *testing.T) {
if hr == nil {
t.Fatal("expected hop in resolved map")
}
if hr.Confidence != "ambiguous" {
t.Fatalf("expected ambiguous, got %s", hr.Confidence)
// With both candidates having GPS and no affinity context, the resolver
// picks the GPS-preferred candidate → confidence is "gps_preference".
if hr.Confidence != "gps_preference" {
t.Fatalf("expected gps_preference, got %s", hr.Confidence)
}
if len(hr.Candidates) != 2 {
t.Fatalf("expected 2 candidates, got %d", len(hr.Candidates))
Expand Down
Loading
Loading