diff --git a/PERFORMANCE.md b/PERFORMANCE.md index c90b530d..76a8c401 100644 --- a/PERFORMANCE.md +++ b/PERFORMANCE.md @@ -1,81 +1,67 @@ -# Performance Optimization Results +# Performance — v2.1.0 -**Dataset:** 27,346 packets, 501 nodes, 2 observers +**Dataset:** 28,014 packets, ~650 nodes, 2 observers +**Hardware:** ARM64 (MikroTik CCR2116), single-core Node.js -## Server Response Times +## A/B Benchmark: v2.0.1 (before) vs v2.1.0 (after) -| Endpoint | Before | After | Improvement | -|---|---|---|---| -| `/api/packets` (main view) | 77.5ms | 2ms (in-memory) | **39× faster** | -| `/api/packets` (with filters) | 77.5ms | 7ms | **11× faster** | -| `/api/analytics/subpaths` (×4 queries) | 937ms + 1.99s + 3.09s + 6.19s | **<1ms each** (pre-warmed) | **6,000× faster** | -| `/api/analytics/rf` | 270ms | 0.7ms (cached) | **386× faster** | -| `/api/analytics/topology` | 697ms | 195ms cold / <1ms cached | **~700× faster** (cached) | -| `/api/analytics/hash-sizes` | 430ms | 128ms cold / <1ms cached | **~430× faster** (cached) | -| `/api/packets/timestamps` | ~10ms | 1.3ms | **8× faster** | -| `/api/packets/:id` | ~25ms | 3ms | **8× faster** | +All times are averages over 3 runs. "Cached" = warm TTL cache hit. -## Payload Sizes +| Endpoint | v2.0.1 | v2.1.0 (cold) | v2.1.0 (cached) | Speedup | +|---|---|---|---|---| +| **Bulk Health** | 7,059 ms | 3 ms | 1 ms | **7,059×** | +| **Node Analytics** | 381 ms | 2 ms | 1 ms | **381×** | +| **Hash Sizes** | 353 ms | 193 ms | 1 ms | **353×** | +| **Topology** | 685 ms | 579 ms | 2 ms | **342×** | +| **RF Analytics** | 253 ms | 235 ms | 1 ms | **253×** | +| **Channels** | 206 ms | 77 ms | 1 ms | **206×** | +| **Node Health** | 195 ms | 1 ms | 1 ms | **195×** | +| **Node Detail** | 133 ms | 1 ms | 1 ms | **133×** | +| **Channel Analytics** | 95 ms | 73 ms | 2 ms | **47×** | +| **Packets (grouped)** | 76 ms | 33 ms | 28 ms | **2×** | +| **Stats** | 2 ms | 1 ms | 1 ms | 2× | +| **Nodes List** | 3 ms | 2 ms | 2 ms | 1× | +| **Observers** | 1 ms | 8 ms | 1 ms | 1× | -| Endpoint | Before | After | Reduction | -|---|---|---|---| -| `/api/analytics/rf` | **1,032 KB** | **22 KB** | **98% smaller** | -| Total RF page load | ~1.1 MB | ~25 KB | **98% reduction** | +## Architecture -## Network Requests Eliminated +### Two-Layer Performance Stack -| Scenario | Before | After | -|---|---|---| -| New packet arrives (flat mode) | Full `/api/packets` re-fetch | **Zero API calls** — WS prepend | -| New packet arrives (grouped mode) | Full `/api/packets?groupByHash=true` re-fetch | **Zero API calls** — client-side group update | -| Subpath analysis (4 parallel queries) | 4 × full 27K packet scan | **1 shared pre-computation**, served from cache | +1. **In-Memory Packet Store** (`packet-store.js`) + - All packets loaded from SQLite into RAM on startup (~28K packets = ~12MB) + - Indexed by `id`, `hash`, `observer`, and `node` (Map-based O(1) lookup) + - Ring buffer with configurable max memory (default 1GB, ~2.3M packets) + - SQLite becomes **write-only** for packets — reads never touch disk + - New packets from MQTT written to both RAM + SQLite -## Architecture Changes +2. **TTL Cache** (`server.js`) + - Computed API responses cached with configurable TTLs (via `config.json`) + - Smart invalidation: packet bursts only invalidate channels/observers; analytics expire by TTL only + - Pre-warmed on startup: subpaths, RF, topology, channels, hash-sizes, bulk-health + - Result: most API responses served in **1-2ms** from cache -### In-Memory Packet Store -- All 27K packets loaded into RAM on startup (~12MB) -- Indexed by: `id`, `hash`, `observer_id`, `node pubkey` -- SQLite is now **write-only** for the packets table -- All reads served from RAM — sub-millisecond -- Configurable memory cap (default 1GB → ~2.3M packets max) -- Ring buffer eviction when limit reached +### Key Optimizations -### Smart Cache Invalidation -- **Before:** Every packet burst nuked ALL caches (including 1-hour analytics) -- **After:** Only channels/observers invalidated on packet burst. Node/health caches invalidated only on ADVERT. Analytics expire by TTL only. +- **Eliminated all `LIKE '%pubkey%'` queries**: Every node-specific endpoint was doing full-table scans on the packets table via `decoded_json LIKE '%pubkey%'`. Replaced with O(1) `pktStore.byNode` Map lookups. +- **Single-pass computations**: Channels, analytics, and subpaths computed in one loop instead of multiple SQL queries. +- **Client-side WebSocket prepend**: New packets appended to the table without re-fetching the API. +- **RF response compression**: Server-side histograms + scatter downsampling (1MB → 15KB). +- **Configurable everything**: All TTLs, packet store limits, and thresholds in `config.json`. -### Server-Side Computation -- RF histograms computed server-side (20-25 bins) instead of sending 27K raw values -- Scatter plot downsampled to 500 representative points (from 27K) -- Subpath analysis: single-pass computation shared across all query variants, pre-warmed on startup +### What Didn't Work -### WebSocket Streaming -- Packets page receives full packet data via WebSocket -- Client-side filtering + prepend — no API round-trip -- Grouped mode: increment counts, update timestamps, keep longest path — all in-browser +- **Background refresh (`setInterval`)**: Attempted to re-warm caches at 80% TTL. Blocked the event loop — Node.js is single-threaded. Response times went from 3ms to 1,200ms. Reverted immediately. +- **Worker threads**: `structuredClone` overhead of 416ms for 28K packets negated the compute savings. Only viable at 10× data growth or with `SharedArrayBuffer` (zero-copy). -## Configuration +## Running the Benchmark -All cache TTLs configurable in `config.json` under `cacheTTL`: +```bash +# Stop the production server first +supervisorctl stop meshcore-analyzer -```json -{ - "cacheTTL": { - "stats": 10, - "channels": 15, - "channelMessages": 10, - "nodeDetail": 300, - "nodeHealth": 300, - "bulkHealth": 600, - "analyticsRF": 1800, - "analyticsTopology": 1800, - "analyticsSubpaths": 3600 - }, - "packetStore": { - "maxMemoryMB": 1024, - "estimatedPacketBytes": 450 - } -} +# Run A/B benchmark (launches two servers: old v2.0.1 vs current) +./benchmark-ab.sh + +# Restart production +supervisorctl start meshcore-analyzer ``` - -No code changes needed to tune — edit config, restart. diff --git a/README.md b/README.md index 4f06fb19..d0554fc7 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,20 @@ Full experience on your phone — proper touch controls, iOS safe area support, - **Mobile Responsive** — proper two-row VCR bar, iOS safe area support, touch-friendly - **Accessible** — ARIA patterns, keyboard navigation, screen reader support, distinct marker shapes +### ⚡ Performance (v2.1.0) + +Two-layer caching architecture: in-memory packet store + TTL response cache. All packet reads served from RAM — SQLite is write-only. Heavy endpoints pre-warmed on startup. + +| Endpoint | Before | After | Speedup | +|---|---|---|---| +| Bulk Health | 7,059 ms | 1 ms | **7,059×** | +| Node Analytics | 381 ms | 1 ms | **381×** | +| Topology | 685 ms | 2 ms | **342×** | +| Node Health | 195 ms | 1 ms | **195×** | +| Node Detail | 133 ms | 1 ms | **133×** | + +See [PERFORMANCE.md](PERFORMANCE.md) for the full benchmark. + ## Quick Start ### Prerequisites diff --git a/package.json b/package.json index 917c6d5e..55959de0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "meshcore-analyzer", - "version": "2.0.1", + "version": "2.1.0", "description": "Community-run alternative to the closed-source `analyzer.letsmesh.net`. MQTT packet collection + open-source web analyzer for the Bay Area MeshCore mesh.", "main": "index.js", "scripts": {