mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-08-12 12:09:44 +00:00
Compare commits
132
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ea12087f2 | ||
|
|
2fd579bc6e | ||
|
|
193c41ff30 | ||
|
|
4bc7690ccb | ||
|
|
ac9494c684 | ||
|
|
4edad5ad26 | ||
|
|
9e3218a113 | ||
|
|
3d57a3f853 | ||
|
|
498fbc0321 | ||
|
|
d9ba9937a6 | ||
|
|
fb63236572 | ||
|
|
7b36968554 | ||
|
|
345788b383 | ||
|
|
6c95993a96 | ||
|
|
6449878702 | ||
|
|
3db016ffc1 | ||
|
|
521cd9654a | ||
|
|
553c18af3a | ||
|
|
a58b92270c | ||
|
|
62a8177634 | ||
|
|
317b59ab10 | ||
|
|
2329639f45 | ||
|
|
ac7d3dd72c | ||
|
|
96a79ce9c1 | ||
|
|
afdd455ed9 | ||
|
|
f5785e89f4 | ||
|
|
caf3851ff8 | ||
|
|
ba6c2ac6ba | ||
|
|
e9d74e1bab | ||
|
|
6873219c7a | ||
|
|
38eb7103b3 | ||
|
|
5cc7332583 | ||
|
|
d0d1657b5c | ||
|
|
11dd180219 | ||
|
|
7342166f0a | ||
|
|
bdbcb337ca | ||
|
|
e078f4bbb6 | ||
|
|
51f823bf7e | ||
|
|
e9b34bb2dd | ||
|
|
54379c3e0c | ||
|
|
6da2a8faaf | ||
|
|
b052375648 | ||
|
|
7361a8a462 | ||
|
|
8e86997ac6 | ||
|
|
e35c8bb97a | ||
|
|
eec9954607 | ||
|
|
852986a009 | ||
|
|
5cb9b9e732 | ||
|
|
c24ae4b617 | ||
|
|
9383201c07 | ||
|
|
38b74b6d10 | ||
|
|
26dc177e19 | ||
|
|
92a7ab7e41 | ||
|
|
8c4381a919 | ||
|
|
f9be0ba30c | ||
|
|
e267fb754d | ||
|
|
4525c87963 | ||
|
|
dae08a0ebb | ||
|
|
bb9b4ba6cb | ||
|
|
1017f422c2 | ||
|
|
3f8698e8e4 | ||
|
|
749fdc114f | ||
|
|
9ff6732e04 | ||
|
|
43d10ac2ef | ||
|
|
ea531e0e65 | ||
|
|
7fd1722986 | ||
|
|
9fe4db7cc6 | ||
|
|
467b01a1b3 | ||
|
|
2fff0e6d7b | ||
|
|
487300460c | ||
|
|
cbcc33f636 | ||
|
|
d5f8b9ba22 | ||
|
|
4ad2068ff2 | ||
|
|
1da2034341 | ||
|
|
e2d320449b | ||
|
|
d667dc0a74 | ||
|
|
e6c30e1a7e | ||
|
|
6b2bc62fc3 | ||
|
|
3d0b3ea551 | ||
|
|
0ad6dd2c6d | ||
|
|
b98f59475f | ||
|
|
9d76a91718 | ||
|
|
c1d94f7db5 | ||
|
|
21b6eb0d63 | ||
|
|
8bf7709970 | ||
|
|
c09fec56ff | ||
|
|
6dbfd331a6 | ||
|
|
a00e1c0e18 | ||
|
|
763d4f707c | ||
|
|
ad467daeeb | ||
|
|
46ce9590f1 | ||
|
|
0022c8fd1f | ||
|
|
7827c8e778 | ||
|
|
cb0218fc4d | ||
|
|
385f49b3d8 | ||
|
|
f4ecc96ccc | ||
|
|
78b666c248 | ||
|
|
d4d569278d | ||
|
|
ee21eafa66 | ||
|
|
c6a90e9896 | ||
|
|
92518ab234 | ||
|
|
cd84f51f8a | ||
|
|
4cd8445233 | ||
|
|
ae17a2be12 | ||
|
|
094a96bd6c | ||
|
|
1efe93d7f6 | ||
|
|
f81ed5b3cf | ||
|
|
d69d9fbf8e | ||
|
|
1d33ac53b0 | ||
|
|
43203b09b7 | ||
|
|
45872c8371 | ||
|
|
356f001027 | ||
|
|
b881a09f02 | ||
|
|
e395c471ed | ||
|
|
74685ac82f | ||
|
|
2754251a53 | ||
|
|
aba20b3eda | ||
|
|
4ea1bf8ebc | ||
|
|
2e28aa3e04 | ||
|
|
b21badbcbd | ||
|
|
7179afcfde | ||
|
|
30ff45ad34 | ||
|
|
70855249c2 | ||
|
|
24f277e5c6 | ||
|
|
ab34d9fb65 | ||
|
|
a1f9dca951 | ||
|
|
170f0ac66d | ||
|
|
eba9e89a72 | ||
|
|
11d2026bb1 | ||
|
|
3255395bd0 | ||
|
|
85e97d2f37 | ||
|
|
4925770aa4 |
@@ -1 +1 @@
|
||||
{"schemaVersion":1,"label":"e2e tests","message":"1178 passed","color":"brightgreen"}
|
||||
{"schemaVersion":1,"label":"e2e tests","message":"659 passed","color":"brightgreen"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"schemaVersion":1,"label":"frontend coverage","message":"39.03%","color":"red"}
|
||||
{"schemaVersion":1,"label":"frontend coverage","message":"38.88%","color":"red"}
|
||||
|
||||
@@ -55,7 +55,9 @@ jobs:
|
||||
set -e -o pipefail
|
||||
cd cmd/server
|
||||
go build .
|
||||
go test -coverprofile=server-coverage.out ./... 2>&1 | tee server-test.log
|
||||
# -race gates PR #1208's atomic.Pointer migration: the race-detector
|
||||
# is what makes path_inspect_atomic_race_test.go actually assert.
|
||||
go test -race -coverprofile=server-coverage.out ./... 2>&1 | tee server-test.log
|
||||
echo "--- Go Server Coverage ---"
|
||||
go tool cover -func=server-coverage.out | tail -1
|
||||
|
||||
@@ -98,8 +100,11 @@ jobs:
|
||||
node test-channel-modal-ux.js
|
||||
node test-channel-issue-1087.js
|
||||
node test-channel-issue-1101.js
|
||||
node test-observer-iata-1188.js
|
||||
node test-pull-to-reconnect-1091.js
|
||||
node test-channel-fluid-layout.js
|
||||
node test-issue-1279-p2-code-filter.js
|
||||
node test-area-filter.js
|
||||
|
||||
- name: Verify proto syntax
|
||||
run: |
|
||||
@@ -187,6 +192,12 @@ jobs:
|
||||
go build -o ../../corescope-server .
|
||||
echo "Go server built successfully"
|
||||
|
||||
- name: Build Go migrate tool
|
||||
run: |
|
||||
cd cmd/migrate
|
||||
go build -o ../../corescope-migrate .
|
||||
echo "Go migrate tool built successfully"
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: npm ci --production=false
|
||||
|
||||
@@ -201,6 +212,15 @@ jobs:
|
||||
- name: Freshen fixture timestamps
|
||||
run: bash tools/freshen-fixture.sh test-fixtures/e2e-fixture.db
|
||||
|
||||
- name: Migrate fixture DB to current schema (#1287)
|
||||
# Server now ASSERTs schema is migrated and refuses to start
|
||||
# otherwise (cmd/server/main.go: dbschema.AssertReady). In prod
|
||||
# the ingestor owns dbschema.Apply, but CI starts only the
|
||||
# server against the committed e2e fixture — so we run the
|
||||
# standalone migrate tool here to bring the fixture up to the
|
||||
# required shape before the server boots.
|
||||
run: ./corescope-migrate -db test-fixtures/e2e-fixture.db
|
||||
|
||||
- name: Start Go server with fixture DB
|
||||
run: |
|
||||
fuser -k 13581/tcp 2>/dev/null || true
|
||||
@@ -226,13 +246,16 @@ jobs:
|
||||
BASE_URL=http://localhost:13581 node test-channel-issue-1087-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
BASE_URL=http://localhost:13581 node test-channel-issue-1111-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
BASE_URL=http://localhost:13581 node test-map-modal-fluid-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
BASE_URL=http://localhost:13581 node test-observer-iata-1188-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-nav-fluid-1055-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-nav-priority-1102-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-nav-priority-1311-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-nav-more-floor-1139-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-bottom-nav-1061-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-gestures-1062-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-gestures-1185-scroll-discriminator-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-gesture-hints-1065-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-touch-gestures-coverage-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
BASE_URL=http://localhost:13581 node test-channel-fluid-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
BASE_URL=http://localhost:13581 node test-table-fluid-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
BASE_URL=http://localhost:13581 node test-charts-fluid-1058-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
@@ -252,8 +275,32 @@ jobs:
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-logo-default-sage-teal-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1109-hamburger-dropdown-visible-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-live-layout-1178-1179-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1205-live-controls-anchor-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-live-mql-leak-1180-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1204-live-panel-structure-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1234-live-chrome-pass2-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1206-vcr-overlap-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1244-live-vcr-row-hints-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
BASE_URL=http://localhost:13581 node test-issue-1224-channels-mobile-ux-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
BASE_URL=http://localhost:13581 node test-issue-1236-map-mobile-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
BASE_URL=http://localhost:13581 node test-issue-1273-qr-overlay-height-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
BASE_URL=http://localhost:13581 node test-issue-1281-location-row-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
BASE_URL=http://localhost:13581 node test-issue-1279-legend-p2-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-home-coverage-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-path-inspector-coverage-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1206-resize-observer-leak-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-nav-drawer-1064-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-audio-live-1297-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-audio-lab-1297-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
BASE_URL=http://localhost:13581 node test-channel-decrypt-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
BASE_URL=http://localhost:13581 node test-channel-qr-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
BASE_URL=http://localhost:13581 node test-channel-color-picker-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
BASE_URL=http://localhost:13581 node test-customize-theme-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
BASE_URL=http://localhost:13581 node test-customize-branding-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
BASE_URL=http://localhost:13581 node test-customize-display-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
BASE_URL=http://localhost:13581 node test-customize-export-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-drag-manager-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1306-collisions-terminology-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
|
||||
- name: Collect frontend coverage (parallel)
|
||||
if: success() && github.event_name == 'push'
|
||||
@@ -263,7 +310,13 @@ jobs:
|
||||
- name: Generate frontend coverage badges
|
||||
if: success()
|
||||
run: |
|
||||
E2E_PASS=$(grep -oP '[0-9]+(?=/)' e2e-output.txt | tail -1 || echo "0")
|
||||
# Aggregate per-suite PASS/FAIL across every test-*-e2e.js summary.
|
||||
# The previous regex (grep -oP '[0-9]+(?=/)' | tail -1) caught a
|
||||
# stray digits-before-slash like the '2' in '2/3 tests passed' from
|
||||
# some sub-output and stamped the badge as '2 passed'. See #1296.
|
||||
eval "$(bash scripts/aggregate-e2e-pass.sh e2e-output.txt)"
|
||||
E2E_PASS=${PASS:-0}
|
||||
E2E_FAIL=${FAIL:-0}
|
||||
|
||||
mkdir -p .badges
|
||||
if [ -f .nyc_output/frontend-coverage.json ] || [ -f .nyc_output/e2e-coverage.json ]; then
|
||||
@@ -276,7 +329,14 @@ jobs:
|
||||
echo "{\"schemaVersion\":1,\"label\":\"frontend coverage\",\"message\":\"${FE_COVERAGE}%\",\"color\":\"${FE_COLOR}\"}" > .badges/frontend-coverage.json
|
||||
echo "## Frontend: ${FE_COVERAGE}% coverage" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
echo "{\"schemaVersion\":1,\"label\":\"e2e tests\",\"message\":\"${E2E_PASS:-0} passed\",\"color\":\"brightgreen\"}" > .badges/e2e-tests.json
|
||||
if [ "${E2E_FAIL:-0}" -gt 0 ]; then
|
||||
E2E_MSG="${E2E_PASS:-0} passed, ${E2E_FAIL} failed"
|
||||
E2E_COLOR="red"
|
||||
else
|
||||
E2E_MSG="${E2E_PASS:-0} passed"
|
||||
E2E_COLOR="brightgreen"
|
||||
fi
|
||||
echo "{\"schemaVersion\":1,\"label\":\"e2e tests\",\"message\":\"${E2E_MSG}\",\"color\":\"${E2E_COLOR}\"}" > .badges/e2e-tests.json
|
||||
|
||||
- name: Stop test server
|
||||
if: always()
|
||||
@@ -416,7 +476,9 @@ jobs:
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
deploy:
|
||||
name: "🚀 Deploy Staging"
|
||||
if: github.event_name == 'push'
|
||||
if: |
|
||||
(github.event_name == 'push' || github.event_name == 'workflow_dispatch')
|
||||
&& github.ref == 'refs/heads/master'
|
||||
needs: [build-and-publish]
|
||||
runs-on: [self-hosted, meshcore-runner-2]
|
||||
steps:
|
||||
|
||||
@@ -31,3 +31,5 @@ cmd/ingestor/ingestor.exe
|
||||
!test-fixtures/e2e-fixture.db
|
||||
corescope-server
|
||||
cmd/server/server
|
||||
# Local-only planning and design files
|
||||
docs/superpowers/
|
||||
|
||||
@@ -43,6 +43,17 @@ scripts/ — Tooling (coverage collector, fixture capture, frontend in
|
||||
2. Go server (`cmd/server/`) polls SQLite for new packets, broadcasts via WebSocket
|
||||
3. Frontend fetches via REST API (`/api/*`), filters/sorts client-side
|
||||
|
||||
### Read/Write Separation Invariant (#1283)
|
||||
- **All DB writes live in `cmd/ingestor/`.** INSERT / UPDATE / DELETE / VACUUM /
|
||||
schema migrations / retention all run in the ingestor process.
|
||||
- **`cmd/server/` is read-only.** It opens SQLite with `mode=ro` and must not
|
||||
acquire a write lock. Adding a write-side helper (e.g. a `cachedRW`-style
|
||||
RW connection) regresses this invariant and races the ingestor → SQLITE_BUSY.
|
||||
- Enforcement: `cmd/server/readonly_invariant_test.go` reflect-asserts that
|
||||
`PruneOldPackets`, `PruneOldMetrics`, and `RemoveStaleObservers` are NOT
|
||||
methods on the server's `*DB`. If you need a new write, add it to
|
||||
`cmd/ingestor/`.
|
||||
|
||||
### What's Deprecated (DO NOT TOUCH)
|
||||
The following were part of the old Node.js backend and have been removed:
|
||||
- `server.js`, `db.js`, `decoder.js`, `server-helpers.js`, `packet-store.js`, `iata-coords.js`
|
||||
|
||||
@@ -19,7 +19,10 @@ COPY internal/geofilter/ ../../internal/geofilter/
|
||||
COPY internal/sigvalidate/ ../../internal/sigvalidate/
|
||||
COPY internal/packetpath/ ../../internal/packetpath/
|
||||
COPY internal/dbconfig/ ../../internal/dbconfig/
|
||||
COPY internal/dbschema/ ../../internal/dbschema/
|
||||
COPY internal/prunequeue/ ../../internal/prunequeue/
|
||||
COPY internal/perfio/ ../../internal/perfio/
|
||||
COPY internal/prunequeue/ ../../internal/prunequeue/
|
||||
RUN go mod download
|
||||
COPY cmd/server/ ./
|
||||
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
|
||||
@@ -32,7 +35,10 @@ COPY internal/geofilter/ ../../internal/geofilter/
|
||||
COPY internal/sigvalidate/ ../../internal/sigvalidate/
|
||||
COPY internal/packetpath/ ../../internal/packetpath/
|
||||
COPY internal/dbconfig/ ../../internal/dbconfig/
|
||||
COPY internal/dbschema/ ../../internal/dbschema/
|
||||
COPY internal/prunequeue/ ../../internal/prunequeue/
|
||||
COPY internal/perfio/ ../../internal/perfio/
|
||||
COPY internal/prunequeue/ ../../internal/prunequeue/
|
||||
RUN go mod download
|
||||
COPY cmd/ingestor/ ./
|
||||
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
|
||||
|
||||
+28
-3
@@ -50,6 +50,7 @@ type Config struct {
|
||||
ChannelKeysPath string `json:"channelKeysPath,omitempty"`
|
||||
ChannelKeys map[string]string `json:"channelKeys,omitempty"`
|
||||
HashChannels []string `json:"hashChannels,omitempty"`
|
||||
HashRegions []string `json:"hashRegions,omitempty"`
|
||||
Retention *RetentionConfig `json:"retention,omitempty"`
|
||||
Metrics *MetricsConfig `json:"metrics,omitempty"`
|
||||
GeoFilter *GeoFilterConfig `json:"geo_filter,omitempty"`
|
||||
@@ -75,6 +76,18 @@ type Config struct {
|
||||
// obsBlacklistSetCached is the lazily-built lowercase set for O(1) lookups.
|
||||
obsBlacklistSetCached map[string]bool
|
||||
obsBlacklistOnce sync.Once
|
||||
|
||||
// NeighborEdgesMaxAgeDays controls neighbor_edges row retention
|
||||
// (#1287 — moved from cmd/server). 0 = default 5.
|
||||
NeighborEdgesMaxAgeDays int `json:"neighborEdgesMaxAgeDays,omitempty"`
|
||||
}
|
||||
|
||||
// NeighborEdgesDaysOrDefault returns the configured pruning window or 5.
|
||||
func (c *Config) NeighborEdgesDaysOrDefault() int {
|
||||
if c == nil || c.NeighborEdgesMaxAgeDays <= 0 {
|
||||
return 5
|
||||
}
|
||||
return c.NeighborEdgesMaxAgeDays
|
||||
}
|
||||
|
||||
// GeoFilterConfig is an alias for the shared geofilter.Config type.
|
||||
@@ -99,9 +112,21 @@ func (f *ForeignAdvertConfig) IsDropMode() bool {
|
||||
|
||||
// RetentionConfig controls how long stale nodes are kept before being moved to inactive_nodes.
|
||||
type RetentionConfig struct {
|
||||
NodeDays int `json:"nodeDays"`
|
||||
ObserverDays int `json:"observerDays"`
|
||||
MetricsDays int `json:"metricsDays"`
|
||||
NodeDays int `json:"nodeDays"`
|
||||
ObserverDays int `json:"observerDays"`
|
||||
MetricsDays int `json:"metricsDays"`
|
||||
// PacketDays is the retention window for transmissions (#1283).
|
||||
// Ownership moved from cmd/server to cmd/ingestor; 0 disables.
|
||||
PacketDays int `json:"packetDays"`
|
||||
}
|
||||
|
||||
// PacketDaysOrZero returns the configured retention.packetDays or 0
|
||||
// (disabled) if not set.
|
||||
func (c *Config) PacketDaysOrZero() int {
|
||||
if c.Retention != nil && c.Retention.PacketDays > 0 {
|
||||
return c.Retention.PacketDays
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// MetricsConfig controls observer metrics collection.
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -158,7 +160,7 @@ func TestHandleMessageChannelMessage(t *testing.T) {
|
||||
payload := []byte(`{"text":"Alice: Hello everyone","channel_idx":3,"SNR":5.0,"RSSI":-95,"score":10,"direction":"rx","sender_timestamp":1700000000}`)
|
||||
msg := &mockMessage{topic: "meshcore/message/channel/2", payload: payload}
|
||||
|
||||
handleMessage(store, "test", source, msg, nil, &Config{})
|
||||
handleMessage(store, "test", source, msg, nil, nil, &Config{})
|
||||
|
||||
var count int
|
||||
if err := store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count); err != nil {
|
||||
@@ -218,7 +220,7 @@ func TestHandleMessageChannelMessageEmptyText(t *testing.T) {
|
||||
store, source := newTestContext(t)
|
||||
|
||||
msg := &mockMessage{topic: "meshcore/message/channel/1", payload: []byte(`{"text":""}`)}
|
||||
handleMessage(store, "test", source, msg, nil, &Config{})
|
||||
handleMessage(store, "test", source, msg, nil, nil, &Config{})
|
||||
|
||||
var count int
|
||||
if err := store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count); err != nil {
|
||||
@@ -233,7 +235,7 @@ func TestHandleMessageChannelNoSender(t *testing.T) {
|
||||
store, source := newTestContext(t)
|
||||
|
||||
msg := &mockMessage{topic: "meshcore/message/channel/1", payload: []byte(`{"text":"no sender here"}`)}
|
||||
handleMessage(store, "test", source, msg, nil, &Config{})
|
||||
handleMessage(store, "test", source, msg, nil, nil, &Config{})
|
||||
|
||||
var count int
|
||||
if err := store.db.QueryRow("SELECT COUNT(*) FROM nodes").Scan(&count); err != nil {
|
||||
@@ -250,7 +252,7 @@ func TestHandleMessageDirectMessage(t *testing.T) {
|
||||
payload := []byte(`{"text":"Bob: Hey there","sender_timestamp":1700000000,"SNR":3.0,"rssi":-100,"Score":8,"Direction":"tx"}`)
|
||||
msg := &mockMessage{topic: "meshcore/message/direct/abc123", payload: payload}
|
||||
|
||||
handleMessage(store, "test", source, msg, nil, &Config{})
|
||||
handleMessage(store, "test", source, msg, nil, nil, &Config{})
|
||||
|
||||
var count int
|
||||
if err := store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count); err != nil {
|
||||
@@ -294,7 +296,7 @@ func TestHandleMessageDirectMessageEmptyText(t *testing.T) {
|
||||
store, source := newTestContext(t)
|
||||
|
||||
msg := &mockMessage{topic: "meshcore/message/direct/abc", payload: []byte(`{"text":""}`)}
|
||||
handleMessage(store, "test", source, msg, nil, &Config{})
|
||||
handleMessage(store, "test", source, msg, nil, nil, &Config{})
|
||||
|
||||
var count int
|
||||
if err := store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count); err != nil {
|
||||
@@ -309,7 +311,7 @@ func TestHandleMessageDirectNoSender(t *testing.T) {
|
||||
store, source := newTestContext(t)
|
||||
|
||||
msg := &mockMessage{topic: "meshcore/message/direct/xyz", payload: []byte(`{"text":"message with no colon"}`)}
|
||||
handleMessage(store, "test", source, msg, nil, &Config{})
|
||||
handleMessage(store, "test", source, msg, nil, nil, &Config{})
|
||||
|
||||
var count int
|
||||
if err := store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count); err != nil {
|
||||
@@ -328,7 +330,7 @@ func TestHandleMessageUppercaseScoreDirection(t *testing.T) {
|
||||
payload := []byte(`{"raw":"` + rawHex + `","Score":9.0,"Direction":"tx"}`)
|
||||
msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: payload}
|
||||
|
||||
handleMessage(store, "test", source, msg, nil, &Config{})
|
||||
handleMessage(store, "test", source, msg, nil, nil, &Config{})
|
||||
|
||||
var score *float64
|
||||
var direction *string
|
||||
@@ -349,7 +351,7 @@ func TestHandleMessageChannelLowercaseFields(t *testing.T) {
|
||||
|
||||
payload := []byte(`{"text":"Test: msg","snr":3.0,"rssi":-90,"Score":5,"Direction":"rx"}`)
|
||||
msg := &mockMessage{topic: "meshcore/message/channel/0", payload: payload}
|
||||
handleMessage(store, "test", source, msg, nil, &Config{})
|
||||
handleMessage(store, "test", source, msg, nil, nil, &Config{})
|
||||
|
||||
var count int
|
||||
if err := store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count); err != nil {
|
||||
@@ -365,7 +367,7 @@ func TestHandleMessageDirectLowercaseFields(t *testing.T) {
|
||||
|
||||
payload := []byte(`{"text":"Test: msg","snr":2.0,"rssi":-85,"score":7,"direction":"tx"}`)
|
||||
msg := &mockMessage{topic: "meshcore/message/direct/xyz", payload: payload}
|
||||
handleMessage(store, "test", source, msg, nil, &Config{})
|
||||
handleMessage(store, "test", source, msg, nil, nil, &Config{})
|
||||
|
||||
var count int
|
||||
if err := store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count); err != nil {
|
||||
@@ -388,7 +390,7 @@ func TestHandleMessageAdvertWithTelemetry(t *testing.T) {
|
||||
payload: []byte(`{"raw":"` + rawHex + `"}`),
|
||||
}
|
||||
|
||||
handleMessage(store, "test", source, msg, nil, &Config{})
|
||||
handleMessage(store, "test", source, msg, nil, nil, &Config{})
|
||||
|
||||
// Should have created transmission, node, and observer
|
||||
var txCount, nodeCount, obsCount int
|
||||
@@ -430,7 +432,7 @@ func TestHandleMessageAdvertGeoFiltered(t *testing.T) {
|
||||
}
|
||||
// Legacy silent-drop behavior is now opt-in via ForeignAdverts.Mode="drop"
|
||||
// (#730). The new default — flag — is covered by foreign_advert_test.go.
|
||||
handleMessage(store, "test", source, msg, nil, &Config{
|
||||
handleMessage(store, "test", source, msg, nil, nil, &Config{
|
||||
GeoFilter: gf,
|
||||
ForeignAdverts: &ForeignAdvertConfig{Mode: "drop"},
|
||||
})
|
||||
@@ -670,7 +672,7 @@ func TestHandleMessageCorruptedAdvertNoNode(t *testing.T) {
|
||||
topic: "meshcore/SJC/obs1/packets",
|
||||
payload: []byte(`{"raw":"` + rawHex + `"}`),
|
||||
}
|
||||
handleMessage(store, "test", source, msg, nil, &Config{})
|
||||
handleMessage(store, "test", source, msg, nil, nil, &Config{})
|
||||
|
||||
var count int
|
||||
if err := store.db.QueryRow("SELECT COUNT(*) FROM nodes").Scan(&count); err != nil {
|
||||
@@ -692,7 +694,7 @@ func TestHandleMessageNonAdvertPacket(t *testing.T) {
|
||||
topic: "meshcore/SJC/obs1/packets",
|
||||
payload: []byte(`{"raw":"` + rawHex + `"}`),
|
||||
}
|
||||
handleMessage(store, "test", source, msg, nil, &Config{})
|
||||
handleMessage(store, "test", source, msg, nil, nil, &Config{})
|
||||
|
||||
var count int
|
||||
if err := store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count); err != nil {
|
||||
@@ -753,8 +755,13 @@ func TestDecodeAdvertSensorNoName(t *testing.T) {
|
||||
// --- db.go: OpenStore error path (invalid dir) ---
|
||||
|
||||
func TestOpenStoreInvalidPath(t *testing.T) {
|
||||
// Path under /dev/null can't create directory
|
||||
_, err := OpenStore("/dev/null/impossible/path/db.sqlite")
|
||||
// Create a regular file then try to open a DB inside it — impossible on all platforms.
|
||||
f, err := os.CreateTemp(t.TempDir(), "not-a-dir")
|
||||
if err != nil {
|
||||
t.Fatalf("setup: %v", err)
|
||||
}
|
||||
f.Close()
|
||||
_, err = OpenStore(filepath.Join(f.Name(), "db.sqlite"))
|
||||
if err == nil {
|
||||
t.Error("should error on impossible path")
|
||||
}
|
||||
@@ -869,7 +876,7 @@ func TestHandleMessageChannelLongSender(t *testing.T) {
|
||||
longText := "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA: msg"
|
||||
payload := []byte(`{"text":"` + longText + `"}`)
|
||||
msg := &mockMessage{topic: "meshcore/message/channel/1", payload: payload}
|
||||
handleMessage(store, "test", source, msg, nil, &Config{})
|
||||
handleMessage(store, "test", source, msg, nil, nil, &Config{})
|
||||
|
||||
var count int
|
||||
if err := store.db.QueryRow("SELECT COUNT(*) FROM nodes").Scan(&count); err != nil {
|
||||
@@ -888,7 +895,7 @@ func TestHandleMessageDirectLongSender(t *testing.T) {
|
||||
longText := "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB: msg"
|
||||
payload := []byte(`{"text":"` + longText + `"}`)
|
||||
msg := &mockMessage{topic: "meshcore/message/direct/abc", payload: payload}
|
||||
handleMessage(store, "test", source, msg, nil, &Config{})
|
||||
handleMessage(store, "test", source, msg, nil, nil, &Config{})
|
||||
|
||||
var count int
|
||||
if err := store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count); err != nil {
|
||||
@@ -905,7 +912,7 @@ func TestHandleMessageDirectUppercaseScoreDirection(t *testing.T) {
|
||||
|
||||
payload := []byte(`{"text":"X: hi","Score":6,"Direction":"rx"}`)
|
||||
msg := &mockMessage{topic: "meshcore/message/direct/d1", payload: payload}
|
||||
handleMessage(store, "test", source, msg, nil, &Config{})
|
||||
handleMessage(store, "test", source, msg, nil, nil, &Config{})
|
||||
|
||||
var count int
|
||||
if err := store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count); err != nil {
|
||||
@@ -935,7 +942,7 @@ func TestHandleMessageChannelUppercaseScoreDirection(t *testing.T) {
|
||||
|
||||
payload := []byte(`{"text":"Y: hi","Score":4,"Direction":"tx"}`)
|
||||
msg := &mockMessage{topic: "meshcore/message/channel/5", payload: payload}
|
||||
handleMessage(store, "test", source, msg, nil, &Config{})
|
||||
handleMessage(store, "test", source, msg, nil, nil, &Config{})
|
||||
|
||||
var count int
|
||||
if err := store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count); err != nil {
|
||||
@@ -966,7 +973,7 @@ func TestHandleMessageRawLowercaseScore(t *testing.T) {
|
||||
rawHex := "0A00D69FD7A5A7475DB07337749AE61FA53A4788E976"
|
||||
payload := []byte(`{"raw":"` + rawHex + `","score":3.5}`)
|
||||
msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: payload}
|
||||
handleMessage(store, "test", source, msg, nil, &Config{})
|
||||
handleMessage(store, "test", source, msg, nil, nil, &Config{})
|
||||
|
||||
var score *float64
|
||||
if err := store.db.QueryRow("SELECT score FROM observations LIMIT 1").Scan(&score); err != nil {
|
||||
@@ -985,7 +992,7 @@ func TestHandleMessageStatusNoOrigin(t *testing.T) {
|
||||
topic: "meshcore/LAX/obs5/status",
|
||||
payload: []byte(`{"model":"L1"}`),
|
||||
}
|
||||
handleMessage(store, "test", source, msg, nil, &Config{})
|
||||
handleMessage(store, "test", source, msg, nil, nil, &Config{})
|
||||
|
||||
var count int
|
||||
if err := store.db.QueryRow("SELECT COUNT(*) FROM observers WHERE id = 'obs5'").Scan(&count); err != nil {
|
||||
|
||||
+184
-57
@@ -12,6 +12,7 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/meshcore-analyzer/dbschema"
|
||||
"github.com/meshcore-analyzer/packetpath"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
@@ -62,15 +63,16 @@ func (s *DBStats) SnapshotBackfills() map[string]int64 {
|
||||
// Store wraps the SQLite database for packet ingestion.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
path string // filesystem path to the SQLite DB (used to resolve queue dirs)
|
||||
Stats DBStats
|
||||
|
||||
stmtGetTxByHash *sql.Stmt
|
||||
stmtInsertTransmission *sql.Stmt
|
||||
stmtUpdateTxFirstSeen *sql.Stmt
|
||||
stmtInsertObservation *sql.Stmt
|
||||
stmtUpsertNode *sql.Stmt
|
||||
stmtIncrementAdvertCount *sql.Stmt
|
||||
stmtUpsertObserver *sql.Stmt
|
||||
stmtGetTxByHash *sql.Stmt
|
||||
stmtInsertTransmission *sql.Stmt
|
||||
stmtUpdateTxFirstSeen *sql.Stmt
|
||||
stmtInsertObservation *sql.Stmt
|
||||
stmtUpsertNode *sql.Stmt
|
||||
stmtIncrementAdvertCount *sql.Stmt
|
||||
stmtUpsertObserver *sql.Stmt
|
||||
stmtGetObserverRowid *sql.Stmt
|
||||
stmtUpdateObserverLastSeen *sql.Stmt
|
||||
stmtUpdateNodeTelemetry *sql.Stmt
|
||||
@@ -110,7 +112,14 @@ func OpenStoreWithInterval(dbPath string, sampleIntervalSec int) (*Store, error)
|
||||
return nil, fmt.Errorf("applying schema: %w", err)
|
||||
}
|
||||
|
||||
s := &Store{db: db, sampleIntervalSec: sampleIntervalSec}
|
||||
// Apply the additional server-originated migrations (now owned by
|
||||
// the ingestor per #1287). Adds the indexes/columns that used to live
|
||||
// in cmd/server/ensure_*.go: server now ASSERTS these exist.
|
||||
if err := dbschema.Apply(db, log.Printf); err != nil {
|
||||
return nil, fmt.Errorf("dbschema.Apply: %w", err)
|
||||
}
|
||||
|
||||
s := &Store{db: db, path: dbPath, sampleIntervalSec: sampleIntervalSec}
|
||||
if err := s.prepareStatements(); err != nil {
|
||||
return nil, fmt.Errorf("preparing statements: %w", err)
|
||||
}
|
||||
@@ -457,14 +466,14 @@ func applySchema(db *sql.DB) error {
|
||||
log.Println("[migration] dropped_packets table created")
|
||||
}
|
||||
|
||||
// Migration: add raw_hex column to observations (#881)
|
||||
row = db.QueryRow("SELECT 1 FROM _migrations WHERE name = 'observations_raw_hex_v1'")
|
||||
if row.Scan(&migDone) != nil {
|
||||
log.Println("[migration] Adding raw_hex column to observations...")
|
||||
db.Exec(`ALTER TABLE observations ADD COLUMN raw_hex TEXT`)
|
||||
db.Exec(`INSERT INTO _migrations (name) VALUES ('observations_raw_hex_v1')`)
|
||||
log.Println("[migration] observations.raw_hex column added")
|
||||
}
|
||||
// Migration: observations.raw_hex (#881) is now owned by
|
||||
// internal/dbschema/dbschema.go (#1321). The server PRAGMA-detects
|
||||
// this column as hasObsRawHex; keeping a single canonical Apply
|
||||
// path closes the startup race where the server's detector ran
|
||||
// before this ALTER finished.
|
||||
|
||||
// Migration: transmissions.scope_name (#899) is now owned by
|
||||
// internal/dbschema/dbschema.go (#1321). See above.
|
||||
|
||||
// Migration: add last_packet_at column to observers (#last-packet-at)
|
||||
row = db.QueryRow("SELECT 1 FROM _migrations WHERE name = 'observers_last_packet_at_v1'")
|
||||
@@ -542,6 +551,11 @@ func applySchema(db *sql.DB) error {
|
||||
log.Println("[migration] from_pubkey column + index added")
|
||||
}
|
||||
|
||||
// Migration: nodes.default_scope (#899 Feature 3) is now owned by
|
||||
// internal/dbschema/dbschema.go (#1321). The server PRAGMA-detects
|
||||
// this column as hasDefaultScope; keeping a single canonical Apply
|
||||
// path closes the startup race that #1321 documented.
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -554,8 +568,8 @@ func (s *Store) prepareStatements() error {
|
||||
}
|
||||
|
||||
s.stmtInsertTransmission, err = s.db.Prepare(`
|
||||
INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, payload_version, decoded_json, channel_hash, from_pubkey)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, payload_version, decoded_json, channel_hash, scope_name, from_pubkey)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -587,7 +601,7 @@ func (s *Store) prepareStatements() error {
|
||||
role = COALESCE(?, role),
|
||||
lat = COALESCE(?, lat),
|
||||
lon = COALESCE(?, lon),
|
||||
last_seen = ?
|
||||
last_seen = MAX(MIN(COALESCE(last_seen, ''), ?), ?)
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -606,7 +620,7 @@ func (s *Store) prepareStatements() error {
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = COALESCE(?, name),
|
||||
iata = COALESCE(?, iata),
|
||||
last_seen = ?,
|
||||
last_seen = MAX(MIN(COALESCE(last_seen, ''), ?), ?),
|
||||
packet_count = packet_count + 1,
|
||||
model = COALESCE(?, model),
|
||||
firmware = COALESCE(?, firmware),
|
||||
@@ -625,7 +639,14 @@ func (s *Store) prepareStatements() error {
|
||||
return err
|
||||
}
|
||||
|
||||
s.stmtUpdateObserverLastSeen, err = s.db.Prepare("UPDATE observers SET last_seen = ?, last_packet_at = ? WHERE rowid = ?")
|
||||
// Args: ingestNow, rxTime, ingestNow, rxTime, rowid
|
||||
// MIN(existing, ingestNow) clamps any future value already in the DB before
|
||||
// taking MAX with rxTime, so the guard never locks in a past bug's stale future.
|
||||
s.stmtUpdateObserverLastSeen, err = s.db.Prepare(`
|
||||
UPDATE observers SET
|
||||
last_seen = MAX(MIN(COALESCE(last_seen, ''), ?), ?),
|
||||
last_packet_at = MAX(MIN(COALESCE(last_packet_at, ''), ?), ?)
|
||||
WHERE rowid = ?`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -659,9 +680,10 @@ func (s *Store) InsertTransmission(data *PacketData) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
now := data.Timestamp
|
||||
if now == "" {
|
||||
now = time.Now().UTC().Format(time.RFC3339)
|
||||
rxTime := data.Timestamp
|
||||
ingestNow := time.Now().UTC().Format(time.RFC3339)
|
||||
if rxTime == "" {
|
||||
rxTime = ingestNow
|
||||
}
|
||||
|
||||
var txID int64
|
||||
@@ -674,16 +696,17 @@ func (s *Store) InsertTransmission(data *PacketData) (bool, error) {
|
||||
if err == nil {
|
||||
// Existing transmission
|
||||
txID = existingID
|
||||
if now < existingFirstSeen {
|
||||
_, _ = s.stmtUpdateTxFirstSeen.Exec(now, txID)
|
||||
if rxTime < existingFirstSeen {
|
||||
_, _ = s.stmtUpdateTxFirstSeen.Exec(rxTime, txID)
|
||||
}
|
||||
} else {
|
||||
// New transmission
|
||||
isNew = true
|
||||
result, err := s.stmtInsertTransmission.Exec(
|
||||
data.RawHex, hash, now,
|
||||
data.RawHex, hash, rxTime,
|
||||
data.RouteType, data.PayloadType, data.PayloadVersion,
|
||||
data.DecodedJSON, nilIfEmpty(data.ChannelHash),
|
||||
scopeNameForDB(data),
|
||||
nilIfEmpty(data.FromPubkey),
|
||||
)
|
||||
if err != nil {
|
||||
@@ -707,13 +730,13 @@ func (s *Store) InsertTransmission(data *PacketData) (bool, error) {
|
||||
observerIdx = &rowid
|
||||
// Update observer last_seen and last_packet_at on every packet to prevent
|
||||
// low-traffic observers from appearing offline (#463)
|
||||
_, _ = s.stmtUpdateObserverLastSeen.Exec(now, now, rowid)
|
||||
_, _ = s.stmtUpdateObserverLastSeen.Exec(ingestNow, rxTime, ingestNow, rxTime, rowid)
|
||||
}
|
||||
}
|
||||
|
||||
// Insert observation
|
||||
epochTs := time.Now().Unix()
|
||||
if t, err := time.Parse(time.RFC3339, now); err == nil {
|
||||
if t, err := time.Parse(time.RFC3339, rxTime); err == nil {
|
||||
epochTs = t.Unix()
|
||||
}
|
||||
|
||||
@@ -738,13 +761,14 @@ func (s *Store) InsertTransmission(data *PacketData) (bool, error) {
|
||||
|
||||
// UpsertNode inserts or updates a node.
|
||||
func (s *Store) UpsertNode(pubKey, name, role string, lat, lon *float64, lastSeen string) error {
|
||||
ingestNow := time.Now().UTC().Format(time.RFC3339)
|
||||
now := lastSeen
|
||||
if now == "" {
|
||||
now = time.Now().UTC().Format(time.RFC3339)
|
||||
now = ingestNow
|
||||
}
|
||||
_, err := s.stmtUpsertNode.Exec(
|
||||
pubKey, name, role, lat, lon, now, now,
|
||||
name, role, lat, lon, now,
|
||||
name, role, lat, lon, ingestNow, now,
|
||||
)
|
||||
if err != nil {
|
||||
s.Stats.WriteErrors.Add(1)
|
||||
@@ -807,9 +831,23 @@ type ObserverMeta struct {
|
||||
PacketsRecv *int // cumulative packets received since boot
|
||||
}
|
||||
|
||||
// UpsertObserver inserts or updates an observer with optional hardware metadata.
|
||||
// UpsertObserver inserts or updates an observer using the current wall-clock
|
||||
// time as last_seen. Use UpsertObserverAt when the message envelope provides
|
||||
// an observer receive-time (e.g. MQTT status and data packet handlers).
|
||||
func (s *Store) UpsertObserver(id, name, iata string, meta *ObserverMeta) error {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
return s.UpsertObserverAt(id, name, iata, meta, time.Now().UTC().Format(time.RFC3339))
|
||||
}
|
||||
|
||||
// UpsertObserverAt inserts or updates an observer with an explicit lastSeen
|
||||
// timestamp (typically the observer receive-time from the MQTT envelope). The
|
||||
// SQL uses MAX so last_seen never moves backwards — a retained or replayed
|
||||
// message whose rxTime pre-dates the existing last_seen is a no-op for that
|
||||
// field, preventing offline observers from flashing as Online on reconnect.
|
||||
func (s *Store) UpsertObserverAt(id, name, iata string, meta *ObserverMeta, lastSeen string) error {
|
||||
ingestNow := time.Now().UTC().Format(time.RFC3339)
|
||||
if lastSeen == "" {
|
||||
lastSeen = ingestNow
|
||||
}
|
||||
normalizedIATA := strings.TrimSpace(strings.ToUpper(iata))
|
||||
|
||||
var model, firmware, clientVersion, radio interface{}
|
||||
@@ -839,8 +877,8 @@ func (s *Store) UpsertObserver(id, name, iata string, meta *ObserverMeta) error
|
||||
}
|
||||
|
||||
_, err := s.stmtUpsertObserver.Exec(
|
||||
id, name, normalizedIATA, now, now, model, firmware, clientVersion, radio, batteryMv, uptimeSecs, noiseFloor,
|
||||
name, normalizedIATA, now, model, firmware, clientVersion, radio, batteryMv, uptimeSecs, noiseFloor,
|
||||
id, name, normalizedIATA, lastSeen, lastSeen, model, firmware, clientVersion, radio, batteryMv, uptimeSecs, noiseFloor,
|
||||
name, normalizedIATA, ingestNow, lastSeen, model, firmware, clientVersion, radio, batteryMv, uptimeSecs, noiseFloor,
|
||||
)
|
||||
if err != nil {
|
||||
s.Stats.WriteErrors.Add(1)
|
||||
@@ -1086,6 +1124,58 @@ func (s *Store) BackfillPathJSONAsync() {
|
||||
}()
|
||||
}
|
||||
|
||||
// BackfillDefaultScopeAsync populates default_scope for existing nodes that have
|
||||
// transport-scoped ADVERT rows (scope_name IS NOT NULL AND scope_name != “).
|
||||
// Runs in a background goroutine so it does not block MQTT startup.
|
||||
// Uses the from_pubkey index — O(nodes × indexed lookup), not a full table scan.
|
||||
//
|
||||
// Concurrency: the store uses SetMaxOpenConns(1) so all DB writes — including
|
||||
// MQTT packet inserts and any concurrent backfill goroutines — serialize through
|
||||
// the single connection pool. busy_timeout(5000) handles transient cross-process
|
||||
// contention with the read-only server process. No additional locking is needed.
|
||||
func (s *Store) BackfillDefaultScopeAsync(regionKeys map[string][]byte) {
|
||||
// No region keys configured — all scope_name values will be NULL, nothing to backfill.
|
||||
if len(regionKeys) == 0 {
|
||||
return
|
||||
}
|
||||
s.backfillWg.Add(1)
|
||||
go func() {
|
||||
defer s.backfillWg.Done()
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[backfill] default_scope async panic recovered: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
var done int
|
||||
if s.db.QueryRow("SELECT 1 FROM _migrations WHERE name = 'backfill_default_scope_v1'").Scan(&done) == nil {
|
||||
return // already ran
|
||||
}
|
||||
|
||||
res, err := s.db.Exec(`
|
||||
UPDATE nodes SET default_scope = (
|
||||
SELECT t.scope_name FROM transmissions t
|
||||
WHERE t.from_pubkey = nodes.public_key
|
||||
AND t.payload_type = 4
|
||||
AND t.scope_name IS NOT NULL AND t.scope_name != ''
|
||||
ORDER BY t.first_seen DESC LIMIT 1 -- most-recently observed scope wins; first_seen is insertion time
|
||||
) WHERE EXISTS (
|
||||
SELECT 1 FROM transmissions t
|
||||
WHERE t.from_pubkey = nodes.public_key
|
||||
AND t.payload_type = 4
|
||||
AND t.scope_name IS NOT NULL AND t.scope_name != ''
|
||||
)`)
|
||||
if err != nil {
|
||||
log.Printf("[backfill] default_scope: %v", err)
|
||||
return
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
s.Stats.IncBackfill("default_scope")
|
||||
log.Printf("[backfill] default_scope populated for %d nodes", n)
|
||||
s.db.Exec(`INSERT INTO _migrations (name) VALUES ('backfill_default_scope_v1')`)
|
||||
}()
|
||||
}
|
||||
|
||||
// LogStats logs current operational metrics.
|
||||
func (s *Store) LogStats() {
|
||||
log.Printf("[stats] tx_inserted=%d tx_dupes=%d obs_inserted=%d node_upserts=%d observer_upserts=%d write_errors=%d sig_drops=%d",
|
||||
@@ -1194,24 +1284,26 @@ func (s *Store) PruneDroppedPackets(retentionDays int) (int64, error) {
|
||||
|
||||
// PacketData holds the data needed to insert a packet into the DB.
|
||||
type PacketData struct {
|
||||
RawHex string
|
||||
Timestamp string
|
||||
ObserverID string
|
||||
ObserverName string
|
||||
SNR *float64
|
||||
RSSI *float64
|
||||
Score *float64
|
||||
Direction *string
|
||||
Hash string
|
||||
RouteType int
|
||||
PayloadType int
|
||||
PayloadVersion int
|
||||
PathJSON string
|
||||
DecodedJSON string
|
||||
ChannelHash string // grouping key for channel queries (#762)
|
||||
Region string // observer region: payload > topic > source config (#788)
|
||||
Foreign bool // true when ADVERT GPS lies outside configured geofilter (#730)
|
||||
FromPubkey string // pubkey of the originating node, for exact-match attribution (#1143)
|
||||
RawHex string
|
||||
Timestamp string
|
||||
ObserverID string
|
||||
ObserverName string
|
||||
SNR *float64
|
||||
RSSI *float64
|
||||
Score *float64
|
||||
Direction *string
|
||||
Hash string
|
||||
RouteType int
|
||||
PayloadType int
|
||||
PayloadVersion int
|
||||
PathJSON string
|
||||
DecodedJSON string
|
||||
ChannelHash string // grouping key for channel queries (#762)
|
||||
ScopeName string // matched region name, or "" for unknown-scoped
|
||||
IsTransportScoped bool // true when route_type IN (0,3) AND Code1 ≠ "0000"
|
||||
Region string // observer region: payload > topic > source config (#788)
|
||||
Foreign bool // true when ADVERT GPS lies outside configured geofilter (#730)
|
||||
FromPubkey string // pubkey of the originating node, for exact-match attribution (#1143)
|
||||
}
|
||||
|
||||
// nilIfEmpty returns nil for empty strings (for nullable DB columns).
|
||||
@@ -1222,6 +1314,36 @@ func nilIfEmpty(s string) interface{} {
|
||||
return s
|
||||
}
|
||||
|
||||
// scopeNameForDB encodes PacketData scope semantics for DB storage:
|
||||
// non-transport-scoped → nil (SQL NULL); transport-scoped → pointer to ScopeName
|
||||
// (may be "" for unknown region, "#name" for matched region).
|
||||
func scopeNameForDB(data *PacketData) *string {
|
||||
if !data.IsTransportScoped {
|
||||
return nil
|
||||
}
|
||||
s := data.ScopeName
|
||||
return &s
|
||||
}
|
||||
|
||||
// UpdateNodeDefaultScope records the most-recently observed region scope for a
|
||||
// node. Skips the UPDATE when the stored value already matches to avoid
|
||||
// redundant writes on the hot MQTT ingest path. Updates both nodes and
|
||||
// inactive_nodes to stay consistent.
|
||||
func (s *Store) UpdateNodeDefaultScope(pubkey, scope string) error {
|
||||
// Short-circuit: skip if already stored.
|
||||
var cur sql.NullString
|
||||
row := s.db.QueryRow(`SELECT default_scope FROM nodes WHERE public_key = ?`, pubkey)
|
||||
if row.Scan(&cur) == nil && cur.Valid && cur.String == scope {
|
||||
return nil
|
||||
}
|
||||
if _, err := s.db.Exec(`UPDATE nodes SET default_scope = ? WHERE public_key = ?`, scope, pubkey); err != nil {
|
||||
return err
|
||||
}
|
||||
// Mirror to inactive_nodes (node may be there if recently moved by retention).
|
||||
_, err := s.db.Exec(`UPDATE inactive_nodes SET default_scope = ? WHERE public_key = ?`, scope, pubkey)
|
||||
return err
|
||||
}
|
||||
|
||||
// MQTTPacketMessage is the JSON payload from an MQTT raw packet message.
|
||||
type MQTTPacketMessage struct {
|
||||
Raw string `json:"raw"`
|
||||
@@ -1230,15 +1352,15 @@ type MQTTPacketMessage struct {
|
||||
Score *float64 `json:"score"`
|
||||
Direction *string `json:"direction"`
|
||||
Origin string `json:"origin"`
|
||||
Region string `json:"region,omitempty"` // optional region override (#788)
|
||||
Region string `json:"region,omitempty"` // optional region override (#788)
|
||||
Timestamp string `json:"timestamp,omitempty"` // observer receive time, resolved by handler
|
||||
}
|
||||
|
||||
// BuildPacketData constructs a PacketData from a decoded packet and MQTT message.
|
||||
// path_json is derived directly from raw_hex header bytes (not decoded.Path.Hops)
|
||||
// to guarantee the stored path always matches the raw bytes. This matters for
|
||||
// TRACE packets where decoded.Path.Hops is overwritten with payload hops (#886).
|
||||
func BuildPacketData(msg *MQTTPacketMessage, decoded *DecodedPacket, observerID, region string) *PacketData {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
func BuildPacketData(msg *MQTTPacketMessage, decoded *DecodedPacket, observerID, region string, regionKeys map[string][]byte) *PacketData {
|
||||
pathJSON := "[]"
|
||||
// For TRACE packets, path_json must be the payload-decoded route hops
|
||||
// (decoded.Path.Hops), NOT the raw_hex header bytes which are SNR values.
|
||||
@@ -1255,7 +1377,7 @@ func BuildPacketData(msg *MQTTPacketMessage, decoded *DecodedPacket, observerID,
|
||||
|
||||
pd := &PacketData{
|
||||
RawHex: msg.Raw,
|
||||
Timestamp: now,
|
||||
Timestamp: msg.Timestamp,
|
||||
ObserverID: observerID,
|
||||
ObserverName: msg.Origin,
|
||||
SNR: msg.SNR,
|
||||
@@ -1286,6 +1408,11 @@ func BuildPacketData(msg *MQTTPacketMessage, decoded *DecodedPacket, observerID,
|
||||
}
|
||||
}
|
||||
|
||||
if decoded.TransportCodes != nil && decoded.TransportCodes.Code1 != "0000" {
|
||||
pd.IsTransportScoped = true
|
||||
pd.ScopeName = matchScope(regionKeys, byte(decoded.Header.PayloadType), decoded.payloadRaw, decoded.TransportCodes.Code1)
|
||||
}
|
||||
|
||||
// Populate from_pubkey at write time (#1143). ADVERTs carry the
|
||||
// originating node's pubkey directly; other packet types stay NULL
|
||||
// (downstream attribution queries handle NULL gracefully).
|
||||
|
||||
+142
-16
@@ -642,7 +642,7 @@ func TestEndToEndIngest(t *testing.T) {
|
||||
msg := &MQTTPacketMessage{
|
||||
Raw: rawHex,
|
||||
}
|
||||
pktData := BuildPacketData(msg, decoded, "obs1", "SJC")
|
||||
pktData := BuildPacketData(msg, decoded, "obs1", "SJC", nil)
|
||||
if _, err := s.InsertTransmission(pktData); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -830,13 +830,14 @@ func TestBuildPacketData(t *testing.T) {
|
||||
snr := 5.0
|
||||
rssi := -100.0
|
||||
msg := &MQTTPacketMessage{
|
||||
Raw: rawHex,
|
||||
SNR: &snr,
|
||||
RSSI: &rssi,
|
||||
Origin: "test-observer",
|
||||
Raw: rawHex,
|
||||
SNR: &snr,
|
||||
RSSI: &rssi,
|
||||
Origin: "test-observer",
|
||||
Timestamp: "2026-05-16T10:00:00Z",
|
||||
}
|
||||
|
||||
pkt := BuildPacketData(msg, decoded, "obs123", "SJC")
|
||||
pkt := BuildPacketData(msg, decoded, "obs123", "SJC", nil)
|
||||
|
||||
if pkt.RawHex != rawHex {
|
||||
t.Errorf("rawHex mismatch")
|
||||
@@ -865,8 +866,8 @@ func TestBuildPacketData(t *testing.T) {
|
||||
if pkt.PayloadType != decoded.Header.PayloadType {
|
||||
t.Errorf("payloadType mismatch")
|
||||
}
|
||||
if pkt.Timestamp == "" {
|
||||
t.Error("timestamp should be set")
|
||||
if pkt.Timestamp != "2026-05-16T10:00:00Z" {
|
||||
t.Errorf("timestamp=%s, want 2026-05-16T10:00:00Z", pkt.Timestamp)
|
||||
}
|
||||
if pkt.DecodedJSON == "" || pkt.DecodedJSON == "{}" {
|
||||
t.Error("decodedJSON should be populated")
|
||||
@@ -881,7 +882,7 @@ func TestBuildPacketDataWithHops(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
msg := &MQTTPacketMessage{Raw: raw}
|
||||
pkt := BuildPacketData(msg, decoded, "", "")
|
||||
pkt := BuildPacketData(msg, decoded, "", "", nil)
|
||||
|
||||
if pkt.PathJSON == "[]" {
|
||||
t.Error("pathJSON should contain hops")
|
||||
@@ -894,7 +895,7 @@ func TestBuildPacketDataWithHops(t *testing.T) {
|
||||
func TestBuildPacketDataNilSNRRSSI(t *testing.T) {
|
||||
decoded, _ := DecodePacket("0A00"+strings.Repeat("00", 10), nil, false)
|
||||
msg := &MQTTPacketMessage{Raw: "0A00" + strings.Repeat("00", 10)}
|
||||
pkt := BuildPacketData(msg, decoded, "", "")
|
||||
pkt := BuildPacketData(msg, decoded, "", "", nil)
|
||||
|
||||
if pkt.SNR != nil {
|
||||
t.Errorf("SNR should be nil")
|
||||
@@ -1695,7 +1696,7 @@ func TestBuildPacketDataScoreAndDirection(t *testing.T) {
|
||||
Direction: &dir,
|
||||
}
|
||||
|
||||
pkt := BuildPacketData(msg, decoded, "obs1", "SJC")
|
||||
pkt := BuildPacketData(msg, decoded, "obs1", "SJC", nil)
|
||||
if pkt.Score == nil || *pkt.Score != 42.0 {
|
||||
t.Errorf("Score=%v, want 42.0", pkt.Score)
|
||||
}
|
||||
@@ -1707,7 +1708,7 @@ func TestBuildPacketDataScoreAndDirection(t *testing.T) {
|
||||
func TestBuildPacketDataNilScoreDirection(t *testing.T) {
|
||||
decoded, _ := DecodePacket("0A00"+strings.Repeat("00", 10), nil, false)
|
||||
msg := &MQTTPacketMessage{Raw: "0A00" + strings.Repeat("00", 10)}
|
||||
pkt := BuildPacketData(msg, decoded, "", "")
|
||||
pkt := BuildPacketData(msg, decoded, "", "", nil)
|
||||
|
||||
if pkt.Score != nil {
|
||||
t.Errorf("Score should be nil, got %v", *pkt.Score)
|
||||
@@ -2139,7 +2140,7 @@ func TestBuildPacketData_TraceUsesPayloadHops(t *testing.T) {
|
||||
}
|
||||
|
||||
msg := &MQTTPacketMessage{Raw: rawHex}
|
||||
pd := BuildPacketData(msg, decoded, "test-obs", "TST")
|
||||
pd := BuildPacketData(msg, decoded, "test-obs", "TST", nil)
|
||||
|
||||
// For TRACE: path_json MUST be the payload-decoded route hops, NOT the SNR bytes
|
||||
expectedPathJSON := `["67","33","D6","33","67"]`
|
||||
@@ -2171,7 +2172,7 @@ func TestBuildPacketData_NonTracePathJSON(t *testing.T) {
|
||||
}
|
||||
|
||||
msg := &MQTTPacketMessage{Raw: rawHex}
|
||||
pd := BuildPacketData(msg, decoded, "obs1", "TST")
|
||||
pd := BuildPacketData(msg, decoded, "obs1", "TST", nil)
|
||||
|
||||
expectedPathJSON := `["AA","BB"]`
|
||||
if pd.PathJSON != expectedPathJSON {
|
||||
@@ -2179,6 +2180,131 @@ func TestBuildPacketData_NonTracePathJSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopeNameMigration(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "test.db")
|
||||
store, err := OpenStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenStore: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
// Verify column exists
|
||||
rows, err := store.db.Query("PRAGMA table_info(transmissions)")
|
||||
if err != nil {
|
||||
t.Fatalf("PRAGMA: %v", err)
|
||||
}
|
||||
found := false
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var colName, colType string
|
||||
var notNull, pk int
|
||||
var dflt interface{}
|
||||
if err := rows.Scan(&cid, &colName, &colType, ¬Null, &dflt, &pk); err == nil && colName == "scope_name" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
if !found {
|
||||
t.Fatal("scope_name column not found in transmissions")
|
||||
}
|
||||
|
||||
// Verify column actually stores and retrieves values (NULL and non-NULL).
|
||||
_, err = store.db.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, scope_name)
|
||||
VALUES ('aabb', 'hash1', '2026-01-01T00:00:00Z', 0, 5, '#belgium')`)
|
||||
if err != nil {
|
||||
t.Fatalf("insert scoped row: %v", err)
|
||||
}
|
||||
_, err = store.db.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, scope_name)
|
||||
VALUES ('ccdd', 'hash2', '2026-01-01T00:00:01Z', 0, 5, NULL)`)
|
||||
if err != nil {
|
||||
t.Fatalf("insert unscoped row: %v", err)
|
||||
}
|
||||
|
||||
var name string
|
||||
if err := store.db.QueryRow(`SELECT scope_name FROM transmissions WHERE hash = 'hash1'`).Scan(&name); err != nil {
|
||||
t.Fatalf("read scope_name: %v", err)
|
||||
}
|
||||
if name != "#belgium" {
|
||||
t.Errorf("scope_name = %q, want #belgium", name)
|
||||
}
|
||||
|
||||
var nullScope interface{}
|
||||
if err := store.db.QueryRow(`SELECT scope_name FROM transmissions WHERE hash = 'hash2'`).Scan(&nullScope); err != nil {
|
||||
t.Fatalf("read null scope_name: %v", err)
|
||||
}
|
||||
if nullScope != nil {
|
||||
t.Errorf("scope_name for unscoped = %v, want nil", nullScope)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Feature 3: default_scope column on nodes (#899) ---
|
||||
|
||||
func TestUpdateNodeDefaultScope(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "test.db")
|
||||
store, err := OpenStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenStore: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
// Insert a node into nodes and inactive_nodes so both tables can be updated.
|
||||
if _, err := store.db.Exec(`INSERT INTO nodes (public_key, name) VALUES ('pk1', 'Node1')`); err != nil {
|
||||
t.Fatalf("insert node: %v", err)
|
||||
}
|
||||
if _, err := store.db.Exec(`INSERT INTO inactive_nodes (public_key, name) VALUES ('pk1', 'Node1')`); err != nil {
|
||||
t.Fatalf("insert inactive node: %v", err)
|
||||
}
|
||||
|
||||
// First call: writes scope to both tables.
|
||||
if err := store.UpdateNodeDefaultScope("pk1", "#belgium"); err != nil {
|
||||
t.Fatalf("UpdateNodeDefaultScope: %v", err)
|
||||
}
|
||||
var got string
|
||||
if err := store.db.QueryRow(`SELECT default_scope FROM nodes WHERE public_key = 'pk1'`).Scan(&got); err != nil {
|
||||
t.Fatalf("read nodes.default_scope: %v", err)
|
||||
}
|
||||
if got != "#belgium" {
|
||||
t.Errorf("nodes.default_scope = %q, want #belgium", got)
|
||||
}
|
||||
var gotInactive string
|
||||
if err := store.db.QueryRow(`SELECT default_scope FROM inactive_nodes WHERE public_key = 'pk1'`).Scan(&gotInactive); err != nil {
|
||||
t.Fatalf("read inactive_nodes.default_scope: %v", err)
|
||||
}
|
||||
if gotInactive != "#belgium" {
|
||||
t.Errorf("inactive_nodes.default_scope = %q, want #belgium", gotInactive)
|
||||
}
|
||||
|
||||
// Second call with same value: short-circuit, no redundant UPDATE (verify no error and value stable).
|
||||
if err := store.UpdateNodeDefaultScope("pk1", "#belgium"); err != nil {
|
||||
t.Fatalf("UpdateNodeDefaultScope short-circuit: %v", err)
|
||||
}
|
||||
if err := store.db.QueryRow(`SELECT default_scope FROM nodes WHERE public_key = 'pk1'`).Scan(&got); err != nil {
|
||||
t.Fatalf("read after short-circuit: %v", err)
|
||||
}
|
||||
if got != "#belgium" {
|
||||
t.Errorf("after short-circuit nodes.default_scope = %q, want #belgium", got)
|
||||
}
|
||||
|
||||
// Third call with different value: updates both tables.
|
||||
if err := store.UpdateNodeDefaultScope("pk1", "#eu"); err != nil {
|
||||
t.Fatalf("UpdateNodeDefaultScope update: %v", err)
|
||||
}
|
||||
if err := store.db.QueryRow(`SELECT default_scope FROM nodes WHERE public_key = 'pk1'`).Scan(&got); err != nil {
|
||||
t.Fatalf("read after update: %v", err)
|
||||
}
|
||||
if got != "#eu" {
|
||||
t.Errorf("after update nodes.default_scope = %q, want #eu", got)
|
||||
}
|
||||
if err := store.db.QueryRow(`SELECT default_scope FROM inactive_nodes WHERE public_key = 'pk1'`).Scan(&gotInactive); err != nil {
|
||||
t.Fatalf("read inactive after update: %v", err)
|
||||
}
|
||||
if gotInactive != "#eu" {
|
||||
t.Errorf("after update inactive_nodes.default_scope = %q, want #eu", gotInactive)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Issue #888: Backfill path_json from raw_hex ---
|
||||
|
||||
func TestBackfillPathJsonFromRawHex(t *testing.T) {
|
||||
@@ -2369,7 +2495,7 @@ func TestBuildPacketDataRegionFromPayload(t *testing.T) {
|
||||
decoded := &DecodedPacket{
|
||||
Header: Header{RouteType: 1, PayloadType: 3},
|
||||
}
|
||||
pkt := BuildPacketData(msg, decoded, "obs1", "SJC")
|
||||
pkt := BuildPacketData(msg, decoded, "obs1", "SJC", nil)
|
||||
// When payload has region, it should override the topic-derived region
|
||||
if pkt.Region != "PDX" {
|
||||
t.Fatalf("expected region PDX from payload, got %q", pkt.Region)
|
||||
@@ -2381,7 +2507,7 @@ func TestBuildPacketDataRegionFallsBackToTopic(t *testing.T) {
|
||||
decoded := &DecodedPacket{
|
||||
Header: Header{RouteType: 1, PayloadType: 3},
|
||||
}
|
||||
pkt := BuildPacketData(msg, decoded, "obs1", "SJC")
|
||||
pkt := BuildPacketData(msg, decoded, "obs1", "SJC", nil)
|
||||
if pkt.Region != "SJC" {
|
||||
t.Fatalf("expected region SJC from topic, got %q", pkt.Region)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"log"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestHandleMessageDecodeErrorLog_PII — issue #1211 round-0 fix shipped without
|
||||
// a test. Asserts the decode-error log line:
|
||||
// (a) includes structured fields: topic, observer prefix, payload length
|
||||
// (b) observer substring is at most 8 chars
|
||||
// (c) full observer ID is NOT present in the output
|
||||
//
|
||||
// A bare `log.Printf("... observer=%s ...", obs)` would leak the full ID.
|
||||
func TestHandleMessageDecodeErrorLog_PII_Issue1211(t *testing.T) {
|
||||
store, source := newTestContext(t)
|
||||
|
||||
// Use a 64-char observer ID; the prefix MUST be capped at 8 chars in logs.
|
||||
observerID := "abcdef0123456789aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
// Malformed raw — pathByte=0xF6 claims 216 path bytes in a tiny buffer.
|
||||
// This triggers the decode-error path under test.
|
||||
rawHex := "12F6AAAAAAAAAAAAAAAAAAAAAAAAAA"
|
||||
topic := "meshcore/SJC/" + observerID + "/packets"
|
||||
payload := []byte(`{"raw":"` + rawHex + `"}`)
|
||||
msg := &mockMessage{topic: topic, payload: payload}
|
||||
|
||||
var buf bytes.Buffer
|
||||
orig := log.Writer()
|
||||
log.SetOutput(&buf)
|
||||
defer log.SetOutput(orig)
|
||||
|
||||
handleMessage(store, "test", source, msg, nil, nil, &Config{})
|
||||
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "decode error") {
|
||||
t.Fatalf("expected decode-error log; got:\n%s", out)
|
||||
}
|
||||
// (a) structured fields present
|
||||
if !strings.Contains(out, "topic=") {
|
||||
t.Errorf("log missing topic=; got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "observer=") {
|
||||
t.Errorf("log missing observer=; got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "rawHexLen=") {
|
||||
t.Errorf("log missing rawHexLen=; got:\n%s", out)
|
||||
}
|
||||
// (c) full observer ID must NOT appear
|
||||
if strings.Contains(out, observerID) {
|
||||
t.Errorf("log leaked full observer ID; got:\n%s", out)
|
||||
}
|
||||
// (b) observer substring capped at 8 chars — the 9th char ('2') after the
|
||||
// 8-char prefix must NOT appear adjacent to the prefix.
|
||||
if strings.Contains(out, "abcdef01234") {
|
||||
t.Errorf("log observer field longer than 8 chars; got:\n%s", out)
|
||||
}
|
||||
// Positive: 8-char prefix must be present in the log
|
||||
if !strings.Contains(out, "abcdef01") {
|
||||
t.Errorf("log missing 8-char observer prefix; got:\n%s", out)
|
||||
}
|
||||
}
|
||||
+299
-11
@@ -126,6 +126,11 @@ type Payload struct {
|
||||
ChannelHashHex string `json:"channelHashHex,omitempty"`
|
||||
DecryptionStatus string `json:"decryptionStatus,omitempty"`
|
||||
Channel string `json:"channel,omitempty"`
|
||||
// GRP_DATA (PAYLOAD_TYPE_GRP_DATA=0x06) inner fields, decoded after
|
||||
// channel decrypt per firmware/src/helpers/BaseChatMesh.cpp:382-385.
|
||||
DataType *int `json:"dataType,omitempty"`
|
||||
DataLen *int `json:"dataLen,omitempty"`
|
||||
DecryptedBlob string `json:"decryptedBlob,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Sender string `json:"sender,omitempty"`
|
||||
SenderTimestamp uint32 `json:"sender_timestamp,omitempty"`
|
||||
@@ -137,6 +142,23 @@ type Payload struct {
|
||||
TraceFlags *int `json:"traceFlags,omitempty"`
|
||||
RawHex string `json:"raw,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
// MULTIPART (PAYLOAD_TYPE_MULTIPART=0x0A) inner fields, decoded per
|
||||
// firmware/src/Mesh.cpp:289 — byte0 = (remaining<<4) | inner_type.
|
||||
Remaining *int `json:"remaining,omitempty"`
|
||||
InnerType *int `json:"innerType,omitempty"`
|
||||
InnerTypeName string `json:"innerTypeName,omitempty"`
|
||||
InnerAckCrc string `json:"innerAckCrc,omitempty"`
|
||||
InnerPayload string `json:"innerPayload,omitempty"`
|
||||
// CONTROL (PAYLOAD_TYPE_CONTROL=0x0B) byte0 flags, per
|
||||
// firmware/src/Mesh.cpp:69 — byte0 high-bit marks zero-hop direct subset.
|
||||
CtrlFlags string `json:"ctrlFlags,omitempty"`
|
||||
CtrlZeroHop *bool `json:"ctrlZeroHop,omitempty"`
|
||||
CtrlLength *int `json:"ctrlLength,omitempty"`
|
||||
// RAW_CUSTOM (PAYLOAD_TYPE_RAW_CUSTOM=0x0F) — application-defined per
|
||||
// firmware/src/Mesh.cpp:577 (createRawData). Exposes the bare envelope
|
||||
// shape (length + leading tag) so consumers can triage by app id.
|
||||
RawLength *int `json:"rawLength,omitempty"`
|
||||
FirstByteTag string `json:"firstByteTag,omitempty"`
|
||||
}
|
||||
|
||||
// DecodedPacket is the full decoded result.
|
||||
@@ -147,6 +169,7 @@ type DecodedPacket struct {
|
||||
Payload Payload `json:"payload"`
|
||||
Raw string `json:"raw"`
|
||||
Anomaly string `json:"anomaly,omitempty"`
|
||||
payloadRaw []byte
|
||||
}
|
||||
|
||||
func decodeHeader(b byte) Header {
|
||||
@@ -172,9 +195,35 @@ func decodeHeader(b byte) Header {
|
||||
}
|
||||
}
|
||||
|
||||
func decodePath(pathByte byte, buf []byte, offset int) (Path, int) {
|
||||
// Firmware-derived limits — see firmware/src/MeshCore.h:19,21.
|
||||
const (
|
||||
maxPathSize = 64 // MAX_PATH_SIZE — total path bytes allowed
|
||||
maxPacketPayload = 184 // MAX_PACKET_PAYLOAD — max raw payload bytes
|
||||
)
|
||||
|
||||
// isValidPathLen mirrors firmware Packet::isValidPathLen
|
||||
// (firmware/src/Packet.cpp:13-18). hash_size==4 is reserved; total path bytes
|
||||
// must fit within MAX_PATH_SIZE.
|
||||
func isValidPathLen(pathByte byte) bool {
|
||||
hashCount := int(pathByte & 0x3F)
|
||||
hashSize := int(pathByte>>6) + 1
|
||||
if hashSize == 4 {
|
||||
return false // reserved
|
||||
}
|
||||
return hashCount*hashSize <= maxPathSize
|
||||
}
|
||||
|
||||
func decodePath(pathByte byte, buf []byte, offset int) (Path, int, error) {
|
||||
hashSize := int(pathByte>>6) + 1
|
||||
hashCount := int(pathByte & 0x3F)
|
||||
// Exact mirror of firmware Packet::isValidPathLen (Packet.cpp:13-18).
|
||||
// hash_size==4 is reserved and is rejected by firmware regardless of
|
||||
// hash_count, so we must reject 0xC0 etc even on zero-hop packets —
|
||||
// firmware never emits them, so an on-wire pathByte with the upper
|
||||
// 2 bits set to 11 is by definition malformed/adversarial.
|
||||
if !isValidPathLen(pathByte) {
|
||||
return Path{}, 0, fmt.Errorf("invalid path encoding: pathByte 0x%02X (hash_size=%d hash_count=%d) violates firmware validity (Packet.cpp:13-18, MAX_PATH_SIZE=%d)", pathByte, hashSize, hashCount, maxPathSize)
|
||||
}
|
||||
totalBytes := hashSize * hashCount
|
||||
hops := make([]string, 0, hashCount)
|
||||
|
||||
@@ -191,7 +240,7 @@ func decodePath(pathByte byte, buf []byte, offset int) (Path, int) {
|
||||
HashSize: hashSize,
|
||||
HashCount: hashCount,
|
||||
Hops: hops,
|
||||
}, totalBytes
|
||||
}, totalBytes, nil
|
||||
}
|
||||
|
||||
// isTransportRoute delegates to packetpath.IsTransportRoute.
|
||||
@@ -300,6 +349,13 @@ func decodeAdvert(buf []byte, validateSignatures bool) Payload {
|
||||
}
|
||||
name := string(appdata[off:nameEnd])
|
||||
name = sanitizeName(name)
|
||||
// Firmware writes the node name into a 32-byte buffer
|
||||
// (MAX_ADVERT_DATA_SIZE, firmware/src/MeshCore.h:11). Truncate
|
||||
// here so adversarial on-wire adverts can't pollute Payload.Name
|
||||
// with bytes firmware would never emit.
|
||||
if len(name) > 32 {
|
||||
name = name[:32]
|
||||
}
|
||||
p.Name = name
|
||||
off = nameEnd
|
||||
// Skip null terminator(s)
|
||||
@@ -310,6 +366,17 @@ func decodeAdvert(buf []byte, validateSignatures bool) Payload {
|
||||
|
||||
// Telemetry bytes after name: battery_mv(2 LE) + temperature_c(2 LE, signed, /100)
|
||||
// Only sensor nodes (advType=4) carry telemetry bytes.
|
||||
//
|
||||
// Firmware derivation (see firmware/src/helpers/SensorMesh.h and the
|
||||
// SensorHost::handleAdvert path in firmware/src/helpers/SensorMesh.cpp:
|
||||
// the sensor builds appdata as <flags+adv_type><pubkey?><name\0>
|
||||
// followed by two little-endian uint16 fields appended verbatim:
|
||||
// appdata[name_end+0..1] = battery voltage in millivolts (uint16 LE,
|
||||
// valid 0 < mv ≤ 10000)
|
||||
// appdata[name_end+2..3] = temperature × 100 (int16 LE, divide by 100
|
||||
// for °C; valid raw -5000..10000 → -50..100 °C)
|
||||
// We accept only adverts whose flags.Sensor bit is set (firmware
|
||||
// AdvertDataHelpers.h:7-12, ADV_TYPE_SENSOR=4) before parsing telemetry.
|
||||
if p.Flags.Sensor && off+4 <= len(appdata) {
|
||||
batteryMv := int(binary.LittleEndian.Uint16(appdata[off : off+2]))
|
||||
tempRaw := int16(binary.LittleEndian.Uint16(appdata[off+2 : off+4]))
|
||||
@@ -479,6 +546,185 @@ func decodeGrpTxt(buf []byte, channelKeys map[string]string) Payload {
|
||||
}
|
||||
}
|
||||
|
||||
// decodeGrpData decodes PAYLOAD_TYPE_GRP_DATA (0x06). Outer envelope is the
|
||||
// same shape as GRP_TXT (channel_hash(1)+MAC(2)+ciphertext) — see
|
||||
// firmware/src/helpers/BaseChatMesh.cpp:476,500. When the channel key matches,
|
||||
// the decrypted inner is parsed per firmware/src/helpers/BaseChatMesh.cpp:382-385
|
||||
// as data_type(uint16 LE) + data_len(1) + blob(data_len).
|
||||
func decodeGrpData(buf []byte, channelKeys map[string]string) Payload {
|
||||
if len(buf) < 3 {
|
||||
return Payload{Type: "GRP_DATA", Error: "too short", RawHex: hex.EncodeToString(buf)}
|
||||
}
|
||||
channelHash := int(buf[0])
|
||||
channelHashHex := fmt.Sprintf("%02X", buf[0])
|
||||
mac := hex.EncodeToString(buf[1:3])
|
||||
encryptedData := hex.EncodeToString(buf[3:])
|
||||
|
||||
hasKeys := len(channelKeys) > 0
|
||||
if hasKeys && len(encryptedData) >= 10 {
|
||||
for name, key := range channelKeys {
|
||||
plain, err := decryptChannelBlock(encryptedData, mac, key)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
// Inner: data_type(uint16 LE) + data_len(1) + blob (firmware:382-385).
|
||||
if len(plain) < 3 {
|
||||
return Payload{
|
||||
Type: "GRP_DATA",
|
||||
Channel: name,
|
||||
ChannelHash: channelHash,
|
||||
ChannelHashHex: channelHashHex,
|
||||
DecryptionStatus: "decrypted",
|
||||
Error: "inner too short",
|
||||
}
|
||||
}
|
||||
dataType := int(binary.LittleEndian.Uint16(plain[0:2]))
|
||||
dataLen := int(plain[2])
|
||||
if 3+dataLen > len(plain) {
|
||||
return Payload{
|
||||
Type: "GRP_DATA",
|
||||
Channel: name,
|
||||
ChannelHash: channelHash,
|
||||
ChannelHashHex: channelHashHex,
|
||||
DecryptionStatus: "decrypted",
|
||||
DataType: &dataType,
|
||||
DataLen: &dataLen,
|
||||
Error: "inner data_len exceeds buffer",
|
||||
}
|
||||
}
|
||||
blob := hex.EncodeToString(plain[3 : 3+dataLen])
|
||||
return Payload{
|
||||
Type: "GRP_DATA",
|
||||
Channel: name,
|
||||
ChannelHash: channelHash,
|
||||
ChannelHashHex: channelHashHex,
|
||||
DecryptionStatus: "decrypted",
|
||||
DataType: &dataType,
|
||||
DataLen: &dataLen,
|
||||
DecryptedBlob: blob,
|
||||
}
|
||||
}
|
||||
return Payload{
|
||||
Type: "GRP_DATA",
|
||||
ChannelHash: channelHash,
|
||||
ChannelHashHex: channelHashHex,
|
||||
DecryptionStatus: "decryption_failed",
|
||||
MAC: mac,
|
||||
EncryptedData: encryptedData,
|
||||
}
|
||||
}
|
||||
|
||||
return Payload{
|
||||
Type: "GRP_DATA",
|
||||
ChannelHash: channelHash,
|
||||
ChannelHashHex: channelHashHex,
|
||||
DecryptionStatus: "no_key",
|
||||
MAC: mac,
|
||||
EncryptedData: encryptedData,
|
||||
}
|
||||
}
|
||||
|
||||
// decodeMultipart decodes PAYLOAD_TYPE_MULTIPART (0x0A) per
|
||||
// firmware/src/Mesh.cpp:287-310. byte0 = (remaining<<4) | inner_type;
|
||||
// when inner_type == PAYLOAD_TYPE_ACK the next 4 bytes are an ack_crc.
|
||||
func decodeMultipart(buf []byte) Payload {
|
||||
if len(buf) < 1 {
|
||||
return Payload{Type: "MULTIPART", Error: "too short", RawHex: hex.EncodeToString(buf)}
|
||||
}
|
||||
remaining := int(buf[0] >> 4)
|
||||
innerType := int(buf[0] & 0x0F)
|
||||
innerName := payloadTypeNames[innerType]
|
||||
if innerName == "" {
|
||||
innerName = "UNKNOWN"
|
||||
}
|
||||
p := Payload{
|
||||
Type: "MULTIPART",
|
||||
Remaining: &remaining,
|
||||
InnerType: &innerType,
|
||||
InnerTypeName: innerName,
|
||||
}
|
||||
if innerType == PayloadACK && len(buf) >= 5 {
|
||||
// ack_crc is little-endian; surface as canonical big-endian hex
|
||||
// to match decodeAck's extraHash convention.
|
||||
crc := binary.LittleEndian.Uint32(buf[1:5])
|
||||
p.InnerAckCrc = fmt.Sprintf("%08x", crc)
|
||||
} else if len(buf) > 1 {
|
||||
p.InnerPayload = hex.EncodeToString(buf[1:])
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// decodeControl decodes PAYLOAD_TYPE_CONTROL (0x0B) byte0 flags per
|
||||
// firmware/src/Mesh.cpp:69 (high-bit set ⇒ zero-hop direct subset).
|
||||
func decodeControl(buf []byte) Payload {
|
||||
if len(buf) < 1 {
|
||||
return Payload{Type: "CONTROL", Error: "too short", RawHex: hex.EncodeToString(buf)}
|
||||
}
|
||||
zeroHop := buf[0]&0x80 != 0
|
||||
length := len(buf)
|
||||
return Payload{
|
||||
Type: "CONTROL",
|
||||
CtrlFlags: fmt.Sprintf("%02x", buf[0]),
|
||||
CtrlZeroHop: &zeroHop,
|
||||
CtrlLength: &length,
|
||||
RawHex: hex.EncodeToString(buf),
|
||||
}
|
||||
}
|
||||
|
||||
// decodeRawCustom decodes PAYLOAD_TYPE_RAW_CUSTOM (0x0F). Application-defined
|
||||
// payload per firmware/src/Mesh.cpp:577 (createRawData); we only surface the
|
||||
// envelope shape (total length + leading tag byte).
|
||||
func decodeRawCustom(buf []byte) Payload {
|
||||
length := len(buf)
|
||||
p := Payload{
|
||||
Type: "RAW_CUSTOM",
|
||||
RawLength: &length,
|
||||
RawHex: hex.EncodeToString(buf),
|
||||
}
|
||||
if length > 0 {
|
||||
p.FirstByteTag = fmt.Sprintf("%02X", buf[0])
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// decryptChannelBlock performs the MAC verify + AES-128-ECB decrypt step shared
|
||||
// by GRP_TXT and GRP_DATA, returning the raw plaintext block (no further
|
||||
// parsing). See firmware/src/helpers/BaseChatMesh.cpp:376-391.
|
||||
func decryptChannelBlock(ciphertextHex, macHex, channelKeyHex string) ([]byte, error) {
|
||||
channelKey, err := hex.DecodeString(channelKeyHex)
|
||||
if err != nil || len(channelKey) != 16 {
|
||||
return nil, fmt.Errorf("invalid channel key")
|
||||
}
|
||||
macBytes, err := hex.DecodeString(macHex)
|
||||
if err != nil || len(macBytes) != 2 {
|
||||
return nil, fmt.Errorf("invalid MAC")
|
||||
}
|
||||
ciphertext, err := hex.DecodeString(ciphertextHex)
|
||||
if err != nil || len(ciphertext) == 0 {
|
||||
return nil, fmt.Errorf("invalid ciphertext")
|
||||
}
|
||||
channelSecret := make([]byte, 32)
|
||||
copy(channelSecret, channelKey)
|
||||
h := hmac.New(sha256.New, channelSecret)
|
||||
h.Write(ciphertext)
|
||||
calc := h.Sum(nil)
|
||||
if calc[0] != macBytes[0] || calc[1] != macBytes[1] {
|
||||
return nil, fmt.Errorf("MAC verification failed")
|
||||
}
|
||||
if len(ciphertext)%aes.BlockSize != 0 {
|
||||
return nil, fmt.Errorf("ciphertext not aligned to AES block size")
|
||||
}
|
||||
block, err := aes.NewCipher(channelKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
plain := make([]byte, len(ciphertext))
|
||||
for i := 0; i < len(ciphertext); i += aes.BlockSize {
|
||||
block.Decrypt(plain[i:i+aes.BlockSize], ciphertext[i:i+aes.BlockSize])
|
||||
}
|
||||
return plain, nil
|
||||
}
|
||||
|
||||
func decodeAnonReq(buf []byte) Payload {
|
||||
if len(buf) < 35 {
|
||||
return Payload{Type: "ANON_REQ", Error: "too short", RawHex: hex.EncodeToString(buf)}
|
||||
@@ -538,12 +784,20 @@ func decodePayload(payloadType int, buf []byte, channelKeys map[string]string, v
|
||||
return decodeAdvert(buf, validateSignatures)
|
||||
case PayloadGRP_TXT:
|
||||
return decodeGrpTxt(buf, channelKeys)
|
||||
case PayloadGRP_DATA:
|
||||
return decodeGrpData(buf, channelKeys)
|
||||
case PayloadANON_REQ:
|
||||
return decodeAnonReq(buf)
|
||||
case PayloadPATH:
|
||||
return decodePathPayload(buf)
|
||||
case PayloadTRACE:
|
||||
return decodeTrace(buf)
|
||||
case PayloadMULTIPART:
|
||||
return decodeMultipart(buf)
|
||||
case PayloadCONTROL:
|
||||
return decodeControl(buf)
|
||||
case PayloadRAW_CUSTOM:
|
||||
return decodeRawCustom(buf)
|
||||
default:
|
||||
return Payload{Type: "UNKNOWN", RawHex: hex.EncodeToString(buf)}
|
||||
}
|
||||
@@ -584,10 +838,26 @@ func DecodePacket(hexString string, channelKeys map[string]string, validateSigna
|
||||
pathByte := buf[offset]
|
||||
offset++
|
||||
|
||||
path, bytesConsumed := decodePath(pathByte, buf, offset)
|
||||
path, bytesConsumed, decodeErr := decodePath(pathByte, buf, offset)
|
||||
if decodeErr != nil {
|
||||
return nil, decodeErr
|
||||
}
|
||||
offset += bytesConsumed
|
||||
|
||||
// Bounds check: pathByte is wire-supplied (hash_size in upper 2 bits,
|
||||
// hash_count in lower 6 bits → up to 4*63=252 claimed path bytes). A
|
||||
// malformed packet can claim more bytes than the buffer holds — without
|
||||
// this guard `buf[offset:]` panics with `slice bounds out of range
|
||||
// [offset:len(buf)]`. See issue #1211 (prod observed [218:15]).
|
||||
if offset > len(buf) {
|
||||
return nil, fmt.Errorf("packet path length (%d bytes claimed by pathByte 0x%02X) exceeds buffer (%d bytes)", bytesConsumed, pathByte, len(buf))
|
||||
}
|
||||
|
||||
payloadBuf := buf[offset:]
|
||||
// Firmware caps payload at MAX_PACKET_PAYLOAD=184 (firmware/src/MeshCore.h:19).
|
||||
if len(payloadBuf) > maxPacketPayload {
|
||||
return nil, fmt.Errorf("packet payload (%d bytes) exceeds firmware MAX_PACKET_PAYLOAD=%d (MeshCore.h:19)", len(payloadBuf), maxPacketPayload)
|
||||
}
|
||||
payload := decodePayload(header.PayloadType, payloadBuf, channelKeys, validateSignatures)
|
||||
|
||||
// TRACE packets store hop IDs in the payload (buf[9:]) rather than the header
|
||||
@@ -658,6 +928,7 @@ func DecodePacket(hexString string, channelKeys map[string]string, validateSigna
|
||||
Payload: payload,
|
||||
Raw: strings.ToUpper(hexString),
|
||||
Anomaly: anomaly,
|
||||
payloadRaw: payloadBuf,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -775,8 +1046,13 @@ func ValidateAdvert(p *Payload) (bool, string) {
|
||||
|
||||
if p.Flags != nil {
|
||||
role := advertRole(p.Flags)
|
||||
validRoles := map[string]bool{"repeater": true, "companion": true, "room": true, "sensor": true}
|
||||
if !validRoles[role] {
|
||||
// Accept canonical labels plus "none" (ADV_TYPE_NONE=0) and the
|
||||
// "type-N" placeholders we now return for ADV_TYPE 5-15 (FUTURE)
|
||||
// — see firmware/src/helpers/AdvertDataHelpers.h:7-12.
|
||||
validRoles := map[string]bool{
|
||||
"repeater": true, "companion": true, "room": true, "sensor": true, "none": true,
|
||||
}
|
||||
if !validRoles[role] && !strings.HasPrefix(role, "type-") {
|
||||
return false, fmt.Sprintf("unknown role: %s", role)
|
||||
}
|
||||
}
|
||||
@@ -796,17 +1072,29 @@ func sanitizeName(s string) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// advertRole returns a stable role label for an advert. Follows firmware
|
||||
// ADV_TYPE_* constants in firmware/src/helpers/AdvertDataHelpers.h:7-12:
|
||||
// 0 NONE, 1 CHAT, 2 REPEATER, 3 ROOM, 4 SENSOR, 5-15 FUTURE.
|
||||
// Previously this coerced both 0 (NONE) and 5-15 (FUTURE) to "companion",
|
||||
// silently relabelling unknown/reserved types — see issue #1279 P1 #3.
|
||||
func advertRole(f *AdvertFlags) string {
|
||||
if f.Repeater {
|
||||
if f == nil {
|
||||
return "companion"
|
||||
}
|
||||
switch f.Type {
|
||||
case 0:
|
||||
return "none"
|
||||
case 1:
|
||||
return "companion"
|
||||
case 2:
|
||||
return "repeater"
|
||||
}
|
||||
if f.Room {
|
||||
case 3:
|
||||
return "room"
|
||||
}
|
||||
if f.Sensor {
|
||||
case 4:
|
||||
return "sensor"
|
||||
default:
|
||||
return fmt.Sprintf("type-%d", f.Type)
|
||||
}
|
||||
return "companion"
|
||||
}
|
||||
|
||||
func epochToISO(epoch uint32) string {
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// --- Issue #1211 round-1 protocol-correctness regressions ---
|
||||
// See cmd/server/decoder_bounds_test.go for full firmware citations
|
||||
// (firmware/src/Packet.cpp:13-18, firmware/src/MeshCore.h:19-21).
|
||||
|
||||
// pathByte=0xF6 → hash_size=4 (reserved), hash_count=54.
|
||||
// Buffer holds all 216 claimed bytes so the OOB guard does NOT catch.
|
||||
func TestDecodePacketRejectsReservedHashSize_Issue1211(t *testing.T) {
|
||||
raw := "12F6" + strings.Repeat("AB", 216) + strings.Repeat("CD", 8)
|
||||
pkt, err := DecodePacket(raw, nil, false)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error rejecting reserved hash_size=4 (firmware Packet.cpp:13-18); got nil, pkt=%+v", pkt)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "path") {
|
||||
t.Errorf("error should mention path; got %q", err)
|
||||
}
|
||||
}
|
||||
|
||||
// pathByte=0xBF → hash_size=3, hash_count=63, total=189 > MAX_PATH_SIZE=64.
|
||||
func TestDecodePacketRejectsOversizedPath_Issue1211(t *testing.T) {
|
||||
raw := "12BF" + strings.Repeat("AB", 189) + strings.Repeat("CD", 8)
|
||||
pkt, err := DecodePacket(raw, nil, false)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error rejecting hash_count*hash_size > 64; got nil, pkt=%+v", pkt)
|
||||
}
|
||||
}
|
||||
|
||||
// Payload > MAX_PACKET_PAYLOAD (184).
|
||||
func TestDecodePacketRejectsOversizedPayload_Issue1211(t *testing.T) {
|
||||
raw := "1200" + strings.Repeat("AA", 200)
|
||||
pkt, err := DecodePacket(raw, nil, false)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error rejecting payload > MAX_PACKET_PAYLOAD=184 (firmware MeshCore.h:19); got nil, pkt=%+v", pkt)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "payload") {
|
||||
t.Errorf("error should mention payload; got %q", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodePath_RejectsReservedHashSize_Issue1211(t *testing.T) {
|
||||
buf := make([]byte, 216)
|
||||
for i := range buf {
|
||||
buf[i] = 0xAB
|
||||
}
|
||||
_, _, err := decodePath(0xF6, buf, 0)
|
||||
if err == nil {
|
||||
t.Fatalf("decodePath should reject pathByte=0xF6 (hash_size=4 reserved); got nil err")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodePath_RejectsOversizedPath_Issue1211(t *testing.T) {
|
||||
buf := make([]byte, 189)
|
||||
_, _, err := decodePath(0xBF, buf, 0)
|
||||
if err == nil {
|
||||
t.Fatalf("decodePath should reject hash_count*hash_size=189 > MAX_PATH_SIZE=64; got nil err")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodePath_AcceptsValidEncodings_Issue1211(t *testing.T) {
|
||||
buf := []byte{0x01, 0x02, 0x03, 0x04, 0x05}
|
||||
path, consumed, err := decodePath(0x05, buf, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("decodePath rejected valid encoding: %v", err)
|
||||
}
|
||||
if consumed != 5 {
|
||||
t.Errorf("consumed=%d, want 5", consumed)
|
||||
}
|
||||
if path.HashCount != 5 || path.HashSize != 1 {
|
||||
t.Errorf("decode wrong: hashCount=%d hashSize=%d", path.HashCount, path.HashSize)
|
||||
}
|
||||
}
|
||||
|
||||
// Kent #1 — pin tautological assertion: error MUST mention "path length"
|
||||
// AND "exceeds buffer", not just non-nil. Uses firmware-valid pathByte
|
||||
// that exhausts a small buffer, so the OOB guard fires (not validity).
|
||||
func TestDecodePacketBoundsFromWireErrorPhrasing_Issue1211(t *testing.T) {
|
||||
raw := "120A" + strings.Repeat("AA", 5)
|
||||
_, err := DecodePacket(raw, nil, false)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "path length") {
|
||||
t.Errorf("error missing 'path length'; got %q", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "exceeds buffer") {
|
||||
t.Errorf("error missing 'exceeds buffer'; got %q", err)
|
||||
}
|
||||
}
|
||||
|
||||
var _ = hex.EncodeToString
|
||||
+155
-22
@@ -447,6 +447,28 @@ func TestValidateAdvert(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodePacketPayloadRaw(t *testing.T) {
|
||||
// Build a minimal TRANSPORT_FLOOD packet (route_type=0):
|
||||
// header(1) + transport_codes(4) + path_len(1) + payload(N)
|
||||
// Header 0x00 = route_type=TRANSPORT_FLOOD, payload_type=0, version=0
|
||||
// Code1=9A52, Code2=0000, path_len=0x00 (0 hops, hash_size=1)
|
||||
payload := []byte("hello")
|
||||
raw := []byte{0x00, 0x9A, 0x52, 0x00, 0x00, 0x00}
|
||||
raw = append(raw, payload...)
|
||||
hexStr := strings.ToUpper(hex.EncodeToString(raw))
|
||||
|
||||
decoded, err := DecodePacket(hexStr, nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodePacket: %v", err)
|
||||
}
|
||||
if decoded.TransportCodes == nil {
|
||||
t.Fatal("expected TransportCodes, got nil")
|
||||
}
|
||||
if string(decoded.payloadRaw) != string(payload) {
|
||||
t.Errorf("payloadRaw = %v, want %v", decoded.payloadRaw, payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeGrpTxtShort(t *testing.T) {
|
||||
p := decodeGrpTxt([]byte{0x01, 0x02}, nil)
|
||||
if p.Error != "too short" {
|
||||
@@ -631,21 +653,28 @@ func TestDecodeEncryptedPayloadValid(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDecodePayloadGRPData(t *testing.T) {
|
||||
// GRP_DATA (0x06) decoder added for #1279 P0 #1 — envelope only when no
|
||||
// channel key matches (firmware/src/helpers/BaseChatMesh.cpp:500).
|
||||
buf := []byte{0x01, 0x02, 0x03}
|
||||
p := decodePayload(PayloadGRP_DATA, buf, nil, false)
|
||||
if p.Type != "UNKNOWN" {
|
||||
t.Errorf("type=%s, want UNKNOWN", p.Type)
|
||||
}
|
||||
if p.RawHex != "010203" {
|
||||
t.Errorf("rawHex=%s, want 010203", p.RawHex)
|
||||
if p.Type != "GRP_DATA" {
|
||||
t.Errorf("type=%s, want GRP_DATA", p.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodePayloadRAWCustom(t *testing.T) {
|
||||
// #1279 P2 #5: RAW_CUSTOM (0x0F) now exposes envelope shape (length +
|
||||
// first-byte tag) per firmware/src/Mesh.cpp:577 (createRawData).
|
||||
buf := []byte{0xFF, 0xFE}
|
||||
p := decodePayload(PayloadRAW_CUSTOM, buf, nil, false)
|
||||
if p.Type != "UNKNOWN" {
|
||||
t.Errorf("type=%s, want UNKNOWN", p.Type)
|
||||
if p.Type != "RAW_CUSTOM" {
|
||||
t.Errorf("type=%s, want RAW_CUSTOM", p.Type)
|
||||
}
|
||||
if p.RawLength == nil || *p.RawLength != 2 {
|
||||
t.Errorf("rawLength missing or wrong, want 2")
|
||||
}
|
||||
if p.FirstByteTag != "FF" {
|
||||
t.Errorf("firstByteTag=%q, want FF", p.FirstByteTag)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1097,24 +1126,24 @@ func TestDecodeHeaderUnknownTypes(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDecodePayloadMultipart(t *testing.T) {
|
||||
// MULTIPART (0x0A) falls through to default → UNKNOWN
|
||||
// MULTIPART (0x0A) now decoded — #1279 P0 #2 (firmware/src/Mesh.cpp:289).
|
||||
p := decodePayload(PayloadMULTIPART, []byte{0x01, 0x02}, nil, false)
|
||||
if p.Type != "UNKNOWN" {
|
||||
t.Errorf("MULTIPART type=%s, want UNKNOWN", p.Type)
|
||||
if p.Type != "MULTIPART" {
|
||||
t.Errorf("MULTIPART type=%s, want MULTIPART", p.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodePayloadControl(t *testing.T) {
|
||||
// CONTROL (0x0B) falls through to default → UNKNOWN
|
||||
// CONTROL (0x0B) now decoded — #1279 P1 #4 (firmware/src/Mesh.cpp:69).
|
||||
p := decodePayload(PayloadCONTROL, []byte{0x01, 0x02}, nil, false)
|
||||
if p.Type != "UNKNOWN" {
|
||||
t.Errorf("CONTROL type=%s, want UNKNOWN", p.Type)
|
||||
if p.Type != "CONTROL" {
|
||||
t.Errorf("CONTROL type=%s, want CONTROL", p.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodePathTruncatedBuffer(t *testing.T) {
|
||||
// path byte claims 5 hops of 2 bytes = 10 bytes, but only 4 available
|
||||
path, consumed := decodePath(0x45, []byte{0xAA, 0x11, 0xBB, 0x22}, 0)
|
||||
path, consumed, _ := decodePath(0x45, []byte{0xAA, 0x11, 0xBB, 0x22}, 0)
|
||||
if path.HashCount != 5 {
|
||||
t.Errorf("hashCount=%d, want 5", path.HashCount)
|
||||
}
|
||||
@@ -1708,15 +1737,15 @@ func TestZeroHopTransportDirectHashSize(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestZeroHopTransportDirectHashSizeWithNonZeroUpperBits(t *testing.T) {
|
||||
// TRANSPORT_DIRECT (RouteType=3) + REQ (PayloadType=0) → header byte = 0x03
|
||||
// 4 bytes transport codes + pathByte=0xC0 → hash_count=0, hash_size bits=11 → should still get HashSize=0
|
||||
// pathByte=0xC0 → hash_size bits=11 (4, reserved per firmware Packet.cpp:13-18).
|
||||
// Firmware Packet::isValidPathLen rejects this regardless of hash_count,
|
||||
// because hash_size==4 is reserved. Go decoder must mirror that — even
|
||||
// when hash_count==0, an attacker-emitted 0xC0 byte should not be
|
||||
// silently accepted; firmware never emits hash_size==4.
|
||||
hex := "03" + "11223344" + "C0" + repeatHex("AA", 20)
|
||||
pkt, err := DecodePacket(hex, nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodePacket failed: %v", err)
|
||||
}
|
||||
if pkt.Path.HashSize != 0 {
|
||||
t.Errorf("TRANSPORT_DIRECT zero-hop with hash_size bits set: want HashSize=0, got %d", pkt.Path.HashSize)
|
||||
_, err := DecodePacket(hex, nil, false)
|
||||
if err == nil {
|
||||
t.Fatalf("DecodePacket(pathByte=0xC0) succeeded; want error mirroring firmware Packet.cpp:13-18 (hash_size==4 reserved)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1976,3 +2005,107 @@ func TestDecodeTraceExtractsSNRValues(t *testing.T) {
|
||||
t.Errorf("SNRValues[1]=%v, want -2.0", pkt.Payload.SNRValues[1])
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecodePacketBoundsFromWire — regression for issue #1211.
|
||||
//
|
||||
// A malformed packet on the wire claimed pathByte=0xF6 (hash_size=4, hash_count=54
|
||||
// → 216 path bytes) inside a 15-byte buffer. decodePath() returned bytesConsumed=216
|
||||
// without bounds-check, causing the outer slice `payloadBuf := buf[offset:]` to
|
||||
// blow up with `slice bounds out of range [218:15]`.
|
||||
//
|
||||
// Expected behaviour: DecodePacket MUST NOT panic on any input. If the path
|
||||
// length claimed by the wire byte exceeds the buffer, it should return a
|
||||
// clean error.
|
||||
func TestDecodePacketBoundsFromWire_Issue1211(t *testing.T) {
|
||||
// 15-byte buffer: header=0x12 (rt=DIRECT, pt=ADVERT), pathByte=0xF6
|
||||
// (hash_size=4, hash_count=54 → claims 216 path bytes), + 13 garbage bytes.
|
||||
raw := "12F6" + strings.Repeat("AA", 13)
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("DecodePacket panicked on malformed input: %v", r)
|
||||
}
|
||||
}()
|
||||
pkt, err := DecodePacket(raw, nil, false)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for malformed packet (path claims 216 bytes in 15-byte buf), got nil; pkt=%+v", pkt)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecodePacketFuzzTruncated — sweep the decoder with truncated payloads.
|
||||
// Zero panics is the acceptance bar.
|
||||
//
|
||||
// Adv M2: the original loop ran 256*256*20 = 1.3M iterations on every
|
||||
// `go test` (in both packages, so 2.6M total). That is not "fuzzing" — it
|
||||
// is an expensive deterministic sweep that runs in the default unit-test
|
||||
// path with no opt-in. We now:
|
||||
//
|
||||
// - gate the exhaustive sweep on !testing.Short() so `go test -short`
|
||||
// skips it (CI's unit gate runs short)
|
||||
// - keep the full sweep under `go test ./...` to preserve coverage
|
||||
// - prefer `go test -fuzz=FuzzDecodePacketTruncated` for actual
|
||||
// randomized fuzzing (see FuzzDecodePacketTruncated below)
|
||||
func TestDecodePacketFuzzTruncated_Issue1211(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("DecodePacket panicked during fuzz: %v", r)
|
||||
}
|
||||
}()
|
||||
if testing.Short() {
|
||||
t.Skip("skipping exhaustive sweep in -short mode; use FuzzDecodePacketTruncated")
|
||||
}
|
||||
// Sweep every pathByte value with a short tail.
|
||||
for hdr := 0; hdr < 256; hdr++ {
|
||||
for pb := 0; pb < 256; pb++ {
|
||||
for tail := 0; tail < 20; tail++ {
|
||||
raw := hex.EncodeToString([]byte{byte(hdr), byte(pb)}) + strings.Repeat("00", tail)
|
||||
_, _ = DecodePacket(raw, nil, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// FuzzDecodePacketTruncated — native go fuzz target. Run with:
|
||||
//
|
||||
// go test -fuzz=FuzzDecodePacketTruncated -fuzztime=30s ./cmd/ingestor
|
||||
//
|
||||
// Zero panics regardless of input is the acceptance bar.
|
||||
func FuzzDecodePacketTruncated(f *testing.F) {
|
||||
seeds := [][]byte{
|
||||
{0x12, 0xF6, 0xAA, 0xAA, 0xAA},
|
||||
{0x12, 0x00},
|
||||
{0x03, 0x11, 0x22, 0x33, 0x44, 0xC0, 0xAA, 0xAA, 0xAA},
|
||||
}
|
||||
for _, s := range seeds {
|
||||
f.Add(s)
|
||||
}
|
||||
f.Fuzz(func(t *testing.T, data []byte) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("DecodePacket panicked on input %x: %v", data, r)
|
||||
}
|
||||
}()
|
||||
_, _ = DecodePacket(hex.EncodeToString(data), nil, false)
|
||||
})
|
||||
}
|
||||
|
||||
// TestDecodeAdvertOversizedNameTruncated asserts decodeAdvert truncates the
|
||||
// advert name to firmware's MAX_ADVERT_DATA_SIZE=32 (firmware/src/MeshCore.h:11).
|
||||
// Firmware writes the node name into a 32-byte buffer, so any on-wire advert
|
||||
// carrying >32 bytes of name data is adversarial — the Go decoder must not
|
||||
// surface attacker-controlled bytes beyond what firmware would ever emit.
|
||||
func TestDecodeAdvertOversizedNameTruncated(t *testing.T) {
|
||||
pubkey := repeatHex("AA", 32)
|
||||
timestamp := "78563412"
|
||||
signature := repeatHex("BB", 64)
|
||||
flags := "81" // chat(1) | hasName(0x80), no location, no feat1/2
|
||||
// 64-byte ASCII 'X' name with no null terminator (firmware buffer is 32 bytes).
|
||||
name := repeatHex("58", 64)
|
||||
hex := "1200" + pubkey + timestamp + signature + flags + name
|
||||
pkt, err := DecodePacket(hex, nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodePacket: %v", err)
|
||||
}
|
||||
if got := len(pkt.Payload.Name); got > 32 {
|
||||
t.Errorf("name length=%d, want <=32 (MAX_ADVERT_DATA_SIZE firmware/src/MeshCore.h:11)", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ func TestHandleMessageAdvertForeign_FlagModeStoresWithFlag(t *testing.T) {
|
||||
payload: []byte(`{"raw":"` + rawHex + `"}`),
|
||||
}
|
||||
// Default mode (no ForeignAdverts.Mode set) MUST be "flag", per #730 design.
|
||||
handleMessage(store, "test", source, msg, nil, &Config{GeoFilter: gf})
|
||||
handleMessage(store, "test", source, msg, nil, nil, &Config{GeoFilter: gf})
|
||||
|
||||
var nodeCount int
|
||||
if err := store.db.QueryRow("SELECT COUNT(*) FROM nodes").Scan(&nodeCount); err != nil {
|
||||
@@ -70,7 +70,7 @@ func TestHandleMessageAdvertForeign_DropModeStillDrops(t *testing.T) {
|
||||
GeoFilter: gf,
|
||||
ForeignAdverts: &ForeignAdvertConfig{Mode: "drop"},
|
||||
}
|
||||
handleMessage(store, "test", source, msg, nil, cfg)
|
||||
handleMessage(store, "test", source, msg, nil, nil, cfg)
|
||||
|
||||
var nodeCount int
|
||||
if err := store.db.QueryRow("SELECT COUNT(*) FROM nodes").Scan(&nodeCount); err != nil {
|
||||
@@ -99,7 +99,7 @@ func TestHandleMessageAdvertInRegion_NotFlaggedForeign(t *testing.T) {
|
||||
topic: "meshcore/SJC/obs1/packets",
|
||||
payload: []byte(`{"raw":"` + rawHex + `"}`),
|
||||
}
|
||||
handleMessage(store, "test", source, msg, nil, &Config{GeoFilter: gf})
|
||||
handleMessage(store, "test", source, msg, nil, nil, &Config{GeoFilter: gf})
|
||||
|
||||
var foreign int
|
||||
err := store.db.QueryRow("SELECT foreign_advert FROM nodes").Scan(&foreign)
|
||||
|
||||
@@ -77,7 +77,7 @@ func TestBuildPacketData_PopulatesFromPubkey(t *testing.T) {
|
||||
Header: Header{PayloadType: PayloadADVERT},
|
||||
Payload: Payload{Type: "ADVERT", PubKey: pk},
|
||||
}
|
||||
pd := BuildPacketData(msg, decoded, "obs", "")
|
||||
pd := BuildPacketData(msg, decoded, "obs", "", nil)
|
||||
if pd.FromPubkey != pk {
|
||||
t.Fatalf("BuildPacketData FromPubkey = %q, want %q", pd.FromPubkey, pk)
|
||||
}
|
||||
@@ -87,7 +87,7 @@ func TestBuildPacketData_PopulatesFromPubkey(t *testing.T) {
|
||||
Header: Header{PayloadType: 2},
|
||||
Payload: Payload{Type: "TXT_MSG"},
|
||||
}
|
||||
pd2 := BuildPacketData(msg, decoded2, "obs", "")
|
||||
pd2 := BuildPacketData(msg, decoded2, "obs", "", nil)
|
||||
if pd2.FromPubkey != "" {
|
||||
t.Fatalf("BuildPacketData FromPubkey for non-ADVERT = %q, want empty", pd2.FromPubkey)
|
||||
}
|
||||
|
||||
@@ -25,6 +25,10 @@ require github.com/meshcore-analyzer/perfio v0.0.0
|
||||
|
||||
replace github.com/meshcore-analyzer/perfio => ../../internal/perfio
|
||||
|
||||
require github.com/meshcore-analyzer/dbschema v0.0.0
|
||||
|
||||
replace github.com/meshcore-analyzer/dbschema => ../../internal/dbschema
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
@@ -39,3 +43,7 @@ require (
|
||||
modernc.org/mathutil v1.6.0 // indirect
|
||||
modernc.org/memory v1.8.0 // indirect
|
||||
)
|
||||
|
||||
require github.com/meshcore-analyzer/prunequeue v0.0.0
|
||||
|
||||
replace github.com/meshcore-analyzer/prunequeue => ../../internal/prunequeue
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package main
|
||||
|
||||
// Tests for issue #1279 P2 item 5: ingestor RAW_CUSTOM exposure.
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDecodeRawCustomExposesLengthAndTag(t *testing.T) {
|
||||
// header = (1<<6)|(0x0F<<2)|1 = 0x7D ; path byte = 0x00 ; payload = A5 DE AD BE EF
|
||||
hexStr := "7D00A5DEADBEEF"
|
||||
pkt, err := DecodePacket(hexStr, nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if pkt.Payload.Type != "RAW_CUSTOM" {
|
||||
t.Fatalf("payload type = %q, want RAW_CUSTOM", pkt.Payload.Type)
|
||||
}
|
||||
if pkt.Payload.RawLength == nil || *pkt.Payload.RawLength != 5 {
|
||||
got := -1
|
||||
if pkt.Payload.RawLength != nil {
|
||||
got = *pkt.Payload.RawLength
|
||||
}
|
||||
t.Errorf("RawLength=%d, want 5", got)
|
||||
}
|
||||
if !strings.EqualFold(pkt.Payload.FirstByteTag, "A5") {
|
||||
t.Errorf("FirstByteTag=%q, want A5", pkt.Payload.FirstByteTag)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package main
|
||||
|
||||
// Tests for issue #1279 P0+P1 decoder additions.
|
||||
//
|
||||
// Each test uses firmware-derived wire vectors:
|
||||
// - GRP_DATA outer: firmware/src/helpers/BaseChatMesh.cpp:500 (createGroupDatagram)
|
||||
// - GRP_DATA inner: firmware/src/helpers/BaseChatMesh.cpp:382-385
|
||||
// - MULTIPART byte0: firmware/src/Mesh.cpp:289
|
||||
// - MULTIPART ACK inner: firmware/src/Mesh.cpp:292-307
|
||||
// - CONTROL byte0 flags: firmware/src/Mesh.cpp:69 + createControlData at Mesh.cpp:609
|
||||
// - advertRole label rules: firmware/src/helpers/AdvertDataHelpers.h:7-12
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// --- P0 #1: GRP_DATA decoder ---
|
||||
|
||||
// buildChannelEncrypted encrypts arbitrary inner bytes with the channel
|
||||
// key/MAC scheme firmware uses for both GRP_TXT and GRP_DATA (see
|
||||
// BaseChatMesh.cpp:376-391: AES-128-ECB, HMAC-SHA256-trunc-2 MAC).
|
||||
func buildChannelEncrypted(channelKeyHex string, inner []byte) (ctHex, macHex string) {
|
||||
key, _ := hex.DecodeString(channelKeyHex)
|
||||
plain := append([]byte{}, inner...)
|
||||
pad := aes.BlockSize - (len(plain) % aes.BlockSize)
|
||||
if pad != aes.BlockSize {
|
||||
plain = append(plain, make([]byte, pad)...)
|
||||
}
|
||||
block, _ := aes.NewCipher(key)
|
||||
ct := make([]byte, len(plain))
|
||||
for i := 0; i < len(plain); i += aes.BlockSize {
|
||||
block.Encrypt(ct[i:i+aes.BlockSize], plain[i:i+aes.BlockSize])
|
||||
}
|
||||
secret := make([]byte, 32)
|
||||
copy(secret, key)
|
||||
h := hmac.New(sha256.New, secret)
|
||||
h.Write(ct)
|
||||
mac := h.Sum(nil)
|
||||
return hex.EncodeToString(ct), hex.EncodeToString(mac[:2])
|
||||
}
|
||||
|
||||
func TestDecodeGrpDataNoKey(t *testing.T) {
|
||||
// Envelope alone (no key in store).
|
||||
buf := []byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x11}
|
||||
p := decodeGrpData(buf, nil)
|
||||
if p.Type != "GRP_DATA" {
|
||||
t.Fatalf("type=%q want GRP_DATA", p.Type)
|
||||
}
|
||||
if p.ChannelHash != 0xAA {
|
||||
t.Errorf("channelHash=%d want 170", p.ChannelHash)
|
||||
}
|
||||
if p.ChannelHashHex != "AA" {
|
||||
t.Errorf("channelHashHex=%q want AA", p.ChannelHashHex)
|
||||
}
|
||||
if p.MAC != "bbcc" {
|
||||
t.Errorf("mac=%q want bbcc", p.MAC)
|
||||
}
|
||||
if p.EncryptedData != "ddeeff11" {
|
||||
t.Errorf("encryptedData=%q want ddeeff11", p.EncryptedData)
|
||||
}
|
||||
if p.DecryptionStatus != "no_key" {
|
||||
t.Errorf("decryptionStatus=%q want no_key", p.DecryptionStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeGrpDataDecryptedInner(t *testing.T) {
|
||||
// Inner per BaseChatMesh.cpp:382-385: data_type(uint16 LE) + data_len(1) + blob.
|
||||
key := "2cc3d22840e086105ad73443da2cacb8"
|
||||
blob := []byte{0x10, 0x20, 0x30, 0x40, 0x50}
|
||||
inner := []byte{0x34, 0x12, byte(len(blob))} // data_type = 0x1234
|
||||
inner = append(inner, blob...)
|
||||
ctHex, macHex := buildChannelEncrypted(key, inner)
|
||||
|
||||
buf := []byte{0xAB}
|
||||
mb, _ := hex.DecodeString(macHex)
|
||||
buf = append(buf, mb...)
|
||||
cb, _ := hex.DecodeString(ctHex)
|
||||
buf = append(buf, cb...)
|
||||
|
||||
p := decodeGrpData(buf, map[string]string{"test": key})
|
||||
if p.Type != "GRP_DATA" {
|
||||
t.Fatalf("type=%q want GRP_DATA", p.Type)
|
||||
}
|
||||
if p.DecryptionStatus != "decrypted" {
|
||||
t.Fatalf("decryptionStatus=%q want decrypted", p.DecryptionStatus)
|
||||
}
|
||||
if p.DataType == nil || *p.DataType != 0x1234 {
|
||||
t.Errorf("dataType=%v want 0x1234", p.DataType)
|
||||
}
|
||||
if p.DataLen == nil || *p.DataLen != 5 {
|
||||
t.Errorf("dataLen=%v want 5", p.DataLen)
|
||||
}
|
||||
if p.DecryptedBlob != hex.EncodeToString(blob) {
|
||||
t.Errorf("decryptedBlob=%q want %q", p.DecryptedBlob, hex.EncodeToString(blob))
|
||||
}
|
||||
if p.Channel != "test" {
|
||||
t.Errorf("channel=%q want test", p.Channel)
|
||||
}
|
||||
}
|
||||
|
||||
// --- P0 #2: MULTIPART decoder ---
|
||||
|
||||
func TestDecodeMultipartAck(t *testing.T) {
|
||||
// remaining=3, inner_type=PAYLOAD_TYPE_ACK(0x03), ack_crc=0xDEADBEEF.
|
||||
// byte0 = (3<<4) | 3 = 0x33; next 4 bytes are LE crc.
|
||||
buf := []byte{0x33, 0xEF, 0xBE, 0xAD, 0xDE}
|
||||
p := decodeMultipart(buf)
|
||||
if p.Type != "MULTIPART" {
|
||||
t.Fatalf("type=%q want MULTIPART", p.Type)
|
||||
}
|
||||
if p.Remaining == nil || *p.Remaining != 3 {
|
||||
t.Errorf("remaining=%v want 3", p.Remaining)
|
||||
}
|
||||
if p.InnerType == nil || *p.InnerType != 0x03 {
|
||||
t.Errorf("innerType=%v want 3", p.InnerType)
|
||||
}
|
||||
if p.InnerTypeName != "ACK" {
|
||||
t.Errorf("innerTypeName=%q want ACK", p.InnerTypeName)
|
||||
}
|
||||
if p.InnerAckCrc != "deadbeef" {
|
||||
t.Errorf("innerAckCrc=%q want deadbeef", p.InnerAckCrc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeMultipartNonAck(t *testing.T) {
|
||||
// remaining=2, inner_type=0x02 (TXT_MSG), arbitrary inner payload.
|
||||
buf := []byte{0x22, 0x01, 0x02, 0x03}
|
||||
p := decodeMultipart(buf)
|
||||
if p.Remaining == nil || *p.Remaining != 2 {
|
||||
t.Errorf("remaining=%v want 2", p.Remaining)
|
||||
}
|
||||
if p.InnerType == nil || *p.InnerType != 0x02 {
|
||||
t.Errorf("innerType=%v want 2", p.InnerType)
|
||||
}
|
||||
if p.InnerTypeName != "TXT_MSG" {
|
||||
t.Errorf("innerTypeName=%q want TXT_MSG", p.InnerTypeName)
|
||||
}
|
||||
if p.InnerPayload != "010203" {
|
||||
t.Errorf("innerPayload=%q want 010203", p.InnerPayload)
|
||||
}
|
||||
if p.InnerAckCrc != "" {
|
||||
t.Errorf("non-ACK should not surface innerAckCrc, got %q", p.InnerAckCrc)
|
||||
}
|
||||
}
|
||||
|
||||
// --- P1 #3: advertRole label fix ---
|
||||
|
||||
func TestAdvertRoleLabelsRawType(t *testing.T) {
|
||||
// Firmware: ADV_TYPE_NONE=0, CHAT=1, REPEATER=2, ROOM=3, SENSOR=4, 5..15 FUTURE.
|
||||
cases := []struct {
|
||||
typ int
|
||||
want string
|
||||
}{
|
||||
{0, "none"},
|
||||
{1, "companion"},
|
||||
{2, "repeater"},
|
||||
{3, "room"},
|
||||
{4, "sensor"},
|
||||
{5, "type-5"},
|
||||
{15, "type-15"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := advertRole(&AdvertFlags{Type: tc.typ, Repeater: tc.typ == 2, Room: tc.typ == 3, Sensor: tc.typ == 4})
|
||||
if got != tc.want {
|
||||
t.Errorf("advertRole(type=%d) = %q, want %q", tc.typ, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- P1 #4: CONTROL byte0 flags ---
|
||||
|
||||
func TestDecodeControlZeroHop(t *testing.T) {
|
||||
// byte0 = 0x81 (high-bit set ⇒ zero-hop), followed by 3 app bytes.
|
||||
buf := []byte{0x81, 0xAA, 0xBB, 0xCC}
|
||||
p := decodeControl(buf)
|
||||
if p.Type != "CONTROL" {
|
||||
t.Fatalf("type=%q want CONTROL", p.Type)
|
||||
}
|
||||
if p.CtrlFlags != "81" {
|
||||
t.Errorf("ctrlFlags=%q want 81", p.CtrlFlags)
|
||||
}
|
||||
if p.CtrlZeroHop == nil || !*p.CtrlZeroHop {
|
||||
t.Errorf("ctrlZeroHop=%v want true", p.CtrlZeroHop)
|
||||
}
|
||||
if p.CtrlLength == nil || *p.CtrlLength != 4 {
|
||||
t.Errorf("ctrlLength=%v want 4", p.CtrlLength)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeControlMultiHop(t *testing.T) {
|
||||
// byte0 = 0x01 (high-bit clear ⇒ not zero-hop subset).
|
||||
buf := []byte{0x01, 0x42}
|
||||
p := decodeControl(buf)
|
||||
if p.CtrlFlags != "01" {
|
||||
t.Errorf("ctrlFlags=%q want 01", p.CtrlFlags)
|
||||
}
|
||||
if p.CtrlZeroHop == nil || *p.CtrlZeroHop {
|
||||
t.Errorf("ctrlZeroHop=%v want false", p.CtrlZeroHop)
|
||||
}
|
||||
if p.CtrlLength == nil || *p.CtrlLength != 2 {
|
||||
t.Errorf("ctrlLength=%v want 2", p.CtrlLength)
|
||||
}
|
||||
}
|
||||
|
||||
// silence unused-import diagnostics for stub-phase builds
|
||||
var _ = binary.LittleEndian
|
||||
@@ -0,0 +1,98 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// TestIngestorPruneOldPackets enforces #1283: the writer for
|
||||
// transmissions retention lives on the ingestor's *Store. Before the fix,
|
||||
// this lived on cmd/server/*DB and raced with ingestor INSERTs. After
|
||||
// the fix, ingestor owns it and runs it on its own write-locked handle.
|
||||
func TestIngestorPruneOldPackets(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "prune.db")
|
||||
store, err := OpenStore(path)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenStore: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
old := time.Now().UTC().AddDate(0, 0, -10).Format(time.RFC3339)
|
||||
new := time.Now().UTC().Format(time.RFC3339)
|
||||
for i, ts := range []string{old, old, new} {
|
||||
_, err := store.db.Exec(
|
||||
`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, payload_version, decoded_json)
|
||||
VALUES (?, ?, ?, 0, 1, 1, '{}')`,
|
||||
"AA", "h"+string(rune('a'+i)), ts,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("seed tx: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
n, err := store.PruneOldPackets(5)
|
||||
if err != nil {
|
||||
t.Fatalf("PruneOldPackets: %v", err)
|
||||
}
|
||||
if n != 2 {
|
||||
t.Fatalf("expected 2 pruned, got %d", n)
|
||||
}
|
||||
|
||||
var remaining int
|
||||
if err := store.db.QueryRow(`SELECT COUNT(*) FROM transmissions`).Scan(&remaining); err != nil {
|
||||
t.Fatalf("count: %v", err)
|
||||
}
|
||||
if remaining != 1 {
|
||||
t.Fatalf("expected 1 transmission remaining, got %d", remaining)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIngestorVacuumOnStartupMigratesNONEtoINCREMENTAL exercises the
|
||||
// scenario that originally broke in #1283: a fresh DB with
|
||||
// auto_vacuum=NONE, vacuumOnStartup=true, no contention from a server
|
||||
// process. The ingestor must complete the VACUUM and flip auto_vacuum to
|
||||
// INCREMENTAL. Before the fix, the migration ran inside cmd/server and
|
||||
// hit SQLITE_BUSY because the ingestor (sharing the container) was
|
||||
// already writing.
|
||||
func TestIngestorVacuumOnStartupMigratesNONEtoINCREMENTAL(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "vac.db")
|
||||
|
||||
// Create a NONE-auto_vacuum DB (simulates an older deployment).
|
||||
seed, err := sql.Open("sqlite", path+"?_pragma=journal_mode(WAL)")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seed.SetMaxOpenConns(1)
|
||||
if _, err := seed.Exec(`CREATE TABLE dummy(id INTEGER PRIMARY KEY)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var before int
|
||||
seed.QueryRow("PRAGMA auto_vacuum").Scan(&before)
|
||||
if before != 0 {
|
||||
t.Fatalf("precondition: auto_vacuum=%d, want 0", before)
|
||||
}
|
||||
seed.Close()
|
||||
|
||||
store, err := OpenStore(path)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenStore: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
cfg := &Config{DB: &DBConfig{VacuumOnStartup: true}}
|
||||
store.CheckAutoVacuum(cfg)
|
||||
|
||||
var after int
|
||||
if err := store.db.QueryRow("PRAGMA auto_vacuum").Scan(&after); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if after != 2 {
|
||||
t.Fatalf("expected auto_vacuum=2 after ingestor VACUUM, got %d", after)
|
||||
}
|
||||
}
|
||||
+330
-14
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"encoding/hex"
|
||||
@@ -11,11 +12,13 @@ import (
|
||||
"math"
|
||||
"net/http"
|
||||
_ "net/http/pprof"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -60,6 +63,15 @@ func main() {
|
||||
// Async backfill: path_json from raw_hex (#888) — must not block MQTT startup
|
||||
store.BackfillPathJSONAsync()
|
||||
|
||||
// Soft-delete blacklisted observers (#1287 — moved from cmd/server).
|
||||
if len(cfg.ObserverBlacklist) > 0 {
|
||||
store.SoftDeleteBlacklistedObservers(cfg.ObserverBlacklist)
|
||||
}
|
||||
|
||||
// Async backfill: from_pubkey for legacy ADVERT rows (#1143).
|
||||
// Moved from cmd/server in #1287. Best-effort; must not block MQTT.
|
||||
go store.BackfillFromPubkey(5000, 100*time.Millisecond, nil)
|
||||
|
||||
// Check auto_vacuum mode and optionally migrate (#919)
|
||||
store.CheckAutoVacuum(cfg)
|
||||
|
||||
@@ -75,6 +87,19 @@ func main() {
|
||||
metricsDays := cfg.MetricsRetentionDays()
|
||||
store.PruneOldMetrics(metricsDays)
|
||||
store.PruneDroppedPackets(metricsDays)
|
||||
|
||||
// Packet (transmissions) retention: previously lived in cmd/server,
|
||||
// moved to ingestor in #1283 to eliminate cross-process write
|
||||
// contention (SQLITE_BUSY). 0 = disabled.
|
||||
packetDays := cfg.PacketDaysOrZero()
|
||||
if packetDays > 0 {
|
||||
if n, err := store.PruneOldPackets(packetDays); err != nil {
|
||||
log.Printf("[prune] error: %v", err)
|
||||
} else if n > 0 {
|
||||
log.Printf("[prune] startup pruned %d transmissions older than %d days", n, packetDays)
|
||||
}
|
||||
}
|
||||
|
||||
vacuumPages := cfg.IncrementalVacuumPages()
|
||||
store.RunIncrementalVacuum(vacuumPages)
|
||||
|
||||
@@ -109,6 +134,44 @@ func main() {
|
||||
}
|
||||
}()
|
||||
|
||||
// Daily ticker for transmission retention (#1283).
|
||||
var packetRetentionTicker *time.Ticker
|
||||
if packetDays > 0 {
|
||||
packetRetentionTicker = time.NewTicker(24 * time.Hour)
|
||||
go func() {
|
||||
for range packetRetentionTicker.C {
|
||||
if n, err := store.PruneOldPackets(packetDays); err != nil {
|
||||
log.Printf("[prune] error: %v", err)
|
||||
} else if n > 0 {
|
||||
store.RunIncrementalVacuum(vacuumPages)
|
||||
}
|
||||
}
|
||||
}()
|
||||
log.Printf("[prune] auto-prune enabled: packets older than %d days will be removed daily", packetDays)
|
||||
}
|
||||
|
||||
// Daily neighbor_edges retention (#1287 — moved from cmd/server).
|
||||
{
|
||||
nDays := cfg.NeighborEdgesDaysOrDefault()
|
||||
neighborPruneTicker := time.NewTicker(24 * time.Hour)
|
||||
go func() {
|
||||
time.Sleep(4 * time.Minute) // stagger
|
||||
if n, err := store.PruneNeighborEdges(nDays); err != nil {
|
||||
log.Printf("[neighbor-prune] error: %v", err)
|
||||
} else if n > 0 {
|
||||
log.Printf("[neighbor-prune] startup pruned %d edges older than %d days", n, nDays)
|
||||
}
|
||||
for range neighborPruneTicker.C {
|
||||
if n, err := store.PruneNeighborEdges(nDays); err != nil {
|
||||
log.Printf("[neighbor-prune] error: %v", err)
|
||||
} else if n > 0 {
|
||||
log.Printf("[neighbor-prune] pruned %d edges older than %d days", n, nDays)
|
||||
}
|
||||
}
|
||||
}()
|
||||
log.Printf("[neighbor-prune] auto-prune enabled: edges older than %d days", nDays)
|
||||
}
|
||||
|
||||
// Periodic stats logging (every 5 minutes)
|
||||
statsTicker := time.NewTicker(5 * time.Minute)
|
||||
go func() {
|
||||
@@ -117,10 +180,30 @@ func main() {
|
||||
}
|
||||
}()
|
||||
|
||||
// Prune-request queue (#669 M4 / #738): the read-only server enqueues
|
||||
// geo-prune requests as marker files; the ingestor (which holds the
|
||||
// write handle) executes the DELETEs. Process on startup, then every
|
||||
// 15 seconds — short enough for a one-click UX, long enough to avoid
|
||||
// useless wake-ups.
|
||||
store.RunPendingPruneRequests()
|
||||
pruneQueueTicker := time.NewTicker(15 * time.Second)
|
||||
go func() {
|
||||
for range pruneQueueTicker.C {
|
||||
store.RunPendingPruneRequests()
|
||||
}
|
||||
}()
|
||||
|
||||
// Per-second stats file writer for the server's /api/perf/write-sources
|
||||
// endpoint (#1120). Best-effort; never fatal.
|
||||
StartStatsFileWriter(store, time.Second)
|
||||
|
||||
// Neighbor-edges builder (#1287 — Option 4): ingestor owns
|
||||
// neighbor_edges writes. Runs every 60s. Server reads the snapshot
|
||||
// via cmd/server/neighbor_recomputer.go on the same cadence.
|
||||
stopNeighborBuilder := store.StartNeighborEdgesBuilder(NeighborEdgesBuilderInterval)
|
||||
defer stopNeighborBuilder()
|
||||
log.Printf("[neighbor-build] enabled (interval=%s)", NeighborEdgesBuilderInterval)
|
||||
|
||||
channelKeys := loadChannelKeys(cfg, *configPath)
|
||||
if len(channelKeys) > 0 {
|
||||
log.Printf("Loaded %d channel keys for GRP_TXT decryption", len(channelKeys))
|
||||
@@ -128,6 +211,9 @@ func main() {
|
||||
log.Printf("No channel keys loaded — GRP_TXT packets will not be decrypted")
|
||||
}
|
||||
|
||||
regionKeys := loadRegionKeys(cfg)
|
||||
store.BackfillDefaultScopeAsync(regionKeys)
|
||||
|
||||
// Connect to each MQTT source
|
||||
var clients []mqtt.Client
|
||||
connectedCount := 0
|
||||
@@ -141,8 +227,21 @@ func main() {
|
||||
connectTimeout := source.ConnectTimeoutOrDefault()
|
||||
log.Printf("MQTT [%s] connect timeout: %ds", tag, connectTimeout)
|
||||
|
||||
// Pre-allocate the liveness pointer so OnConnect can reset its
|
||||
// stale-message clock on reconnect (PR #1216 r1 item 2). IsConnectedFn
|
||||
// is wired below once the client exists.
|
||||
liveness := &SourceLivenessState{
|
||||
Tag: tag,
|
||||
Broker: source.Broker,
|
||||
}
|
||||
|
||||
opts.SetOnConnectHandler(func(c mqtt.Client) {
|
||||
log.Printf("MQTT [%s] connected to %s", tag, source.Broker)
|
||||
// PR #1216 r1 item 2: clear the stale LastMessageUnix from
|
||||
// before the outage so the watchdog doesn't immediately scream
|
||||
// "stalled for 2h". Also restarts the cold-start grace window
|
||||
// and clears the alert cooldown so a fresh stall edge can fire.
|
||||
liveness.MarkReconnected(time.Now())
|
||||
topics := source.Topics
|
||||
if len(topics) == 0 {
|
||||
topics = []string{"meshcore/#"}
|
||||
@@ -169,10 +268,22 @@ func main() {
|
||||
// Capture source for closure
|
||||
src := source
|
||||
opts.SetDefaultPublishHandler(func(c mqtt.Client, m mqtt.Message) {
|
||||
handleMessage(store, tag, src, m, channelKeys, cfg)
|
||||
handleMessage(store, tag, src, m, channelKeys, regionKeys, cfg)
|
||||
})
|
||||
|
||||
client := mqtt.NewClient(opts)
|
||||
// Wire IsConnectedFn now that the client exists, then register.
|
||||
// Registration BEFORE Connect so the attempt counter is available
|
||||
// to OnConnectAttempt on the very first dial.
|
||||
liveness.IsConnectedFn = client.IsConnected
|
||||
// PR #1216 r2 item 3: tag collisions used to log.Fatalf, which
|
||||
// killed the entire ingestor over one config typo and recreated
|
||||
// the #1212 total-ingest-stop class this PR exists to prevent.
|
||||
// registerLivenessOrSkip logs ERROR + skips liveness registration
|
||||
// for the duplicate; the MQTT source still attempts to connect,
|
||||
// it just isn't tracked by the watchdog. First registration
|
||||
// remains authoritative.
|
||||
registerLivenessOrSkip(liveness)
|
||||
token := client.Connect()
|
||||
// With ConnectRetry=true, token.Wait() blocks forever for unreachable brokers.
|
||||
// WaitTimeout lets startup proceed; the client keeps retrying in the background
|
||||
@@ -212,6 +323,12 @@ func main() {
|
||||
log.Printf("Running — %d MQTT source(s) connected", connectedCount)
|
||||
}
|
||||
|
||||
// #1212: per-source stall watchdog. Detects "silently dead" sources
|
||||
// where the client reports connected but no messages have flowed. Logs
|
||||
// a WARN line every minute for any source silent for >5m. Scan every
|
||||
// 60s so detection latency is bounded.
|
||||
stopWatchdog := runLivenessWatchdog(60*time.Second, 5*time.Minute)
|
||||
|
||||
// Wait for shutdown signal
|
||||
sig := make(chan os.Signal, 1)
|
||||
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
|
||||
@@ -220,7 +337,12 @@ func main() {
|
||||
log.Println("Shutting down...")
|
||||
retentionTicker.Stop()
|
||||
metricsRetentionTicker.Stop()
|
||||
if packetRetentionTicker != nil {
|
||||
packetRetentionTicker.Stop()
|
||||
}
|
||||
statsTicker.Stop()
|
||||
pruneQueueTicker.Stop()
|
||||
stopWatchdog()
|
||||
store.LogStats() // final stats on shutdown
|
||||
for _, c := range clients {
|
||||
c.Disconnect(5000) // 5s to allow in-flight messages to drain
|
||||
@@ -230,16 +352,62 @@ func main() {
|
||||
|
||||
// buildMQTTOpts creates MQTT client options for a source with bounded reconnect
|
||||
// backoff, connect timeout, and TLS/auth configuration.
|
||||
//
|
||||
// Logs every TCP/TLS dial via OnConnectAttempt. Unlike SetReconnectingHandler
|
||||
// (which only fires inside paho's reconnect goroutine and can be silent if
|
||||
// that loop never iterates), OnConnectAttempt fires on every attempt — the
|
||||
// initial Connect() and every reconnect. This is the observability fix for
|
||||
// #1212 (prod outage on 2026-05-15 where the disconnect was logged but no
|
||||
// reconnect activity was ever visible).
|
||||
func buildMQTTOpts(source MQTTSource) *mqtt.ClientOptions {
|
||||
tag := source.Name
|
||||
if tag == "" {
|
||||
tag = source.Broker
|
||||
}
|
||||
// #1337: paho defaults silently throttle delivery on this broker.
|
||||
// - CleanSession=true + empty ClientID (random per reconnect) made the
|
||||
// broker treat every reconnect as a brand-new session and discard the
|
||||
// backlog it had queued since the previous disconnect. With watchdog
|
||||
// reconnects every ~5min on staging, this lost ~99% of messages.
|
||||
// - Order=true serialized the default publish handler; one slow packet
|
||||
// blocked all others, compounding the loss under bursts.
|
||||
// Fix: persistent unique ClientID + CleanSession=false (broker keeps
|
||||
// our subscription state across reconnects and forwards what we missed),
|
||||
// explicit KeepAlive so half-open TCP is detected at the paho layer, and
|
||||
// Order=false for parallel handler dispatch.
|
||||
hostname, _ := os.Hostname()
|
||||
if hostname == "" {
|
||||
hostname = "unknown-host"
|
||||
}
|
||||
clientID := "corescope-ingestor-" + hostname + "-" + tag
|
||||
|
||||
opts := mqtt.NewClientOptions().
|
||||
AddBroker(source.Broker).
|
||||
SetClientID(clientID).
|
||||
SetCleanSession(false).
|
||||
SetKeepAlive(30 * time.Second).
|
||||
SetOrderMatters(false).
|
||||
SetAutoReconnect(true).
|
||||
SetConnectRetry(true).
|
||||
SetOrderMatters(true).
|
||||
SetMaxReconnectInterval(30 * time.Second).
|
||||
SetConnectTimeout(10 * time.Second).
|
||||
SetWriteTimeout(10 * time.Second)
|
||||
|
||||
opts.SetConnectionAttemptHandler(func(broker *url.URL, tlsCfg *tls.Config) *tls.Config {
|
||||
// Look up the per-source liveness state (registered in main) so we
|
||||
// can attach an attempt counter. If not yet registered (first dial
|
||||
// from Connect()), fall through with attempt=1.
|
||||
var attempt int64 = 1
|
||||
livenessRegistryMu.RLock()
|
||||
s := livenessRegistry[tag]
|
||||
livenessRegistryMu.RUnlock()
|
||||
if s != nil {
|
||||
attempt = atomic.AddInt64(&s.AttemptCount, 1)
|
||||
}
|
||||
log.Printf("MQTT [%s] connection attempt #%d to %s", tag, attempt, broker.String())
|
||||
return tlsCfg
|
||||
})
|
||||
|
||||
if source.Username != "" {
|
||||
opts.SetUsername(source.Username)
|
||||
}
|
||||
@@ -254,7 +422,10 @@ func buildMQTTOpts(source MQTTSource) *mqtt.ClientOptions {
|
||||
return opts
|
||||
}
|
||||
|
||||
func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message, channelKeys map[string]string, cfg *Config) {
|
||||
func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message, channelKeys map[string]string, regionKeys map[string][]byte, cfg *Config) {
|
||||
// Liveness watchdog (#1212): record receipt before any processing so a
|
||||
// slow handler still counts as "source is alive". Cheap atomic store.
|
||||
markLivenessForTag(tag, time.Now())
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("MQTT [%s] panic in handler: %v", tag, r)
|
||||
@@ -296,7 +467,7 @@ func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message,
|
||||
name, _ := msg["origin"].(string)
|
||||
iata := parts[1]
|
||||
meta := extractObserverMeta(msg)
|
||||
if err := store.UpsertObserver(observerID, name, iata, meta); err != nil {
|
||||
if err := store.UpsertObserverAt(observerID, name, iata, meta, resolveRxTime(msg, tag)); err != nil {
|
||||
log.Printf("MQTT [%s] observer status error: %v", tag, err)
|
||||
}
|
||||
// Insert metrics sample from status message
|
||||
@@ -340,7 +511,29 @@ func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message,
|
||||
validateSigs := cfg.ShouldValidateSignatures()
|
||||
decoded, err := DecodePacket(rawHex, channelKeys, validateSigs)
|
||||
if err != nil {
|
||||
log.Printf("MQTT [%s] decode error: %v", tag, err)
|
||||
// Per #1211: include enough context to repro malformed-packet drops,
|
||||
// but NEVER log the full observer ID (PII / fingerprinting risk).
|
||||
// We log:
|
||||
// - topic prefix (with observer segment elided)
|
||||
// - 8-char observer prefix
|
||||
// - payload length, claimed length (rawHex len)
|
||||
obs := ""
|
||||
if len(parts) > 2 {
|
||||
obs = parts[2]
|
||||
}
|
||||
// Build a redacted topic that replaces parts[2] (the observer id)
|
||||
// with the 8-char prefix, so the rest of the topic is preserved
|
||||
// for debugging without leaking the full identifier.
|
||||
redactedTopic := topic
|
||||
if len(parts) > 2 {
|
||||
redactedParts := make([]string, len(parts))
|
||||
copy(redactedParts, parts)
|
||||
if len(parts[2]) > 8 {
|
||||
redactedParts[2] = parts[2][:8]
|
||||
}
|
||||
redactedTopic = strings.Join(redactedParts, "/")
|
||||
}
|
||||
log.Printf("MQTT [%s] decode error: %v (topic=%s observer=%.8s rawHexLen=%d)", tag, err, redactedTopic, obs, len(rawHex))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -358,6 +551,7 @@ func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message,
|
||||
}
|
||||
|
||||
mqttMsg := &MQTTPacketMessage{Raw: rawHex}
|
||||
mqttMsg.Timestamp = resolveRxTime(msg, tag)
|
||||
// Parse optional region from JSON payload (#788)
|
||||
if v, ok := msg["region"].(string); ok && v != "" {
|
||||
mqttMsg.Region = v
|
||||
@@ -446,7 +640,7 @@ func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message,
|
||||
log.Printf("MQTT [%s] foreign advert: node=%s name=%s lat=%.4f lon=%.4f observer=%s",
|
||||
tag, truncPK, decoded.Payload.Name, lat, lon, firstNonEmpty(mqttMsg.Origin, observerID))
|
||||
}
|
||||
pktData := BuildPacketData(mqttMsg, decoded, observerID, region)
|
||||
pktData := BuildPacketData(mqttMsg, decoded, observerID, region, regionKeys)
|
||||
pktData.Foreign = foreign
|
||||
isNew, err := store.InsertTransmission(pktData)
|
||||
if err != nil {
|
||||
@@ -472,10 +666,16 @@ func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message,
|
||||
log.Printf("MQTT [%s] node telemetry update error: %v", tag, err)
|
||||
}
|
||||
}
|
||||
// Update default_scope when advert carries a matched transport scope (#899)
|
||||
if pktData.IsTransportScoped {
|
||||
if err := store.UpdateNodeDefaultScope(decoded.Payload.PubKey, pktData.ScopeName); err != nil {
|
||||
log.Printf("MQTT [%s] node default_scope update error: %v", tag, err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Non-ADVERT packets: store normally (routing/channel messages from
|
||||
// in-area observers are relevant regardless of relay hop origin).
|
||||
pktData := BuildPacketData(mqttMsg, decoded, observerID, region)
|
||||
pktData := BuildPacketData(mqttMsg, decoded, observerID, region, regionKeys)
|
||||
if _, err := store.InsertTransmission(pktData); err != nil {
|
||||
log.Printf("MQTT [%s] db insert error: %v", tag, err)
|
||||
}
|
||||
@@ -489,7 +689,7 @@ func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message,
|
||||
if mqttMsg.Region != "" {
|
||||
effectiveRegion = mqttMsg.Region
|
||||
}
|
||||
if err := store.UpsertObserver(observerID, origin, effectiveRegion, nil); err != nil {
|
||||
if err := store.UpsertObserverAt(observerID, origin, effectiveRegion, nil, mqttMsg.Timestamp); err != nil {
|
||||
log.Printf("MQTT [%s] observer upsert error: %v", tag, err)
|
||||
}
|
||||
}
|
||||
@@ -533,8 +733,9 @@ func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message,
|
||||
|
||||
decodedJSON, _ := json.Marshal(channelMsg)
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
hashInput := fmt.Sprintf("ch:%s:%s:%s", channelIdx, text, now)
|
||||
ingestNow := time.Now().UTC().Format(time.RFC3339)
|
||||
rxTime := resolveRxTime(msg, tag)
|
||||
hashInput := fmt.Sprintf("ch:%s:%s:%s", channelIdx, text, ingestNow)
|
||||
h := sha256.Sum256([]byte(hashInput))
|
||||
hash := hex.EncodeToString(h[:])[:16]
|
||||
|
||||
@@ -574,7 +775,7 @@ func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message,
|
||||
}
|
||||
|
||||
pktData := &PacketData{
|
||||
Timestamp: now,
|
||||
Timestamp: rxTime,
|
||||
ObserverID: "companion",
|
||||
ObserverName: "L1 Pro (BLE)",
|
||||
SNR: snr,
|
||||
@@ -626,8 +827,9 @@ func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message,
|
||||
|
||||
decodedJSON, _ := json.Marshal(dm)
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
hashInput := fmt.Sprintf("dm:%s:%s", text, now)
|
||||
ingestNow := time.Now().UTC().Format(time.RFC3339)
|
||||
rxTime := resolveRxTime(msg, tag)
|
||||
hashInput := fmt.Sprintf("dm:%s:%s", text, ingestNow)
|
||||
h := sha256.Sum256([]byte(hashInput))
|
||||
hash := hex.EncodeToString(h[:])[:16]
|
||||
|
||||
@@ -667,7 +869,7 @@ func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message,
|
||||
}
|
||||
|
||||
pktData := &PacketData{
|
||||
Timestamp: now,
|
||||
Timestamp: rxTime,
|
||||
ObserverID: "companion",
|
||||
ObserverName: "L1 Pro (BLE)",
|
||||
SNR: snr,
|
||||
@@ -851,6 +1053,71 @@ func firstNonEmpty(vals ...string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// resolveRxTime returns the observer receive-time for a packet, taken from
|
||||
// the MQTT envelope's "timestamp" field. Falls back to ingest time only when
|
||||
// the field is missing, unparseable, or implausibly in the future (a
|
||||
// clock-skewed observer). Result is always RFC3339 UTC.
|
||||
//
|
||||
// The envelope timestamp is stamped by the uploader when the radio receives
|
||||
// the frame, not when the MQTT message is published — so a buffered packet
|
||||
// uploaded hours late still carries its true receive time. Using ingest time
|
||||
// (time.Now()) here mis-dated such packets by the upload delay.
|
||||
func resolveRxTime(msg map[string]interface{}, tag string) string {
|
||||
now := time.Now().UTC()
|
||||
raw, _ := msg["timestamp"].(string)
|
||||
if raw == "" {
|
||||
return now.Format(time.RFC3339)
|
||||
}
|
||||
t, err := parseEnvelopeTime(raw)
|
||||
if err != nil {
|
||||
log.Printf("MQTT [%s] unparseable timestamp %q, using ingest time", tag, raw)
|
||||
return now.Format(time.RFC3339)
|
||||
}
|
||||
// Hard reject: > 14h ahead is a genuine clock error (UTC+14 is the maximum
|
||||
// standard offset, so nothing valid should be further ahead than that).
|
||||
if t.After(now.Add(14 * time.Hour)) {
|
||||
log.Printf("MQTT [%s] future timestamp %q, using ingest time", tag, raw)
|
||||
return now.Format(time.RFC3339)
|
||||
}
|
||||
// Hard reject: > 30 days in the past is an RTC-reset node reporting a
|
||||
// factory date (e.g. 2020-01-01). Such a value would permanently drag
|
||||
// transmissions.first_seen backwards via stmtUpdateTxFirstSeen in
|
||||
// InsertTransmission. No legitimate buffered upload is that stale.
|
||||
if t.Before(now.Add(-30 * 24 * time.Hour)) {
|
||||
log.Printf("MQTT [%s] stale timestamp %q (>30d old), using ingest time", tag, raw)
|
||||
return now.Format(time.RFC3339)
|
||||
}
|
||||
// Soft clamp: naive local-clock timestamps from UTC+N observers are parsed
|
||||
// as-if UTC, making them appear N hours in the future. A UTC+2 observer's
|
||||
// live packet looks 2h ahead, but it is NOT a buffered packet — the whole
|
||||
// point of using rxTime is to preserve the past timestamp for packets that
|
||||
// were buffered offline. If rxTime is ahead of now, the packet is live and
|
||||
// ingest time is the correct value. This also prevents storing future
|
||||
// timestamps that would show ⚠️ in the UI for every packet from UTC+N nodes.
|
||||
if t.After(now) {
|
||||
return now.Format(time.RFC3339)
|
||||
}
|
||||
return t.UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
// parseEnvelopeTime parses the MQTT envelope timestamp. Two on-wire forms
|
||||
// occur: zone-aware ISO8601 (RFC3339), and a naive local-clock ISO string
|
||||
// with no zone (python datetime.isoformat()). Zone-aware layouts are tried
|
||||
// first; naive layouts are assumed UTC, leaving a bounded residual offset
|
||||
// equal to the observer's UTC offset for naive-timestamp uploaders.
|
||||
func parseEnvelopeTime(s string) (time.Time, error) {
|
||||
for _, layout := range []string{
|
||||
time.RFC3339, // 2026-05-16T10:00:00Z / +02:00
|
||||
"2006-01-02T15:04:05.999999", // python isoformat w/ microseconds
|
||||
"2006-01-02T15:04:05", // naive ISO
|
||||
} {
|
||||
if t, err := time.Parse(layout, s); err == nil {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
return time.Time{}, fmt.Errorf("unrecognized timestamp layout: %q", s)
|
||||
}
|
||||
|
||||
// deriveHashtagChannelKey derives an AES-128 key from a channel name.
|
||||
// Same algorithm as Node.js: SHA-256(channelName) → first 32 hex chars (16 bytes).
|
||||
func deriveHashtagChannelKey(channelName string) string {
|
||||
@@ -916,6 +1183,55 @@ func loadChannelKeys(cfg *Config, configPath string) map[string]string {
|
||||
return keys
|
||||
}
|
||||
|
||||
func loadRegionKeys(cfg *Config) map[string][]byte {
|
||||
keys := make(map[string][]byte)
|
||||
for _, raw := range cfg.HashRegions {
|
||||
name := strings.TrimSpace(raw)
|
||||
if name == "" {
|
||||
log.Printf("[regions] skipping empty hashRegions entry")
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(name, "#") {
|
||||
name = "#" + name
|
||||
}
|
||||
if _, exists := keys[name]; exists {
|
||||
log.Printf("[regions] duplicate region %q ignored", name)
|
||||
continue
|
||||
}
|
||||
h := sha256.Sum256([]byte(name))
|
||||
keys[name] = h[:16]
|
||||
}
|
||||
if len(keys) > 0 {
|
||||
log.Printf("[regions] %d region key(s) loaded", len(keys))
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// matchScope performs one HMAC-SHA256 per configured region. Expected
|
||||
// len(regionKeys) ≤ 50; beyond that, consider a pre-indexed lookup table.
|
||||
func matchScope(regionKeys map[string][]byte, payloadType byte, payloadRaw []byte, code1 string) string {
|
||||
if code1 == "0000" || len(regionKeys) == 0 || len(payloadRaw) == 0 {
|
||||
return ""
|
||||
}
|
||||
for name, key := range regionKeys {
|
||||
mac := hmac.New(sha256.New, key)
|
||||
mac.Write([]byte{payloadType})
|
||||
mac.Write(payloadRaw)
|
||||
hmacBytes := mac.Sum(nil)
|
||||
code := uint16(hmacBytes[0]) | uint16(hmacBytes[1])<<8
|
||||
if code == 0 {
|
||||
code = 1
|
||||
} else if code == 0xFFFF {
|
||||
code = 0xFFFE
|
||||
}
|
||||
codeBytes := [2]byte{byte(code & 0xFF), byte(code >> 8)}
|
||||
if strings.ToUpper(hex.EncodeToString(codeBytes[:])) == code1 {
|
||||
return name
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Version info (set via ldflags)
|
||||
var version = "dev"
|
||||
|
||||
|
||||
+118
-33
@@ -1,6 +1,8 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"math"
|
||||
"os"
|
||||
@@ -133,7 +135,7 @@ func TestHandleMessageRawPacket(t *testing.T) {
|
||||
payload := []byte(`{"raw":"` + rawHex + `","SNR":5.5,"RSSI":-100.0,"origin":"myobs"}`)
|
||||
msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: payload}
|
||||
|
||||
handleMessage(store, "test", source, msg, nil, &Config{})
|
||||
handleMessage(store, "test", source, msg, nil, nil, &Config{})
|
||||
|
||||
var count int
|
||||
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
|
||||
@@ -150,7 +152,7 @@ func TestHandleMessageRawPacketAdvert(t *testing.T) {
|
||||
payload := []byte(`{"raw":"` + rawHex + `"}`)
|
||||
msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: payload}
|
||||
|
||||
handleMessage(store, "test", source, msg, nil, &Config{})
|
||||
handleMessage(store, "test", source, msg, nil, nil, &Config{})
|
||||
|
||||
// Should create a node from the ADVERT
|
||||
var count int
|
||||
@@ -172,7 +174,7 @@ func TestHandleMessageInvalidJSON(t *testing.T) {
|
||||
msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: []byte(`not json`)}
|
||||
|
||||
// Should not panic
|
||||
handleMessage(store, "test", source, msg, nil, &Config{})
|
||||
handleMessage(store, "test", source, msg, nil, nil, &Config{})
|
||||
|
||||
var count int
|
||||
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
|
||||
@@ -189,7 +191,7 @@ func TestHandleMessageStatusTopic(t *testing.T) {
|
||||
payload: []byte(`{"origin":"MyObserver"}`),
|
||||
}
|
||||
|
||||
handleMessage(store, "test", source, msg, nil, &Config{})
|
||||
handleMessage(store, "test", source, msg, nil, nil, &Config{})
|
||||
|
||||
var name, iata string
|
||||
err := store.db.QueryRow("SELECT name, iata FROM observers WHERE id = 'obs1'").Scan(&name, &iata)
|
||||
@@ -210,11 +212,11 @@ func TestHandleMessageSkipStatusTopics(t *testing.T) {
|
||||
|
||||
// meshcore/status should be skipped
|
||||
msg1 := &mockMessage{topic: "meshcore/status", payload: []byte(`{"raw":"0A00"}`)}
|
||||
handleMessage(store, "test", source, msg1, nil, &Config{})
|
||||
handleMessage(store, "test", source, msg1, nil, nil, &Config{})
|
||||
|
||||
// meshcore/events/connection should be skipped
|
||||
msg2 := &mockMessage{topic: "meshcore/events/connection", payload: []byte(`{"raw":"0A00"}`)}
|
||||
handleMessage(store, "test", source, msg2, nil, &Config{})
|
||||
handleMessage(store, "test", source, msg2, nil, nil, &Config{})
|
||||
|
||||
var count int
|
||||
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
|
||||
@@ -233,7 +235,7 @@ func TestHandleMessageIATAFilter(t *testing.T) {
|
||||
topic: "meshcore/SJC/obs1/packets",
|
||||
payload: []byte(`{"raw":"` + rawHex + `"}`),
|
||||
}
|
||||
handleMessage(store, "test", source, msg, nil, &Config{})
|
||||
handleMessage(store, "test", source, msg, nil, nil, &Config{})
|
||||
|
||||
var count int
|
||||
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
|
||||
@@ -246,7 +248,7 @@ func TestHandleMessageIATAFilter(t *testing.T) {
|
||||
topic: "meshcore/LAX/obs2/packets",
|
||||
payload: []byte(`{"raw":"` + rawHex + `"}`),
|
||||
}
|
||||
handleMessage(store, "test", source, msg2, nil, &Config{})
|
||||
handleMessage(store, "test", source, msg2, nil, nil, &Config{})
|
||||
|
||||
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
|
||||
if count != 1 {
|
||||
@@ -264,7 +266,7 @@ func TestHandleMessageIATAFilterNoRegion(t *testing.T) {
|
||||
topic: "meshcore",
|
||||
payload: []byte(`{"raw":"` + rawHex + `"}`),
|
||||
}
|
||||
handleMessage(store, "test", source, msg, nil, &Config{})
|
||||
handleMessage(store, "test", source, msg, nil, nil, &Config{})
|
||||
|
||||
// No region part → filter doesn't apply, message goes through
|
||||
// Actually the code checks len(parts) > 1 for IATA filter
|
||||
@@ -280,7 +282,7 @@ func TestHandleMessageNoRawHex(t *testing.T) {
|
||||
topic: "meshcore/SJC/obs1/packets",
|
||||
payload: []byte(`{"type":"companion","data":"something"}`),
|
||||
}
|
||||
handleMessage(store, "test", source, msg, nil, &Config{})
|
||||
handleMessage(store, "test", source, msg, nil, nil, &Config{})
|
||||
|
||||
var count int
|
||||
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
|
||||
@@ -298,7 +300,7 @@ func TestHandleMessageBadRawHex(t *testing.T) {
|
||||
topic: "meshcore/SJC/obs1/packets",
|
||||
payload: []byte(`{"raw":"ZZZZ"}`),
|
||||
}
|
||||
handleMessage(store, "test", source, msg, nil, &Config{})
|
||||
handleMessage(store, "test", source, msg, nil, nil, &Config{})
|
||||
|
||||
var count int
|
||||
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
|
||||
@@ -315,7 +317,7 @@ func TestHandleMessageWithSNRRSSIAsNumbers(t *testing.T) {
|
||||
payload := []byte(`{"raw":"` + rawHex + `","SNR":7.2,"RSSI":-95}`)
|
||||
msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: payload}
|
||||
|
||||
handleMessage(store, "test", source, msg, nil, &Config{})
|
||||
handleMessage(store, "test", source, msg, nil, nil, &Config{})
|
||||
|
||||
var snr, rssi *float64
|
||||
store.db.QueryRow("SELECT snr, rssi FROM observations LIMIT 1").Scan(&snr, &rssi)
|
||||
@@ -334,7 +336,7 @@ func TestHandleMessageMinimalTopic(t *testing.T) {
|
||||
topic: "meshcore/SJC",
|
||||
payload: []byte(`{"raw":"` + rawHex + `"}`),
|
||||
}
|
||||
handleMessage(store, "test", source, msg, nil, &Config{})
|
||||
handleMessage(store, "test", source, msg, nil, nil, &Config{})
|
||||
|
||||
var count int
|
||||
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
|
||||
@@ -355,7 +357,7 @@ func TestHandleMessageCorruptedAdvert(t *testing.T) {
|
||||
topic: "meshcore/SJC/obs1/packets",
|
||||
payload: []byte(`{"raw":"` + rawHex + `"}`),
|
||||
}
|
||||
handleMessage(store, "test", source, msg, nil, &Config{})
|
||||
handleMessage(store, "test", source, msg, nil, nil, &Config{})
|
||||
|
||||
// Transmission should be inserted (even if advert is invalid)
|
||||
var count int
|
||||
@@ -381,7 +383,7 @@ func TestHandleMessageNoObserverID(t *testing.T) {
|
||||
topic: "packets",
|
||||
payload: []byte(`{"raw":"` + rawHex + `","origin":"obs1"}`),
|
||||
}
|
||||
handleMessage(store, "test", source, msg, nil, &Config{})
|
||||
handleMessage(store, "test", source, msg, nil, nil, &Config{})
|
||||
|
||||
var count int
|
||||
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
|
||||
@@ -403,7 +405,7 @@ func TestHandleMessageSNRNotFloat(t *testing.T) {
|
||||
// SNR as a string value — should not parse as float
|
||||
payload := []byte(`{"raw":"` + rawHex + `","SNR":"bad","RSSI":"bad"}`)
|
||||
msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: payload}
|
||||
handleMessage(store, "test", source, msg, nil, &Config{})
|
||||
handleMessage(store, "test", source, msg, nil, nil, &Config{})
|
||||
|
||||
var count int
|
||||
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
|
||||
@@ -419,7 +421,7 @@ func TestHandleMessageOriginExtraction(t *testing.T) {
|
||||
rawHex := "0A00D69FD7A5A7475DB07337749AE61FA53A4788E976"
|
||||
payload := []byte(`{"raw":"` + rawHex + `","origin":"MyOrigin"}`)
|
||||
msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: payload}
|
||||
handleMessage(store, "test", source, msg, nil, &Config{})
|
||||
handleMessage(store, "test", source, msg, nil, nil, &Config{})
|
||||
|
||||
// Verify origin was extracted to observer name
|
||||
var name string
|
||||
@@ -442,7 +444,7 @@ func TestHandleMessagePanicRecovery(t *testing.T) {
|
||||
}
|
||||
|
||||
// Should not panic — the defer/recover should catch it
|
||||
handleMessage(store, "test", source, msg, nil, &Config{})
|
||||
handleMessage(store, "test", source, msg, nil, nil, &Config{})
|
||||
}
|
||||
|
||||
func TestHandleMessageStatusOriginFallback(t *testing.T) {
|
||||
@@ -454,7 +456,7 @@ func TestHandleMessageStatusOriginFallback(t *testing.T) {
|
||||
topic: "meshcore/SJC/obs1/status",
|
||||
payload: []byte(`{"type":"status"}`),
|
||||
}
|
||||
handleMessage(store, "test", source, msg, nil, &Config{})
|
||||
handleMessage(store, "test", source, msg, nil, nil, &Config{})
|
||||
|
||||
var name string
|
||||
err := store.db.QueryRow("SELECT name FROM observers WHERE id = 'obs1'").Scan(&name)
|
||||
@@ -480,18 +482,20 @@ func TestEpochToISO(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAdvertRole(t *testing.T) {
|
||||
// advertRole now keys off AdvertFlags.Type (firmware ADV_TYPE_*) — see
|
||||
// firmware/src/helpers/AdvertDataHelpers.h:7-12 and issue #1279 P1 #3.
|
||||
tests := []struct {
|
||||
name string
|
||||
flags *AdvertFlags
|
||||
want string
|
||||
}{
|
||||
{"repeater", &AdvertFlags{Repeater: true}, "repeater"},
|
||||
{"room", &AdvertFlags{Room: true}, "room"},
|
||||
{"sensor", &AdvertFlags{Sensor: true}, "sensor"},
|
||||
{"companion (default)", &AdvertFlags{Chat: true}, "companion"},
|
||||
{"companion (no flags)", &AdvertFlags{}, "companion"},
|
||||
{"repeater takes priority", &AdvertFlags{Repeater: true, Room: true}, "repeater"},
|
||||
{"room before sensor", &AdvertFlags{Room: true, Sensor: true}, "room"},
|
||||
{"none (type 0)", &AdvertFlags{Type: 0}, "none"},
|
||||
{"companion (type 1)", &AdvertFlags{Type: 1, Chat: true}, "companion"},
|
||||
{"repeater (type 2)", &AdvertFlags{Type: 2, Repeater: true}, "repeater"},
|
||||
{"room (type 3)", &AdvertFlags{Type: 3, Room: true}, "room"},
|
||||
{"sensor (type 4)", &AdvertFlags{Type: 4, Sensor: true}, "sensor"},
|
||||
{"future type-5", &AdvertFlags{Type: 5}, "type-5"},
|
||||
{"nil flags falls back to companion", nil, "companion"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -643,7 +647,7 @@ func TestHandleMessageWithLowercaseSNRRSSI(t *testing.T) {
|
||||
payload := []byte(`{"raw":"` + rawHex + `","snr":5.5,"rssi":-102}`)
|
||||
msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: payload}
|
||||
|
||||
handleMessage(store, "test", source, msg, nil, &Config{})
|
||||
handleMessage(store, "test", source, msg, nil, nil, &Config{})
|
||||
|
||||
var snr, rssi *float64
|
||||
store.db.QueryRow("SELECT snr, rssi FROM observations LIMIT 1").Scan(&snr, &rssi)
|
||||
@@ -664,7 +668,7 @@ func TestHandleMessageSNRRSSIUppercaseWins(t *testing.T) {
|
||||
payload := []byte(`{"raw":"` + rawHex + `","SNR":7.2,"snr":1.0,"RSSI":-95,"rssi":-50}`)
|
||||
msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: payload}
|
||||
|
||||
handleMessage(store, "test", source, msg, nil, &Config{})
|
||||
handleMessage(store, "test", source, msg, nil, nil, &Config{})
|
||||
|
||||
var snr, rssi *float64
|
||||
store.db.QueryRow("SELECT snr, rssi FROM observations LIMIT 1").Scan(&snr, &rssi)
|
||||
@@ -684,7 +688,7 @@ func TestHandleMessageNoSNRRSSI(t *testing.T) {
|
||||
payload := []byte(`{"raw":"` + rawHex + `"}`)
|
||||
msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: payload}
|
||||
|
||||
handleMessage(store, "test", source, msg, nil, &Config{})
|
||||
handleMessage(store, "test", source, msg, nil, nil, &Config{})
|
||||
|
||||
var snr, rssi *float64
|
||||
store.db.QueryRow("SELECT snr, rssi FROM observations LIMIT 1").Scan(&snr, &rssi)
|
||||
@@ -755,7 +759,7 @@ func TestIATAFilterDoesNotDropStatusMessages(t *testing.T) {
|
||||
topic: "meshcore/BFL/bfl-obs1/status",
|
||||
payload: []byte(`{"origin":"BFLObserver","stats":{"noise_floor":-105.0}}`),
|
||||
}
|
||||
handleMessage(store, "test", source, msg, nil, &Config{})
|
||||
handleMessage(store, "test", source, msg, nil, nil, &Config{})
|
||||
|
||||
var name string
|
||||
var noiseFloor *float64
|
||||
@@ -776,7 +780,7 @@ func TestIATAFilterDoesNotDropStatusMessages(t *testing.T) {
|
||||
topic: "meshcore/BFL/bfl-obs1/packets",
|
||||
payload: []byte(`{"raw":"` + rawHex + `"}`),
|
||||
}
|
||||
handleMessage(store, "test", source, pktMsg, nil, &Config{})
|
||||
handleMessage(store, "test", source, pktMsg, nil, nil, &Config{})
|
||||
var count int
|
||||
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
|
||||
if count != 0 {
|
||||
@@ -784,6 +788,87 @@ func TestIATAFilterDoesNotDropStatusMessages(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRegionKeys(t *testing.T) {
|
||||
cfg := &Config{HashRegions: []string{"#belgium", "eu", " #Test ", "", "#belgium"}}
|
||||
keys := loadRegionKeys(cfg)
|
||||
|
||||
// Deduplication + normalization
|
||||
if len(keys) != 3 {
|
||||
t.Fatalf("len(keys) = %d, want 3", len(keys))
|
||||
}
|
||||
// Pre-computed: SHA256("#belgium")[:16]. Hardcoded so a change to the key
|
||||
// derivation algorithm (hash function, truncation length) breaks this test
|
||||
// even if both sides were updated together.
|
||||
wantBelgium, _ := hex.DecodeString("7085b78ed010599094f8c8e7d1aa0e27")
|
||||
if got := keys["#belgium"]; !bytes.Equal(got, wantBelgium) {
|
||||
t.Errorf("#belgium key mismatch: got %x, want %x", got, wantBelgium)
|
||||
}
|
||||
// "eu" should be normalized to "#eu"
|
||||
if _, ok := keys["#eu"]; !ok {
|
||||
t.Error("expected #eu key")
|
||||
}
|
||||
// " #Test " should be normalized to "#Test"
|
||||
if _, ok := keys["#Test"]; !ok {
|
||||
t.Error("expected #Test key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchScope(t *testing.T) {
|
||||
// Fixed known-answer vectors only — no in-test HMAC computation.
|
||||
// Keys and Code1 values are pre-computed externally so a wrong algorithm
|
||||
// that produces consistent wrong results on both sides would still fail.
|
||||
|
||||
// Vector 1: "#test"/payloadType=5/"hello" → Code1=2AB5
|
||||
// Key = SHA256("#test")[:16] = 9cd8fcf22a47333b591d96a2b848b73f
|
||||
testKey, _ := hex.DecodeString("9cd8fcf22a47333b591d96a2b848b73f")
|
||||
testKeys := map[string][]byte{"#test": testKey}
|
||||
if got := matchScope(testKeys, 5, []byte("hello"), "2AB5"); got != "#test" {
|
||||
t.Errorf("#test vector: matchScope = %q, want #test", got)
|
||||
}
|
||||
|
||||
// Vector 2: "#belgium"/payloadType=5/"hello" → Code1=4A75
|
||||
// Key = SHA256("#belgium")[:16] = 7085b78ed010599094f8c8e7d1aa0e27
|
||||
belgiumKey, _ := hex.DecodeString("7085b78ed010599094f8c8e7d1aa0e27")
|
||||
belgiumKeys := map[string][]byte{"#belgium": belgiumKey}
|
||||
if got := matchScope(belgiumKeys, 5, []byte("hello"), "4A75"); got != "#belgium" {
|
||||
t.Errorf("#belgium vector: matchScope = %q, want #belgium", got)
|
||||
}
|
||||
|
||||
// Code1=0000 (unscoped transport) → no region matched
|
||||
if got := matchScope(belgiumKeys, 5, []byte("hello"), "0000"); got != "" {
|
||||
t.Errorf("unscoped: matchScope = %q, want empty", got)
|
||||
}
|
||||
|
||||
// Code1 present but matches no configured region → empty string
|
||||
if got := matchScope(belgiumKeys, 5, []byte("hello"), "BEEF"); got != "" {
|
||||
t.Errorf("no match: matchScope = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPacketDataScopeMatching(t *testing.T) {
|
||||
// Fixed known-answer packet: TRANSPORT_FLOOD, payloadType=5, payload="hello",
|
||||
// Code1=2AB5 (pre-computed for region "#test").
|
||||
// header=0x14 (route_type=0 FLOOD, payloadType=5 → 5<<2), Code1=[0x2A,0xB5],
|
||||
// Code2=[0,0], path_len=0, payload="hello" (68 65 6C 6C 6F).
|
||||
const rawHex = "142AB500000068656C6C6F"
|
||||
key, _ := hex.DecodeString("9cd8fcf22a47333b591d96a2b848b73f") // SHA256("#test")[:16]
|
||||
regionKeys := map[string][]byte{"#test": key}
|
||||
|
||||
decoded, err := DecodePacket(rawHex, nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodePacket: %v", err)
|
||||
}
|
||||
|
||||
msg := &MQTTPacketMessage{Raw: rawHex}
|
||||
pktData := BuildPacketData(msg, decoded, "obs1", "region1", regionKeys)
|
||||
if pktData.ScopeName != "#test" {
|
||||
t.Errorf("ScopeName = %q, want #test", pktData.ScopeName)
|
||||
}
|
||||
if !pktData.IsTransportScoped {
|
||||
t.Error("IsTransportScoped should be true")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMQTTConnectRetryTimeoutDoesNotBlock verifies that WaitTimeout returns within
|
||||
// the deadline for an unreachable broker when ConnectRetry=true (#910). Previously,
|
||||
// token.Wait() would block forever in this configuration.
|
||||
@@ -916,7 +1001,7 @@ func TestHandleMessageObserverIATAWhitelist(t *testing.T) {
|
||||
handleMessage(store, "test", source, &mockMessage{
|
||||
topic: "meshcore/GOT/obs1/status",
|
||||
payload: []byte(`{"origin":"node1","noise_floor":-110}`),
|
||||
}, nil, cfg)
|
||||
}, nil, nil, cfg)
|
||||
|
||||
var count int
|
||||
store.db.QueryRow("SELECT COUNT(*) FROM observers WHERE id='obs1'").Scan(&count)
|
||||
@@ -928,7 +1013,7 @@ func TestHandleMessageObserverIATAWhitelist(t *testing.T) {
|
||||
handleMessage(store, "test", source, &mockMessage{
|
||||
topic: "meshcore/ARN/obs2/status",
|
||||
payload: []byte(`{"origin":"node2","noise_floor":-105}`),
|
||||
}, nil, cfg)
|
||||
}, nil, nil, cfg)
|
||||
|
||||
store.db.QueryRow("SELECT COUNT(*) FROM observers WHERE id='obs2'").Scan(&count)
|
||||
if count != 1 {
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/meshcore-analyzer/dbschema"
|
||||
)
|
||||
|
||||
// PruneOldPackets deletes transmissions (and their child observations)
|
||||
// older than `days`. Returns count of transmissions deleted.
|
||||
//
|
||||
// Owned by the ingestor per #1283: the writer process is the only one
|
||||
// allowed to hold the DB write lock; previously this lived in
|
||||
// cmd/server/db.go and raced ingestor INSERTs (SQLITE_BUSY).
|
||||
func (s *Store) PruneOldPackets(days int) (int64, error) {
|
||||
if days <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
cutoff := time.Now().UTC().AddDate(0, 0, -days).Format(time.RFC3339)
|
||||
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("prune begin: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Delete child observations first (no CASCADE in SQLite).
|
||||
if _, err := tx.Exec(`DELETE FROM observations WHERE transmission_id IN (
|
||||
SELECT id FROM transmissions WHERE first_seen < ?
|
||||
)`, cutoff); err != nil {
|
||||
return 0, fmt.Errorf("prune observations: %w", err)
|
||||
}
|
||||
|
||||
res, err := tx.Exec(`DELETE FROM transmissions WHERE first_seen < ?`, cutoff)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("prune transmissions: %w", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, fmt.Errorf("prune commit: %w", err)
|
||||
}
|
||||
if n > 0 {
|
||||
log.Printf("[prune] deleted %d transmissions older than %d days", n, days)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// SoftDeleteBlacklistedObservers marks observers in the blacklist as
|
||||
// inactive=1 so they are hidden from API responses. Owned by ingestor
|
||||
// per #1287. Runs once at startup.
|
||||
func (s *Store) SoftDeleteBlacklistedObservers(blacklist []string) {
|
||||
n, err := dbschema.SoftDeleteBlacklistedObservers(s.db, blacklist)
|
||||
if err != nil {
|
||||
log.Printf("[observer-blacklist] warning: soft-delete failed: %v", err)
|
||||
return
|
||||
}
|
||||
if n > 0 {
|
||||
log.Printf("[observer-blacklist] soft-deleted %d blacklisted observer(s)", n)
|
||||
}
|
||||
}
|
||||
|
||||
// PruneNeighborEdges deletes rows older than maxAgeDays from
|
||||
// neighbor_edges. Owned by the ingestor per #1287 (was in cmd/server).
|
||||
// Returns DB rows deleted.
|
||||
func (s *Store) PruneNeighborEdges(maxAgeDays int) (int64, error) {
|
||||
if maxAgeDays <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
cutoff := time.Now().UTC().Add(-time.Duration(maxAgeDays) * 24 * time.Hour).Format(time.RFC3339)
|
||||
res, err := s.db.Exec("DELETE FROM neighbor_edges WHERE last_seen < ?", cutoff)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("prune neighbor_edges: %w", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n > 0 {
|
||||
log.Printf("[neighbor-prune] removed %d DB rows older than %d days", n, maxAgeDays)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// ─── from_pubkey backfill (#1143) ──────────────────────────────────────────
|
||||
//
|
||||
// Moved from cmd/server/from_pubkey_migration.go in #1287. Runs from the
|
||||
// ingestor's maintenance loop. Populates transmissions.from_pubkey for
|
||||
// ADVERT rows whose value is still NULL, by parsing decoded_json.pubKey.
|
||||
|
||||
// FromPubkeyBackfillStats holds progress for /api/healthz exposure.
|
||||
// The ingestor exposes these via stats_file.go so the server can read
|
||||
// them without writing.
|
||||
type FromPubkeyBackfillStats struct {
|
||||
Total int64 `json:"total"`
|
||||
Processed int64 `json:"processed"`
|
||||
Done bool `json:"done"`
|
||||
}
|
||||
|
||||
// BackfillFromPubkey scans transmissions where from_pubkey IS NULL and
|
||||
// payload_type = 4 (ADVERT) and populates from_pubkey from decoded_json.
|
||||
// Chunked + yields between batches. Safe to call repeatedly; once a row
|
||||
// is set to either "" or hex it never matches the WHERE clause again.
|
||||
func (s *Store) BackfillFromPubkey(chunkSize int, yieldDuration time.Duration, progress func(total, processed int64, done bool)) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[backfill] from_pubkey panic recovered: %v", r)
|
||||
}
|
||||
if progress != nil {
|
||||
progress(0, 0, true) // signal done; values overwritten below if collected
|
||||
}
|
||||
}()
|
||||
if chunkSize <= 0 {
|
||||
chunkSize = 5000
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := s.db.QueryRow(
|
||||
"SELECT COUNT(*) FROM transmissions WHERE from_pubkey IS NULL AND payload_type = 4",
|
||||
).Scan(&total); err != nil {
|
||||
log.Printf("[backfill] from_pubkey count error: %v", err)
|
||||
return
|
||||
}
|
||||
if total == 0 {
|
||||
log.Println("[backfill] from_pubkey: nothing to do")
|
||||
if progress != nil {
|
||||
progress(0, 0, true)
|
||||
}
|
||||
return
|
||||
}
|
||||
if progress != nil {
|
||||
progress(total, 0, false)
|
||||
}
|
||||
log.Printf("[backfill] from_pubkey starting: %d ADVERT rows", total)
|
||||
|
||||
stmt, err := s.db.Prepare("UPDATE transmissions SET from_pubkey = ? WHERE id = ?")
|
||||
if err != nil {
|
||||
log.Printf("[backfill] from_pubkey prepare: %v", err)
|
||||
return
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
var processed int64
|
||||
for {
|
||||
rows, err := s.db.Query(
|
||||
"SELECT id, decoded_json FROM transmissions WHERE from_pubkey IS NULL AND payload_type = 4 LIMIT ?",
|
||||
chunkSize)
|
||||
if err != nil {
|
||||
log.Printf("[backfill] from_pubkey select: %v", err)
|
||||
return
|
||||
}
|
||||
type row struct {
|
||||
id int64
|
||||
pk string
|
||||
}
|
||||
batch := make([]row, 0, chunkSize)
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var dj sql.NullString
|
||||
if err := rows.Scan(&id, &dj); err != nil {
|
||||
continue
|
||||
}
|
||||
batch = append(batch, row{id: id, pk: extractPubkeyFromAdvertJSON(dj.String)})
|
||||
}
|
||||
rows.Close()
|
||||
if len(batch) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
log.Printf("[backfill] from_pubkey begin tx: %v", err)
|
||||
return
|
||||
}
|
||||
txStmt := tx.Stmt(stmt)
|
||||
for _, b := range batch {
|
||||
// Sentinel: "" = scanned-no-pubkey (so the WHERE clause
|
||||
// won't keep rescanning this row). hex = real pubkey.
|
||||
var val interface{} = ""
|
||||
if b.pk != "" {
|
||||
val = b.pk
|
||||
}
|
||||
if _, err := txStmt.Exec(val, b.id); err != nil {
|
||||
log.Printf("[backfill] from_pubkey update id=%d: %v", b.id, err)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
log.Printf("[backfill] from_pubkey commit: %v", err)
|
||||
return
|
||||
}
|
||||
processed += int64(len(batch))
|
||||
if progress != nil {
|
||||
progress(total, processed, false)
|
||||
}
|
||||
if len(batch) < chunkSize {
|
||||
break
|
||||
}
|
||||
if yieldDuration > 0 {
|
||||
time.Sleep(yieldDuration)
|
||||
}
|
||||
}
|
||||
log.Printf("[backfill] from_pubkey complete: %d rows processed", processed)
|
||||
if progress != nil {
|
||||
progress(total, processed, true)
|
||||
}
|
||||
}
|
||||
|
||||
// extractPubkeyFromAdvertJSON parses an ADVERT decoded_json blob and
|
||||
// returns the pubKey field, or "" if absent/invalid.
|
||||
func extractPubkeyFromAdvertJSON(s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(s), &m); err != nil {
|
||||
return ""
|
||||
}
|
||||
if v, ok := m["pubKey"].(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"log"
|
||||
"net/url"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PR #1216 r1 item 5 (kent #1 / adv MAJOR-2): the original assertion was
|
||||
// tautological — it only checked OnConnectAttempt != nil, which passes
|
||||
// even if the handler is a no-op. This version invokes the wired handler,
|
||||
// captures log output, and asserts the OBSERVABLE behaviour operators
|
||||
// rely on during a #1212-class outage:
|
||||
// - the configured source tag appears in the log line
|
||||
// - the broker URL appears in the log line
|
||||
// - the per-source AttemptCount increments on every invocation (proving
|
||||
// the handler is wired to the right state, not just a stub)
|
||||
// - the tlsCfg passed in is returned unchanged (no surprise TLS rewrite)
|
||||
func TestBuildMQTTOpts_InstrumentsConnectionAttempt(t *testing.T) {
|
||||
defer snapshotAndResetRegistry(t)()
|
||||
|
||||
source := MQTTSource{Broker: "tcp://localhost:1883", Name: "obs-tag"}
|
||||
opts := buildMQTTOpts(source)
|
||||
|
||||
if opts.OnConnectAttempt == nil {
|
||||
t.Fatal("OnConnectAttempt must be wired in buildMQTTOpts (#1212 / PR #1216 r1)")
|
||||
}
|
||||
|
||||
// Register the liveness state so the handler can find it and increment
|
||||
// the attempt counter (same wiring main.go does).
|
||||
liveness := &SourceLivenessState{Tag: "obs-tag", Broker: source.Broker}
|
||||
if err := registerLivenessState(liveness); err != nil {
|
||||
t.Fatalf("test setup: registerLivenessState: %v", err)
|
||||
}
|
||||
|
||||
// Capture log output via log.SetOutput. Save/restore so other tests
|
||||
// running serially don't lose their writer.
|
||||
var buf bytes.Buffer
|
||||
origOut := log.Writer()
|
||||
origFlags := log.Flags()
|
||||
log.SetOutput(&buf)
|
||||
log.SetFlags(0)
|
||||
defer func() {
|
||||
log.SetOutput(origOut)
|
||||
log.SetFlags(origFlags)
|
||||
}()
|
||||
|
||||
brokerURL, err := url.Parse(source.Broker)
|
||||
if err != nil {
|
||||
t.Fatalf("test setup: parse broker url: %v", err)
|
||||
}
|
||||
tlsIn := &tls.Config{ServerName: "sentinel.test"}
|
||||
|
||||
// Invoke the handler twice — operators need to see attempt # increment
|
||||
// per dial to gauge backoff progress.
|
||||
tlsOut1 := opts.OnConnectAttempt(brokerURL, tlsIn)
|
||||
tlsOut2 := opts.OnConnectAttempt(brokerURL, tlsIn)
|
||||
|
||||
if tlsOut1 != tlsIn || tlsOut2 != tlsIn {
|
||||
t.Errorf("OnConnectAttempt must pass tlsCfg through unchanged (got %p, %p; want %p)", tlsOut1, tlsOut2, tlsIn)
|
||||
}
|
||||
|
||||
logOut := buf.String()
|
||||
if !strings.Contains(logOut, "obs-tag") {
|
||||
t.Errorf("log output must include the source tag for operator grep; got %q", logOut)
|
||||
}
|
||||
if !strings.Contains(logOut, source.Broker) {
|
||||
t.Errorf("log output must include the broker URL so operators can correlate against config; got %q", logOut)
|
||||
}
|
||||
if !strings.Contains(logOut, "#1") || !strings.Contains(logOut, "#2") {
|
||||
t.Errorf("log output must show attempt #1 and #2 across the two invocations (per-source counter); got %q", logOut)
|
||||
}
|
||||
|
||||
if got := atomic.LoadInt64(&liveness.AttemptCount); got != 2 {
|
||||
t.Errorf("AttemptCount must increment per dial (got %d after 2 invocations, want 2)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// RED: the watchdog acceptance criterion from #1212 — even when the client
|
||||
// reports connected, if NO packets have flowed for >threshold, log a warning.
|
||||
// This is a separate detection layer that catches "silently dead" sockets
|
||||
// (broker accepted TCP but stopped forwarding, half-open TCP, etc.).
|
||||
func TestMQTTStallWatchdog_FiresOnSilentSource(t *testing.T) {
|
||||
state := &SourceLivenessState{Tag: "test", Broker: "tcp://x:1883"}
|
||||
atomic.StoreInt64(&state.LastMessageUnix, time.Now().Add(-10*time.Minute).Unix())
|
||||
state.IsConnectedFn = func() bool { return true }
|
||||
|
||||
msg, kind := checkSourceLiveness(state, 5*time.Minute, time.Now())
|
||||
if kind != LivenessStalled {
|
||||
t.Fatalf("watchdog should flag stall when source connected but no message for 10m (threshold 5m); got kind=%v msg=%q", kind, msg)
|
||||
}
|
||||
if !strings.Contains(msg, "no messages") {
|
||||
t.Errorf("stall message should mention 'no messages'; got %q", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "test") {
|
||||
t.Errorf("stall message should include the source tag; got %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMQTTStallWatchdog_QuietWhenRecent(t *testing.T) {
|
||||
state := &SourceLivenessState{Tag: "test", Broker: "tcp://x:1883"}
|
||||
atomic.StoreInt64(&state.LastMessageUnix, time.Now().Add(-30*time.Second).Unix())
|
||||
state.IsConnectedFn = func() bool { return true }
|
||||
|
||||
_, kind := checkSourceLiveness(state, 5*time.Minute, time.Now())
|
||||
if kind != LivenessOK {
|
||||
t.Fatal("watchdog should NOT flag stall when last message was 30s ago and threshold is 5m")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMQTTStallWatchdog_QuietWhenDisconnected(t *testing.T) {
|
||||
// When disconnected, paho's own reconnect logging covers it — the
|
||||
// watchdog should only fire for the silent-while-connected case.
|
||||
state := &SourceLivenessState{Tag: "test", Broker: "tcp://x:1883"}
|
||||
atomic.StoreInt64(&state.LastMessageUnix, time.Now().Add(-1*time.Hour).Unix())
|
||||
state.IsConnectedFn = func() bool { return false }
|
||||
|
||||
_, kind := checkSourceLiveness(state, 5*time.Minute, time.Now())
|
||||
if kind != LivenessDisconnected {
|
||||
t.Fatalf("watchdog must classify a !IsConnected source as LivenessDisconnected (silent state), not LivenessOK — r2 item 1 prevents disconnect→recovery mis-classification; got kind=%v", kind)
|
||||
}
|
||||
}
|
||||
|
||||
// snapshotAndResetRegistry isolates the package-level livenessRegistry for a
|
||||
// single test. Returns a restore func to defer. Without this, parallel or
|
||||
// previously-registered sources leak into the watchdog goroutine under test.
|
||||
func snapshotAndResetRegistry(t *testing.T) func() {
|
||||
t.Helper()
|
||||
livenessRegistryMu.Lock()
|
||||
saved := livenessRegistry
|
||||
livenessRegistry = map[string]*SourceLivenessState{}
|
||||
livenessRegistryMu.Unlock()
|
||||
return func() {
|
||||
livenessRegistryMu.Lock()
|
||||
livenessRegistry = saved
|
||||
livenessRegistryMu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// RED-then-GREEN: the watchdog GOROUTINE (not just checkSourceLiveness) must
|
||||
// fan out emits across the registry on each tick, AND must exit cleanly when
|
||||
// the stop signal fires. Originally runLivenessWatchdog used `for range
|
||||
// t.C` — ticker.Stop() does not close the channel, so the goroutine
|
||||
// leaked past shutdown. This test asserts both:
|
||||
// - tick → emit for every stalled source in the registry
|
||||
// - stop → goroutine returns within a short bound
|
||||
func TestMQTTStallWatchdog_LoopEmitsAndStopsCleanly(t *testing.T) {
|
||||
defer snapshotAndResetRegistry(t)()
|
||||
|
||||
s1 := &SourceLivenessState{Tag: "alpha", Broker: "tcp://a:1883", IsConnectedFn: func() bool { return true }}
|
||||
s2 := &SourceLivenessState{Tag: "beta", Broker: "tcp://b:1883", IsConnectedFn: func() bool { return true }}
|
||||
atomic.StoreInt64(&s1.LastMessageUnix, time.Now().Add(-10*time.Minute).Unix())
|
||||
atomic.StoreInt64(&s2.LastMessageUnix, time.Now().Add(-10*time.Minute).Unix())
|
||||
registerLivenessState(s1)
|
||||
registerLivenessState(s2)
|
||||
|
||||
tick := make(chan time.Time, 1)
|
||||
done := make(chan struct{})
|
||||
|
||||
var mu sync.Mutex
|
||||
var emits []string
|
||||
emit := func(args ...any) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(args) > 0 {
|
||||
if s, ok := args[0].(string); ok {
|
||||
emits = append(emits, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
exited := make(chan struct{})
|
||||
go func() {
|
||||
runLivenessWatchdogLoop(tick, done, 5*time.Minute, emit)
|
||||
close(exited)
|
||||
}()
|
||||
|
||||
tick <- time.Now()
|
||||
// Drain: wait briefly for the emits to land. Polling instead of sleeping
|
||||
// keeps the test fast on a healthy machine.
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
mu.Lock()
|
||||
n := len(emits)
|
||||
mu.Unlock()
|
||||
if n >= 2 {
|
||||
break
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
mu.Lock()
|
||||
got := append([]string(nil), emits...)
|
||||
mu.Unlock()
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2 stall emits (alpha+beta), got %d: %v", len(got), got)
|
||||
}
|
||||
|
||||
close(done)
|
||||
select {
|
||||
case <-exited:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("watchdog goroutine did not exit within 2s of stop — ticker leak regression")
|
||||
}
|
||||
}
|
||||
|
||||
// PR #1216 r1 item 6 (kent #2 / adv MAJOR-3): the original test had no
|
||||
// assertions gating behaviour — it called stop() and trusted `-race` to
|
||||
// catch leaks. `-race` does NOT detect goroutine leaks. This version
|
||||
// captures runtime.NumGoroutine() before/after and asserts the watchdog's
|
||||
// goroutine actually exited. Allows ±1 slack for unrelated runtime
|
||||
// bookkeeping (gc, finalizer).
|
||||
func TestMQTTStallWatchdog_RunStopsCleanly(t *testing.T) {
|
||||
defer snapshotAndResetRegistry(t)()
|
||||
|
||||
// Settle: let any prior-test goroutines finish before sampling baseline.
|
||||
runtime.GC()
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
before := runtime.NumGoroutine()
|
||||
|
||||
stop := runLivenessWatchdog(10*time.Millisecond, 5*time.Minute)
|
||||
// Let the watchdog run a few ticks so we're sure it's truly spawned.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if mid := runtime.NumGoroutine(); mid <= before {
|
||||
t.Fatalf("watchdog goroutine did not spawn: before=%d mid=%d", before, mid)
|
||||
}
|
||||
|
||||
stop()
|
||||
|
||||
// Poll for the goroutine count to return to baseline (±1 slack).
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
var after int
|
||||
for time.Now().Before(deadline) {
|
||||
runtime.Gosched()
|
||||
after = runtime.NumGoroutine()
|
||||
if after <= before+1 {
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("watchdog goroutine leaked: before=%d after=%d (delta %d) — stop() did not signal the loop to exit", before, after, after-before)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Issue #1337: paho client misconfigured — ingestor receives 200× fewer
|
||||
// messages than mosquitto_sub on the same broker/creds/topics. Root cause
|
||||
// (hypothesis 1+5): paho defaults — CleanSession=true, empty ClientID
|
||||
// (auto-random per reconnect), Order=true (handler serialized) — combined
|
||||
// with the reconnect-every-5min watchdog meant the broker dropped queued
|
||||
// messages on every reconnect AND the handler couldn't keep up under load.
|
||||
//
|
||||
// These tests pin the four paho options that fix the gap:
|
||||
// 1. CleanSession=false — broker keeps the subscription state across
|
||||
// reconnects instead of treating each dial
|
||||
// as a brand-new session.
|
||||
// 2. ClientID = persistent — broker recognizes the returning session.
|
||||
// Empty ClientID makes paho generate a fresh
|
||||
// random one on every reconnect, which is
|
||||
// treated as a new client by the broker.
|
||||
// 3. KeepAlive = 30s — half-open TCP detected at the paho layer
|
||||
// instead of waiting for OS keepalive.
|
||||
// 4. Order = false — handler dispatch is parallel; one slow
|
||||
// packet does not block all the others.
|
||||
//
|
||||
// All four must be set in buildMQTTOpts. This test fails on master.
|
||||
|
||||
func TestBuildMQTTOpts_PersistentSession_Issue1337(t *testing.T) {
|
||||
source := MQTTSource{
|
||||
Broker: "ssl://broker.example:8883",
|
||||
Name: "sjc-test",
|
||||
}
|
||||
opts := buildMQTTOpts(source)
|
||||
|
||||
if opts.CleanSession {
|
||||
t.Error("CleanSession must be false (#1337): broker drops queued msgs across reconnects when true")
|
||||
}
|
||||
|
||||
host, _ := os.Hostname()
|
||||
if opts.ClientID == "" {
|
||||
t.Fatal("ClientID must be set to a persistent value (#1337): empty = paho generates random per reconnect, broker treats every reconnect as new session")
|
||||
}
|
||||
if !strings.Contains(opts.ClientID, "sjc-test") {
|
||||
t.Errorf("ClientID must embed source name for uniqueness across sources, got %q", opts.ClientID)
|
||||
}
|
||||
if host != "" && !strings.Contains(opts.ClientID, host) {
|
||||
t.Errorf("ClientID must embed hostname for uniqueness across deployments, got %q (host=%q)", opts.ClientID, host)
|
||||
}
|
||||
|
||||
if opts.KeepAlive != int64((30 * time.Second).Seconds()) {
|
||||
t.Errorf("KeepAlive must be 30s (#1337): got %ds — needed so paho detects half-open TCP", opts.KeepAlive)
|
||||
}
|
||||
|
||||
if opts.Order {
|
||||
t.Error("Order must be false (#1337): default true serializes handler dispatch; a slow packet stalls all others")
|
||||
}
|
||||
}
|
||||
|
||||
// Stability: ClientID must be deterministic for a given (hostname, source)
|
||||
// across two builds. Otherwise reconnect = new session = lost backlog.
|
||||
func TestBuildMQTTOpts_ClientIDStableAcrossBuilds_Issue1337(t *testing.T) {
|
||||
source := MQTTSource{Broker: "ssl://broker.example:8883", Name: "stable-test"}
|
||||
a := buildMQTTOpts(source).ClientID
|
||||
b := buildMQTTOpts(source).ClientID
|
||||
if a == "" {
|
||||
t.Fatal("ClientID empty")
|
||||
}
|
||||
if a != b {
|
||||
t.Errorf("ClientID must be stable across buildMQTTOpts calls (#1337): %q vs %q — random = broker drops session on reconnect", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
// Distinct sources must NOT share a ClientID — broker disconnects the older
|
||||
// session whenever a duplicate ClientID connects, causing flapping.
|
||||
func TestBuildMQTTOpts_ClientIDUniquePerSource_Issue1337(t *testing.T) {
|
||||
a := buildMQTTOpts(MQTTSource{Broker: "ssl://a:8883", Name: "alpha"}).ClientID
|
||||
b := buildMQTTOpts(MQTTSource{Broker: "ssl://b:8883", Name: "beta"}).ClientID
|
||||
if a == b {
|
||||
t.Errorf("distinct sources must get distinct ClientIDs (#1337): both got %q — duplicate IDs cause broker to disconnect the older one, infinite flap", a)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// heartbeatInterval is how often the watchdog re-emits a still-stalled
|
||||
// reminder once the initial WARN edge has fired. 1h matches the pager
|
||||
// budget — frequent enough that an unattended stall is noticed within a
|
||||
// shift, infrequent enough not to spam ops chat.
|
||||
const livenessHeartbeatInterval = time.Hour
|
||||
|
||||
// LivenessKind enumerates the watchdog verdicts for a source. Edge-triggered
|
||||
// transitions use this to decide whether to emit (and what severity).
|
||||
type LivenessKind int
|
||||
|
||||
const (
|
||||
LivenessOK LivenessKind = iota
|
||||
LivenessStalled
|
||||
LivenessNeverReceived
|
||||
LivenessRecovered
|
||||
LivenessHeartbeat
|
||||
// LivenessDisconnected (PR #1216 r2 item 1): paho reports !IsConnected.
|
||||
// Distinct from LivenessOK so processLivenessTransition does NOT
|
||||
// interpret a TCP drop as recovery and fire a spurious "messages
|
||||
// flowing again" INFO when the source actually went from silently
|
||||
// broken to overtly broken. paho's own reconnect logging already
|
||||
// covers the disconnect — this kind exists solely to keep the
|
||||
// transition engine from mis-classifying it.
|
||||
LivenessDisconnected
|
||||
)
|
||||
|
||||
// SourceLivenessState tracks per-source last-message timestamp and connection
|
||||
// state for the stall watchdog (#1212). LastMessageUnix is updated by the
|
||||
// message handler via atomic store; the watchdog reads it via atomic load.
|
||||
//
|
||||
// PR #1216 r1 added:
|
||||
// - StartedAt: re-stamped on reconnect to suppress transient-stall WARNs
|
||||
// during paho's reconnect window.
|
||||
// - LastAlertUnix: edge-trigger cooldown; prevents 60-per-hour re-emits
|
||||
// of the same WARN.
|
||||
//
|
||||
// PR #1216 r2 added:
|
||||
// - FirstConnectedAt: stamped ONCE at registration, never reset. The
|
||||
// cold-start "NEVER received" alarm uses this so a broker that flaps
|
||||
// in CONNECT → SUBSCRIBE-deny cannot indefinitely re-arm the grace
|
||||
// window. r1's StartedAt-as-grace-clock conflated transient-stall
|
||||
// suppression with cold-start grace; r2 separates them.
|
||||
type SourceLivenessState struct {
|
||||
Tag string
|
||||
Broker string
|
||||
LastMessageUnix int64 // atomic; unix seconds of last successfully received MQTT message
|
||||
// FirstConnectedAt (PR #1216 r2 item 2) is stamped ONCE at
|
||||
// registerLivenessState time and never reset. Cold-start grace
|
||||
// checks against this so a flapping broker (CONNECT ok, SUBSCRIBE
|
||||
// ACL-denied — the #1212 shape) can no longer suppress the
|
||||
// "NEVER received" alarm by re-stamping StartedAt on every reconnect.
|
||||
FirstConnectedAt int64 // atomic; unix seconds of first registration
|
||||
StartedAt int64 // atomic; unix seconds when the source was registered / last reconnected (transient-stall tracking)
|
||||
LastAlertUnix int64 // atomic; unix seconds of last emit (WARN or heartbeat); 0 means quiet
|
||||
IsConnectedFn func() bool
|
||||
// AttemptCount is incremented on every TCP/TLS connection attempt. Used
|
||||
// by ConnectionAttemptHandler to log attempt # independent of paho's
|
||||
// internal reconnect-loop state. atomic.
|
||||
AttemptCount int64
|
||||
}
|
||||
|
||||
// MarkMessage records the time of a received MQTT message. Cheap; safe to
|
||||
// call from the message-handling hot path.
|
||||
func (s *SourceLivenessState) MarkMessage(now time.Time) {
|
||||
atomic.StoreInt64(&s.LastMessageUnix, now.Unix())
|
||||
}
|
||||
|
||||
// MarkReconnected clears stale liveness state so the watchdog does not
|
||||
// false-alarm on a pre-outage timestamp after paho re-establishes the
|
||||
// connection (PR #1216 r1 item 2). Resets LastMessageUnix, re-stamps
|
||||
// StartedAt (transient-stall window restarts), and clears LastAlertUnix
|
||||
// (edge-trigger re-arms).
|
||||
//
|
||||
// PR #1216 r2 item 2: FirstConnectedAt is INTENTIONALLY not touched here.
|
||||
// Under broker flap (CONNECT ok, SUBSCRIBE ACL-denied — exact #1212
|
||||
// class) r1 reset StartedAt on every reconnect, indefinitely re-arming
|
||||
// the cold-start grace and silencing the headline "NEVER received"
|
||||
// alarm. Cold-start grace now reads FirstConnectedAt instead, so the
|
||||
// alarm fires after the FIRST grace window regardless of reconnect
|
||||
// churn.
|
||||
func (s *SourceLivenessState) MarkReconnected(now time.Time) {
|
||||
atomic.StoreInt64(&s.LastMessageUnix, 0)
|
||||
atomic.StoreInt64(&s.StartedAt, now.Unix())
|
||||
atomic.StoreInt64(&s.LastAlertUnix, 0)
|
||||
}
|
||||
|
||||
// checkSourceLiveness returns (message, kind) describing the source's
|
||||
// liveness state. kind==LivenessOK means quiet/healthy; kind==
|
||||
// LivenessDisconnected means paho is not connected (silent state — no
|
||||
// emit, no recovery). Any other kind indicates the caller may want to
|
||||
// emit (subject to edge-trigger).
|
||||
//
|
||||
// Cold-start (PR #1216 r1 item 1, r2 item 2): when LastMessageUnix==0,
|
||||
// the source has never published a single message. If FirstConnectedAt
|
||||
// was stamped at registration and more than `threshold` has elapsed,
|
||||
// this is the #1212 failure class — wrong channel hash, ACL drops
|
||||
// SUBSCRIBE, half-open TCP after CONNECT, or a broker that loops
|
||||
// CONNECT-then-disconnect. We emit a DISTINCT "NEVER received" alarm
|
||||
// so operators can grep for it independently of generic stalls. Using
|
||||
// FirstConnectedAt (not the reconnect-reset StartedAt) ensures broker
|
||||
// flap cannot silence this alarm.
|
||||
func checkSourceLiveness(s *SourceLivenessState, threshold time.Duration, now time.Time) (string, LivenessKind) {
|
||||
if s == nil || s.IsConnectedFn == nil {
|
||||
return "", LivenessOK
|
||||
}
|
||||
if !s.IsConnectedFn() {
|
||||
// paho's reconnect handler covers the disconnected case. Return
|
||||
// a DISTINCT kind so the transition engine does not mis-classify
|
||||
// disconnect as recovery (PR #1216 r2 item 1).
|
||||
return "", LivenessDisconnected
|
||||
}
|
||||
last := atomic.LoadInt64(&s.LastMessageUnix)
|
||||
if last == 0 {
|
||||
firstConnected := atomic.LoadInt64(&s.FirstConnectedAt)
|
||||
if firstConnected == 0 {
|
||||
// Registration didn't stamp FirstConnectedAt — conservative: stay quiet.
|
||||
return "", LivenessOK
|
||||
}
|
||||
sinceFirst := now.Sub(time.Unix(firstConnected, 0))
|
||||
if sinceFirst < threshold {
|
||||
return "", LivenessOK
|
||||
}
|
||||
msg := fmt.Sprintf("MQTT [%s] WATCHDOG: client reports connected to %s but has NEVER received a message in %s (threshold %s) — check channel hash / subscribe ACL / half-open TCP",
|
||||
s.Tag, s.Broker, sinceFirst.Round(time.Second), threshold)
|
||||
return msg, LivenessNeverReceived
|
||||
}
|
||||
silentFor := now.Sub(time.Unix(last, 0))
|
||||
if silentFor < threshold {
|
||||
return "", LivenessOK
|
||||
}
|
||||
msg := fmt.Sprintf("MQTT [%s] WATCHDOG: client reports connected to %s but no messages received for %s (threshold %s) — possible half-open socket or upstream stall",
|
||||
s.Tag, s.Broker, silentFor.Round(time.Second), threshold)
|
||||
return msg, LivenessStalled
|
||||
}
|
||||
|
||||
// livenessRegistry is a package-level lookup so handleMessage (called with
|
||||
// only `tag string`) can mark liveness without threading the state through
|
||||
// every call site. Reads dominate (per message); writes happen once per
|
||||
// source at startup.
|
||||
var (
|
||||
livenessRegistry = map[string]*SourceLivenessState{}
|
||||
livenessRegistryMu sync.RWMutex
|
||||
)
|
||||
|
||||
// registerLivenessState publishes a state to the registry by tag. Returns
|
||||
// an error on tag collision (PR #1216 r1 item 4) so operators see a
|
||||
// startup misconfiguration instead of silently losing AttemptCount and
|
||||
// LastMessageUnix for the clobbered source. The collision case is real:
|
||||
// two MQTT sources with empty Name fall back to Broker; two sources with
|
||||
// duplicate Name; copy-paste in config.json. Caller (main) decides whether
|
||||
// to fatal or just log and skip. The first registration remains
|
||||
// authoritative — we do NOT overwrite.
|
||||
//
|
||||
// Also stamps StartedAt (transient-stall window) and FirstConnectedAt
|
||||
// (cold-start grace anchor — never reset; see r2 item 2 in
|
||||
// MarkReconnected) so the cold-start watchdog has its clocks.
|
||||
func registerLivenessState(s *SourceLivenessState) error {
|
||||
livenessRegistryMu.Lock()
|
||||
defer livenessRegistryMu.Unlock()
|
||||
if existing, ok := livenessRegistry[s.Tag]; ok {
|
||||
return fmt.Errorf("liveness registry: duplicate tag %q (existing broker=%s, new broker=%s) — fix config so each MQTT source has a unique Name", s.Tag, existing.Broker, s.Broker)
|
||||
}
|
||||
nowUnix := time.Now().Unix()
|
||||
if atomic.LoadInt64(&s.StartedAt) == 0 {
|
||||
atomic.StoreInt64(&s.StartedAt, nowUnix)
|
||||
}
|
||||
if atomic.LoadInt64(&s.FirstConnectedAt) == 0 {
|
||||
atomic.StoreInt64(&s.FirstConnectedAt, nowUnix)
|
||||
}
|
||||
livenessRegistry[s.Tag] = s
|
||||
return nil
|
||||
}
|
||||
|
||||
// registerLivenessOrSkip (PR #1216 r2 item 3) is the main-callsite wrapper
|
||||
// that replaces the previous log.Fatalf on tag collision. Fatal at
|
||||
// startup over a config typo would kill the entire ingestor and recreate
|
||||
// the #1212 total-ingest-stop class this PR exists to prevent. On
|
||||
// collision we log ERROR + skip — the MQTT source still attempts to
|
||||
// connect, it just won't be tracked by the liveness watchdog. Returns
|
||||
// true iff the source was registered.
|
||||
func registerLivenessOrSkip(s *SourceLivenessState) bool {
|
||||
if err := registerLivenessState(s); err != nil {
|
||||
log.Printf("[ingestor] ERROR: source tag collision %q — skipping duplicate liveness registration, this source will connect but will not be tracked by the watchdog (%v)", s.Tag, err)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// markLivenessForTag is the hot-path entry point: O(1) map lookup +
|
||||
// atomic store. Safe to call for unknown tags (no-op).
|
||||
func markLivenessForTag(tag string, now time.Time) {
|
||||
livenessRegistryMu.RLock()
|
||||
s := livenessRegistry[tag]
|
||||
livenessRegistryMu.RUnlock()
|
||||
if s != nil {
|
||||
s.MarkMessage(now)
|
||||
}
|
||||
}
|
||||
|
||||
// runLivenessWatchdog starts a goroutine that scans the registry every
|
||||
// `interval` and logs a warning for any source that has been silent while
|
||||
// connected for more than `threshold`. Returns a stop function that halts
|
||||
// the ticker AND signals the goroutine to exit (time.Ticker.Stop does NOT
|
||||
// close the channel, so a naive `for range t.C` would leak). interval
|
||||
// should be a fraction of threshold (e.g. threshold/5) so detection
|
||||
// latency is bounded.
|
||||
func runLivenessWatchdog(interval, threshold time.Duration) (stop func()) {
|
||||
t := time.NewTicker(interval)
|
||||
done := make(chan struct{})
|
||||
go runLivenessWatchdogLoop(t.C, done, threshold, log.Print)
|
||||
return func() {
|
||||
t.Stop()
|
||||
close(done)
|
||||
}
|
||||
}
|
||||
|
||||
// runLivenessWatchdogLoop is the goroutine body, extracted so tests can
|
||||
// drive it with a synthetic tick channel and capture log output without
|
||||
// racing on the real ticker.
|
||||
//
|
||||
// Edge-triggered (PR #1216 r1 item 3):
|
||||
// - quiet → stalled / never-received: emit WARN once, record LastAlertUnix
|
||||
// - still stalled, < heartbeat interval since last alert: suppress
|
||||
// - still stalled, ≥ heartbeat interval since last alert: emit reminder,
|
||||
// refresh LastAlertUnix
|
||||
// - stalled → flowing: emit recovery INFO once, clear LastAlertUnix
|
||||
//
|
||||
// Without this, the original loop re-emitted the same WARN on every 60s
|
||||
// tick (60 alerts/hr/source) — the kind of log flood that trains ops to
|
||||
// mute alerts and miss the next real outage.
|
||||
func runLivenessWatchdogLoop(tick <-chan time.Time, done <-chan struct{}, threshold time.Duration, emit func(...any)) {
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
case now, ok := <-tick:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
livenessRegistryMu.RLock()
|
||||
states := make([]*SourceLivenessState, 0, len(livenessRegistry))
|
||||
for _, s := range livenessRegistry {
|
||||
states = append(states, s)
|
||||
}
|
||||
livenessRegistryMu.RUnlock()
|
||||
for _, s := range states {
|
||||
msg, kind := checkSourceLiveness(s, threshold, now)
|
||||
processLivenessTransition(s, kind, msg, now, emit)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// processLivenessTransition applies the edge-trigger rules and updates
|
||||
// LastAlertUnix accordingly. Separated for testability and to keep the
|
||||
// loop body small.
|
||||
func processLivenessTransition(s *SourceLivenessState, kind LivenessKind, msg string, now time.Time, emit func(...any)) {
|
||||
lastAlert := atomic.LoadInt64(&s.LastAlertUnix)
|
||||
switch kind {
|
||||
case LivenessStalled, LivenessNeverReceived:
|
||||
if lastAlert == 0 {
|
||||
// First detection — fire WARN edge.
|
||||
emit(msg)
|
||||
atomic.StoreInt64(&s.LastAlertUnix, now.Unix())
|
||||
return
|
||||
}
|
||||
// Already alerted; only re-emit on heartbeat interval to avoid log flood.
|
||||
if now.Sub(time.Unix(lastAlert, 0)) >= livenessHeartbeatInterval {
|
||||
emit(fmt.Sprintf("MQTT [%s] WATCHDOG heartbeat: still stalled — %s", s.Tag, msg))
|
||||
atomic.StoreInt64(&s.LastAlertUnix, now.Unix())
|
||||
}
|
||||
case LivenessOK:
|
||||
if lastAlert != 0 {
|
||||
// Recovered: emit INFO once, clear the cooldown.
|
||||
emit(fmt.Sprintf("MQTT [%s] WATCHDOG INFO: messages flowing again (recovered)", s.Tag))
|
||||
atomic.StoreInt64(&s.LastAlertUnix, 0)
|
||||
}
|
||||
case LivenessDisconnected:
|
||||
// PR #1216 r2 item 1: disconnect is NOT recovery. Stay completely
|
||||
// silent — paho's reconnect handler already logs the drop — and
|
||||
// preserve LastAlertUnix so the WARN edge can re-fire if/when
|
||||
// the source comes back stalled. Clearing the cooldown here
|
||||
// would mean a flapping source spams the WARN every cycle.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PR #1216 round-1 review fixes. Tests are RED before the fix lands:
|
||||
// - Item 1: cold-start blind spot — silent-from-start source never alarmed.
|
||||
// - Item 2: reconnect reset — stale LastMessageUnix triggers false stall after recovery.
|
||||
// - Item 3: log flood — every-60s rescan re-emits same WARN forever.
|
||||
// - Item 4: tag collision in registerLivenessState silently overwrites prior state.
|
||||
|
||||
// waitFor polls until emits reaches `want` items or the deadline elapses.
|
||||
// Used to serialize "drain this tick before mutating state" in goroutine
|
||||
// tests so we observe deterministic edge transitions.
|
||||
func waitFor(t *testing.T, mu *sync.Mutex, emits *[]string, want int, timeout time.Duration) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
mu.Lock()
|
||||
n := len(*emits)
|
||||
mu.Unlock()
|
||||
if n >= want {
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
t.Fatalf("timeout waiting for %d emits; got %d: %v", want, len(*emits), *emits)
|
||||
}
|
||||
|
||||
// Item 1 (RED): a source that connects but never receives a message is
|
||||
// invisible to the current watchdog (LastMessageUnix==0 → skip). This is
|
||||
// the exact #1212 failure class — wrong channel hash, ACL drops SUBSCRIBE,
|
||||
// half-open TCP after CONNECT. Fix: stamp StartedAt at registration; when
|
||||
// LastMessageUnix==0 AND now-StartedAt > threshold, alarm with a distinct
|
||||
// "NEVER received" message.
|
||||
func TestMQTTStallWatchdog_FiresOnSilentFromStart(t *testing.T) {
|
||||
now := time.Now()
|
||||
state := &SourceLivenessState{
|
||||
Tag: "cold",
|
||||
Broker: "tcp://x:1883",
|
||||
IsConnectedFn: func() bool { return true },
|
||||
}
|
||||
atomic.StoreInt64(&state.StartedAt, now.Add(-10*time.Minute).Unix())
|
||||
atomic.StoreInt64(&state.FirstConnectedAt, now.Add(-10*time.Minute).Unix())
|
||||
// LastMessageUnix stays 0 — never received anything.
|
||||
|
||||
msg, kind := checkSourceLiveness(state, 5*time.Minute, now)
|
||||
if kind != LivenessNeverReceived {
|
||||
t.Fatalf("expected LivenessNeverReceived for silent-from-start source after threshold; got kind=%v msg=%q", kind, msg)
|
||||
}
|
||||
if !strings.Contains(strings.ToUpper(msg), "NEVER") {
|
||||
t.Errorf("cold-start alarm must mention NEVER received to distinguish from generic stall; got %q", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "cold") {
|
||||
t.Errorf("alarm must include source tag; got %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMQTTStallWatchdog_QuietDuringColdStartGrace(t *testing.T) {
|
||||
now := time.Now()
|
||||
state := &SourceLivenessState{
|
||||
Tag: "warming-up",
|
||||
Broker: "tcp://x:1883",
|
||||
IsConnectedFn: func() bool { return true },
|
||||
}
|
||||
atomic.StoreInt64(&state.StartedAt, now.Add(-30*time.Second).Unix())
|
||||
atomic.StoreInt64(&state.FirstConnectedAt, now.Add(-30*time.Second).Unix())
|
||||
|
||||
_, kind := checkSourceLiveness(state, 5*time.Minute, now)
|
||||
if kind != LivenessOK {
|
||||
t.Fatalf("must NOT alarm during cold-start grace (30s in, threshold 5m); got kind=%v", kind)
|
||||
}
|
||||
}
|
||||
|
||||
// Item 2 (RED): after a long outage + paho reconnect, LastMessageUnix is
|
||||
// still 2h-old → watchdog screams "stalled for 2h" immediately. Fix: reset
|
||||
// LastMessageUnix (and the cold-start clock) on OnConnect. This test
|
||||
// asserts the reset method does what's required so the next watchdog scan
|
||||
// stays quiet for the grace window.
|
||||
func TestMQTTStallWatchdog_OnReconnectResetsClocks(t *testing.T) {
|
||||
now := time.Now()
|
||||
state := &SourceLivenessState{
|
||||
Tag: "flaky",
|
||||
Broker: "tcp://x:1883",
|
||||
IsConnectedFn: func() bool { return true },
|
||||
}
|
||||
// 2-hour-old timestamp from before the outage.
|
||||
atomic.StoreInt64(&state.LastMessageUnix, now.Add(-2*time.Hour).Unix())
|
||||
atomic.StoreInt64(&state.StartedAt, now.Add(-3*time.Hour).Unix())
|
||||
// Stale alert cooldown from before the outage too — must NOT carry forward.
|
||||
atomic.StoreInt64(&state.LastAlertUnix, now.Add(-90*time.Minute).Unix())
|
||||
|
||||
state.MarkReconnected(now)
|
||||
|
||||
if last := atomic.LoadInt64(&state.LastMessageUnix); last != 0 {
|
||||
t.Errorf("LastMessageUnix must be cleared on reconnect so a stale pre-outage timestamp does not trip the watchdog; got %d", last)
|
||||
}
|
||||
if started := atomic.LoadInt64(&state.StartedAt); started != now.Unix() {
|
||||
t.Errorf("StartedAt must be re-stamped on reconnect so the cold-start grace window restarts; got %d want %d", started, now.Unix())
|
||||
}
|
||||
if alert := atomic.LoadInt64(&state.LastAlertUnix); alert != 0 {
|
||||
t.Errorf("LastAlertUnix must be cleared on reconnect so edge-trigger re-arms; got %d", alert)
|
||||
}
|
||||
|
||||
// Now drive checkSourceLiveness immediately after reconnect: must NOT alarm.
|
||||
_, kind := checkSourceLiveness(state, 5*time.Minute, now.Add(1*time.Second))
|
||||
if kind != LivenessOK {
|
||||
t.Fatalf("watchdog must stay quiet immediately after MarkReconnected; got kind=%v", kind)
|
||||
}
|
||||
}
|
||||
|
||||
// Item 3 (RED): the watchdog loop currently re-emits the same WARN on every
|
||||
// 60s tick (60 alerts/hr/source). Fix: edge-trigger — emit WARN once on
|
||||
// quiet→stalled transition, INFO once on stalled→flowing recovery, and an
|
||||
// hourly heartbeat while still stalled. Asserts: 3 consecutive ticks on a
|
||||
// stalled source produce exactly ONE WARN.
|
||||
func TestMQTTStallWatchdog_EdgeTriggeredEmitsOnlyOnce(t *testing.T) {
|
||||
defer snapshotAndResetRegistry(t)()
|
||||
|
||||
now := time.Now()
|
||||
s := &SourceLivenessState{
|
||||
Tag: "stuck",
|
||||
Broker: "tcp://x:1883",
|
||||
IsConnectedFn: func() bool { return true },
|
||||
}
|
||||
atomic.StoreInt64(&s.LastMessageUnix, now.Add(-10*time.Minute).Unix())
|
||||
atomic.StoreInt64(&s.StartedAt, now.Add(-20*time.Minute).Unix())
|
||||
registerLivenessState(s)
|
||||
|
||||
var mu sync.Mutex
|
||||
var emits []string
|
||||
emit := func(args ...any) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(args) > 0 {
|
||||
if str, ok := args[0].(string); ok {
|
||||
emits = append(emits, str)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tick := make(chan time.Time, 3)
|
||||
done := make(chan struct{})
|
||||
exited := make(chan struct{})
|
||||
go func() {
|
||||
runLivenessWatchdogLoop(tick, done, 5*time.Minute, emit)
|
||||
close(exited)
|
||||
}()
|
||||
|
||||
// Three back-to-back ticks within the heartbeat window. Only the first
|
||||
// should emit a WARN; the other two must be suppressed (edge-triggered).
|
||||
tick <- now
|
||||
tick <- now.Add(30 * time.Second)
|
||||
tick <- now.Add(60 * time.Second)
|
||||
|
||||
// Wait for ticks to drain.
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
mu.Lock()
|
||||
n := len(emits)
|
||||
mu.Unlock()
|
||||
if n >= 1 && time.Since(deadline.Add(-2*time.Second)) > 200*time.Millisecond {
|
||||
break
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
close(done)
|
||||
<-exited
|
||||
|
||||
mu.Lock()
|
||||
got := append([]string(nil), emits...)
|
||||
mu.Unlock()
|
||||
|
||||
warns := 0
|
||||
for _, e := range got {
|
||||
if strings.Contains(e, "WATCHDOG") || strings.Contains(e, "stalled") || strings.Contains(strings.ToUpper(e), "WARN") {
|
||||
warns++
|
||||
}
|
||||
}
|
||||
if warns != 1 {
|
||||
t.Fatalf("expected exactly 1 stall WARN across 3 consecutive scans (edge-trigger); got %d: %v", warns, got)
|
||||
}
|
||||
}
|
||||
|
||||
// Item 3 (RED): on stalled→flowing transition, a recovery INFO must fire
|
||||
// exactly once. Future ticks must stay silent until a new stall edge.
|
||||
func TestMQTTStallWatchdog_RecoveryEmitOnce(t *testing.T) {
|
||||
defer snapshotAndResetRegistry(t)()
|
||||
|
||||
now := time.Now()
|
||||
s := &SourceLivenessState{
|
||||
Tag: "src-b",
|
||||
Broker: "tcp://x:1883",
|
||||
IsConnectedFn: func() bool { return true },
|
||||
}
|
||||
atomic.StoreInt64(&s.LastMessageUnix, now.Add(-10*time.Minute).Unix())
|
||||
atomic.StoreInt64(&s.StartedAt, now.Add(-20*time.Minute).Unix())
|
||||
registerLivenessState(s)
|
||||
|
||||
var mu sync.Mutex
|
||||
var emits []string
|
||||
emit := func(args ...any) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(args) > 0 {
|
||||
if str, ok := args[0].(string); ok {
|
||||
emits = append(emits, str)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tick := make(chan time.Time, 4)
|
||||
done := make(chan struct{})
|
||||
exited := make(chan struct{})
|
||||
go func() {
|
||||
runLivenessWatchdogLoop(tick, done, 5*time.Minute, emit)
|
||||
close(exited)
|
||||
}()
|
||||
|
||||
tick <- now // → WARN
|
||||
// Wait for the goroutine to drain that tick and record the WARN edge
|
||||
// before we mutate state — otherwise we race the loop and the first
|
||||
// emit observes the "recovered" timestamp instead of the stall.
|
||||
waitFor(t, &mu, &emits, 1, 2*time.Second)
|
||||
// Source recovers: a recent message arrives.
|
||||
atomic.StoreInt64(&s.LastMessageUnix, now.Add(30*time.Second).Unix())
|
||||
tick <- now.Add(60 * time.Second) // → recovery INFO
|
||||
waitFor(t, &mu, &emits, 2, 2*time.Second)
|
||||
tick <- now.Add(120 * time.Second) // → silent
|
||||
tick <- now.Add(180 * time.Second) // → silent
|
||||
|
||||
// Brief settle so any (incorrect) extra emits land before we count.
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
close(done)
|
||||
<-exited
|
||||
|
||||
mu.Lock()
|
||||
got := append([]string(nil), emits...)
|
||||
mu.Unlock()
|
||||
|
||||
infos := 0
|
||||
for _, e := range got {
|
||||
upper := strings.ToUpper(e)
|
||||
if strings.Contains(upper, "RECOVER") || strings.Contains(upper, "FLOWING") {
|
||||
infos++
|
||||
}
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected exactly 2 emits (1 WARN + 1 recovery INFO); got %d: %v", len(got), got)
|
||||
}
|
||||
if infos != 1 {
|
||||
t.Fatalf("expected exactly 1 recovery INFO emit; got %d (all=%v)", infos, got)
|
||||
}
|
||||
}
|
||||
|
||||
// Item 4 (RED): registerLivenessState silently overwrites on tag collision
|
||||
// (empty-Name + same broker, duplicate Name). Must detect & report.
|
||||
func TestRegisterLivenessState_DetectsTagCollision(t *testing.T) {
|
||||
defer snapshotAndResetRegistry(t)()
|
||||
|
||||
a := &SourceLivenessState{Tag: "dup", Broker: "tcp://a:1883"}
|
||||
b := &SourceLivenessState{Tag: "dup", Broker: "tcp://b:1883"}
|
||||
|
||||
if err := registerLivenessState(a); err != nil {
|
||||
t.Fatalf("first registration must succeed; got %v", err)
|
||||
}
|
||||
if err := registerLivenessState(b); err == nil {
|
||||
t.Fatal("second registration with same tag must return a collision error (current behavior silently clobbers)")
|
||||
}
|
||||
|
||||
// And the registry must still hold the FIRST registration — clobbering
|
||||
// AttemptCount/LastMessageUnix invisibly is the bug.
|
||||
livenessRegistryMu.RLock()
|
||||
got := livenessRegistry["dup"]
|
||||
livenessRegistryMu.RUnlock()
|
||||
if got != a {
|
||||
t.Errorf("on collision, first registration must remain authoritative (got pointer for broker=%s)", got.Broker)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PR #1216 round-2 review fixes. Tests RED before the fix lands.
|
||||
//
|
||||
// r1 closed the cold-start blind spot but introduced three new failure
|
||||
// modes that r2 must eliminate:
|
||||
//
|
||||
// r2 #1 — checkSourceLiveness returns LivenessOK for BOTH "messages
|
||||
// flowing" AND "disconnected/never-connected". A stalled source
|
||||
// whose TCP eventually RSTs trips processLivenessTransition's
|
||||
// recovery branch and emits "messages flowing again (recovered)"
|
||||
// while going from silently broken to overtly broken. Fix: a
|
||||
// distinct LivenessDisconnected kind that the transition
|
||||
// function treats as a silent (no-emit) state, so the alert
|
||||
// cooldown does not collapse on a non-event.
|
||||
//
|
||||
// r2 #2 — MarkReconnected re-stamps StartedAt on every reconnect, so
|
||||
// the cold-start grace clock restarts forever under a broker
|
||||
// flap (CONNECT ok, SUBSCRIBE ACL-denied — the exact #1212
|
||||
// shape). The headline "NEVER received" alarm never fires.
|
||||
// Fix: separate FirstConnectedAt (set once at registration,
|
||||
// never reset) from StartedAt (free to reset on reconnect for
|
||||
// transient-stall tracking). Cold-start grace must use
|
||||
// FirstConnectedAt.
|
||||
//
|
||||
// r2 #3 — main.go calls log.Fatalf on a tag collision in the liveness
|
||||
// registry, killing the entire ingestor over one config typo.
|
||||
// That recreates the #1212 total-ingest-stop failure class
|
||||
// this PR exists to prevent. Fix: log an ERROR and skip
|
||||
// liveness registration for the duplicate — the MQTT source
|
||||
// still attempts to connect, just isn't tracked by the
|
||||
// watchdog (the first registration remains authoritative).
|
||||
|
||||
// r2 #1 RED: a stalled source whose connection then drops must NOT emit
|
||||
// "recovered". The current code does — checkSourceLiveness returns
|
||||
// LivenessOK for both genuine recovery and disconnection, so
|
||||
// processLivenessTransition sees lastAlert!=0 + kind==LivenessOK and
|
||||
// fires the recovery INFO. Operators reading the log think the source
|
||||
// healed when it actually died.
|
||||
func TestMQTTStallWatchdog_NoFalseRecoveryOnDisconnect(t *testing.T) {
|
||||
defer snapshotAndResetRegistry(t)()
|
||||
|
||||
now := time.Now()
|
||||
var connected atomic.Bool
|
||||
connected.Store(true)
|
||||
|
||||
s := &SourceLivenessState{
|
||||
Tag: "drops-after-stall",
|
||||
Broker: "tcp://x:1883",
|
||||
IsConnectedFn: func() bool { return connected.Load() },
|
||||
}
|
||||
atomic.StoreInt64(&s.LastMessageUnix, now.Add(-10*time.Minute).Unix())
|
||||
atomic.StoreInt64(&s.StartedAt, now.Add(-20*time.Minute).Unix())
|
||||
if err := registerLivenessState(s); err != nil {
|
||||
t.Fatalf("setup: registerLivenessState: %v", err)
|
||||
}
|
||||
|
||||
var mu sync.Mutex
|
||||
var emits []string
|
||||
emit := func(args ...any) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(args) > 0 {
|
||||
if str, ok := args[0].(string); ok {
|
||||
emits = append(emits, str)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tick := make(chan time.Time, 2)
|
||||
done := make(chan struct{})
|
||||
exited := make(chan struct{})
|
||||
go func() {
|
||||
runLivenessWatchdogLoop(tick, done, 5*time.Minute, emit)
|
||||
close(exited)
|
||||
}()
|
||||
|
||||
// Tick 1: source connected + 10m silent → WARN edge.
|
||||
tick <- now
|
||||
waitFor(t, &mu, &emits, 1, 2*time.Second)
|
||||
|
||||
// The TCP socket RSTs — paho flips IsConnected to false. The watchdog
|
||||
// must NOT interpret this as recovery; the source went from silently
|
||||
// broken to overtly broken.
|
||||
connected.Store(false)
|
||||
tick <- now.Add(60 * time.Second)
|
||||
|
||||
// Settle so any (incorrect) extra emits land before we count.
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
close(done)
|
||||
<-exited
|
||||
|
||||
mu.Lock()
|
||||
got := append([]string(nil), emits...)
|
||||
mu.Unlock()
|
||||
|
||||
for _, e := range got {
|
||||
upper := strings.ToUpper(e)
|
||||
if strings.Contains(upper, "RECOVER") || strings.Contains(upper, "FLOWING AGAIN") {
|
||||
t.Fatalf("watchdog must NOT emit recovery INFO when a stalled source disconnects; got %q (all=%v)", e, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// r2 #2 RED: a broker that ACKs CONNECT but denies SUBSCRIBE causes paho
|
||||
// to loop CONNECT → drop → CONNECT → drop. Each reconnect calls
|
||||
// MarkReconnected, which re-stamps StartedAt=now and resets the
|
||||
// cold-start grace clock. After 30 minutes of flapping, the source has
|
||||
// still NEVER received a message, but the "NEVER received" alarm never
|
||||
// fires because sinceStart is always sub-threshold. Fix: track
|
||||
// FirstConnectedAt separately from StartedAt; the cold-start check must
|
||||
// use the former.
|
||||
func TestMQTTStallWatchdog_ColdStartSurvivesBrokerFlap(t *testing.T) {
|
||||
defer snapshotAndResetRegistry(t)()
|
||||
|
||||
t0 := time.Now()
|
||||
s := &SourceLivenessState{
|
||||
Tag: "flapping-acl-deny",
|
||||
Broker: "tcp://acl-denied:1883",
|
||||
IsConnectedFn: func() bool { return true },
|
||||
}
|
||||
// First registration stamps FirstConnectedAt (and StartedAt) at t0.
|
||||
if err := registerLivenessState(s); err != nil {
|
||||
t.Fatalf("setup: registerLivenessState: %v", err)
|
||||
}
|
||||
|
||||
// Paho keeps re-establishing the TCP/MQTT session every minute. No
|
||||
// message ever arrives because SUBSCRIBE is denied. Each reconnect
|
||||
// resets StartedAt.
|
||||
for i := 1; i <= 6; i++ {
|
||||
s.MarkReconnected(t0.Add(time.Duration(i) * time.Minute))
|
||||
}
|
||||
|
||||
// 6m after the very first connection — well past the 5m cold-start
|
||||
// threshold. The headline alarm must fire.
|
||||
now := t0.Add(6*time.Minute + 30*time.Second)
|
||||
_, kind := checkSourceLiveness(s, 5*time.Minute, now)
|
||||
if kind != LivenessNeverReceived {
|
||||
t.Fatalf("under broker flap (#1212 ACL-deny class), cold-start alarm must fire based on FirstConnectedAt, not the most recent reconnect; got kind=%v", kind)
|
||||
}
|
||||
}
|
||||
|
||||
// Sanity check: a single transient reconnect WITHIN the cold-start window
|
||||
// must NOT prematurely trip the NeverReceived alarm — the grace was
|
||||
// designed for that. This guards against an over-correction where r2
|
||||
// switches blindly to FirstConnectedAt and ignores legitimate startup
|
||||
// jitter.
|
||||
func TestMQTTStallWatchdog_TransientReconnectDuringGraceStaysQuiet(t *testing.T) {
|
||||
defer snapshotAndResetRegistry(t)()
|
||||
|
||||
t0 := time.Now()
|
||||
s := &SourceLivenessState{
|
||||
Tag: "transient-reconnect",
|
||||
Broker: "tcp://x:1883",
|
||||
IsConnectedFn: func() bool { return true },
|
||||
}
|
||||
if err := registerLivenessState(s); err != nil {
|
||||
t.Fatalf("setup: registerLivenessState: %v", err)
|
||||
}
|
||||
|
||||
// 30s in, one transient reconnect.
|
||||
s.MarkReconnected(t0.Add(30 * time.Second))
|
||||
|
||||
// 1m after registration — still inside the 5m grace.
|
||||
_, kind := checkSourceLiveness(s, 5*time.Minute, t0.Add(1*time.Minute))
|
||||
if kind != LivenessOK {
|
||||
t.Fatalf("during cold-start grace, transient reconnects must stay quiet; got kind=%v", kind)
|
||||
}
|
||||
}
|
||||
|
||||
// r2 #3 RED: tag collision must not kill the ingestor. main.go currently
|
||||
// log.Fatalf's, which recreates the #1212 total-ingest-stop class this
|
||||
// PR exists to prevent. registerLivenessOrSkip is the small helper main
|
||||
// will call instead: log an ERROR + skip liveness registration for the
|
||||
// duplicate, return false so the caller knows the source is connecting
|
||||
// untracked. The first registration remains authoritative.
|
||||
func TestRegisterLivenessOrSkip_LogsErrorAndDoesNotExitOnCollision(t *testing.T) {
|
||||
defer snapshotAndResetRegistry(t)()
|
||||
|
||||
var buf bytes.Buffer
|
||||
origOut := log.Writer()
|
||||
origFlags := log.Flags()
|
||||
log.SetOutput(&buf)
|
||||
log.SetFlags(0)
|
||||
defer func() {
|
||||
log.SetOutput(origOut)
|
||||
log.SetFlags(origFlags)
|
||||
}()
|
||||
|
||||
a := &SourceLivenessState{Tag: "dup", Broker: "tcp://a:1883"}
|
||||
b := &SourceLivenessState{Tag: "dup", Broker: "tcp://b:1883"}
|
||||
|
||||
if ok := registerLivenessOrSkip(a); !ok {
|
||||
t.Fatalf("first registration must succeed; helper returned false (log=%q)", buf.String())
|
||||
}
|
||||
if ok := registerLivenessOrSkip(b); ok {
|
||||
t.Fatalf("second registration with same tag must return false (skip); helper returned true (log=%q)", buf.String())
|
||||
}
|
||||
|
||||
logOut := buf.String()
|
||||
if !strings.Contains(logOut, "ERROR") {
|
||||
t.Errorf("collision must be logged at ERROR severity so operators see it without it crashing the process; got %q", logOut)
|
||||
}
|
||||
if !strings.Contains(logOut, "dup") {
|
||||
t.Errorf("collision log must include the offending tag; got %q", logOut)
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(logOut), "skip") {
|
||||
t.Errorf("collision log must say the duplicate is being skipped so operators know the source is untracked; got %q", logOut)
|
||||
}
|
||||
|
||||
// And the registry still holds the FIRST registration.
|
||||
livenessRegistryMu.RLock()
|
||||
got := livenessRegistry["dup"]
|
||||
livenessRegistryMu.RUnlock()
|
||||
if got != a {
|
||||
t.Errorf("first registration must remain authoritative after collision-skip; got pointer for broker=%s", got.Broker)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// NeighborEdgesBuilderInterval is how often the ingestor rescans
|
||||
// observations and refreshes neighbor_edges. Server reads with the
|
||||
// same 60s cadence (see cmd/server/neighbor_recomputer.go); a 60s
|
||||
// pulse here is sufficient to keep the snapshot fresh.
|
||||
const NeighborEdgesBuilderInterval = 60 * time.Second
|
||||
|
||||
// payloadADVERT mirrors the constant in cmd/server/decoder.go.
|
||||
// Duplicated rather than imported so the ingestor binary stays
|
||||
// independent of the server package.
|
||||
const payloadADVERT = 0x04
|
||||
|
||||
// edgeRow is one row to upsert into neighbor_edges. (a, b) is already
|
||||
// canonical-ordered (a <= b).
|
||||
type edgeRow struct {
|
||||
a, b, ts string
|
||||
}
|
||||
|
||||
// StartNeighborEdgesBuilder launches the periodic builder. On each
|
||||
// tick it rescans recent observations + transmissions and upserts
|
||||
// derived neighbor_edges rows. Builder is the only writer to
|
||||
// neighbor_edges (#1287).
|
||||
//
|
||||
// The function returns a stop closure. Initial build runs synchronously
|
||||
// before the ticker starts so the server's first snapshot load picks
|
||||
// up real data instead of an empty table.
|
||||
func (s *Store) StartNeighborEdgesBuilder(interval time.Duration) func() {
|
||||
if interval <= 0 {
|
||||
interval = NeighborEdgesBuilderInterval
|
||||
}
|
||||
stop := make(chan struct{})
|
||||
done := make(chan struct{})
|
||||
|
||||
// Synchronous warm-up: a single pass so the first server load
|
||||
// after process start sees a populated table.
|
||||
if n, err := s.buildAndPersistNeighborEdges(); err != nil {
|
||||
log.Printf("[neighbor-build] initial build error: %v", err)
|
||||
} else {
|
||||
log.Printf("[neighbor-build] initial build: %d edges upserted", n)
|
||||
}
|
||||
|
||||
var stopOnce sync.Once
|
||||
go func() {
|
||||
defer close(done)
|
||||
t := time.NewTicker(interval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-t.C:
|
||||
if n, err := s.buildAndPersistNeighborEdges(); err != nil {
|
||||
log.Printf("[neighbor-build] tick error: %v", err)
|
||||
} else if n > 0 {
|
||||
log.Printf("[neighbor-build] %d edges upserted", n)
|
||||
}
|
||||
case <-stop:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return func() {
|
||||
stopOnce.Do(func() { close(stop) })
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// buildAndPersistNeighborEdges scans transmissions + observations,
|
||||
// extracts edge candidates (originator↔first-hop on ADVERTs;
|
||||
// observer↔last-hop on all packet types) and upserts them into
|
||||
// neighbor_edges. Returns count of attempted upserts.
|
||||
//
|
||||
// Resolution of hop-prefix → full pubkey is done via a one-shot
|
||||
// SELECT of (lowered) pubkey prefixes from nodes. Prefixes with
|
||||
// multiple candidates are skipped (matches the conservative
|
||||
// resolution rule in cmd/server/extractEdgesFromObs).
|
||||
func (s *Store) buildAndPersistNeighborEdges() (int, error) {
|
||||
prefixIdx, err := buildPrefixIndex(s.db)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("build prefix index: %w", err)
|
||||
}
|
||||
|
||||
rows, err := s.db.Query(`SELECT
|
||||
t.payload_type,
|
||||
t.decoded_json,
|
||||
COALESCE(t.from_pubkey, ''),
|
||||
COALESCE(o.path_json, ''),
|
||||
COALESCE(obs.id, '') AS observer_id,
|
||||
o.timestamp
|
||||
FROM observations o
|
||||
JOIN transmissions t ON t.id = o.transmission_id
|
||||
LEFT JOIN observers obs ON obs.rowid = o.observer_idx`)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("scan observations: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var edges []edgeRow
|
||||
for rows.Next() {
|
||||
var payloadType sql.NullInt64
|
||||
var decodedJSON, fromPubkey, pathJSON, observerID string
|
||||
var epochTs int64
|
||||
if err := rows.Scan(&payloadType, &decodedJSON, &fromPubkey, &pathJSON, &observerID, &epochTs); err != nil {
|
||||
continue
|
||||
}
|
||||
fromNode := strings.ToLower(fromPubkey)
|
||||
if fromNode == "" {
|
||||
fromNode = strings.ToLower(extractPubkeyFromAdvertJSON(decodedJSON))
|
||||
}
|
||||
isAdvert := payloadType.Valid && payloadType.Int64 == int64(payloadADVERT)
|
||||
ts := time.Unix(epochTs, 0).UTC().Format(time.RFC3339)
|
||||
observerPK := strings.ToLower(observerID)
|
||||
path := parsePathArray(pathJSON)
|
||||
|
||||
if len(path) == 0 {
|
||||
if isAdvert && fromNode != "" && fromNode != observerPK && observerPK != "" {
|
||||
edges = append(edges, canonEdge(fromNode, observerPK, ts))
|
||||
}
|
||||
continue
|
||||
}
|
||||
if isAdvert && fromNode != "" {
|
||||
if resolved, ok := resolvePrefix(prefixIdx, path[0]); ok && resolved != fromNode {
|
||||
edges = append(edges, canonEdge(fromNode, resolved, ts))
|
||||
}
|
||||
}
|
||||
if observerPK != "" {
|
||||
last := path[len(path)-1]
|
||||
if resolved, ok := resolvePrefix(prefixIdx, last); ok && resolved != observerPK {
|
||||
edges = append(edges, canonEdge(observerPK, resolved, ts))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(edges) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("begin: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
stmt, err := tx.Prepare(`INSERT INTO neighbor_edges (node_a, node_b, count, last_seen)
|
||||
VALUES (?, ?, 1, ?)
|
||||
ON CONFLICT(node_a, node_b) DO UPDATE SET
|
||||
count = count + 1,
|
||||
last_seen = MAX(last_seen, excluded.last_seen)`)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("prepare: %w", err)
|
||||
}
|
||||
defer stmt.Close()
|
||||
var firstErr error
|
||||
for _, e := range edges {
|
||||
if _, err := stmt.Exec(e.a, e.b, e.ts); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
if firstErr != nil {
|
||||
return 0, fmt.Errorf("upsert: %w", firstErr)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, fmt.Errorf("commit: %w", err)
|
||||
}
|
||||
return len(edges), nil
|
||||
}
|
||||
|
||||
// canonEdge orders the pair so node_a <= node_b (matches the existing
|
||||
// schema convention used by the loader and the bridge recomputer).
|
||||
func canonEdge(a, b, ts string) edgeRow {
|
||||
if a > b {
|
||||
a, b = b, a
|
||||
}
|
||||
return edgeRow{a, b, ts}
|
||||
}
|
||||
|
||||
// parsePathArray returns the hop strings from a path_json blob.
|
||||
// Defensive against missing/invalid JSON.
|
||||
func parsePathArray(s string) []string {
|
||||
if s == "" || s == "[]" {
|
||||
return nil
|
||||
}
|
||||
var arr []string
|
||||
if json.Unmarshal([]byte(s), &arr) != nil {
|
||||
return nil
|
||||
}
|
||||
return arr
|
||||
}
|
||||
|
||||
// prefixIndex maps a hop prefix (lowercase) → all full pubkeys whose
|
||||
// public_key starts with that prefix. Prefixes with > 1 candidate are
|
||||
// considered ambiguous and skipped during resolution.
|
||||
type prefixIndex map[string][]string
|
||||
|
||||
// buildPrefixIndex reads nodes.public_key and builds the prefix → pubkey
|
||||
// map. We index every 1-byte (2 hex char) prefix length the firmware
|
||||
// uses (1, 2, 3, 4, 6, 8). Memory cost is O(nodes × len(prefixLens)).
|
||||
func buildPrefixIndex(db *sql.DB) (prefixIndex, error) {
|
||||
rows, err := db.Query(`SELECT public_key FROM nodes`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
idx := make(prefixIndex, 1024)
|
||||
var prefixLens = []int{1 * 2, 2 * 2, 3 * 2, 4 * 2, 6 * 2, 8 * 2}
|
||||
for rows.Next() {
|
||||
var pk string
|
||||
if err := rows.Scan(&pk); err != nil {
|
||||
continue
|
||||
}
|
||||
pkLower := strings.ToLower(pk)
|
||||
for _, n := range prefixLens {
|
||||
if len(pkLower) < n {
|
||||
continue
|
||||
}
|
||||
prefix := pkLower[:n]
|
||||
idx[prefix] = append(idx[prefix], pkLower)
|
||||
}
|
||||
}
|
||||
return idx, nil
|
||||
}
|
||||
|
||||
// resolvePrefix returns the single resolved pubkey if exactly one
|
||||
// candidate matches, otherwise (zero || multiple), it returns ok=false
|
||||
// (matches the conservative server-side resolver in
|
||||
// cmd/server/extractEdgesFromObs).
|
||||
func resolvePrefix(idx prefixIndex, hop string) (string, bool) {
|
||||
h := strings.ToLower(hop)
|
||||
candidates := idx[h]
|
||||
if len(candidates) != 1 {
|
||||
return "", false
|
||||
}
|
||||
return candidates[0], true
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestNeighborEdgesBuilderUpsertsFromObservations enforces issue
|
||||
// #1287 Option 4: the INGESTOR builds neighbor_edges from raw
|
||||
// observations/transmissions and persists them. Server is read-only.
|
||||
//
|
||||
// Synthesize a tiny DB with one ADVERT observation whose path[0]
|
||||
// uniquely resolves to a known node, then assert the builder writes
|
||||
// the expected edge.
|
||||
func TestNeighborEdgesBuilderUpsertsFromObservations(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "build.db")
|
||||
|
||||
// Open via the ingestor's normal opener so applySchema and
|
||||
// dbschema.Apply both run (the builder requires neighbor_edges +
|
||||
// observers.iata etc.).
|
||||
store, err := OpenStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenStore: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
// Seed two nodes whose pubkey prefixes will be used as hops.
|
||||
if _, err := store.db.Exec(
|
||||
`INSERT INTO nodes (public_key, name) VALUES (?, ?), (?, ?)`,
|
||||
"aaaaaaaaaa", "from-node",
|
||||
"bbbbbbbbbb", "first-hop",
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Seed one observer.
|
||||
if _, err := store.db.Exec(
|
||||
`INSERT INTO observers (id, name) VALUES (?, ?)`,
|
||||
"obs-1", "observer-1",
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var obsRowid int64
|
||||
if err := store.db.QueryRow(`SELECT rowid FROM observers WHERE id = ?`, "obs-1").Scan(&obsRowid); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Insert one ADVERT transmission with from_pubkey = aaaaa…
|
||||
res, err := store.db.Exec(
|
||||
`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, payload_version, decoded_json, from_pubkey)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
"", "h1", "2026-01-01T00:00:00Z", 0, payloadADVERT, 0, "{}", "aaaaaaaaaa",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
txID, _ := res.LastInsertId()
|
||||
|
||||
// Insert one observation whose path[0] = "bb" (2-hex prefix unique
|
||||
// to bbbbb… in the nodes table). Expected edge: a↔b.
|
||||
if _, err := store.db.Exec(
|
||||
`INSERT INTO observations (transmission_id, observer_idx, path_json, timestamp) VALUES (?, ?, ?, ?)`,
|
||||
txID, obsRowid, `["bb"]`, int64(1735689600),
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
n, err := store.buildAndPersistNeighborEdges()
|
||||
if err != nil {
|
||||
t.Fatalf("buildAndPersistNeighborEdges: %v", err)
|
||||
}
|
||||
if n == 0 {
|
||||
t.Fatal("expected at least 1 edge upserted, got 0")
|
||||
}
|
||||
|
||||
var got int
|
||||
if err := store.db.QueryRow(`SELECT COUNT(*) FROM neighbor_edges WHERE node_a = ? AND node_b = ?`, "aaaaaaaaaa", "bbbbbbbbbb").Scan(&got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != 1 {
|
||||
t.Fatalf("expected the a↔b edge to be persisted; got %d rows", got)
|
||||
}
|
||||
}
|
||||
|
||||
// (test ends here)
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
// Package main: ingestor-side processor for prune-request marker files
|
||||
// written by the read-only server (see internal/prunequeue).
|
||||
//
|
||||
// The server cannot DELETE because it opens SQLite mode=ro (#1283/#1289).
|
||||
// Instead, the server writes request-<id>.json under <dataDir>/prune-requests/
|
||||
// and the ingestor consumes it here.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/meshcore-analyzer/prunequeue"
|
||||
)
|
||||
|
||||
// DeleteNodesByPubkeys deletes nodes by public key. Returns the count deleted.
|
||||
// Only the ingestor calls this (server has no write handle).
|
||||
func (s *Store) DeleteNodesByPubkeys(pubkeys []string) (int64, error) {
|
||||
if len(pubkeys) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
// Chunk to keep statements under SQLite's variable limit (default 999).
|
||||
const chunk = 500
|
||||
var total int64
|
||||
for start := 0; start < len(pubkeys); start += chunk {
|
||||
end := start + chunk
|
||||
if end > len(pubkeys) {
|
||||
end = len(pubkeys)
|
||||
}
|
||||
batch := pubkeys[start:end]
|
||||
placeholders := strings.Repeat("?,", len(batch))
|
||||
placeholders = placeholders[:len(placeholders)-1]
|
||||
args := make([]interface{}, len(batch))
|
||||
for i, pk := range batch {
|
||||
args[i] = pk
|
||||
}
|
||||
// Cascade cleanup: a node row carries the canonical identity, but
|
||||
// observations/transmissions reference the pubkey too via observer
|
||||
// metadata and originator fields. There are no FK constraints in
|
||||
// the current schema (#669 review note), so we explicitly clear
|
||||
// the most obvious follow-on rows that would otherwise become
|
||||
// orphans visible to operators.
|
||||
//
|
||||
// Conservative scope: only the `nodes` row is removed here. The
|
||||
// referenced observation/transmission history is retained for
|
||||
// audit; operators can run the regular packet-retention prune to
|
||||
// age it out. If a future schema introduces FKs, revisit.
|
||||
res, err := s.db.Exec("DELETE FROM nodes WHERE public_key IN ("+placeholders+")", args...)
|
||||
if err != nil {
|
||||
return total, fmt.Errorf("delete batch [%d:%d]: %w", start, end, err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
total += n
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
// RunPendingPruneRequests scans the prune-requests/ directory next to the
|
||||
// SQLite database and processes any request-<id>.json markers written by
|
||||
// the server. Each request is honored verbatim — the server is responsible
|
||||
// for the TOCTOU snapshot (only pubkeys that were still outside the
|
||||
// geofilter at confirm time). After running DELETE, the ingestor writes
|
||||
// result-<id>.json and removes the request file (atomic, via os.Rename in
|
||||
// prunequeue.WriteResult).
|
||||
//
|
||||
// Safe to call from a ticker — no-op when the queue is empty.
|
||||
func (s *Store) RunPendingPruneRequests() {
|
||||
paths, err := prunequeue.ListPending(s.path)
|
||||
if err != nil {
|
||||
log.Printf("[prune-queue] list pending failed: %v", err)
|
||||
return
|
||||
}
|
||||
if len(paths) == 0 {
|
||||
return
|
||||
}
|
||||
for _, p := range paths {
|
||||
req, err := prunequeue.ReadRequest(p)
|
||||
if err != nil {
|
||||
log.Printf("[prune-queue] read %s failed: %v — removing", p, err)
|
||||
_ = os.Remove(p)
|
||||
continue
|
||||
}
|
||||
log.Printf("[prune-queue] processing request %s: %d pubkey(s) (%s)",
|
||||
req.ID, len(req.Pubkeys), req.Reason)
|
||||
start := time.Now()
|
||||
deleted, derr := s.DeleteNodesByPubkeys(req.Pubkeys)
|
||||
res := prunequeue.Result{
|
||||
ID: req.ID,
|
||||
RequestedAt: req.RequestedAt,
|
||||
CompletedAt: time.Now().UTC(),
|
||||
Deleted: deleted,
|
||||
}
|
||||
if derr != nil {
|
||||
res.Error = derr.Error()
|
||||
log.Printf("[prune-queue] request %s FAILED after %s: %v", req.ID, time.Since(start), derr)
|
||||
} else {
|
||||
log.Printf("[prune-queue] request %s deleted %d node(s) in %s", req.ID, deleted, time.Since(start))
|
||||
}
|
||||
if werr := prunequeue.WriteResult(s.path, res); werr != nil {
|
||||
log.Printf("[prune-queue] write result for %s failed: %v", req.ID, werr)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/meshcore-analyzer/prunequeue"
|
||||
)
|
||||
|
||||
func TestRunPendingPruneRequests(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "test.db")
|
||||
|
||||
store, err := OpenStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenStore: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
// Seed two nodes; one will be pruned, one will be kept.
|
||||
if _, err := store.db.Exec(`INSERT INTO nodes (public_key, name, role, lat, lon, last_seen, first_seen)
|
||||
VALUES ('aaaa', 'gone', 'companion', 1.0, 1.0, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z'),
|
||||
('bbbb', 'kept', 'companion', 2.0, 2.0, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')`); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
id := prunequeue.NewID()
|
||||
if err := prunequeue.WriteRequest(dbPath, prunequeue.Request{
|
||||
ID: id,
|
||||
RequestedAt: time.Now().UTC(),
|
||||
Reason: "geo-prune-test",
|
||||
Pubkeys: []string{"aaaa"},
|
||||
}); err != nil {
|
||||
t.Fatalf("WriteRequest: %v", err)
|
||||
}
|
||||
|
||||
store.RunPendingPruneRequests()
|
||||
|
||||
// Request file gone, result file present.
|
||||
if exists, _ := prunequeue.RequestExists(dbPath, id); exists {
|
||||
t.Error("request file should have been consumed")
|
||||
}
|
||||
res, err := prunequeue.ReadResult(dbPath, id)
|
||||
if err != nil || res == nil {
|
||||
t.Fatalf("ReadResult: res=%v err=%v", res, err)
|
||||
}
|
||||
if res.Deleted != 1 {
|
||||
t.Errorf("expected Deleted=1, got %d", res.Deleted)
|
||||
}
|
||||
if res.Error != "" {
|
||||
t.Errorf("unexpected error: %s", res.Error)
|
||||
}
|
||||
|
||||
// Verify DB state: aaaa gone, bbbb kept.
|
||||
var n int
|
||||
store.db.QueryRow("SELECT COUNT(*) FROM nodes WHERE public_key='aaaa'").Scan(&n)
|
||||
if n != 0 {
|
||||
t.Errorf("expected 'aaaa' deleted, got count=%d", n)
|
||||
}
|
||||
store.db.QueryRow("SELECT COUNT(*) FROM nodes WHERE public_key='bbbb'").Scan(&n)
|
||||
if n != 1 {
|
||||
t.Errorf("expected 'bbbb' kept, got count=%d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPendingPruneRequests_EmptyQueueIsNoop(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "test.db")
|
||||
store, err := OpenStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenStore: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
// Must not panic / error on empty queue.
|
||||
store.RunPendingPruneRequests()
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseEnvelopeTime(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
ok bool
|
||||
}{
|
||||
{"rfc3339 utc", "2026-05-16T10:00:00Z", true},
|
||||
{"rfc3339 offset", "2026-05-16T12:00:00+02:00", true},
|
||||
{"naive iso", "2026-05-16T10:00:00", true},
|
||||
{"naive iso micros", "2026-05-16T10:00:00.123456", true},
|
||||
{"garbage", "not-a-time", false},
|
||||
{"empty", "", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
_, err := parseEnvelopeTime(c.in)
|
||||
if (err == nil) != c.ok {
|
||||
t.Fatalf("parseEnvelopeTime(%q): want ok=%v, got err=%v", c.in, c.ok, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRxTime(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
|
||||
mustParse := func(s string) time.Time {
|
||||
t.Helper()
|
||||
parsed, err := time.Parse(time.RFC3339, s)
|
||||
if err != nil {
|
||||
t.Fatalf("result %q is not RFC3339: %v", s, err)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
nearNow := func(s string) bool {
|
||||
d := mustParse(s).Sub(now)
|
||||
if d < 0 {
|
||||
d = -d
|
||||
}
|
||||
return d <= time.Minute
|
||||
}
|
||||
|
||||
rx := now.Add(-5 * time.Hour).Format(time.RFC3339)
|
||||
if got := resolveRxTime(map[string]interface{}{"timestamp": rx}, "test"); got != rx {
|
||||
t.Errorf("plausible past timestamp: got %q want %q", got, rx)
|
||||
}
|
||||
if got := resolveRxTime(map[string]interface{}{}, "test"); !nearNow(got) {
|
||||
t.Errorf("missing timestamp: got %q, expected ~now", got)
|
||||
}
|
||||
if got := resolveRxTime(map[string]interface{}{"timestamp": "garbage"}, "test"); !nearNow(got) {
|
||||
t.Errorf("garbage timestamp: got %q, expected ~now", got)
|
||||
}
|
||||
future := now.Add(48 * time.Hour).Format(time.RFC3339)
|
||||
if got := resolveRxTime(map[string]interface{}{"timestamp": future}, "test"); !nearNow(got) {
|
||||
t.Errorf("future timestamp: got %q, expected ~now (rejected)", got)
|
||||
}
|
||||
|
||||
// RTC-reset node reporting a factory date — must not drag first_seen back.
|
||||
factory := "2020-01-01T00:00:00Z"
|
||||
if got := resolveRxTime(map[string]interface{}{"timestamp": factory}, "test"); !nearNow(got) {
|
||||
t.Errorf("stale factory timestamp: got %q, expected ~now (rejected)", got)
|
||||
}
|
||||
// Just past the 30-day floor → rejected.
|
||||
stale := now.Add(-31 * 24 * time.Hour).Format(time.RFC3339)
|
||||
if got := resolveRxTime(map[string]interface{}{"timestamp": stale}, "test"); !nearNow(got) {
|
||||
t.Errorf("stale timestamp >30d: got %q, expected ~now (rejected)", got)
|
||||
}
|
||||
// Just inside the 30-day floor → used verbatim.
|
||||
recent := now.Add(-29 * 24 * time.Hour).Format(time.RFC3339)
|
||||
if got := resolveRxTime(map[string]interface{}{"timestamp": recent}, "test"); got != recent {
|
||||
t.Errorf("recent timestamp <30d: got %q want %q", got, recent)
|
||||
}
|
||||
}
|
||||
@@ -61,7 +61,7 @@ func TestSigValidation_ValidAdvertStored(t *testing.T) {
|
||||
msg := newMockMsg("meshcore/US/obs1/packet", `{"raw":"`+rawHex+`","origin":"TestObs"}`)
|
||||
cfg := &Config{}
|
||||
|
||||
handleMessage(store, "test", source, msg, nil, cfg)
|
||||
handleMessage(store, "test", source, msg, nil, nil, cfg)
|
||||
|
||||
// Verify packet was stored
|
||||
var count int
|
||||
@@ -98,7 +98,7 @@ func TestSigValidation_TamperedSignatureDropped(t *testing.T) {
|
||||
msg := newMockMsg("meshcore/US/obs1/packet", `{"raw":"`+tamperedHex+`","origin":"TestObs"}`)
|
||||
cfg := &Config{}
|
||||
|
||||
handleMessage(store, "test", source, msg, nil, cfg)
|
||||
handleMessage(store, "test", source, msg, nil, nil, cfg)
|
||||
|
||||
// Verify packet was NOT stored in transmissions
|
||||
var txCount int
|
||||
@@ -157,7 +157,7 @@ func TestSigValidation_TruncatedAppdataDropped(t *testing.T) {
|
||||
msg := newMockMsg("meshcore/US/obs1/packet", `{"raw":"`+truncatedHex+`","origin":"TestObs"}`)
|
||||
cfg := &Config{}
|
||||
|
||||
handleMessage(store, "test", source, msg, nil, cfg)
|
||||
handleMessage(store, "test", source, msg, nil, nil, cfg)
|
||||
|
||||
var txCount int
|
||||
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&txCount)
|
||||
@@ -192,7 +192,7 @@ func TestSigValidation_DisabledByConfig(t *testing.T) {
|
||||
falseVal := false
|
||||
cfg := &Config{ValidateSignatures: &falseVal}
|
||||
|
||||
handleMessage(store, "test", source, msg, nil, cfg)
|
||||
handleMessage(store, "test", source, msg, nil, nil, cfg)
|
||||
|
||||
// With validation disabled, tampered packet should be stored
|
||||
var txCount int
|
||||
@@ -225,7 +225,7 @@ func TestSigValidation_DropCounterIncrements(t *testing.T) {
|
||||
rawBytes[76] = '0'
|
||||
}
|
||||
msg := newMockMsg("meshcore/US/obs1/packet", `{"raw":"`+string(rawBytes)+`","origin":"Obs"}`)
|
||||
handleMessage(store, "test", source, msg, nil, cfg)
|
||||
handleMessage(store, "test", source, msg, nil, nil, cfg)
|
||||
}
|
||||
|
||||
if store.Stats.SignatureDrops.Load() != 3 {
|
||||
@@ -258,7 +258,7 @@ func TestSigValidation_LogContainsFields(t *testing.T) {
|
||||
msg := newMockMsg("meshcore/US/obs1/packet", `{"raw":"`+string(rawBytes)+`","origin":"MyObserver"}`)
|
||||
cfg := &Config{}
|
||||
|
||||
handleMessage(store, "test", source, msg, nil, cfg)
|
||||
handleMessage(store, "test", source, msg, nil, nil, cfg)
|
||||
|
||||
var hash, reason, obsID, obsName, pubkey, nodeName string
|
||||
err = store.db.QueryRow("SELECT hash, reason, observer_id, observer_name, node_pubkey, node_name FROM dropped_packets LIMIT 1").
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"os"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/meshcore-analyzer/perfio"
|
||||
@@ -67,7 +66,7 @@ func writeStatsAtomic(path string, b []byte) error {
|
||||
// O_NOFOLLOW: if tmp is a pre-existing symlink, openat fails with ELOOP
|
||||
// instead of clobbering the symlink target. O_TRUNC zeroes existing
|
||||
// regular-file content. 0o600 — no need for world-readable.
|
||||
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC|syscall.O_NOFOLLOW, 0o600)
|
||||
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC|oNoFollow, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
import "syscall"
|
||||
|
||||
// oNoFollow is syscall.O_NOFOLLOW on platforms that define it (all non-Windows targets).
|
||||
// On Windows this constant does not exist; see stats_file_nofollow_windows.go.
|
||||
const oNoFollow = syscall.O_NOFOLLOW
|
||||
@@ -0,0 +1,8 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
// oNoFollow is 0 on Windows: O_NOFOLLOW is not defined in the Windows syscall
|
||||
// package. The ingestor is only deployed on Linux where the flag is enforced;
|
||||
// on Windows the flag is a no-op so the binary compiles and tests run.
|
||||
const oNoFollow = 0
|
||||
@@ -0,0 +1,22 @@
|
||||
module github.com/corescope/migrate
|
||||
|
||||
go 1.22
|
||||
|
||||
require (
|
||||
github.com/meshcore-analyzer/dbschema v0.0.0
|
||||
modernc.org/sqlite v1.34.5
|
||||
)
|
||||
|
||||
replace github.com/meshcore-analyzer/dbschema => ../../internal/dbschema
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/sys v0.22.0 // indirect
|
||||
modernc.org/libc v1.55.3 // indirect
|
||||
modernc.org/mathutil v1.6.0 // indirect
|
||||
modernc.org/memory v1.8.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,43 @@
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
|
||||
golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
|
||||
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
|
||||
golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
|
||||
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
|
||||
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
||||
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
|
||||
modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
|
||||
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
||||
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
||||
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
|
||||
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
|
||||
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
|
||||
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
|
||||
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
|
||||
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
|
||||
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
||||
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
|
||||
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
|
||||
modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g=
|
||||
modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE=
|
||||
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
||||
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
@@ -0,0 +1,55 @@
|
||||
// Command migrate runs all dbschema migrations against a SQLite
|
||||
// CoreScope database and exits. Used by CI / one-shot tooling to bring
|
||||
// an unmigrated fixture (or a fresh DB) up to the schema shape the
|
||||
// read-only server (cmd/server) requires via dbschema.AssertReady.
|
||||
//
|
||||
// In production the ingestor (cmd/ingestor) runs dbschema.Apply at
|
||||
// startup before subscribing to MQTT — this binary exists so CI's E2E
|
||||
// job can migrate the e2e-fixture.db without booting the full ingestor
|
||||
// (which needs MQTT brokers).
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// migrate -db path/to/file.db
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"flag"
|
||||
"log"
|
||||
|
||||
"github.com/meshcore-analyzer/dbschema"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
func main() {
|
||||
dbPath := flag.String("db", "", "path to SQLite database to migrate (required)")
|
||||
flag.Parse()
|
||||
|
||||
if *dbPath == "" {
|
||||
log.Fatalf("[migrate] -db is required")
|
||||
}
|
||||
|
||||
log.SetFlags(log.LstdFlags | log.Lmsgprefix)
|
||||
log.SetPrefix("[migrate] ")
|
||||
|
||||
db, err := sql.Open("sqlite", *dbPath)
|
||||
if err != nil {
|
||||
log.Fatalf("open %s: %v", *dbPath, err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if err := db.Ping(); err != nil {
|
||||
log.Fatalf("ping %s: %v", *dbPath, err)
|
||||
}
|
||||
|
||||
if err := dbschema.Apply(db, log.Printf); err != nil {
|
||||
log.Fatalf("dbschema.Apply: %v", err)
|
||||
}
|
||||
|
||||
if err := dbschema.AssertReady(db); err != nil {
|
||||
log.Fatalf("dbschema.AssertReady after Apply: %v (this is a bug — Apply did not produce a ready schema)", err)
|
||||
}
|
||||
|
||||
log.Printf("OK: %s is migrated and ready", *dbPath)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// Test that the migrate binary brings the e2e fixture DB up to the
|
||||
// shape required by cmd/server's dbschema.AssertReady. Regression test
|
||||
// for PR #1289 / fix for the CI "Server failed to start within 30s"
|
||||
// failure: AssertReady fired against the unmigrated fixture and the
|
||||
// server fatal-logged before opening its HTTP listener.
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/meshcore-analyzer/dbschema"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// fixtureCandidates lists possible locations of the committed e2e
|
||||
// fixture DB relative to this test's package directory. We resolve
|
||||
// against runtime cwd which is cmd/migrate when `go test` runs.
|
||||
var fixtureCandidates = []string{
|
||||
"../../test-fixtures/e2e-fixture.db",
|
||||
}
|
||||
|
||||
func locateFixture(t *testing.T) string {
|
||||
t.Helper()
|
||||
for _, p := range fixtureCandidates {
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
abs, _ := filepath.Abs(p)
|
||||
return abs
|
||||
}
|
||||
}
|
||||
t.Skipf("e2e fixture not found (looked in: %v)", fixtureCandidates)
|
||||
return ""
|
||||
}
|
||||
|
||||
func copyFile(t *testing.T, src, dst string) {
|
||||
t.Helper()
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
t.Fatalf("open src: %v", err)
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.Create(dst)
|
||||
if err != nil {
|
||||
t.Fatalf("create dst: %v", err)
|
||||
}
|
||||
defer out.Close()
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
t.Fatalf("copy: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrateBringsFixtureToReady is the gate test for the CI bug.
|
||||
// Before the fix landed, AssertReady against the committed fixture
|
||||
// returned an error ("missing: inactive_nodes.foreign_advert" etc.).
|
||||
// After Apply(), AssertReady must return nil.
|
||||
func TestMigrateBringsFixtureToReady(t *testing.T) {
|
||||
src := locateFixture(t)
|
||||
dst := filepath.Join(t.TempDir(), "fixture-copy.db")
|
||||
copyFile(t, src, dst)
|
||||
|
||||
db, err := sql.Open("sqlite", dst)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Sanity: the committed fixture is missing at least one expected
|
||||
// migration column. If this stops being true, either someone
|
||||
// pre-migrated the fixture (and this test no longer protects #1289)
|
||||
// or AssertReady's required set changed.
|
||||
if err := dbschema.AssertReady(db); err == nil {
|
||||
t.Logf("note: fixture already passes AssertReady; skipping pre-condition assertion")
|
||||
}
|
||||
|
||||
if err := dbschema.Apply(db, t.Logf); err != nil {
|
||||
t.Fatalf("Apply: %v", err)
|
||||
}
|
||||
if err := dbschema.AssertReady(db); err != nil {
|
||||
t.Fatalf("AssertReady after Apply: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
// Package main: analytics recomputer (issue #1240).
|
||||
//
|
||||
// Steady-state background recompute loop for expensive analytics
|
||||
// endpoints. Reads always hit an atomic-pointer cache; compute runs
|
||||
// on a fixed ticker in a goroutine. This eliminates the on-request
|
||||
// compute-then-cache pattern where the first reader after expiry pays
|
||||
// the full compute cost and blocks under writer contention.
|
||||
//
|
||||
// See issue #1240 and AGENTS.md "Performance is a feature".
|
||||
package main
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// analyticsRecomputer holds the latest snapshot of an analytics result
|
||||
// in an atomic.Value, refreshed periodically by a background goroutine.
|
||||
//
|
||||
// Lifecycle:
|
||||
// 1. Construct via newAnalyticsRecomputer(...)
|
||||
// 2. Call Start() — runs initial compute synchronously, then launches
|
||||
// the recompute goroutine. Initial compute is synchronous so the
|
||||
// first Load() after Start returns never sees a nil cache.
|
||||
// 3. Call Load() any number of times concurrently — never blocks
|
||||
// beyond an atomic-pointer load.
|
||||
// 4. Call Stop() to terminate the background goroutine cleanly.
|
||||
//
|
||||
// Compute func is called WITHOUT any lock held by this struct, so it
|
||||
// may freely take any application-level locks it needs.
|
||||
type analyticsRecomputer struct {
|
||||
name string
|
||||
interval time.Duration
|
||||
compute func() interface{}
|
||||
|
||||
cache atomic.Value // holds interface{} — the latest snapshot
|
||||
stop chan struct{}
|
||||
done chan struct{}
|
||||
|
||||
startOnce sync.Once
|
||||
stopOnce sync.Once
|
||||
|
||||
// Stats (atomic).
|
||||
computeRuns atomic.Int64
|
||||
lastComputeNs atomic.Int64 // duration of last compute in nanoseconds
|
||||
}
|
||||
|
||||
// newAnalyticsRecomputer constructs an unstarted recomputer.
|
||||
// interval must be > 0; compute must be non-nil.
|
||||
func newAnalyticsRecomputer(name string, interval time.Duration, compute func() interface{}) *analyticsRecomputer {
|
||||
if interval <= 0 {
|
||||
interval = 5 * time.Minute
|
||||
}
|
||||
return &analyticsRecomputer{
|
||||
name: name,
|
||||
interval: interval,
|
||||
compute: compute,
|
||||
stop: make(chan struct{}),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Start runs the initial compute synchronously (so the first Load
|
||||
// after Start returns a populated snapshot, never nil), then launches
|
||||
// a background goroutine to periodically recompute.
|
||||
//
|
||||
// Calling Start multiple times is a no-op after the first call.
|
||||
func (r *analyticsRecomputer) Start() {
|
||||
r.startOnce.Do(func() {
|
||||
// Initial synchronous compute — first read must NOT see empty
|
||||
// or uninitialized data (acceptance criterion #1240).
|
||||
r.runOnce()
|
||||
go r.loop()
|
||||
})
|
||||
}
|
||||
|
||||
func (r *analyticsRecomputer) loop() {
|
||||
defer close(r.done)
|
||||
t := time.NewTicker(r.interval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-t.C:
|
||||
r.runOnce()
|
||||
case <-r.stop:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *analyticsRecomputer) runOnce() {
|
||||
if r.compute == nil {
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
// Don't let a compute panic kill the background goroutine.
|
||||
// The previous snapshot remains valid.
|
||||
_ = recover()
|
||||
}()
|
||||
t0 := time.Now()
|
||||
result := r.compute()
|
||||
r.lastComputeNs.Store(int64(time.Since(t0)))
|
||||
r.computeRuns.Add(1)
|
||||
if result != nil {
|
||||
r.cache.Store(result)
|
||||
}
|
||||
}
|
||||
|
||||
// Load returns the most recently computed snapshot, or nil if Start
|
||||
// has not been called (or the very first compute returned nil).
|
||||
// Never blocks beyond a single atomic load.
|
||||
func (r *analyticsRecomputer) Load() interface{} {
|
||||
v := r.cache.Load()
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// Stop signals the background goroutine to exit and waits for it.
|
||||
// Safe to call multiple times. Safe to call before Start (no-op).
|
||||
func (r *analyticsRecomputer) Stop() {
|
||||
r.stopOnce.Do(func() {
|
||||
close(r.stop)
|
||||
})
|
||||
// Only wait if the goroutine was actually started.
|
||||
select {
|
||||
case <-r.done:
|
||||
case <-time.After(5 * time.Second):
|
||||
// Defensive timeout: shouldn't happen in practice.
|
||||
}
|
||||
}
|
||||
|
||||
// LastComputeDuration returns the duration of the most recent compute.
|
||||
func (r *analyticsRecomputer) LastComputeDuration() time.Duration {
|
||||
return time.Duration(r.lastComputeNs.Load())
|
||||
}
|
||||
|
||||
// ComputeRuns returns the total number of compute invocations.
|
||||
func (r *analyticsRecomputer) ComputeRuns() int64 {
|
||||
return r.computeRuns.Load()
|
||||
}
|
||||
|
||||
// AnalyticsRecomputeIntervals lets callers (main.go) override the
|
||||
// per-endpoint recompute interval from config.json. Zero values fall
|
||||
// back to the defaultInterval passed to StartAnalyticsRecomputers.
|
||||
type AnalyticsRecomputeIntervals struct {
|
||||
Topology time.Duration
|
||||
RF time.Duration
|
||||
Distance time.Duration
|
||||
Channels time.Duration
|
||||
HashCollisions time.Duration
|
||||
HashSizes time.Duration
|
||||
Roles time.Duration
|
||||
ObserversClockSkew time.Duration
|
||||
NodesClockSkew time.Duration
|
||||
}
|
||||
|
||||
func pickInterval(override, def time.Duration) time.Duration {
|
||||
if override > 0 {
|
||||
return override
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// StartAnalyticsRecomputers wires each analytics endpoint to a
|
||||
// background recompute goroutine. Each runs an initial compute
|
||||
// synchronously (so the first read after startup is a cache hit, never
|
||||
// cold) and then refreshes on a ticker.
|
||||
//
|
||||
// All recomputers serve the DEFAULT query shape only: region="" and
|
||||
// zero-window (no ?since= / ?until= params). Region-keyed or windowed
|
||||
// queries continue to use the legacy on-request compute + TTL cache —
|
||||
// the recomputer count would explode if we maintained one per
|
||||
// (endpoint × region × window) combination, and region filtering is
|
||||
// fast read-time work anyway.
|
||||
//
|
||||
// Returns a stop closure that signals all goroutines and blocks until
|
||||
// they exit. Safe to call once per PacketStore. Idempotent if called
|
||||
// multiple times (subsequent calls return the first stop closure).
|
||||
func (s *PacketStore) StartAnalyticsRecomputers(defaultInterval time.Duration, overrides ...AnalyticsRecomputeIntervals) func() {
|
||||
if defaultInterval <= 0 {
|
||||
defaultInterval = 5 * time.Minute
|
||||
}
|
||||
var ov AnalyticsRecomputeIntervals
|
||||
if len(overrides) > 0 {
|
||||
ov = overrides[0]
|
||||
}
|
||||
|
||||
s.analyticsRecomputerMu.Lock()
|
||||
if s.recompTopology != nil {
|
||||
// Already started; return a no-op so the caller's defer is harmless.
|
||||
s.analyticsRecomputerMu.Unlock()
|
||||
return func() {}
|
||||
}
|
||||
|
||||
// Each recomputer wraps the underlying compute* function with the
|
||||
// default arguments. We use computeAnalytics* (not GetAnalytics*) to
|
||||
// bypass the legacy TTL cache layer — the recomputer IS the cache.
|
||||
s.recompTopology = newAnalyticsRecomputer(
|
||||
"topology", pickInterval(ov.Topology, defaultInterval),
|
||||
func() interface{} { return s.computeAnalyticsTopology("", "", TimeWindow{}) },
|
||||
)
|
||||
s.recompRF = newAnalyticsRecomputer(
|
||||
"rf", pickInterval(ov.RF, defaultInterval),
|
||||
func() interface{} { return s.computeAnalyticsRF("", "", TimeWindow{}) },
|
||||
)
|
||||
s.recompDistance = newAnalyticsRecomputer(
|
||||
"distance", pickInterval(ov.Distance, defaultInterval),
|
||||
func() interface{} { return s.computeAnalyticsDistance("", "") },
|
||||
)
|
||||
s.recompChannels = newAnalyticsRecomputer(
|
||||
"channels", pickInterval(ov.Channels, defaultInterval),
|
||||
func() interface{} { return s.computeAnalyticsChannels("", "", TimeWindow{}) },
|
||||
)
|
||||
s.recompHashCollisions = newAnalyticsRecomputer(
|
||||
"hash-collisions", pickInterval(ov.HashCollisions, defaultInterval),
|
||||
func() interface{} { return s.computeHashCollisions("", "") },
|
||||
)
|
||||
s.recompHashSizes = newAnalyticsRecomputer(
|
||||
"hash-sizes", pickInterval(ov.HashSizes, defaultInterval),
|
||||
func() interface{} { return s.computeAnalyticsHashSizesWithCapability("", "") },
|
||||
)
|
||||
s.recompRoles = newAnalyticsRecomputer(
|
||||
"roles", pickInterval(ov.Roles, defaultInterval),
|
||||
func() interface{} { return s.computeAnalyticsRoles() },
|
||||
)
|
||||
s.recompObserversClockSkew = newAnalyticsRecomputer(
|
||||
"observers-clock-skew", pickInterval(ov.ObserversClockSkew, defaultInterval),
|
||||
func() interface{} { return s.computeObserverCalibrations() },
|
||||
)
|
||||
s.recompNodesClockSkew = newAnalyticsRecomputer(
|
||||
"nodes-clock-skew", pickInterval(ov.NodesClockSkew, defaultInterval),
|
||||
func() interface{} { return s.computeFleetClockSkew() },
|
||||
)
|
||||
all := []*analyticsRecomputer{
|
||||
s.recompTopology, s.recompRF, s.recompDistance,
|
||||
s.recompChannels, s.recompHashCollisions, s.recompHashSizes,
|
||||
s.recompRoles,
|
||||
s.recompObserversClockSkew, s.recompNodesClockSkew,
|
||||
}
|
||||
s.analyticsRecomputerMu.Unlock()
|
||||
|
||||
for _, rc := range all {
|
||||
rc.Start()
|
||||
}
|
||||
|
||||
return func() {
|
||||
for _, rc := range all {
|
||||
rc.Stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"sort"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func numGoroutinesForTest() int { return runtime.NumGoroutine() }
|
||||
|
||||
// TestAnalyticsRecomputerSteadyStateLatency asserts that issue #1240's
|
||||
// steady-state background recompute is in place: reads of the common
|
||||
// analytics endpoints (region="") return from cache in <50ms p99 even
|
||||
// under simulated ingest load.
|
||||
//
|
||||
// On master (pre-fix), GetAnalyticsTopology holds s.mu.RLock for the
|
||||
// entire compute. Concurrent ingest writers (s.mu.Lock) starve readers
|
||||
// or vice versa, producing per-read latencies in the hundreds of
|
||||
// milliseconds. The cache TTL doesn't help: after every expiry one
|
||||
// reader still pays the full compute cost.
|
||||
//
|
||||
// Post-fix, GetAnalyticsTopology with region="" and zero window must
|
||||
// Load() from the background-refreshed atomic snapshot — never blocking
|
||||
// under writer contention.
|
||||
func TestAnalyticsRecomputerSteadyStateLatency(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping latency timing test in -short mode")
|
||||
}
|
||||
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
store := NewPacketStore(db, nil)
|
||||
|
||||
// Populate with enough records to make on-request compute non-trivial.
|
||||
const N = 20000
|
||||
hops := make([]distHopRecord, N)
|
||||
for i := 0; i < N; i++ {
|
||||
hops[i] = distHopRecord{
|
||||
FromName: "A", FromPk: "aa",
|
||||
ToName: "B", ToPk: "bb",
|
||||
Dist: float64(i%500) + 0.5,
|
||||
Type: []string{"R↔R", "C↔R", "C↔C"}[i%3],
|
||||
Hash: "h",
|
||||
Timestamp: "2024-01-01T00:00:00Z",
|
||||
HourBucket: "2024-01-01-00",
|
||||
}
|
||||
}
|
||||
store.mu.Lock()
|
||||
store.distHops = hops
|
||||
store.mu.Unlock()
|
||||
|
||||
// Start the recomputer infrastructure. On master this method
|
||||
// doesn't exist, so this test won't compile until the GREEN commit
|
||||
// lands; the RED commit lands the test + a stub. Stub returns
|
||||
// without wiring background recompute, so the test still fails on
|
||||
// the latency assertion below.
|
||||
stop := store.StartAnalyticsRecomputers(10 * time.Millisecond)
|
||||
defer stop()
|
||||
|
||||
// Give the initial compute a moment to populate.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// Simulated writer: contend for s.mu.Lock. This is what makes the
|
||||
// non-recomputer path miss the latency target — the old
|
||||
// GetAnalyticsTopology grabs s.mu.RLock for the entire compute and
|
||||
// blocks behind every writer cycle.
|
||||
var stopWriters atomic.Bool
|
||||
var writerWg sync.WaitGroup
|
||||
const Writers = 4
|
||||
writerWg.Add(Writers)
|
||||
for w := 0; w < Writers; w++ {
|
||||
go func() {
|
||||
defer writerWg.Done()
|
||||
for !stopWriters.Load() {
|
||||
store.mu.Lock()
|
||||
// Trivial mutation: extend distHops by one and shrink back.
|
||||
store.distHops = append(store.distHops, distHopRecord{
|
||||
Dist: 1, Hash: "x", Timestamp: "2024-01-01T00:00:00Z",
|
||||
})
|
||||
store.distHops = store.distHops[:len(store.distHops)-1]
|
||||
store.mu.Unlock()
|
||||
// Brief pause to keep the lock-cycle rate realistic.
|
||||
time.Sleep(100 * time.Microsecond)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// 100 concurrent reads.
|
||||
const Readers = 100
|
||||
latencies := make([]time.Duration, Readers)
|
||||
var rwg sync.WaitGroup
|
||||
rwg.Add(Readers)
|
||||
for i := 0; i < Readers; i++ {
|
||||
i := i
|
||||
go func() {
|
||||
defer rwg.Done()
|
||||
t0 := time.Now()
|
||||
r := store.GetAnalyticsDistance("", "")
|
||||
latencies[i] = time.Since(t0)
|
||||
if r == nil {
|
||||
t.Errorf("reader %d got nil result", i)
|
||||
}
|
||||
}()
|
||||
}
|
||||
rwg.Wait()
|
||||
stopWriters.Store(true)
|
||||
writerWg.Wait()
|
||||
|
||||
sort.Slice(latencies, func(i, j int) bool { return latencies[i] < latencies[j] })
|
||||
p50 := latencies[Readers/2]
|
||||
p99 := latencies[(Readers*99)/100]
|
||||
|
||||
t.Logf("analytics distance read latency: p50=%v p99=%v max=%v",
|
||||
p50, p99, latencies[Readers-1])
|
||||
|
||||
// p99 budget: 50ms. Atomic-pointer load + JSON-shape map return
|
||||
// should be sub-millisecond; 50ms leaves margin for goroutine
|
||||
// scheduling jitter under concurrent test runs.
|
||||
const budget = 50 * time.Millisecond
|
||||
if p99 > budget {
|
||||
t.Fatalf("p99 read latency %v exceeds %v budget (issue #1240 not in effect)", p99, budget)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAnalyticsRecomputerShutdownNoLeak asserts the background
|
||||
// goroutines started by StartAnalyticsRecomputers exit cleanly when
|
||||
// the returned stop function is called — no leak across server
|
||||
// shutdown (issue #1240 acceptance criterion).
|
||||
func TestAnalyticsRecomputerShutdownNoLeak(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
store := NewPacketStore(db, nil)
|
||||
|
||||
// Use a tight tick so we know recompute is actually running (not
|
||||
// just blocked on the ticker).
|
||||
stop := store.StartAnalyticsRecomputers(20 * time.Millisecond)
|
||||
|
||||
// Snapshot active goroutines a beat after start.
|
||||
time.Sleep(80 * time.Millisecond)
|
||||
startGoroutines := runtimeNumGoroutine()
|
||||
|
||||
stop()
|
||||
|
||||
// After stop returns, give the scheduler a beat to reap exits.
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
var endGoroutines int
|
||||
for time.Now().Before(deadline) {
|
||||
endGoroutines = runtimeNumGoroutine()
|
||||
if endGoroutines <= startGoroutines-5 { // we started 6 recomputers
|
||||
break
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
|
||||
// We expect ~6 fewer goroutines than the snapshot taken DURING
|
||||
// recompute (one per registered recomputer). Allow some slack
|
||||
// since test runners can have flaky goroutine counts.
|
||||
if endGoroutines >= startGoroutines {
|
||||
t.Fatalf("goroutine leak after stop: %d → %d (expected fewer)",
|
||||
startGoroutines, endGoroutines)
|
||||
}
|
||||
t.Logf("goroutines: during=%d after=%d (Δ=%d)",
|
||||
startGoroutines, endGoroutines, startGoroutines-endGoroutines)
|
||||
}
|
||||
|
||||
// runtimeNumGoroutine is wrapped to keep the imports section of the
|
||||
// production file minimal.
|
||||
func runtimeNumGoroutine() int {
|
||||
// imported below
|
||||
return numGoroutinesForTest()
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
func mustExecDB(t *testing.T, db *DB, q string) {
|
||||
t.Helper()
|
||||
if _, err := db.conn.Exec(q); err != nil {
|
||||
t.Fatalf("exec %q: %v", q, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAreaEntryParsing(t *testing.T) {
|
||||
raw := `{
|
||||
"port": 3000,
|
||||
"areas": {
|
||||
"BEL": {
|
||||
"label": "Belgium",
|
||||
"polygon": [[50.0, 2.5], [51.5, 2.5], [51.5, 6.4], [50.0, 6.4]]
|
||||
},
|
||||
"BOX": {
|
||||
"label": "Bounding Box Area",
|
||||
"latMin": 50.0, "latMax": 51.5, "lonMin": 2.5, "lonMax": 6.4
|
||||
}
|
||||
}
|
||||
}`
|
||||
var cfg Config
|
||||
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if len(cfg.Areas) != 2 {
|
||||
t.Fatalf("want 2 areas, got %d", len(cfg.Areas))
|
||||
}
|
||||
bel := cfg.Areas["BEL"]
|
||||
if bel.Label != "Belgium" {
|
||||
t.Errorf("label: want Belgium, got %q", bel.Label)
|
||||
}
|
||||
if len(bel.Polygon) != 4 {
|
||||
t.Errorf("polygon: want 4 points, got %d", len(bel.Polygon))
|
||||
}
|
||||
box := cfg.Areas["BOX"]
|
||||
if box.LatMin == nil || *box.LatMin != 50.0 {
|
||||
t.Error("LatMin not parsed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetNodePubkeysInArea_Polygon(t *testing.T) {
|
||||
db := setupTestDBv2(t)
|
||||
mustExecDB(t, db, `INSERT INTO nodes (public_key, lat, lon) VALUES ('pk-inside', 50.85, 4.35)`)
|
||||
mustExecDB(t, db, `INSERT INTO nodes (public_key, lat, lon) VALUES ('pk-outside', 48.0, 4.35)`)
|
||||
mustExecDB(t, db, `INSERT INTO nodes (public_key, lat, lon) VALUES ('pk-nogps', NULL, NULL)`)
|
||||
mustExecDB(t, db, `INSERT INTO nodes (public_key, lat, lon) VALUES ('pk-zero', 0.0, 0.0)`)
|
||||
|
||||
entry := AreaEntry{
|
||||
Label: "Belgium",
|
||||
Polygon: [][2]float64{{50.0, 2.5}, {51.5, 2.5}, {51.5, 6.4}, {50.0, 6.4}},
|
||||
}
|
||||
pks, err := db.GetNodePubkeysInArea(entry)
|
||||
if err != nil {
|
||||
t.Fatalf("GetNodePubkeysInArea: %v", err)
|
||||
}
|
||||
if len(pks) != 1 || pks[0] != "pk-inside" {
|
||||
t.Errorf("want [pk-inside], got %v", pks)
|
||||
}
|
||||
}
|
||||
|
||||
// newTestStoreWithDB builds a minimal PacketStore wired to the given DB and config.
|
||||
func newTestStoreWithDB(t *testing.T, db *DB, cfg *Config) *PacketStore {
|
||||
t.Helper()
|
||||
return &PacketStore{
|
||||
db: db,
|
||||
config: cfg,
|
||||
byNode: make(map[string][]*StoreTx),
|
||||
byTxID: make(map[int]*StoreTx),
|
||||
byObsID: make(map[int]*StoreObs),
|
||||
byObserver: make(map[string][]*StoreObs),
|
||||
byHash: make(map[string]*StoreTx),
|
||||
byPayloadType: make(map[int][]*StoreTx),
|
||||
nodeHashes: make(map[string]map[string]bool),
|
||||
byPathHop: make(map[string][]*StoreTx),
|
||||
advertPubkeys: make(map[string]int),
|
||||
rfCache: make(map[string]*cachedResult),
|
||||
topoCache: make(map[string]*cachedResult),
|
||||
hashCache: make(map[string]*cachedResult),
|
||||
collisionCache: make(map[string]*cachedResult),
|
||||
chanCache: make(map[string]*cachedResult),
|
||||
distCache: make(map[string]*cachedResult),
|
||||
subpathCache: make(map[string]*cachedResult),
|
||||
regionObsCache: make(map[string]map[string]bool),
|
||||
areaNodeCache: make(map[string]map[string]bool),
|
||||
areaNodeCacheTimes: make(map[string]time.Time),
|
||||
rfCacheTTL: 15 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAreaNodes_UnknownKey(t *testing.T) {
|
||||
db := setupTestDBv2(t)
|
||||
cfg := &Config{Areas: map[string]AreaEntry{
|
||||
"BEL": {Label: "Belgium", Polygon: [][2]float64{{50.0, 2.5}, {51.5, 2.5}, {51.5, 6.4}, {50.0, 6.4}}},
|
||||
}}
|
||||
s := newTestStoreWithDB(t, db, cfg)
|
||||
result := s.resolveAreaNodes("UNKNOWN")
|
||||
if result != nil {
|
||||
t.Errorf("want nil for unknown area, got %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAreaNodes_CacheHit(t *testing.T) {
|
||||
db := setupTestDBv2(t)
|
||||
mustExecDB(t, db, `INSERT INTO nodes (public_key, lat, lon) VALUES ('pk1', 50.85, 4.35)`)
|
||||
|
||||
cfg := &Config{Areas: map[string]AreaEntry{
|
||||
"BEL": {Label: "Belgium", Polygon: [][2]float64{{50.0, 2.5}, {51.5, 2.5}, {51.5, 6.4}, {50.0, 6.4}}},
|
||||
}}
|
||||
s := newTestStoreWithDB(t, db, cfg)
|
||||
|
||||
r1 := s.resolveAreaNodes("BEL")
|
||||
if !r1["pk1"] {
|
||||
t.Fatal("pk1 should be in area BEL on first call")
|
||||
}
|
||||
|
||||
// Delete node so a live DB query would return nothing — second call must use cache.
|
||||
mustExecDB(t, db, `DELETE FROM nodes WHERE public_key = 'pk1'`)
|
||||
|
||||
r2 := s.resolveAreaNodes("BEL")
|
||||
if !r2["pk1"] {
|
||||
t.Fatal("cache hit should still return pk1 after DB delete")
|
||||
}
|
||||
}
|
||||
|
||||
// ingestAdvert adds a synthetic ADVERT packet to the store's in-memory packet list.
|
||||
func ingestAdvert(t *testing.T, s *PacketStore, hash, decodedJSON string) {
|
||||
t.Helper()
|
||||
pt := PayloadADVERT
|
||||
tx := &StoreTx{
|
||||
Hash: hash,
|
||||
FirstSeen: "2026-01-01T00:00:00Z",
|
||||
PayloadType: &pt,
|
||||
DecodedJSON: decodedJSON,
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.packets = append(s.packets, tx)
|
||||
s.byHash[hash] = tx
|
||||
s.byPayloadType[PayloadADVERT] = append(s.byPayloadType[PayloadADVERT], tx)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func TestFilterPacketsByArea(t *testing.T) {
|
||||
db := setupTestDBv2(t)
|
||||
mustExecDB(t, db, `INSERT INTO nodes (public_key, lat, lon) VALUES ('inside-node', 50.85, 4.35)`)
|
||||
mustExecDB(t, db, `INSERT INTO nodes (public_key, lat, lon) VALUES ('outside-node', 48.0, 4.35)`)
|
||||
|
||||
cfg := &Config{Areas: map[string]AreaEntry{
|
||||
"BEL": {Label: "Belgium", Polygon: [][2]float64{{50.0, 2.5}, {51.5, 2.5}, {51.5, 6.4}, {50.0, 6.4}}},
|
||||
}}
|
||||
s := newTestStoreWithDB(t, db, cfg)
|
||||
|
||||
ingestAdvert(t, s, "hash-in", `{"public_key":"inside-node","name":"Inside"}`)
|
||||
ingestAdvert(t, s, "hash-out", `{"public_key":"outside-node","name":"Outside"}`)
|
||||
|
||||
result := s.QueryPackets(PacketQuery{Limit: 50, Area: "BEL"})
|
||||
if result.Total != 1 {
|
||||
t.Fatalf("want 1 packet in area BEL, got %d (packets: %v)", result.Total, result.Packets)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyticsRFAreaFilter(t *testing.T) {
|
||||
db := setupTestDBv2(t)
|
||||
mustExecDB(t, db, `INSERT INTO nodes (public_key, lat, lon) VALUES ('inside-node', 50.85, 4.35)`)
|
||||
mustExecDB(t, db, `INSERT INTO nodes (public_key, lat, lon) VALUES ('outside-node', 48.0, 4.35)`)
|
||||
|
||||
cfg := &Config{Areas: map[string]AreaEntry{
|
||||
"BEL": {Label: "Belgium", Polygon: [][2]float64{{50.0, 2.5}, {51.5, 2.5}, {51.5, 6.4}, {50.0, 6.4}}},
|
||||
}}
|
||||
s := newTestStoreWithDB(t, db, cfg)
|
||||
|
||||
ingestAdvert(t, s, "hash-rf-in", `{"public_key":"inside-node","name":"Inside"}`)
|
||||
ingestAdvert(t, s, "hash-rf-out", `{"public_key":"outside-node","name":"Outside"}`)
|
||||
|
||||
result := s.GetAnalyticsRF("", "BEL")
|
||||
if result == nil {
|
||||
t.Fatal("GetAnalyticsRF returned nil")
|
||||
}
|
||||
total, _ := result["totalTransmissions"].(int)
|
||||
if total != 1 {
|
||||
t.Errorf("want totalTransmissions=1 for BEL, got %d", total)
|
||||
}
|
||||
}
|
||||
|
||||
// ingestChanMsg adds a synthetic GRP_TXT packet with the given sender pubkey and channel hash.
|
||||
func ingestChanMsg(t *testing.T, s *PacketStore, hash, senderPK string, chanHash int) {
|
||||
t.Helper()
|
||||
pt := PayloadGRP_TXT
|
||||
decodedJSON := fmt.Sprintf(`{"public_key":%q,"channelHash":%d}`, senderPK, chanHash)
|
||||
tx := &StoreTx{
|
||||
Hash: hash,
|
||||
FirstSeen: "2026-01-01T00:00:00Z",
|
||||
PayloadType: &pt,
|
||||
DecodedJSON: decodedJSON,
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.packets = append(s.packets, tx)
|
||||
s.byHash[hash] = tx
|
||||
s.byPayloadType[PayloadGRP_TXT] = append(s.byPayloadType[PayloadGRP_TXT], tx)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func TestAnalyticsChannelsAreaFilter(t *testing.T) {
|
||||
db := setupTestDBv2(t)
|
||||
mustExecDB(t, db, `INSERT INTO nodes (public_key, lat, lon) VALUES ('inside-node', 50.85, 4.35)`)
|
||||
mustExecDB(t, db, `INSERT INTO nodes (public_key, lat, lon) VALUES ('outside-node', 48.0, 4.35)`)
|
||||
|
||||
cfg := &Config{Areas: map[string]AreaEntry{
|
||||
"BEL": {Label: "Belgium", Polygon: [][2]float64{{50.0, 2.5}, {51.5, 2.5}, {51.5, 6.4}, {50.0, 6.4}}},
|
||||
}}
|
||||
s := newTestStoreWithDB(t, db, cfg)
|
||||
|
||||
// inside-node sends on channel hash 42, outside-node on channel hash 99.
|
||||
ingestChanMsg(t, s, "ch-in", "inside-node", 42)
|
||||
ingestChanMsg(t, s, "ch-out", "outside-node", 99)
|
||||
|
||||
unfiltered := s.GetAnalyticsChannels("", "")
|
||||
filtered := s.GetAnalyticsChannels("", "BEL")
|
||||
if filtered == nil {
|
||||
t.Fatal("GetAnalyticsChannels returned nil")
|
||||
}
|
||||
unfilteredCount, _ := unfiltered["activeChannels"].(int)
|
||||
filteredCount, _ := filtered["activeChannels"].(int)
|
||||
if unfilteredCount != 2 {
|
||||
t.Errorf("want 2 active channels unfiltered, got %d", unfilteredCount)
|
||||
}
|
||||
if filteredCount != 1 {
|
||||
t.Errorf("want 1 active channel for BEL, got %d", filteredCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetNodePubkeysInArea_BoundingBox(t *testing.T) {
|
||||
db := setupTestDBv2(t)
|
||||
mustExecDB(t, db, `INSERT INTO nodes (public_key, lat, lon) VALUES ('in', 50.5, 5.0)`)
|
||||
mustExecDB(t, db, `INSERT INTO nodes (public_key, lat, lon) VALUES ('out', 52.0, 5.0)`)
|
||||
|
||||
minLat, maxLat, minLon, maxLon := 50.0, 51.5, 2.5, 6.4
|
||||
entry := AreaEntry{LatMin: &minLat, LatMax: &maxLat, LonMin: &minLon, LonMax: &maxLon}
|
||||
pks, err := db.GetNodePubkeysInArea(entry)
|
||||
if err != nil {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
if len(pks) != 1 || pks[0] != "in" {
|
||||
t.Errorf("want [in], got %v", pks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleConfigAreas(t *testing.T) {
|
||||
db := setupTestDBv2(t)
|
||||
cfg := &Config{Areas: map[string]AreaEntry{
|
||||
"BEL": {Label: "Belgium", Polygon: [][2]float64{{50.0, 2.5}, {51.5, 2.5}, {51.5, 6.4}, {50.0, 6.4}}},
|
||||
"MST": {Label: "Maastricht"},
|
||||
}}
|
||||
|
||||
r := mux.NewRouter()
|
||||
srv := &Server{db: db, cfg: cfg}
|
||||
r.HandleFunc("/api/config/areas", srv.handleConfigAreas).Methods("GET")
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/config/areas", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("want 200, got %d", w.Code)
|
||||
}
|
||||
var result []map[string]string
|
||||
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(result) != 2 {
|
||||
t.Fatalf("want 2 areas, got %d", len(result))
|
||||
}
|
||||
keys := map[string]bool{}
|
||||
for _, entry := range result {
|
||||
keys[entry["key"]] = true
|
||||
if entry["label"] == "" {
|
||||
t.Errorf("missing label for key %q", entry["key"])
|
||||
}
|
||||
}
|
||||
if !keys["BEL"] || !keys["MST"] {
|
||||
t.Errorf("expected BEL and MST, got %v", keys)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleConfigAreasEmpty(t *testing.T) {
|
||||
db := setupTestDBv2(t)
|
||||
cfg := &Config{}
|
||||
|
||||
r := mux.NewRouter()
|
||||
srv := &Server{db: db, cfg: cfg}
|
||||
r.HandleFunc("/api/config/areas", srv.handleConfigAreas).Methods("GET")
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/config/areas", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
var result []interface{}
|
||||
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(result) != 0 {
|
||||
t.Errorf("want empty array, got %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAreaNodes_CalledBeforeRLock(t *testing.T) {
|
||||
// Verify resolveAreaNodes doesn't deadlock when called concurrently with writes.
|
||||
// This test catches the anti-pattern where resolveAreaNodes (which does a DB
|
||||
// query) is called while holding s.mu.RLock().
|
||||
db := setupTestDBv2(t)
|
||||
mustExecDB(t, db, `INSERT INTO nodes (public_key, lat, lon) VALUES ('n1', 50.85, 4.35)`)
|
||||
|
||||
cfg := &Config{Areas: map[string]AreaEntry{
|
||||
"BEL": {Label: "Belgium", Polygon: [][2]float64{{50.0, 2.5}, {51.5, 2.5}, {51.5, 6.4}, {50.0, 6.4}}},
|
||||
}}
|
||||
s := newTestStoreWithDB(t, db, cfg)
|
||||
ingestAdvert(t, s, "h1", `{"public_key":"n1","name":"N1"}`)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 5; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
s.GetBulkHealth(10, "", "BEL")
|
||||
}()
|
||||
}
|
||||
wg.Wait() // must not deadlock
|
||||
}
|
||||
|
||||
func TestResolveAreaNodes_PerKeyTTL(t *testing.T) {
|
||||
db := setupTestDBv2(t)
|
||||
mustExecDB(t, db, `INSERT INTO nodes (public_key, lat, lon) VALUES ('bel-node', 50.85, 4.35)`)
|
||||
mustExecDB(t, db, `INSERT INTO nodes (public_key, lat, lon) VALUES ('nl-node', 52.4, 4.9)`)
|
||||
|
||||
cfg := &Config{Areas: map[string]AreaEntry{
|
||||
"BEL": {Label: "Belgium", Polygon: [][2]float64{{50.0, 2.5}, {51.5, 2.5}, {51.5, 6.4}, {50.0, 6.4}}},
|
||||
"NL": {Label: "Netherlands", Polygon: [][2]float64{{51.5, 3.4}, {53.6, 3.4}, {53.6, 7.2}, {51.5, 7.2}}},
|
||||
}}
|
||||
s := newTestStoreWithDB(t, db, cfg)
|
||||
|
||||
// Populate both keys into cache.
|
||||
r1 := s.resolveAreaNodes("BEL")
|
||||
if !r1["bel-node"] {
|
||||
t.Fatal("bel-node should be in BEL")
|
||||
}
|
||||
r2 := s.resolveAreaNodes("NL")
|
||||
if !r2["nl-node"] {
|
||||
t.Fatal("nl-node should be in NL")
|
||||
}
|
||||
|
||||
// Delete both nodes from DB to prove cache still serves them.
|
||||
mustExecDB(t, db, `DELETE FROM nodes`)
|
||||
|
||||
// BEL cache should still be warm (not evicted by NL query).
|
||||
r3 := s.resolveAreaNodes("BEL")
|
||||
if !r3["bel-node"] {
|
||||
t.Error("BEL cache was evicted by NL query (global TTL bug)")
|
||||
}
|
||||
// NL cache should still be warm too.
|
||||
r4 := s.resolveAreaNodes("NL")
|
||||
if !r4["nl-node"] {
|
||||
t.Error("NL cache was evicted unexpectedly")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetBulkHealth_AreaBypassesCap(t *testing.T) {
|
||||
db := setupTestDBv2(t)
|
||||
|
||||
// Insert 510 nodes inside BEL — all at 50.85, 4.35.
|
||||
for i := 0; i < 510; i++ {
|
||||
mustExecDB(t, db, fmt.Sprintf(
|
||||
`INSERT INTO nodes (public_key, lat, lon) VALUES ('node-%d', 50.85, 4.35)`, i,
|
||||
))
|
||||
}
|
||||
|
||||
cfg := &Config{Areas: map[string]AreaEntry{
|
||||
"BEL": {Label: "Belgium", Polygon: [][2]float64{{50.0, 2.5}, {51.5, 2.5}, {51.5, 6.4}, {50.0, 6.4}}},
|
||||
}}
|
||||
s := newTestStoreWithDB(t, db, cfg)
|
||||
|
||||
// With limit=10 but area filter active, all 510 in-area nodes must be returned.
|
||||
result := s.GetBulkHealth(10, "", "BEL")
|
||||
if len(result) != 510 {
|
||||
t.Errorf("want 510 nodes from area BEL, got %d", len(result))
|
||||
}
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// TestBackfillAsyncChunked verifies that backfillResolvedPathsAsync processes
|
||||
// observations in chunks, yields between batches, and sets the completion flag.
|
||||
func TestBackfillAsyncChunked(t *testing.T) {
|
||||
store := &PacketStore{
|
||||
packets: make([]*StoreTx, 0),
|
||||
byHash: make(map[string]*StoreTx),
|
||||
byTxID: make(map[int]*StoreTx),
|
||||
byObsID: make(map[int]*StoreObs),
|
||||
}
|
||||
|
||||
// No pending observations → should complete immediately.
|
||||
backfillResolvedPathsAsync(store, "", 100, time.Millisecond, 24)
|
||||
if !store.backfillComplete.Load() {
|
||||
t.Fatal("expected backfillComplete to be true with empty store")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBackfillStatusHeader verifies the X-CoreScope-Status header is set correctly.
|
||||
func TestBackfillStatusHeader(t *testing.T) {
|
||||
store := &PacketStore{
|
||||
packets: make([]*StoreTx, 0),
|
||||
byHash: make(map[string]*StoreTx),
|
||||
byTxID: make(map[int]*StoreTx),
|
||||
byObsID: make(map[int]*StoreObs),
|
||||
}
|
||||
|
||||
srv := &Server{store: store}
|
||||
|
||||
handler := srv.backfillStatusMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(200)
|
||||
}))
|
||||
|
||||
// Before backfill completes → backfilling
|
||||
req := httptest.NewRequest("GET", "/api/stats", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if got := rec.Header().Get("X-CoreScope-Status"); got != "backfilling" {
|
||||
t.Fatalf("expected 'backfilling', got %q", got)
|
||||
}
|
||||
|
||||
// After backfill completes → ready
|
||||
store.backfillComplete.Store(true)
|
||||
rec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if got := rec.Header().Get("X-CoreScope-Status"); got != "ready" {
|
||||
t.Fatalf("expected 'ready', got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStatsBackfillFields verifies /api/stats includes backfill fields.
|
||||
func TestStatsBackfillFields(t *testing.T) {
|
||||
db := setupTestDBv2(t)
|
||||
defer db.Close()
|
||||
seedV2Data(t, db)
|
||||
|
||||
store := &PacketStore{
|
||||
db: db,
|
||||
packets: make([]*StoreTx, 0),
|
||||
byHash: make(map[string]*StoreTx),
|
||||
byTxID: make(map[int]*StoreTx),
|
||||
byObsID: make(map[int]*StoreObs),
|
||||
loaded: true,
|
||||
}
|
||||
|
||||
cfg := &Config{Port: 0}
|
||||
hub := NewHub()
|
||||
srv := NewServer(db, cfg, hub)
|
||||
srv.store = store
|
||||
|
||||
router := mux.NewRouter()
|
||||
srv.RegisterRoutes(router)
|
||||
|
||||
// While backfilling
|
||||
req := httptest.NewRequest("GET", "/api/stats", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse stats response: %v", err)
|
||||
}
|
||||
|
||||
if backfilling, ok := resp["backfilling"]; !ok {
|
||||
t.Fatal("missing 'backfilling' field in stats response")
|
||||
} else if backfilling != true {
|
||||
t.Fatalf("expected backfilling=true, got %v", backfilling)
|
||||
}
|
||||
|
||||
if _, ok := resp["backfillProgress"]; !ok {
|
||||
t.Fatal("missing 'backfillProgress' field in stats response")
|
||||
}
|
||||
|
||||
// Check header
|
||||
if got := rec.Header().Get("X-CoreScope-Status"); got != "backfilling" {
|
||||
t.Fatalf("expected X-CoreScope-Status=backfilling, got %q", got)
|
||||
}
|
||||
|
||||
// After backfill completes
|
||||
store.backfillComplete.Store(true)
|
||||
// Invalidate stats cache
|
||||
srv.statsMu.Lock()
|
||||
srv.statsCache = nil
|
||||
srv.statsMu.Unlock()
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
resp = nil
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse stats response: %v", err)
|
||||
}
|
||||
|
||||
if backfilling, ok := resp["backfilling"]; !ok || backfilling != false {
|
||||
t.Fatalf("expected backfilling=false after completion, got %v", backfilling)
|
||||
}
|
||||
|
||||
if got := rec.Header().Get("X-CoreScope-Status"); got != "ready" {
|
||||
t.Fatalf("expected X-CoreScope-Status=ready, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -162,7 +162,7 @@ func createTestDBWithAgedPackets(t *testing.T, numRecent, numOld int) string {
|
||||
}
|
||||
execOrFail(`CREATE TABLE transmissions (id INTEGER PRIMARY KEY, raw_hex TEXT, hash TEXT, first_seen TEXT, route_type INTEGER, payload_type INTEGER, payload_version INTEGER, decoded_json TEXT)`)
|
||||
execOrFail(`CREATE TABLE observations (id INTEGER PRIMARY KEY, transmission_id INTEGER, observer_id TEXT, observer_name TEXT, direction TEXT, snr REAL, rssi REAL, score INTEGER, path_json TEXT, timestamp TEXT, raw_hex TEXT)`)
|
||||
execOrFail(`CREATE TABLE observers (rowid INTEGER PRIMARY KEY, id TEXT, name TEXT)`)
|
||||
execOrFail(`CREATE TABLE observers (rowid INTEGER PRIMARY KEY, id TEXT, name TEXT, iata TEXT)`)
|
||||
execOrFail(`CREATE TABLE nodes (pubkey TEXT PRIMARY KEY, name TEXT, role TEXT, lat REAL, lon REAL, last_seen TEXT, first_seen TEXT, frequency REAL)`)
|
||||
execOrFail(`CREATE TABLE schema_version (version INTEGER)`)
|
||||
execOrFail(`INSERT INTO schema_version (version) VALUES (1)`)
|
||||
@@ -172,16 +172,20 @@ func createTestDBWithAgedPackets(t *testing.T, numRecent, numOld int) string {
|
||||
id := 1
|
||||
// Insert old packets (48 hours ago)
|
||||
for i := 0; i < numOld; i++ {
|
||||
ts := now.Add(-48 * time.Hour).Add(time.Duration(i) * time.Second).Format(time.RFC3339)
|
||||
oldT := now.Add(-48 * time.Hour).Add(time.Duration(i) * time.Second)
|
||||
ts := oldT.Format(time.RFC3339)
|
||||
conn.Exec("INSERT INTO transmissions VALUES (?,?,?,?,0,4,1,?)", id, "aa", fmt.Sprintf("old%d", i), ts, `{}`)
|
||||
conn.Exec("INSERT INTO observations VALUES (?,?,?,?,?,?,?,?,?,?,?)", id, id, "obs1", "Obs1", "RX", -10.0, -80.0, 5, `[]`, ts, "")
|
||||
// observations.timestamp is INTEGER (unix seconds) in production schema
|
||||
// — keep the fixture consistent so the RFC3339 subquery matches.
|
||||
conn.Exec("INSERT INTO observations VALUES (?,?,?,?,?,?,?,?,?,?,?)", id, id, "obs1", "Obs1", "RX", -10.0, -80.0, 5, `[]`, oldT.Unix(), "")
|
||||
id++
|
||||
}
|
||||
// Insert recent packets (within last hour)
|
||||
for i := 0; i < numRecent; i++ {
|
||||
ts := now.Add(-30 * time.Minute).Add(time.Duration(i) * time.Second).Format(time.RFC3339)
|
||||
newT := now.Add(-30 * time.Minute).Add(time.Duration(i) * time.Second)
|
||||
ts := newT.Format(time.RFC3339)
|
||||
conn.Exec("INSERT INTO transmissions VALUES (?,?,?,?,0,4,1,?)", id, "bb", fmt.Sprintf("new%d", i), ts, `{}`)
|
||||
conn.Exec("INSERT INTO observations VALUES (?,?,?,?,?,?,?,?,?,?,?)", id, id, "obs1", "Obs1", "RX", -10.0, -80.0, 5, `[]`, ts, "")
|
||||
conn.Exec("INSERT INTO observations VALUES (?,?,?,?,?,?,?,?,?,?,?)", id, id, "obs1", "Obs1", "RX", -10.0, -80.0, 5, `[]`, newT.Unix(), "")
|
||||
id++
|
||||
}
|
||||
return dbPath
|
||||
@@ -317,7 +321,7 @@ func createTestDBAt(tb testing.TB, dbPath string, numTx int) {
|
||||
direction TEXT, snr REAL, rssi REAL, score INTEGER,
|
||||
path_json TEXT, timestamp TEXT, raw_hex TEXT
|
||||
)`)
|
||||
execOrFail(`CREATE TABLE IF NOT EXISTS observers (rowid INTEGER PRIMARY KEY, id TEXT, name TEXT)`)
|
||||
execOrFail(`CREATE TABLE IF NOT EXISTS observers (rowid INTEGER PRIMARY KEY, id TEXT, name TEXT, iata TEXT)`)
|
||||
execOrFail(`CREATE TABLE IF NOT EXISTS nodes (
|
||||
pubkey TEXT PRIMARY KEY, name TEXT, role TEXT, lat REAL, lon REAL,
|
||||
last_seen TEXT, first_seen TEXT, frequency REAL
|
||||
@@ -368,7 +372,7 @@ func createTestDBWithObs(tb testing.TB, dbPath string, numTx int) {
|
||||
id INTEGER PRIMARY KEY, transmission_id INTEGER, observer_id TEXT, observer_name TEXT,
|
||||
direction TEXT, snr REAL, rssi REAL, score INTEGER, path_json TEXT, timestamp TEXT, raw_hex TEXT
|
||||
)`)
|
||||
execOrFail(`CREATE TABLE IF NOT EXISTS observers (rowid INTEGER PRIMARY KEY, id TEXT, name TEXT)`)
|
||||
execOrFail(`CREATE TABLE IF NOT EXISTS observers (rowid INTEGER PRIMARY KEY, id TEXT, name TEXT, iata TEXT)`)
|
||||
execOrFail(`CREATE TABLE IF NOT EXISTS nodes (
|
||||
pubkey TEXT PRIMARY KEY, name TEXT, role TEXT, lat REAL, lon REAL,
|
||||
last_seen TEXT, first_seen TEXT, frequency REAL
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// TestBridgeScore_HandleNodesSurface verifies that /api/nodes
|
||||
// includes a `bridge_score` field on repeater rows after the bridge
|
||||
// recomputer has run. Drives the line-graph A-B-C-D through the full
|
||||
// pipeline: insert nodes, populate the neighbor graph, force a
|
||||
// recompute, hit the handler, parse the response. Issue #672 axis 2.
|
||||
func TestBridgeScore_HandleNodesSurface(t *testing.T) {
|
||||
db := setupCapabilityTestDB(t)
|
||||
defer db.conn.Close()
|
||||
// handleNodes/db.GetNodes selects a foreign_advert column not in
|
||||
// the minimal capability-test schema.
|
||||
if _, err := db.conn.Exec(`ALTER TABLE nodes ADD COLUMN foreign_advert INTEGER DEFAULT 0`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Four repeater nodes in a line.
|
||||
pks := []string{
|
||||
"aaaa000000000000000000000000000000000000000000000000000000000000",
|
||||
"bbbb000000000000000000000000000000000000000000000000000000000000",
|
||||
"cccc000000000000000000000000000000000000000000000000000000000000",
|
||||
"dddd000000000000000000000000000000000000000000000000000000000000",
|
||||
}
|
||||
recent := time.Now().UTC().Format("2006-01-02T15:04:05.000Z")
|
||||
for _, pk := range pks {
|
||||
if _, err := db.conn.Exec(`INSERT INTO nodes
|
||||
(public_key, name, role, lat, lon, last_seen, first_seen, advert_count)
|
||||
VALUES (?, ?, 'repeater', 37.5, -122.0, ?, ?, 10)`,
|
||||
pk, "node-"+pk[:4], recent, recent); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
store := NewPacketStore(db, nil)
|
||||
// Build neighbor graph with the line A-B-C-D. Add each edge
|
||||
// `count` times so its time-decayed Score saturates.
|
||||
g := NewNeighborGraph()
|
||||
now := time.Now()
|
||||
obs := "obs-test"
|
||||
snr := 5.0
|
||||
for i := 0; i < 10; i++ {
|
||||
g.upsertEdge(pks[0], pks[1], "aa", obs, &snr, now)
|
||||
g.upsertEdge(pks[1], pks[2], "bb", obs, &snr, now)
|
||||
g.upsertEdge(pks[2], pks[3], "cc", obs, &snr, now)
|
||||
}
|
||||
store.graph.Store(g)
|
||||
|
||||
// Direct invocation of the recomputer's compute path — bypassing
|
||||
// StartBridgeScoreRecomputer's package-level once-flag (which is
|
||||
// problematic across tests).
|
||||
recomputeBridgeScoresSafe(store)
|
||||
|
||||
snap := store.GetBridgeScoreMap()
|
||||
if len(snap) == 0 {
|
||||
t.Fatalf("expected non-empty bridge score snapshot, got empty")
|
||||
}
|
||||
// Sanity: middle nodes b/c must be positive, ends must be zero.
|
||||
if snap[pks[1]] <= 0 || snap[pks[2]] <= 0 {
|
||||
t.Errorf("middle nodes should have positive bridge: b=%v c=%v",
|
||||
snap[pks[1]], snap[pks[2]])
|
||||
}
|
||||
if snap[pks[0]] != 0 || snap[pks[3]] != 0 {
|
||||
t.Errorf("end nodes should have zero bridge: a=%v d=%v",
|
||||
snap[pks[0]], snap[pks[3]])
|
||||
}
|
||||
|
||||
// Wire a Server, call handleNodes, parse the response.
|
||||
cfg := &Config{Port: 3000}
|
||||
hub := NewHub()
|
||||
srv := NewServer(db, cfg, hub)
|
||||
srv.store = store
|
||||
|
||||
router := mux.NewRouter()
|
||||
srv.RegisterRoutes(router)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/nodes?limit=100", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != 200 {
|
||||
t.Fatalf("handleNodes status: want 200, got %d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
Nodes []map[string]interface{} `json:"nodes"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v body=%s", err, rr.Body.String())
|
||||
}
|
||||
gotBy := map[string]map[string]interface{}{}
|
||||
for _, n := range resp.Nodes {
|
||||
if pk, _ := n["public_key"].(string); pk != "" {
|
||||
gotBy[pk] = n
|
||||
}
|
||||
}
|
||||
for _, pk := range pks {
|
||||
n, ok := gotBy[pk]
|
||||
if !ok {
|
||||
t.Errorf("node %s missing from response", pk[:4])
|
||||
continue
|
||||
}
|
||||
if _, has := n["bridge_score"]; !has {
|
||||
t.Errorf("node %s: bridge_score field absent from response", pk[:4])
|
||||
}
|
||||
}
|
||||
// Middle node B must report a non-zero bridge_score; end node A
|
||||
// must report exactly zero. These two assertions together prevent
|
||||
// a "field present but always 0" regression.
|
||||
if v, _ := gotBy[pks[1]]["bridge_score"].(float64); v <= 0 {
|
||||
t.Errorf("middle node B bridge_score in API response should be > 0, got %v", v)
|
||||
}
|
||||
if v, _ := gotBy[pks[0]]["bridge_score"].(float64); v != 0 {
|
||||
t.Errorf("end node A bridge_score in API response should be 0, got %v", v)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
// Package main: bridge-axis recomputer (issue #672 axis 2 of 4).
|
||||
//
|
||||
// Steady-state background loop that recomputes the per-pubkey bridge
|
||||
// centrality score over the in-memory NeighborGraph and stores the
|
||||
// resulting map atomically. handleNodes reads via a single atomic
|
||||
// load — no lock contention with ingest or with other recomputers
|
||||
// (same pattern as #1240 / #1248).
|
||||
//
|
||||
// Interval default: 5 minutes. The graph itself rebuilds asynchronously
|
||||
// on its own schedule (path_inspect.go); a 5-minute cadence here is
|
||||
// well within the freshness budget for a structural metric (centrality
|
||||
// changes slowly — a new edge or evicted node nudges scores by
|
||||
// fractions of a percent).
|
||||
//
|
||||
// Cost (Brandes + Dijkstra): O(V · (E + V log V)). Staging-scale ~600
|
||||
// nodes / ~2 000 edges ≈ ~4.8M ops, well under 100 ms in practice. On
|
||||
// host-fleet scale (5 000 nodes / 30 000 edges) it is still seconds,
|
||||
// running in a background goroutine off the request path.
|
||||
package main
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// bridgeRecomputerDefaultInterval is how often the bridge score map is
|
||||
// rebuilt. 5 minutes mirrors analytics_recomputer (#1240) and
|
||||
// repeater_enrich_recomputer (#1262); centrality is a slow-moving
|
||||
// structural signal and does not warrant tighter cadence.
|
||||
const bridgeRecomputerDefaultInterval = 5 * time.Minute
|
||||
|
||||
// bridgeRecompStartedMu serializes start of the bridge recomputer.
|
||||
// We do not currently expose Stop publicly — the goroutine lives for
|
||||
// the lifetime of the process — but keeping the started flag local
|
||||
// (instead of on PacketStore) avoids further field churn in store.go.
|
||||
var (
|
||||
bridgeRecompStartedMu sync.Mutex
|
||||
bridgeRecompStarted bool
|
||||
)
|
||||
|
||||
// StartBridgeScoreRecomputer launches the bridge-centrality recomputer
|
||||
// (issue #672 axis 2). It performs an initial synchronous compute so
|
||||
// that the very first /api/nodes after server start hits a populated
|
||||
// snapshot instead of returning bridge_score=0 for every node, then
|
||||
// reschedules every `interval` (default 5min if <= 0).
|
||||
//
|
||||
// Idempotent: subsequent calls are no-ops and return a no-op stop
|
||||
// closure.
|
||||
func (s *PacketStore) StartBridgeScoreRecomputer(interval time.Duration) func() {
|
||||
if interval <= 0 {
|
||||
interval = bridgeRecomputerDefaultInterval
|
||||
}
|
||||
|
||||
bridgeRecompStartedMu.Lock()
|
||||
if bridgeRecompStarted {
|
||||
bridgeRecompStartedMu.Unlock()
|
||||
return func() {}
|
||||
}
|
||||
bridgeRecompStarted = true
|
||||
stop := make(chan struct{})
|
||||
done := make(chan struct{})
|
||||
bridgeRecompStartedMu.Unlock()
|
||||
|
||||
// Initial synchronous prewarm — see comment above.
|
||||
recomputeBridgeScoresSafe(s)
|
||||
|
||||
var stopOnce sync.Once
|
||||
go func() {
|
||||
defer close(done)
|
||||
t := time.NewTicker(interval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-t.C:
|
||||
recomputeBridgeScoresSafe(s)
|
||||
case <-stop:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return func() {
|
||||
stopOnce.Do(func() {
|
||||
close(stop)
|
||||
})
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// recomputeBridgeScoresSafe runs ComputeBridgeScores over the current
|
||||
// neighbor graph and installs the result. Panics in compute are
|
||||
// swallowed (defensive) so the goroutine never dies; the previous
|
||||
// snapshot remains valid.
|
||||
func recomputeBridgeScoresSafe(s *PacketStore) {
|
||||
defer func() { _ = recover() }()
|
||||
graph := s.graph.Load()
|
||||
if graph == nil {
|
||||
// No graph yet — install an empty map so readers get a defined
|
||||
// zero rather than a nil sentinel (handleNodes treats both as
|
||||
// 0.0, but an explicit empty snapshot avoids "is this ready
|
||||
// yet?" confusion in operator-facing tooling).
|
||||
empty := map[string]float64{}
|
||||
s.bridgeScoreMap.Store(&empty)
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
edges := bridgeEdgesFromGraph(graph, now)
|
||||
scores := ComputeBridgeScores(edges)
|
||||
s.bridgeScoreMap.Store(&scores)
|
||||
}
|
||||
|
||||
// bridgeEdgesFromGraph snapshots the NeighborGraph into a flat slice
|
||||
// of BridgeEdge tuples with weight = Score(now) * Confidence(), per
|
||||
// the convention established by #1235. Edges with unresolved B
|
||||
// endpoints (no concrete pubkey yet — only a hop prefix) are skipped:
|
||||
// they contribute no betweenness signal because the second endpoint
|
||||
// is unknown.
|
||||
func bridgeEdgesFromGraph(graph *NeighborGraph, now time.Time) []BridgeEdge {
|
||||
all := graph.AllEdges()
|
||||
out := make([]BridgeEdge, 0, len(all))
|
||||
for _, e := range all {
|
||||
if e == nil {
|
||||
continue
|
||||
}
|
||||
if e.NodeA == "" || e.NodeB == "" {
|
||||
// Unresolved (prefix-only) — no defined second endpoint.
|
||||
continue
|
||||
}
|
||||
w := e.Score(now) * e.Confidence()
|
||||
if w < bridgeMinWeightEpsilon {
|
||||
continue
|
||||
}
|
||||
out = append(out, BridgeEdge{A: e.NodeA, B: e.NodeB, Weight: w})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// GetBridgeScore returns the bridge centrality score for a pubkey in
|
||||
// [0, 1], or 0 if the recomputer has not run yet or the pubkey is not
|
||||
// in the graph. Lookup is case-insensitive (the score map keys are
|
||||
// lowercase, matching byPathHop convention).
|
||||
func (s *PacketStore) GetBridgeScore(pubkey string) float64 {
|
||||
if pubkey == "" {
|
||||
return 0
|
||||
}
|
||||
snap := s.bridgeScoreMap.Load()
|
||||
if snap == nil {
|
||||
return 0
|
||||
}
|
||||
m := *snap
|
||||
if v, ok := m[pubkey]; ok {
|
||||
return v
|
||||
}
|
||||
// Try lowercase form.
|
||||
lc := pubkey
|
||||
for i := 0; i < len(lc); i++ {
|
||||
if lc[i] >= 'A' && lc[i] <= 'Z' {
|
||||
b := []byte(pubkey)
|
||||
for j := i; j < len(b); j++ {
|
||||
if b[j] >= 'A' && b[j] <= 'Z' {
|
||||
b[j] += 'a' - 'A'
|
||||
}
|
||||
}
|
||||
lc = string(b)
|
||||
break
|
||||
}
|
||||
}
|
||||
if v, ok := m[lc]; ok {
|
||||
return v
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// GetBridgeScoreMap returns a defensive copy-by-reference of the
|
||||
// current bridge score snapshot. Nil-safe: returns an empty map if
|
||||
// no snapshot has been installed yet. Map is read-only by convention
|
||||
// — callers MUST NOT mutate it (the snapshot is shared across all
|
||||
// concurrent readers).
|
||||
func (s *PacketStore) GetBridgeScoreMap() map[string]float64 {
|
||||
snap := s.bridgeScoreMap.Load()
|
||||
if snap == nil {
|
||||
return map[string]float64{}
|
||||
}
|
||||
return *snap
|
||||
}
|
||||
|
||||
// resetBridgeRecomputerForTest is a test-only helper to allow the
|
||||
// integration test to re-Start the recomputer in a fresh process
|
||||
// (which would otherwise be blocked by the package-level
|
||||
// bridgeRecompStarted flag). Production code must not call this.
|
||||
func resetBridgeRecomputerForTest() {
|
||||
bridgeRecompStartedMu.Lock()
|
||||
bridgeRecompStarted = false
|
||||
bridgeRecompStartedMu.Unlock()
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
// Package main: bridge axis of repeater usefulness score (issue #672,
|
||||
// axis 2 of 4). The "Bridge" signal is the betweenness centrality of a
|
||||
// node in the (undirected, weighted) neighbor graph: a high value means
|
||||
// the node lies on many shortest paths between other pairs and is hence
|
||||
// structurally important — removing it would force traffic around or
|
||||
// fragment the mesh.
|
||||
//
|
||||
// Algorithm: Brandes' algorithm (1) with Dijkstra for weighted
|
||||
// shortest paths. Complexity O(V · (E + V log V)). For the staging
|
||||
// graph (~600 nodes, ~2 000 edges) this is ~4.8M ops — trivial,
|
||||
// completes in milliseconds. We accumulate raw betweenness across all
|
||||
// sources, halve (an undirected pair is counted from each endpoint
|
||||
// once), then normalize by the max observed value so the per-node
|
||||
// score is in [0, 1].
|
||||
//
|
||||
// Edge weight follows the convention established by #1235: the
|
||||
// affinity score (count + recency decay) is multiplied by the
|
||||
// observer-diversity confidence — stronger, more corroborated
|
||||
// neighborships are preferred when there is a choice of paths.
|
||||
// Geo-rejected edges are already excluded from the input graph at
|
||||
// build time (#1230) so we don't have to re-filter here.
|
||||
//
|
||||
// For Dijkstra we need a DISTANCE (lower = better) not an affinity
|
||||
// (higher = better), so we convert: cost = 1 / max(epsilon, weight).
|
||||
// epsilon avoids divide-by-zero on a degenerate zero-weight edge.
|
||||
//
|
||||
// (1) Brandes, "A Faster Algorithm for Betweenness Centrality" (2001).
|
||||
package main
|
||||
|
||||
import (
|
||||
"container/heap"
|
||||
"math"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// BridgeEdge is the algorithm-facing edge tuple consumed by
|
||||
// ComputeBridgeScores. Endpoints A and B are pubkeys (case preserved
|
||||
// by caller; we lowercase internally for stable keying). Weight is
|
||||
// the affinity (higher = stronger connection). Edges with zero or
|
||||
// negative weight are skipped — they would break Dijkstra's
|
||||
// relaxation invariant.
|
||||
type BridgeEdge struct {
|
||||
A, B string
|
||||
Weight float64
|
||||
}
|
||||
|
||||
// bridgeMinWeightEpsilon is the floor applied to weights before we
|
||||
// invert them into Dijkstra distances. 1e-9 is small enough that any
|
||||
// real weight (Score in [0,1] times Confidence in [0,1]) dominates,
|
||||
// but large enough to avoid Inf when weight is exactly zero.
|
||||
const bridgeMinWeightEpsilon = 1e-9
|
||||
|
||||
// ComputeBridgeScores returns a map pubkey → bridge score in [0, 1]
|
||||
// computed via Brandes' weighted betweenness centrality on the
|
||||
// undirected graph defined by `edges`. Returned map is keyed by the
|
||||
// lowercase pubkey form (matching the byPathHop / persisted-edge
|
||||
// convention). Nodes appearing in the graph but with zero betweenness
|
||||
// are still present in the map with value 0.0.
|
||||
//
|
||||
// Self-loops (A == B) and edges with weight < epsilon are silently
|
||||
// skipped. Duplicate edges between the same pair keep the cheapest
|
||||
// (= the highest-weight) version — consistent with shortest-path
|
||||
// semantics.
|
||||
//
|
||||
// Pure (no global state, no locks); safe to call concurrently.
|
||||
// Cost: O(V · (E + V log V)).
|
||||
func ComputeBridgeScores(edges []BridgeEdge) map[string]float64 {
|
||||
// 1. Build adjacency list with distance = 1/weight.
|
||||
adj := make(map[string]map[string]float64)
|
||||
addOrMerge := func(a, b string, dist float64) {
|
||||
m, ok := adj[a]
|
||||
if !ok {
|
||||
m = make(map[string]float64)
|
||||
adj[a] = m
|
||||
}
|
||||
if existing, has := m[b]; !has || dist < existing {
|
||||
m[b] = dist
|
||||
}
|
||||
}
|
||||
for _, e := range edges {
|
||||
a := strings.ToLower(strings.TrimSpace(e.A))
|
||||
b := strings.ToLower(strings.TrimSpace(e.B))
|
||||
if a == "" || b == "" || a == b {
|
||||
continue
|
||||
}
|
||||
w := e.Weight
|
||||
if w < bridgeMinWeightEpsilon {
|
||||
continue
|
||||
}
|
||||
dist := 1.0 / w
|
||||
addOrMerge(a, b, dist)
|
||||
addOrMerge(b, a, dist)
|
||||
}
|
||||
if len(adj) == 0 {
|
||||
return map[string]float64{}
|
||||
}
|
||||
|
||||
nodes := make([]string, 0, len(adj))
|
||||
for n := range adj {
|
||||
nodes = append(nodes, n)
|
||||
}
|
||||
|
||||
bc := make(map[string]float64, len(nodes))
|
||||
for _, n := range nodes {
|
||||
bc[n] = 0
|
||||
}
|
||||
|
||||
// 2. Brandes outer loop: one Dijkstra-based single-source shortest
|
||||
// path computation per source vertex.
|
||||
for _, s := range nodes {
|
||||
stack := make([]string, 0, len(nodes))
|
||||
pred := make(map[string][]string, len(nodes))
|
||||
sigma := make(map[string]float64, len(nodes))
|
||||
dist := make(map[string]float64, len(nodes))
|
||||
for _, n := range nodes {
|
||||
sigma[n] = 0
|
||||
dist[n] = math.Inf(1)
|
||||
}
|
||||
sigma[s] = 1
|
||||
dist[s] = 0
|
||||
|
||||
pq := &bridgePQ{}
|
||||
heap.Init(pq)
|
||||
heap.Push(pq, bridgePQItem{node: s, dist: 0})
|
||||
|
||||
visited := make(map[string]bool, len(nodes))
|
||||
for pq.Len() > 0 {
|
||||
top := heap.Pop(pq).(bridgePQItem)
|
||||
v := top.node
|
||||
if visited[v] {
|
||||
continue
|
||||
}
|
||||
visited[v] = true
|
||||
stack = append(stack, v)
|
||||
|
||||
for w, edgeDist := range adj[v] {
|
||||
alt := dist[v] + edgeDist
|
||||
if alt < dist[w]-1e-12 {
|
||||
dist[w] = alt
|
||||
sigma[w] = sigma[v]
|
||||
pred[w] = append(pred[w][:0], v)
|
||||
heap.Push(pq, bridgePQItem{node: w, dist: alt})
|
||||
} else if math.Abs(alt-dist[w]) <= 1e-12 {
|
||||
sigma[w] += sigma[v]
|
||||
pred[w] = append(pred[w], v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Back-propagation: walk the stack in reverse order.
|
||||
delta := make(map[string]float64, len(nodes))
|
||||
for i := len(stack) - 1; i >= 0; i-- {
|
||||
w := stack[i]
|
||||
for _, v := range pred[w] {
|
||||
if sigma[w] == 0 {
|
||||
continue
|
||||
}
|
||||
delta[v] += (sigma[v] / sigma[w]) * (1.0 + delta[w])
|
||||
}
|
||||
if w != s {
|
||||
bc[w] += delta[w]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Undirected graphs double-count each (s,t) pair, so halve.
|
||||
for k := range bc {
|
||||
bc[k] /= 2.0
|
||||
}
|
||||
|
||||
// 5. Normalize by max so scores live in [0, 1]. If max is 0
|
||||
// (clique or single edge) we leave everything at zero.
|
||||
maxBC := 0.0
|
||||
for _, v := range bc {
|
||||
if v > maxBC {
|
||||
maxBC = v
|
||||
}
|
||||
}
|
||||
if maxBC > 0 {
|
||||
for k, v := range bc {
|
||||
bc[k] = v / maxBC
|
||||
}
|
||||
}
|
||||
return bc
|
||||
}
|
||||
|
||||
// ─── min-heap for Dijkstra ─────────────────────────────────────────────────────
|
||||
|
||||
type bridgePQItem struct {
|
||||
node string
|
||||
dist float64
|
||||
}
|
||||
|
||||
type bridgePQ []bridgePQItem
|
||||
|
||||
func (h bridgePQ) Len() int { return len(h) }
|
||||
func (h bridgePQ) Less(i, j int) bool { return h[i].dist < h[j].dist }
|
||||
func (h bridgePQ) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
|
||||
func (h *bridgePQ) Push(x interface{}) { *h = append(*h, x.(bridgePQItem)) }
|
||||
func (h *bridgePQ) Pop() interface{} {
|
||||
old := *h
|
||||
n := len(old)
|
||||
it := old[n-1]
|
||||
*h = old[:n-1]
|
||||
return it
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestComputeBridgeScores_LineGraph asserts the canonical property of
|
||||
// betweenness centrality on a 4-node line A-B-C-D: the two middle
|
||||
// nodes B and C have non-zero centrality (every path between an end
|
||||
// and a far end traverses them) while the two leaves A and D bridge
|
||||
// no pairs and score zero. This is the RED test for issue #672 bridge
|
||||
// axis — it fails on master where ComputeBridgeScores is a stub.
|
||||
func TestComputeBridgeScores_LineGraph(t *testing.T) {
|
||||
edges := []BridgeEdge{
|
||||
{A: "a", B: "b", Weight: 1.0},
|
||||
{A: "b", B: "c", Weight: 1.0},
|
||||
{A: "c", B: "d", Weight: 1.0},
|
||||
}
|
||||
scores := ComputeBridgeScores(edges)
|
||||
|
||||
for _, leaf := range []string{"a", "d"} {
|
||||
if v, ok := scores[leaf]; !ok || v != 0 {
|
||||
t.Errorf("leaf %q: want score 0 (present), got %v ok=%v", leaf, v, ok)
|
||||
}
|
||||
}
|
||||
for _, mid := range []string{"b", "c"} {
|
||||
v, ok := scores[mid]
|
||||
if !ok {
|
||||
t.Errorf("middle %q: missing from result map", mid)
|
||||
continue
|
||||
}
|
||||
if v <= 0 {
|
||||
t.Errorf("middle %q: want non-zero centrality, got %v", mid, v)
|
||||
}
|
||||
}
|
||||
// Normalization: max must equal 1.0 exactly when any node has
|
||||
// non-zero centrality.
|
||||
maxScore := 0.0
|
||||
for _, v := range scores {
|
||||
if v > maxScore {
|
||||
maxScore = v
|
||||
}
|
||||
}
|
||||
if math.Abs(maxScore-1.0) > 1e-9 {
|
||||
t.Errorf("max normalized score: want 1.0, got %v", maxScore)
|
||||
}
|
||||
}
|
||||
|
||||
// TestComputeBridgeScores_TriangleNoBridge: in a fully connected
|
||||
// triangle every node has at least one alternate path so betweenness
|
||||
// is zero everywhere. The map should still contain all three nodes
|
||||
// (so callers can distinguish "in graph but unimportant" from
|
||||
// "not in graph") with explicit zero values.
|
||||
func TestComputeBridgeScores_TriangleNoBridge(t *testing.T) {
|
||||
edges := []BridgeEdge{
|
||||
{A: "x", B: "y", Weight: 1.0},
|
||||
{A: "y", B: "z", Weight: 1.0},
|
||||
{A: "z", B: "x", Weight: 1.0},
|
||||
}
|
||||
scores := ComputeBridgeScores(edges)
|
||||
for _, n := range []string{"x", "y", "z"} {
|
||||
if v, ok := scores[n]; !ok || v != 0 {
|
||||
t.Errorf("triangle node %q: want 0 present, got %v ok=%v", n, v, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestComputeBridgeScores_Empty: an empty edge list yields an empty
|
||||
// (non-nil) map. Defensive check so the recomputer can swap in an
|
||||
// empty result without crashing the lookup path.
|
||||
func TestComputeBridgeScores_Empty(t *testing.T) {
|
||||
scores := ComputeBridgeScores(nil)
|
||||
if scores == nil {
|
||||
t.Fatal("want non-nil empty map, got nil")
|
||||
}
|
||||
if len(scores) != 0 {
|
||||
t.Errorf("want empty map, got %d entries", len(scores))
|
||||
}
|
||||
}
|
||||
|
||||
// TestComputeBridgeScores_WeightSensitive verifies the algorithm uses
|
||||
// edge weights as affinity (higher = preferred). In a graph A-B-D and
|
||||
// A-C-D where the B-route has weight 1.0 and the C-route has weight
|
||||
// 0.1, shortest path (max-affinity = min 1/w) goes through B, so B
|
||||
// has positive centrality and C does not. This is the "mutation
|
||||
// test" — flip the cost formula (e.g., remove the 1/w inversion) and
|
||||
// this test inverts.
|
||||
func TestComputeBridgeScores_WeightSensitive(t *testing.T) {
|
||||
edges := []BridgeEdge{
|
||||
{A: "a", B: "b", Weight: 1.0},
|
||||
{A: "b", B: "d", Weight: 1.0},
|
||||
{A: "a", B: "c", Weight: 0.1},
|
||||
{A: "c", B: "d", Weight: 0.1},
|
||||
}
|
||||
scores := ComputeBridgeScores(edges)
|
||||
if scores["b"] <= scores["c"] {
|
||||
t.Errorf("stronger-weight intermediary b should outrank c: b=%v c=%v",
|
||||
scores["b"], scores["c"])
|
||||
}
|
||||
}
|
||||
@@ -68,7 +68,7 @@ func TestComputeAnalyticsChannels_MergesEncryptedAndDecrypted(t *testing.T) {
|
||||
}
|
||||
|
||||
store := newChannelTestStore(packets)
|
||||
result := store.computeAnalyticsChannels("", TimeWindow{})
|
||||
result := store.computeAnalyticsChannels("", "", TimeWindow{})
|
||||
|
||||
channels := result["channels"].([]map[string]interface{})
|
||||
if len(channels) != 1 {
|
||||
@@ -98,7 +98,7 @@ func TestComputeAnalyticsChannels_RejectsRainbowTableMismatch(t *testing.T) {
|
||||
}
|
||||
|
||||
store := newChannelTestStore(packets)
|
||||
result := store.computeAnalyticsChannels("", TimeWindow{})
|
||||
result := store.computeAnalyticsChannels("", "", TimeWindow{})
|
||||
|
||||
channels := result["channels"].([]map[string]interface{})
|
||||
if len(channels) != 2 {
|
||||
|
||||
+106
-10
@@ -54,6 +54,20 @@ const (
|
||||
// drift rarely exceeds 1 hour, while epoch-0 RTCs produce ~1.7B sec.
|
||||
bimodalSkewThresholdSec = 3600.0
|
||||
|
||||
// rtcResetOutlierThresholdSec is the absolute skew above which a
|
||||
// sample is treated as obvious sensor garbage — an RTC-reset advert
|
||||
// where the firmware emitted its factory timestamp (typically off by
|
||||
// months/years). These samples are excluded from the recent-window
|
||||
// "good/bad" split (bug #1285 — single RTC-reset advert among 30
|
||||
// healthy adverts must not flip a node to bimodal_clock) and from the
|
||||
// per-hash evidence median (a 700-day median is not actionable for
|
||||
// operators). They remain in the raw sample stream and the RTC-reset
|
||||
// badge logic which surfaces them separately. 24h is a generous floor:
|
||||
// real drift is fractions of a sec/advert, real clock-skew tops out
|
||||
// in the hours range; anything above a day is structurally not a
|
||||
// drift signal.
|
||||
rtcResetOutlierThresholdSec = 24 * 3600.0
|
||||
|
||||
// maxPlausibleSkewJumpSec is the largest skew change between
|
||||
// consecutive samples that we treat as physical drift. Anything larger
|
||||
// (e.g. a GPS sync that jumps the clock by minutes/days) is rejected
|
||||
@@ -560,13 +574,25 @@ func (s *PacketStore) getNodeClockSkewLocked(pubkey string) *NodeClockSkew {
|
||||
// no_clock — goodFraction < 0.10 (essentially no real clock)
|
||||
// bimodal_clock — 0.10 <= goodFraction < 0.80 AND badCount > 0
|
||||
// ok/warn/etc. — goodFraction >= 0.80 (normal, outliers filtered)
|
||||
//
|
||||
// RTC-reset outliers (|skew| > 24h — single advert where the firmware
|
||||
// emitted its factory timestamp) are EXCLUDED from this split (bug
|
||||
// #1285): they're not "bimodal-bad real-but-large skew" but obvious
|
||||
// sensor garbage, surfaced separately via the RTC-reset badge. Counting
|
||||
// them as bimodal-bad produces a false-alarm warning ("3 of last 5
|
||||
// adverts had nonsense timestamps") on otherwise-healthy nodes.
|
||||
var goodSamples []float64
|
||||
var rtcResetCount int
|
||||
for _, v := range recentVals {
|
||||
if math.Abs(v) <= bimodalSkewThresholdSec {
|
||||
absV := math.Abs(v)
|
||||
switch {
|
||||
case absV > rtcResetOutlierThresholdSec:
|
||||
rtcResetCount++ // ignored for good/bad classification
|
||||
case absV <= bimodalSkewThresholdSec:
|
||||
goodSamples = append(goodSamples, v)
|
||||
}
|
||||
}
|
||||
recentSampleCount := len(recentVals)
|
||||
recentSampleCount := len(recentVals) - rtcResetCount
|
||||
recentBadCount := recentSampleCount - len(goodSamples)
|
||||
var goodFraction float64
|
||||
if recentSampleCount > 0 {
|
||||
@@ -586,8 +612,9 @@ func (s *PacketStore) getNodeClockSkewLocked(pubkey string) *NodeClockSkew {
|
||||
}
|
||||
} else {
|
||||
// Normal path: if there are good samples, use their median
|
||||
// (filters out rare outliers in ≥80% good case).
|
||||
if len(goodSamples) > 0 && recentBadCount > 0 {
|
||||
// (filters out rare outliers in ≥80% good case, and rejects
|
||||
// RTC-reset outliers regardless of bimodal/bad counts — #1285).
|
||||
if len(goodSamples) > 0 {
|
||||
recentSkew = median(goodSamples)
|
||||
}
|
||||
severity = classifySkew(math.Abs(recentSkew))
|
||||
@@ -668,7 +695,7 @@ func (s *PacketStore) getNodeClockSkewLocked(pubkey string) *NodeClockSkew {
|
||||
recentEvidence = append(recentEvidence, HashEvidence{
|
||||
Hash: eh.hash,
|
||||
Observers: observers,
|
||||
MedianCorrectedSkewSec: round(median(corrSkews), 1),
|
||||
MedianCorrectedSkewSec: round(hashEvidenceMedian(corrSkews), 1),
|
||||
Timestamp: eh.ts,
|
||||
})
|
||||
}
|
||||
@@ -694,9 +721,40 @@ func (s *PacketStore) getNodeClockSkewLocked(pubkey string) *NodeClockSkew {
|
||||
}
|
||||
}
|
||||
|
||||
// GetFleetClockSkew returns clock skew data for all nodes that have skew data.
|
||||
// Must NOT be called with s.mu held.
|
||||
func (s *PacketStore) GetFleetClockSkew() []*NodeClockSkew {
|
||||
// GetFleetClockSkew returns clock skew data for all nodes, optionally
|
||||
// filtered to area. With no area, prefers the steady-state recomputer
|
||||
// snapshot (issue #1265). Must NOT be called with s.mu held.
|
||||
func (s *PacketStore) GetFleetClockSkew(area string) []*NodeClockSkew {
|
||||
if area == "" {
|
||||
s.analyticsRecomputerMu.RLock()
|
||||
rc := s.recompNodesClockSkew
|
||||
s.analyticsRecomputerMu.RUnlock()
|
||||
if rc != nil {
|
||||
if v := rc.Load(); v != nil {
|
||||
if r, ok := v.([]*NodeClockSkew); ok {
|
||||
return r
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return s.computeFleetClockSkewForArea(area)
|
||||
}
|
||||
|
||||
// computeFleetClockSkew wraps computeFleetClockSkewForArea with no area
|
||||
// filter; called by the steady-state recomputer. Must NOT be called with
|
||||
// s.mu held.
|
||||
func (s *PacketStore) computeFleetClockSkew() []*NodeClockSkew {
|
||||
return s.computeFleetClockSkewForArea("")
|
||||
}
|
||||
|
||||
// computeFleetClockSkewForArea is the underlying compute. Must NOT be
|
||||
// called with s.mu held.
|
||||
func (s *PacketStore) computeFleetClockSkewForArea(area string) []*NodeClockSkew {
|
||||
var areaNodes map[string]bool
|
||||
if area != "" {
|
||||
areaNodes = s.resolveAreaNodes(area)
|
||||
}
|
||||
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
@@ -707,8 +765,11 @@ func (s *PacketStore) GetFleetClockSkew() []*NodeClockSkew {
|
||||
nameMap[ni.PublicKey] = ni
|
||||
}
|
||||
|
||||
var results []*NodeClockSkew
|
||||
var results = []*NodeClockSkew{}
|
||||
for pubkey := range s.byNode {
|
||||
if areaNodes != nil && !areaNodes[pubkey] {
|
||||
continue
|
||||
}
|
||||
cs := s.getNodeClockSkewLocked(pubkey)
|
||||
if cs == nil {
|
||||
continue
|
||||
@@ -727,8 +788,26 @@ func (s *PacketStore) GetFleetClockSkew() []*NodeClockSkew {
|
||||
return results
|
||||
}
|
||||
|
||||
// GetObserverCalibrations returns the current observer clock offsets.
|
||||
// GetObserverCalibrations returns the current observer clock offsets,
|
||||
// preferring the steady-state recomputer snapshot (issue #1265). Falls
|
||||
// back to an on-request compute when the recomputer is not running.
|
||||
func (s *PacketStore) GetObserverCalibrations() []ObserverCalibration {
|
||||
s.analyticsRecomputerMu.RLock()
|
||||
rc := s.recompObserversClockSkew
|
||||
s.analyticsRecomputerMu.RUnlock()
|
||||
if rc != nil {
|
||||
if v := rc.Load(); v != nil {
|
||||
if r, ok := v.([]ObserverCalibration); ok {
|
||||
return r
|
||||
}
|
||||
}
|
||||
}
|
||||
return s.computeObserverCalibrations()
|
||||
}
|
||||
|
||||
// computeObserverCalibrations is the underlying compute used by the
|
||||
// recomputer and on-request fallback. Must NOT be called with s.mu held.
|
||||
func (s *PacketStore) computeObserverCalibrations() []ObserverCalibration {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
@@ -768,6 +847,23 @@ func median(vals []float64) float64 {
|
||||
return sorted[n/2]
|
||||
}
|
||||
|
||||
// hashEvidenceMedian returns the median corrected skew for a single
|
||||
// transmission hash, filtering out RTC-reset outliers (|skew| > 24h —
|
||||
// firmware emitting factory timestamp). Issue #1285: a single outlier
|
||||
// observer was dragging the displayed median to ~-704d on an otherwise
|
||||
// healthy node. If filtering leaves zero usable samples (every observer
|
||||
// of this hash saw a reset-shaped advert), return 0 so the UI can render
|
||||
// "insufficient data" rather than the garbage outlier value.
|
||||
func hashEvidenceMedian(vals []float64) float64 {
|
||||
clean := vals[:0:0]
|
||||
for _, v := range vals {
|
||||
if math.Abs(v) <= rtcResetOutlierThresholdSec {
|
||||
clean = append(clean, v)
|
||||
}
|
||||
}
|
||||
return median(clean)
|
||||
}
|
||||
|
||||
func mean(vals []float64) float64 {
|
||||
if len(vals) == 0 {
|
||||
return 0
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package main
|
||||
|
||||
// Regression tests for #1285:
|
||||
//
|
||||
// Bug A: per-hash evidence's MedianCorrectedSkewSec includes 700-day RTC-reset
|
||||
// outliers, dragging the displayed median into "−704d 18h" garbage even
|
||||
// though every recent sample is small (< 30s).
|
||||
//
|
||||
// Bug B: RecentBadSampleCount counts samples outside the *displayed* recent
|
||||
// window (or counts against raw skew, not corrected) → "3 of last 5
|
||||
// adverts had nonsense timestamps" warning fires on healthy nodes.
|
||||
//
|
||||
// Both must pass without disturbing the existing bimodal / no-clock logic.
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Synthesizes the repro from issue #1285:
|
||||
// 30 healthy adverts (skew ~−20s) + 1 historical advert with an RTC reset
|
||||
// (advertTS = 2024-06-13, observed today → −705d skew).
|
||||
// Returns the populated store.
|
||||
func seedIssue1285Repro(t *testing.T) *PacketStore {
|
||||
t.Helper()
|
||||
ps := NewPacketStore(nil, nil)
|
||||
pt := 4 // ADVERT
|
||||
|
||||
const pubkey = "RTCRESET"
|
||||
const skewSec = int64(-20) // node clock is 20s BEHIND wall-clock
|
||||
|
||||
baseObs := int64(1779000000) // ~mid-2026
|
||||
rtcResetAdv := int64(1718281640) // 2024-06-13 (from the issue repro)
|
||||
|
||||
var txs []*StoreTx
|
||||
|
||||
// 30 healthy adverts spanning the older end of the recent window.
|
||||
for i := 0; i < 30; i++ {
|
||||
obsTS := baseObs + int64(i)*60
|
||||
advTS := obsTS + skewSec
|
||||
tx := &StoreTx{
|
||||
Hash: "healthy-" + formatInt64(int64(i)),
|
||||
PayloadType: &pt,
|
||||
DecodedJSON: `{"payload":{"timestamp":` + formatInt64(advTS) + `}}`,
|
||||
Observations: []*StoreObs{
|
||||
{ObserverID: "obs1", Timestamp: time.Unix(obsTS, 0).UTC().Format(time.RFC3339)},
|
||||
},
|
||||
}
|
||||
txs = append(txs, tx)
|
||||
}
|
||||
|
||||
// One RTC-reset packet observed MOST RECENTLY (so it sits in the
|
||||
// per-hash evidence list AND is included in the recent-window count
|
||||
// on master). Its advertTS is from 2024 → corrected skew ≈ -60M sec.
|
||||
rtcResetObs := baseObs + int64(30*60) + 60
|
||||
rtcTx := &StoreTx{
|
||||
Hash: "rtc-reset-0001",
|
||||
PayloadType: &pt,
|
||||
DecodedJSON: `{"payload":{"timestamp":` + formatInt64(rtcResetAdv) + `}}`,
|
||||
Observations: []*StoreObs{
|
||||
{ObserverID: "obs1", Timestamp: time.Unix(rtcResetObs, 0).UTC().Format(time.RFC3339)},
|
||||
},
|
||||
}
|
||||
txs = append(txs, rtcTx)
|
||||
|
||||
ps.mu.Lock()
|
||||
ps.byNode[pubkey] = txs
|
||||
for _, tx := range txs {
|
||||
ps.byPayloadType[4] = append(ps.byPayloadType[4], tx)
|
||||
}
|
||||
ps.clockSkew.computeInterval = 0
|
||||
ps.mu.Unlock()
|
||||
return ps
|
||||
}
|
||||
|
||||
// Bug A — per-hash evidence median must EXCLUDE the 705-day RTC-reset outlier.
|
||||
// On master this asserts on the RTC-reset hash's MedianCorrectedSkewSec being
|
||||
// ≈ −60M sec ("−704d 18h"); after the fix the field is suppressed (0) or
|
||||
// otherwise marked insufficient, never displayed as the garbage value.
|
||||
func TestIssue1285_HashEvidence_OutlierExcludedFromMedian(t *testing.T) {
|
||||
ps := seedIssue1285Repro(t)
|
||||
r := ps.GetNodeClockSkew("RTCRESET")
|
||||
if r == nil {
|
||||
t.Fatal("expected clock skew result")
|
||||
}
|
||||
|
||||
// The recent-hash evidence list is the source of the "median corrected:
|
||||
// −704d 18h" string in the UI. After the fix, NO entry in this list
|
||||
// should report a |median| above the 24h sanity threshold — the fix is
|
||||
// to drop outlier samples (or flag the hash as insufficient-data) before
|
||||
// publishing the median.
|
||||
const maxSaneAbsSec = float64(24 * 3600)
|
||||
for _, ev := range r.RecentHashEvidence {
|
||||
if abs(ev.MedianCorrectedSkewSec) > maxSaneAbsSec {
|
||||
t.Errorf("hash %s exposes outlier-dominated median %.0fs (~%.1fd); "+
|
||||
"expected entry to be filtered out or marked insufficient (|median| <= %.0fs)",
|
||||
ev.Hash, ev.MedianCorrectedSkewSec,
|
||||
ev.MedianCorrectedSkewSec/86400, maxSaneAbsSec)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bug B — RecentBadSampleCount must be 0 when every sample in the recent
|
||||
// window is healthy (<30s |corrected skew|). On master this fires because
|
||||
// "recent" is computed over the wrong set (or against raw skew).
|
||||
func TestIssue1285_RecentBadCount_NotPollutedByOldOutlier(t *testing.T) {
|
||||
ps := seedIssue1285Repro(t)
|
||||
r := ps.GetNodeClockSkew("RTCRESET")
|
||||
if r == nil {
|
||||
t.Fatal("expected clock skew result")
|
||||
}
|
||||
if r.RecentBadSampleCount != 0 {
|
||||
t.Errorf("RecentBadSampleCount = %d, want 0 — recent samples are all "+
|
||||
"~−20s (healthy); the historical RTC-reset outlier is outside the "+
|
||||
"recent window and must not be counted", r.RecentBadSampleCount)
|
||||
}
|
||||
if r.Severity == SkewBimodalClock || r.Severity == SkewNoClock {
|
||||
t.Errorf("severity = %v, want ok/warning — recent samples are all "+
|
||||
"healthy (~−20s skew), one historical outlier must not flip the node "+
|
||||
"to bimodal/no-clock", r.Severity)
|
||||
}
|
||||
}
|
||||
|
||||
func abs(v float64) float64 {
|
||||
if v < 0 {
|
||||
return -v
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sort"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Issue #1265: /api/observers/clock-skew (3.3s) and /api/nodes/clock-skew (8.9s)
|
||||
// must be wired into the steady-state analytics recomputer so reads serve
|
||||
// from an atomic-pointer snapshot in <100ms p99 under concurrent load.
|
||||
|
||||
func TestClockSkewRecomputersRegistered(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
store := NewPacketStore(db, nil)
|
||||
|
||||
stop := store.StartAnalyticsRecomputers(50 * time.Millisecond)
|
||||
defer stop()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
store.analyticsRecomputerMu.RLock()
|
||||
rcObs := store.recompObserversClockSkew
|
||||
rcNodes := store.recompNodesClockSkew
|
||||
store.analyticsRecomputerMu.RUnlock()
|
||||
|
||||
if rcObs == nil {
|
||||
t.Fatalf("recompObserversClockSkew not registered after StartAnalyticsRecomputers (issue #1265 not fixed)")
|
||||
}
|
||||
if rcNodes == nil {
|
||||
t.Fatalf("recompNodesClockSkew not registered after StartAnalyticsRecomputers (issue #1265 not fixed)")
|
||||
}
|
||||
if rcObs.Load() == nil {
|
||||
t.Fatalf("recompObserversClockSkew snapshot is nil after initial compute")
|
||||
}
|
||||
if rcNodes.Load() == nil {
|
||||
t.Fatalf("recompNodesClockSkew snapshot is nil after initial compute")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClockSkewHandlersSteadyStateLatency(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
store := NewPacketStore(db, nil)
|
||||
stop := store.StartAnalyticsRecomputers(50 * time.Millisecond)
|
||||
defer stop()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
s := &Server{store: store}
|
||||
|
||||
endpoints := []struct {
|
||||
name string
|
||||
path string
|
||||
handler http.HandlerFunc
|
||||
}{
|
||||
{"observers", "/api/observers/clock-skew", s.handleObserverClockSkew},
|
||||
{"nodes", "/api/nodes/clock-skew", s.handleFleetClockSkew},
|
||||
}
|
||||
|
||||
for _, ep := range endpoints {
|
||||
ep := ep
|
||||
t.Run(ep.name, func(t *testing.T) {
|
||||
const readers = 8
|
||||
const perReader = 25
|
||||
var (
|
||||
mu sync.Mutex
|
||||
samples []time.Duration
|
||||
wg sync.WaitGroup
|
||||
)
|
||||
wg.Add(readers)
|
||||
for r := 0; r < readers; r++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < perReader; i++ {
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, ep.path, nil)
|
||||
t0 := time.Now()
|
||||
ep.handler(rr, req)
|
||||
dt := time.Since(t0)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("%s status = %d, want 200", ep.path, rr.Code)
|
||||
}
|
||||
mu.Lock()
|
||||
samples = append(samples, dt)
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
sort.Slice(samples, func(i, j int) bool { return samples[i] < samples[j] })
|
||||
p99 := samples[int(float64(len(samples))*0.99)]
|
||||
if p99 > 100*time.Millisecond {
|
||||
t.Fatalf("%s p99 latency = %v over %d reqs, want <100ms (recomputer snapshot)", ep.path, p99, len(samples))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -708,9 +708,9 @@ func TestBimodalClock_845(t *testing.T) {
|
||||
|
||||
baseObs := int64(1700000000)
|
||||
var txs []*StoreTx
|
||||
// 6 good samples (-5s each), 4 bad samples (-50000000s each) = 60% good
|
||||
// 6 good samples (-5s each), 4 bad samples (-7200s each) = 60% good
|
||||
// Interleave so the recent window (last 5) captures both good and bad.
|
||||
skews := []int64{-5, -5, -50000000, -5, -50000000, -5, -50000000, -5, -50000000, -5}
|
||||
skews := []int64{-5, -5, -7200, -5, -7200, -5, -7200, -5, -7200, -5}
|
||||
for i := 0; i < 10; i++ {
|
||||
obsTS := baseObs + int64(i)*60
|
||||
advTS := obsTS + skews[i]
|
||||
@@ -794,14 +794,14 @@ func TestMostlyGood_OK_845(t *testing.T) {
|
||||
|
||||
baseObs := int64(1700000000)
|
||||
var txs []*StoreTx
|
||||
// 9 good at -5s, 1 bad at -50000000s
|
||||
// 9 good at -5s, 1 bad at -7200s
|
||||
for i := 0; i < 10; i++ {
|
||||
obsTS := baseObs + int64(i)*60
|
||||
var skew int64
|
||||
if i < 9 {
|
||||
skew = -5
|
||||
} else {
|
||||
skew = -50000000
|
||||
skew = -7200
|
||||
}
|
||||
advTS := obsTS + skew
|
||||
tx := &StoreTx{
|
||||
@@ -882,7 +882,7 @@ func TestFiftyFifty_Bimodal_845(t *testing.T) {
|
||||
if i%2 == 0 {
|
||||
skew = -10
|
||||
} else {
|
||||
skew = -50000000
|
||||
skew = -7200
|
||||
}
|
||||
tx := &StoreTx{
|
||||
Hash: fmt.Sprintf("fifty-%04d", i),
|
||||
|
||||
@@ -33,7 +33,7 @@ func TestCollisionDetailsIncludeNodePairs(t *testing.T) {
|
||||
store := NewPacketStore(db, nil)
|
||||
store.Load()
|
||||
|
||||
result := store.GetAnalyticsHashCollisions("")
|
||||
result := store.GetAnalyticsHashCollisions("", "")
|
||||
bySize, ok := result["by_size"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("expected by_size map")
|
||||
@@ -109,7 +109,7 @@ func TestCollisionDetailsEmptyWhenNoCollisions(t *testing.T) {
|
||||
store := NewPacketStore(db, nil)
|
||||
store.Load()
|
||||
|
||||
result := store.GetAnalyticsHashCollisions("")
|
||||
result := store.GetAnalyticsHashCollisions("", "")
|
||||
bySize, ok := result["by_size"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("expected by_size map")
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"compress/gzip"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// gzipWriterPool pools *gzip.Writer instances to avoid the ~256KB sliding
|
||||
// window allocation on every compressed response. Writers are Reset() to the
|
||||
// new underlying writer on Get and returned via gzipPut after Close.
|
||||
//
|
||||
// We use a bounded buffered channel rather than sync.Pool because sync.Pool
|
||||
// is aggressively reaped by the GC (full clear after two GC cycles), which
|
||||
// makes it lose its pooled entries under any workload that triggers GC —
|
||||
// notably the -race-enabled test suite where allocations are inflated ~8x
|
||||
// and GC fires repeatedly during a 200-request loop. A channel keeps the
|
||||
// gzip.Writer instances live across GC cycles, which is exactly the
|
||||
// guarantee `TestGZipMiddleware_PoolReusesWriters` asserts.
|
||||
const gzipPoolCapacity = 64
|
||||
|
||||
var gzipWriterPool = make(chan *gzip.Writer, gzipPoolCapacity)
|
||||
|
||||
func gzipGet() *gzip.Writer {
|
||||
select {
|
||||
case gz := <-gzipWriterPool:
|
||||
return gz
|
||||
default:
|
||||
// gzip.NewWriterLevel only errors on invalid level; DefaultCompression
|
||||
// is always valid, so the error branch is unreachable. Fall back to
|
||||
// the default writer (same level) so we always return a usable writer.
|
||||
gz, err := gzip.NewWriterLevel(discardWriter{}, gzip.DefaultCompression)
|
||||
if err != nil {
|
||||
return gzip.NewWriter(discardWriter{})
|
||||
}
|
||||
return gz
|
||||
}
|
||||
}
|
||||
|
||||
func gzipPut(gz *gzip.Writer) {
|
||||
// Reset to a no-op writer so the pooled instance does not retain a
|
||||
// reference to the previous http.ResponseWriter (which would defeat GC
|
||||
// of the request's allocations).
|
||||
gz.Reset(discardWriter{})
|
||||
select {
|
||||
case gzipWriterPool <- gz:
|
||||
default:
|
||||
// Pool full; drop the writer and let GC reclaim it.
|
||||
}
|
||||
}
|
||||
|
||||
type discardWriter struct{}
|
||||
|
||||
func (discardWriter) Write(p []byte) (int, error) { return len(p), nil }
|
||||
|
||||
// defaultCompressibleTypes is the conservative allow-list of MIME types the
|
||||
// middleware will gzip-encode. Anything already compressed (images, video,
|
||||
// fonts, octet-stream, x-gzip, …) bypasses the encoder entirely.
|
||||
var defaultCompressibleTypes = []string{
|
||||
"application/json",
|
||||
"application/javascript",
|
||||
"application/x-javascript",
|
||||
"application/xml",
|
||||
"text/html",
|
||||
"text/css",
|
||||
"text/plain",
|
||||
"text/xml",
|
||||
"image/svg+xml",
|
||||
}
|
||||
|
||||
// gzipResponseWriter wraps http.ResponseWriter and compresses Write() output
|
||||
// only when the response Content-Type matches the configured allow-list and
|
||||
// no upstream handler has already set Content-Encoding. It also propagates
|
||||
// Flush / Hijack to the underlying writer (required for SSE and WebSocket).
|
||||
type gzipResponseWriter struct {
|
||||
http.ResponseWriter
|
||||
gz *gzip.Writer
|
||||
level int
|
||||
allowedTypes []string
|
||||
wroteHeader bool
|
||||
compressActive bool
|
||||
}
|
||||
|
||||
// init lazily decides per response whether to compress, based on the response
|
||||
// headers the inner handler has set. We must defer this until WriteHeader (or
|
||||
// the first Write call) because Content-Type is set by the handler, not the
|
||||
// middleware.
|
||||
func (g *gzipResponseWriter) init() {
|
||||
if g.wroteHeader {
|
||||
return
|
||||
}
|
||||
g.wroteHeader = true
|
||||
|
||||
h := g.ResponseWriter.Header()
|
||||
// Don't double-encode.
|
||||
if h.Get("Content-Encoding") != "" {
|
||||
g.compressActive = false
|
||||
return
|
||||
}
|
||||
if !isCompressibleContentType(h.Get("Content-Type"), g.allowedTypes) {
|
||||
g.compressActive = false
|
||||
return
|
||||
}
|
||||
|
||||
// Lease a writer from the pool and rebind it to the real ResponseWriter.
|
||||
gz := gzipGet()
|
||||
gz.Reset(g.ResponseWriter)
|
||||
g.gz = gz
|
||||
g.compressActive = true
|
||||
|
||||
h.Set("Content-Encoding", "gzip")
|
||||
h.Add("Vary", "Accept-Encoding")
|
||||
// gzip stream length is unknown — strip any precomputed length.
|
||||
h.Del("Content-Length")
|
||||
}
|
||||
|
||||
func (g *gzipResponseWriter) WriteHeader(code int) {
|
||||
g.init()
|
||||
g.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (g *gzipResponseWriter) Write(b []byte) (int, error) {
|
||||
g.init()
|
||||
if !g.compressActive {
|
||||
return g.ResponseWriter.Write(b)
|
||||
}
|
||||
return g.gz.Write(b)
|
||||
}
|
||||
|
||||
// Flush propagates to the underlying writer so SSE / streaming handlers can
|
||||
// push chunks to the client immediately. We must also flush the gzip writer
|
||||
// when active, otherwise the buffered DEFLATE block never reaches the wire.
|
||||
func (g *gzipResponseWriter) Flush() {
|
||||
if g.compressActive && g.gz != nil {
|
||||
_ = g.gz.Flush()
|
||||
}
|
||||
if f, ok := g.ResponseWriter.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
// Hijack delegates to the underlying writer's Hijacker. We refuse to hijack a
|
||||
// connection that has already started a gzip stream — that would leave the
|
||||
// caller with a half-written DEFLATE block.
|
||||
func (g *gzipResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||
if h, ok := g.ResponseWriter.(http.Hijacker); ok {
|
||||
return h.Hijack()
|
||||
}
|
||||
return nil, nil, http.ErrNotSupported
|
||||
}
|
||||
|
||||
// close releases the pooled gzip.Writer back to the pool.
|
||||
func (g *gzipResponseWriter) close() {
|
||||
if g.gz == nil {
|
||||
return
|
||||
}
|
||||
_ = g.gz.Close()
|
||||
gzipPut(g.gz)
|
||||
g.gz = nil
|
||||
}
|
||||
|
||||
// isCompressibleContentType returns true if ct matches one of allow (which
|
||||
// is the configured allow-list, or defaultCompressibleTypes). Matching is
|
||||
// done on the bare MIME type, ignoring any "; charset=..." parameters.
|
||||
func isCompressibleContentType(ct string, allow []string) bool {
|
||||
if ct == "" {
|
||||
// No content-type set → handler hasn't decided yet. Refuse to
|
||||
// compress; we cannot guess. Most real handlers set Content-Type
|
||||
// before the first Write.
|
||||
return false
|
||||
}
|
||||
mt := ct
|
||||
if idx := strings.Index(mt, ";"); idx >= 0 {
|
||||
mt = mt[:idx]
|
||||
}
|
||||
mt = strings.TrimSpace(strings.ToLower(mt))
|
||||
|
||||
// Hard skip: anything that is already compressed.
|
||||
if strings.HasPrefix(mt, "image/") && mt != "image/svg+xml" {
|
||||
return false
|
||||
}
|
||||
if strings.HasPrefix(mt, "video/") || strings.HasPrefix(mt, "audio/") {
|
||||
return false
|
||||
}
|
||||
switch mt {
|
||||
case "application/x-gzip", "application/gzip", "application/zip",
|
||||
"application/x-bzip2", "application/x-7z-compressed",
|
||||
"application/x-rar-compressed", "application/x-zstd",
|
||||
"application/octet-stream", "application/pdf":
|
||||
return false
|
||||
}
|
||||
|
||||
if len(allow) == 0 {
|
||||
allow = defaultCompressibleTypes
|
||||
}
|
||||
for _, a := range allow {
|
||||
if strings.EqualFold(mt, a) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// gzipMiddleware compresses HTTP responses when the client supports gzip and
|
||||
// the response Content-Type is in the allow-list. WebSocket upgrade requests
|
||||
// pass through unmodified. The middleware uses the default allow-list and
|
||||
// gzip.DefaultCompression — for configurable behaviour use
|
||||
// gzipMiddlewareWithConfig.
|
||||
func gzipMiddleware(next http.Handler) http.Handler {
|
||||
return gzipMiddlewareWithConfig(nil, next)
|
||||
}
|
||||
|
||||
// gzipMiddlewareWithConfig is the configurable form of gzipMiddleware. When
|
||||
// cfg is nil, defaults (gzip.DefaultCompression, defaultCompressibleTypes)
|
||||
// are used.
|
||||
func gzipMiddlewareWithConfig(cfg *CompressionConfig, next http.Handler) http.Handler {
|
||||
level := gzip.DefaultCompression
|
||||
var allow []string
|
||||
if cfg != nil {
|
||||
if cfg.Level >= gzip.BestSpeed && cfg.Level <= gzip.BestCompression {
|
||||
level = cfg.Level
|
||||
}
|
||||
if len(cfg.ContentTypes) > 0 {
|
||||
allow = cfg.ContentTypes
|
||||
}
|
||||
}
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if strings.EqualFold(r.Header.Get("Upgrade"), "websocket") {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
grw := &gzipResponseWriter{
|
||||
ResponseWriter: w,
|
||||
level: level,
|
||||
allowedTypes: allow,
|
||||
}
|
||||
defer grw.close()
|
||||
next.ServeHTTP(grw, r)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package main
|
||||
|
||||
// Tests added in response to PR #934 review findings. These tests demonstrate
|
||||
// the four behaviors the original implementation lacked:
|
||||
//
|
||||
// 1. gzipResponseWriter must implement http.Flusher (SSE / streaming).
|
||||
// 2. gzipResponseWriter must implement http.Hijacker (WebSocket / raw conn).
|
||||
// 3. gzip.Writer instances must be pooled (sync.Pool) to avoid the
|
||||
// ~256KB window allocation per request.
|
||||
// 4. A content-type allow-list must skip already-compressed payloads
|
||||
// (images, video, application/x-gzip, …) and must skip responses
|
||||
// whose handler already set its own Content-Encoding header.
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGZipResponseWriter_ImplementsFlusher(t *testing.T) {
|
||||
seen := false
|
||||
handler := gzipMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := w.(http.Flusher); ok {
|
||||
seen = true
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"ok":true}`))
|
||||
}))
|
||||
req := httptest.NewRequest("GET", "/api/events", nil)
|
||||
req.Header.Set("Accept-Encoding", "gzip")
|
||||
handler.ServeHTTP(httptest.NewRecorder(), req)
|
||||
if !seen {
|
||||
t.Error("gzipResponseWriter must implement http.Flusher (required for SSE / streaming endpoints)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGZipResponseWriter_ImplementsHijacker(t *testing.T) {
|
||||
seen := false
|
||||
handler := gzipMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := w.(http.Hijacker); ok {
|
||||
seen = true
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{}`))
|
||||
}))
|
||||
srv := httptest.NewServer(handler)
|
||||
defer srv.Close()
|
||||
req, _ := http.NewRequest("GET", srv.URL+"/api/x", nil)
|
||||
req.Header.Set("Accept-Encoding", "gzip")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if !seen {
|
||||
t.Error("gzipResponseWriter must implement http.Hijacker (required for raw conn / WebSocket upgrade)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGZipMiddleware_SkipsImageContentType(t *testing.T) {
|
||||
payload := strings.Repeat("\x89PNGfakebinary", 64)
|
||||
handler := gzipMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
w.Write([]byte(payload))
|
||||
}))
|
||||
req := httptest.NewRequest("GET", "/tiles/1.png", nil)
|
||||
req.Header.Set("Accept-Encoding", "gzip")
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
if got := rr.Header().Get("Content-Encoding"); got == "gzip" {
|
||||
t.Errorf("image/png responses must NOT be gzip-encoded, got Content-Encoding=%q", got)
|
||||
}
|
||||
if rr.Body.String() != payload {
|
||||
t.Errorf("image body was mutated; expected pass-through")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGZipMiddleware_SkipsAlreadyEncodedResponses(t *testing.T) {
|
||||
handler := gzipMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Content-Encoding", "br")
|
||||
w.Write([]byte("alreadybrotlied"))
|
||||
}))
|
||||
req := httptest.NewRequest("GET", "/api/x", nil)
|
||||
req.Header.Set("Accept-Encoding", "gzip")
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
if got := rr.Header().Get("Content-Encoding"); got != "br" {
|
||||
t.Errorf("handler-set Content-Encoding must be preserved, got %q (gzip middleware double-wrapped)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGZipMiddleware_AllowsJSON(t *testing.T) {
|
||||
body := `{"nodes":[{"id":"abc"}]}`
|
||||
handler := gzipMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Write([]byte(body))
|
||||
}))
|
||||
req := httptest.NewRequest("GET", "/api/nodes", nil)
|
||||
req.Header.Set("Accept-Encoding", "gzip")
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
if rr.Header().Get("Content-Encoding") != "gzip" {
|
||||
t.Fatalf("application/json must still be compressed, got %q", rr.Header().Get("Content-Encoding"))
|
||||
}
|
||||
gz, err := gzip.NewReader(rr.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("invalid gzip: %v", err)
|
||||
}
|
||||
defer gz.Close()
|
||||
decoded, _ := io.ReadAll(gz)
|
||||
if string(decoded) != body {
|
||||
t.Errorf("decoded=%q, want %q", string(decoded), body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGZipMiddleware_PoolReusesWriters(t *testing.T) {
|
||||
body := strings.Repeat("x", 1024)
|
||||
handler := gzipMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(body))
|
||||
}))
|
||||
// Warm the pool: first N requests pay the one-time allocation cost.
|
||||
for i := 0; i < 16; i++ {
|
||||
req := httptest.NewRequest("GET", "/api", nil)
|
||||
req.Header.Set("Accept-Encoding", "gzip")
|
||||
handler.ServeHTTP(httptest.NewRecorder(), req)
|
||||
}
|
||||
runtime.GC()
|
||||
var before runtime.MemStats
|
||||
runtime.ReadMemStats(&before)
|
||||
|
||||
const N = 200
|
||||
for i := 0; i < N; i++ {
|
||||
req := httptest.NewRequest("GET", "/api", nil)
|
||||
req.Header.Set("Accept-Encoding", "gzip")
|
||||
handler.ServeHTTP(httptest.NewRecorder(), req)
|
||||
}
|
||||
var after runtime.MemStats
|
||||
runtime.ReadMemStats(&after)
|
||||
allocBytes := after.TotalAlloc - before.TotalAlloc
|
||||
|
||||
// Each gzip.Writer carries a ~256KB sliding window. Without a sync.Pool,
|
||||
// N=200 requests allocate roughly N * 256KB = 50MB. With a pool the
|
||||
// per-request alloc footprint should be a tiny fraction of that.
|
||||
// 10MB ceiling gives generous headroom for testing.AllocsPerRun noise
|
||||
// while still catching a regression to the unpooled implementation.
|
||||
if allocBytes > 10*1024*1024 {
|
||||
t.Errorf("gzip.Writer not pooled: %d bytes allocated across %d requests (expected ≤10MB)", allocBytes, N)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCompressionConfigDefaults(t *testing.T) {
|
||||
cfg := &Config{}
|
||||
if cfg.GZipEnabled() {
|
||||
t.Error("GZipEnabled should be false when compression is nil")
|
||||
}
|
||||
if cfg.WSCompressionEnabled() {
|
||||
t.Error("WSCompressionEnabled should be false when compression is nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompressionConfigExplicitFalse(t *testing.T) {
|
||||
cfg := &Config{Compression: &CompressionConfig{GZip: false, Websocket: false}}
|
||||
if cfg.GZipEnabled() {
|
||||
t.Error("GZipEnabled should be false")
|
||||
}
|
||||
if cfg.WSCompressionEnabled() {
|
||||
t.Error("WSCompressionEnabled should be false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompressionConfigEnabled(t *testing.T) {
|
||||
cfg := &Config{Compression: &CompressionConfig{GZip: true, Websocket: true}}
|
||||
if !cfg.GZipEnabled() {
|
||||
t.Error("GZipEnabled should be true")
|
||||
}
|
||||
if !cfg.WSCompressionEnabled() {
|
||||
t.Error("WSCompressionEnabled should be true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGZipMiddlewareCompresses(t *testing.T) {
|
||||
body := `{"nodes":[{"id":"abc"}]}`
|
||||
handler := gzipMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(body))
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/nodes", nil)
|
||||
req.Header.Set("Accept-Encoding", "gzip")
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Header().Get("Content-Encoding") != "gzip" {
|
||||
t.Errorf("expected Content-Encoding: gzip, got %q", rr.Header().Get("Content-Encoding"))
|
||||
}
|
||||
if rr.Header().Get("Vary") != "Accept-Encoding" {
|
||||
t.Errorf("expected Vary: Accept-Encoding, got %q", rr.Header().Get("Vary"))
|
||||
}
|
||||
gz, err := gzip.NewReader(rr.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("response is not valid gzip: %v", err)
|
||||
}
|
||||
defer gz.Close()
|
||||
decoded, err := io.ReadAll(gz)
|
||||
if err != nil {
|
||||
t.Fatalf("reading gzip: %v", err)
|
||||
}
|
||||
if string(decoded) != body {
|
||||
t.Errorf("decompressed body = %q, want %q", string(decoded), body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGZipMiddlewareSkipsNoAcceptEncoding(t *testing.T) {
|
||||
handler := gzipMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("hello"))
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/nodes", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Header().Get("Content-Encoding") != "" {
|
||||
t.Errorf("expected no Content-Encoding, got %q", rr.Header().Get("Content-Encoding"))
|
||||
}
|
||||
if rr.Body.String() != "hello" {
|
||||
t.Errorf("expected plain body, got %q", rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGZipMiddlewareSkipsWebSocket(t *testing.T) {
|
||||
called := false
|
||||
handler := gzipMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
called = true
|
||||
w.Write([]byte("ws"))
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest("GET", "/ws", nil)
|
||||
req.Header.Set("Accept-Encoding", "gzip")
|
||||
req.Header.Set("Upgrade", "websocket")
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
if !called {
|
||||
t.Error("expected next handler to be called")
|
||||
}
|
||||
if rr.Header().Get("Content-Encoding") != "" {
|
||||
t.Errorf("WebSocket should not be gzip-encoded, got %q", rr.Header().Get("Content-Encoding"))
|
||||
}
|
||||
}
|
||||
+178
-4
@@ -2,16 +2,28 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/meshcore-analyzer/dbconfig"
|
||||
"github.com/meshcore-analyzer/geofilter"
|
||||
)
|
||||
|
||||
// AreaEntry defines a geographic area by polygon or bounding box.
|
||||
type AreaEntry struct {
|
||||
Label string `json:"label"`
|
||||
Polygon [][2]float64 `json:"polygon,omitempty"`
|
||||
LatMin *float64 `json:"latMin,omitempty"`
|
||||
LatMax *float64 `json:"latMax,omitempty"`
|
||||
LonMin *float64 `json:"lonMin,omitempty"`
|
||||
LonMax *float64 `json:"lonMax,omitempty"`
|
||||
}
|
||||
|
||||
// Config mirrors the Node.js config.json structure (read-only fields).
|
||||
type Config struct {
|
||||
Port int `json:"port"`
|
||||
@@ -69,6 +81,8 @@ type Config struct {
|
||||
|
||||
GeoFilter *GeoFilterConfig `json:"geo_filter,omitempty"`
|
||||
|
||||
Areas map[string]AreaEntry `json:"areas,omitempty"`
|
||||
|
||||
Timestamps *TimestampConfig `json:"timestamps,omitempty"`
|
||||
|
||||
// CORSAllowedOrigins is the list of origins permitted to make cross-origin
|
||||
@@ -87,9 +101,13 @@ type Config struct {
|
||||
obsBlacklistSetCached map[string]bool
|
||||
obsBlacklistOnce sync.Once
|
||||
|
||||
Compression *CompressionConfig `json:"compression,omitempty"`
|
||||
ResolvedPath *ResolvedPathConfig `json:"resolvedPath,omitempty"`
|
||||
NeighborGraph *NeighborGraphConfig `json:"neighborGraph,omitempty"`
|
||||
|
||||
// Analytics steady-state background recompute (issue #1240).
|
||||
Analytics *AnalyticsConfig `json:"analytics,omitempty"`
|
||||
|
||||
// BatteryThresholds: voltage cutoffs for low/critical alerts (#663).
|
||||
BatteryThresholds *BatteryThresholdsConfig `json:"batteryThresholds,omitempty"`
|
||||
}
|
||||
@@ -122,6 +140,39 @@ func IsWeakAPIKey(key string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// CompressionConfig controls HTTP gzip and WebSocket permessage-deflate compression.
|
||||
// Both are disabled by default — enable only when the upstream proxy does not already compress.
|
||||
type CompressionConfig struct {
|
||||
GZip bool `json:"gzip"`
|
||||
Websocket bool `json:"websocket"`
|
||||
|
||||
// Level is the gzip compression level (1=BestSpeed … 9=BestCompression).
|
||||
// 0 / out-of-range means "use compress/gzip.DefaultCompression".
|
||||
Level int `json:"level,omitempty"`
|
||||
|
||||
// MinSizeBytes is an advisory minimum response size below which gzip
|
||||
// would not pay off. Currently informational — kept here so operators
|
||||
// can express intent and so future small-body fast-paths can use it.
|
||||
MinSizeBytes int `json:"minSizeBytes,omitempty"`
|
||||
|
||||
// ContentTypes overrides the default compressible-MIME allow-list. When
|
||||
// empty, a conservative default (application/json, text/html, text/css,
|
||||
// application/javascript, text/plain, image/svg+xml, application/xml)
|
||||
// is used. Already-compressed types (image/*, video/*, application/zip,
|
||||
// application/x-gzip, …) are always skipped.
|
||||
ContentTypes []string `json:"contentTypes,omitempty"`
|
||||
}
|
||||
|
||||
// GZipEnabled returns true when HTTP gzip compression is explicitly enabled.
|
||||
func (c *Config) GZipEnabled() bool {
|
||||
return c.Compression != nil && c.Compression.GZip
|
||||
}
|
||||
|
||||
// WSCompressionEnabled returns true when WebSocket permessage-deflate is explicitly enabled.
|
||||
func (c *Config) WSCompressionEnabled() bool {
|
||||
return c.Compression != nil && c.Compression.Websocket
|
||||
}
|
||||
|
||||
// ResolvedPathConfig controls async backfill behavior.
|
||||
type ResolvedPathConfig struct {
|
||||
BackfillHours int `json:"backfillHours"` // how far back (hours) to scan for NULL resolved_path (default 24)
|
||||
@@ -129,14 +180,16 @@ type ResolvedPathConfig struct {
|
||||
|
||||
// NeighborGraphConfig controls neighbor edge pruning.
|
||||
type NeighborGraphConfig struct {
|
||||
MaxAgeDays int `json:"maxAgeDays"` // edges older than this are pruned (default 5)
|
||||
MaxAgeDays int `json:"maxAgeDays"` // edges older than this are pruned (default 5)
|
||||
MaxEdgeKm float64 `json:"maxEdgeKm"` // geo-implausibility threshold (km); 0 = default 500; negative disables (#1228)
|
||||
}
|
||||
|
||||
// PacketStoreConfig controls in-memory packet store limits.
|
||||
type PacketStoreConfig struct {
|
||||
RetentionHours float64 `json:"retentionHours"` // max age of packets in hours (0 = unlimited)
|
||||
MaxMemoryMB int `json:"maxMemoryMB"` // hard memory ceiling in MB (0 = unlimited)
|
||||
MaxResolvedPubkeyIndexEntries int `json:"maxResolvedPubkeyIndexEntries"` // warning threshold for index size (0 = 5M default)
|
||||
RetentionHours float64 `json:"retentionHours"` // max age of packets in hours (0 = unlimited)
|
||||
MaxMemoryMB int `json:"maxMemoryMB"` // hard memory ceiling in MB (0 = unlimited)
|
||||
MaxResolvedPubkeyIndexEntries int `json:"maxResolvedPubkeyIndexEntries"` // warning threshold for index size (0 = 5M default)
|
||||
HotStartupHours float64 `json:"hotStartupHours"` // load only this many hours synchronously; 0 = disabled
|
||||
}
|
||||
|
||||
// GeoFilterConfig is an alias for the shared geofilter.Config type.
|
||||
@@ -184,6 +237,19 @@ func (c *Config) NeighborMaxAgeDays() int {
|
||||
return 5
|
||||
}
|
||||
|
||||
// NeighborMaxEdgeKm returns the geo-implausibility threshold in km.
|
||||
// 0 (unset) → DefaultMaxEdgeKm (500). Negative → 0 (filter disabled).
|
||||
// See issue #1228.
|
||||
func (c *Config) NeighborMaxEdgeKm() float64 {
|
||||
if c == nil || c.NeighborGraph == nil || c.NeighborGraph.MaxEdgeKm == 0 {
|
||||
return DefaultMaxEdgeKm
|
||||
}
|
||||
if c.NeighborGraph.MaxEdgeKm < 0 {
|
||||
return 0
|
||||
}
|
||||
return c.NeighborGraph.MaxEdgeKm
|
||||
}
|
||||
|
||||
type TimestampConfig struct {
|
||||
DefaultMode string `json:"defaultMode"` // "ago" | "absolute"
|
||||
Timezone string `json:"timezone"` // "local" | "utc"
|
||||
@@ -428,6 +494,62 @@ func (c *Config) IsBlacklisted(pubkey string) bool {
|
||||
return c.blacklistSet()[strings.ToLower(strings.TrimSpace(pubkey))]
|
||||
}
|
||||
|
||||
// SaveGeoFilter writes the geo_filter section back to config.json on disk.
|
||||
// Pass gf=nil to remove the filter. The rest of config.json is preserved as-is.
|
||||
func SaveGeoFilter(configDir string, gf *GeoFilterConfig) error {
|
||||
var configPath string
|
||||
for _, p := range []string{
|
||||
filepath.Join(configDir, "config.json"),
|
||||
filepath.Join(configDir, "data", "config.json"),
|
||||
} {
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
configPath = p
|
||||
break
|
||||
}
|
||||
}
|
||||
if configPath == "" {
|
||||
return fmt.Errorf("config.json not found in %s", configDir)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read config: %w", err)
|
||||
}
|
||||
|
||||
// Parse as a raw map so non-struct fields (_comment, etc.) are preserved.
|
||||
var raw map[string]interface{}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return fmt.Errorf("parse config: %w", err)
|
||||
}
|
||||
|
||||
if gf == nil || len(gf.Polygon) == 0 {
|
||||
delete(raw, "geo_filter")
|
||||
} else {
|
||||
// Round-trip through JSON to get a plain interface{} value.
|
||||
b, _ := json.Marshal(gf)
|
||||
var v interface{}
|
||||
_ = json.Unmarshal(b, &v)
|
||||
raw["geo_filter"] = v
|
||||
}
|
||||
|
||||
out, err := json.MarshalIndent(raw, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal config: %w", err)
|
||||
}
|
||||
out = append(out, '\n')
|
||||
|
||||
// Atomic write: temp file + rename.
|
||||
tmp := configPath + ".tmp"
|
||||
if err := os.WriteFile(tmp, out, 0644); err != nil {
|
||||
return fmt.Errorf("write config: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmp, configPath); err != nil {
|
||||
os.Remove(tmp)
|
||||
return fmt.Errorf("rename config: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// obsBlacklistSet lazily builds and caches the observerBlacklist as a set for O(1) lookups.
|
||||
func (c *Config) obsBlacklistSet() map[string]bool {
|
||||
c.obsBlacklistOnce.Do(func() {
|
||||
@@ -453,3 +575,55 @@ func (c *Config) IsObserverBlacklisted(id string) bool {
|
||||
}
|
||||
return c.obsBlacklistSet()[strings.ToLower(strings.TrimSpace(id))]
|
||||
}
|
||||
|
||||
// AnalyticsConfig controls steady-state background recompute of
|
||||
// analytics endpoints (issue #1240).
|
||||
//
|
||||
// DefaultIntervalSeconds applies to every endpoint that does not have
|
||||
// an explicit per-endpoint override in RecomputeIntervalSeconds. The
|
||||
// project default is 300 (5 minutes): the operator's guiding principle
|
||||
// is "serving slightly stale data quickly is better than real-time
|
||||
// data slowly." Lower values give fresher data at higher CPU cost.
|
||||
//
|
||||
// RecomputeIntervalSeconds keys (all optional):
|
||||
// topology, rf, distance, channels, hashCollisions, hashSizes, roles, observersClockSkew, nodesClockSkew
|
||||
type AnalyticsConfig struct {
|
||||
DefaultIntervalSeconds int `json:"defaultIntervalSeconds,omitempty"`
|
||||
RecomputeIntervalSeconds map[string]int `json:"recomputeIntervalSeconds,omitempty"`
|
||||
}
|
||||
|
||||
// AnalyticsDefaultRecomputeInterval returns the configured default
|
||||
// recompute interval, or 5 minutes if unset/invalid.
|
||||
func (c *Config) AnalyticsDefaultRecomputeInterval() time.Duration {
|
||||
if c != nil && c.Analytics != nil && c.Analytics.DefaultIntervalSeconds > 0 {
|
||||
return time.Duration(c.Analytics.DefaultIntervalSeconds) * time.Second
|
||||
}
|
||||
return 5 * time.Minute
|
||||
}
|
||||
|
||||
// AnalyticsRecomputeIntervals returns the per-endpoint override map.
|
||||
// Returns the zero value (all defaults) if the analytics block is
|
||||
// absent or empty.
|
||||
func (c *Config) AnalyticsRecomputeIntervals() AnalyticsRecomputeIntervals {
|
||||
out := AnalyticsRecomputeIntervals{}
|
||||
if c == nil || c.Analytics == nil || c.Analytics.RecomputeIntervalSeconds == nil {
|
||||
return out
|
||||
}
|
||||
get := func(key string) time.Duration {
|
||||
v, ok := c.Analytics.RecomputeIntervalSeconds[key]
|
||||
if !ok || v <= 0 {
|
||||
return 0
|
||||
}
|
||||
return time.Duration(v) * time.Second
|
||||
}
|
||||
out.Topology = get("topology")
|
||||
out.RF = get("rf")
|
||||
out.Distance = get("distance")
|
||||
out.Channels = get("channels")
|
||||
out.HashCollisions = get("hashCollisions")
|
||||
out.HashSizes = get("hashSizes")
|
||||
out.Roles = get("roles")
|
||||
out.ObserversClockSkew = get("observersClockSkew")
|
||||
out.NodesClockSkew = get("nodesClockSkew")
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
func TestBackfillHoursDefault(t *testing.T) {
|
||||
@@ -72,106 +68,3 @@ func TestGraphPruneOlderThan(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneNeighborEdgesDB(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
dbPath := filepath.Join(tmpDir, "test.db")
|
||||
db, err := sql.Open("sqlite", "file:"+dbPath+"?_journal_mode=WAL")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
_, err = db.Exec(`CREATE TABLE neighbor_edges (
|
||||
node_a TEXT NOT NULL,
|
||||
node_b TEXT NOT NULL,
|
||||
count INTEGER DEFAULT 1,
|
||||
last_seen TEXT,
|
||||
PRIMARY KEY (node_a, node_b)
|
||||
)`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
old := now.Add(-60 * 24 * time.Hour)
|
||||
|
||||
db.Exec("INSERT INTO neighbor_edges (node_a, node_b, count, last_seen) VALUES (?, ?, 5, ?)",
|
||||
"aaa", "bbb", now.Format(time.RFC3339))
|
||||
db.Exec("INSERT INTO neighbor_edges (node_a, node_b, count, last_seen) VALUES (?, ?, 3, ?)",
|
||||
"ccc", "ddd", old.Format(time.RFC3339))
|
||||
|
||||
g := NewNeighborGraph()
|
||||
g.upsertEdge("aaa", "bbb", "bb", "obs1", nil, now)
|
||||
g.upsertEdge("ccc", "ddd", "dd", "obs1", nil, old)
|
||||
|
||||
pruned, err := PruneNeighborEdges(dbPath, g, 30)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if pruned != 1 {
|
||||
t.Errorf("PruneNeighborEdges pruned %d DB rows, want 1", pruned)
|
||||
}
|
||||
|
||||
var count int
|
||||
db.QueryRow("SELECT COUNT(*) FROM neighbor_edges").Scan(&count)
|
||||
if count != 1 {
|
||||
t.Errorf("expected 1 row in DB after prune, got %d", count)
|
||||
}
|
||||
|
||||
if len(g.AllEdges()) != 1 {
|
||||
t.Errorf("expected 1 in-memory edge after prune, got %d", len(g.AllEdges()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackfillRespectsHourWindow(t *testing.T) {
|
||||
store := &PacketStore{}
|
||||
|
||||
now := time.Now().UTC()
|
||||
oldTime := now.Add(-48 * time.Hour).Format(time.RFC3339Nano)
|
||||
newTime := now.Add(-30 * time.Minute).Format(time.RFC3339Nano)
|
||||
|
||||
store.packets = []*StoreTx{
|
||||
{
|
||||
ID: 1,
|
||||
Hash: "old-hash",
|
||||
FirstSeen: oldTime,
|
||||
Observations: []*StoreObs{
|
||||
{ID: 1, PathJSON: `["abc"]`},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: 2,
|
||||
Hash: "new-hash",
|
||||
FirstSeen: newTime,
|
||||
Observations: []*StoreObs{
|
||||
{ID: 2, PathJSON: `["def"]`},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// With a 1-hour window, only the new tx should be processed.
|
||||
// backfillResolvedPathsAsync will find no prefix map and finish quickly,
|
||||
// but we can verify the pending count reflects the window.
|
||||
go backfillResolvedPathsAsync(store, "", 100, time.Millisecond, 1)
|
||||
|
||||
// Wait for completion
|
||||
for i := 0; i < 100; i++ {
|
||||
if store.backfillComplete.Load() {
|
||||
break
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
|
||||
if !store.backfillComplete.Load() {
|
||||
t.Fatal("backfill did not complete")
|
||||
}
|
||||
|
||||
// With no prefix map, total should be 0 (early exit) or just the new one
|
||||
// The function exits early when pm == nil, so backfillTotal stays at 0
|
||||
// if there were pending items but no pm. Let's verify it didn't process
|
||||
// the old one by checking total <= 1.
|
||||
total := store.backfillTotal.Load()
|
||||
if total > 1 {
|
||||
t.Errorf("backfill total = %d, want <= 1 (old tx should be excluded by hour window)", total)
|
||||
}
|
||||
}
|
||||
|
||||
+31
-25
@@ -1334,8 +1334,11 @@ func TestBuildTransmissionWhereRFC3339(t *testing.T) {
|
||||
if len(args) != 1 {
|
||||
t.Errorf("expected 1 arg, got %d", len(args))
|
||||
}
|
||||
if !strings.Contains(where[0], "observations") {
|
||||
t.Error("expected observations subquery for RFC3339 since")
|
||||
// PR #1187 r2: RFC3339 since/until MUST use observations.timestamp
|
||||
// subquery so re-observed packets (older first_seen but recent
|
||||
// observation) are still included. Anything else breaks semantics.
|
||||
if !strings.Contains(where[0], "observations") || !strings.Contains(where[0], "timestamp >= ?") {
|
||||
t.Errorf("expected observations.timestamp subquery for RFC3339 since, got %q", where[0])
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1348,6 +1351,9 @@ func TestBuildTransmissionWhereRFC3339(t *testing.T) {
|
||||
if len(args) != 1 {
|
||||
t.Errorf("expected 1 arg, got %d", len(args))
|
||||
}
|
||||
if !strings.Contains(where[0], "observations") || !strings.Contains(where[0], "timestamp <= ?") {
|
||||
t.Errorf("expected observations.timestamp subquery for RFC3339 until, got %q", where[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-RFC3339 since", func(t *testing.T) {
|
||||
@@ -1356,8 +1362,8 @@ func TestBuildTransmissionWhereRFC3339(t *testing.T) {
|
||||
if len(where) != 1 {
|
||||
t.Errorf("expected 1 clause, got %d", len(where))
|
||||
}
|
||||
if strings.Contains(where[0], "observations") {
|
||||
t.Error("expected direct first_seen comparison for non-RFC3339")
|
||||
if !strings.Contains(where[0], "first_seen") {
|
||||
t.Error("expected first_seen comparison for non-RFC3339 since")
|
||||
}
|
||||
})
|
||||
|
||||
@@ -2172,7 +2178,7 @@ func TestStoreGetBulkHealthWithStore(t *testing.T) {
|
||||
store := NewPacketStore(db, nil)
|
||||
store.Load()
|
||||
|
||||
results := store.GetBulkHealth(50, "")
|
||||
results := store.GetBulkHealth(50, "", "")
|
||||
if len(results) == 0 {
|
||||
t.Error("expected bulk health results")
|
||||
}
|
||||
@@ -2187,7 +2193,7 @@ func TestStoreGetBulkHealthWithStore(t *testing.T) {
|
||||
}
|
||||
|
||||
t.Run("with region filter", func(t *testing.T) {
|
||||
results := store.GetBulkHealth(50, "SJC")
|
||||
results := store.GetBulkHealth(50, "SJC", "")
|
||||
_ = results
|
||||
})
|
||||
}
|
||||
@@ -2198,7 +2204,7 @@ func TestStoreGetAnalyticsHashSizes(t *testing.T) {
|
||||
store := NewPacketStore(db, nil)
|
||||
store.Load()
|
||||
|
||||
result := store.GetAnalyticsHashSizes("")
|
||||
result := store.GetAnalyticsHashSizes("", "")
|
||||
if result["total"] == nil {
|
||||
t.Error("expected total field")
|
||||
}
|
||||
@@ -2209,7 +2215,7 @@ func TestStoreGetAnalyticsHashSizes(t *testing.T) {
|
||||
_ = dist
|
||||
|
||||
t.Run("with region", func(t *testing.T) {
|
||||
r := store.GetAnalyticsHashSizes("SJC")
|
||||
r := store.GetAnalyticsHashSizes("SJC", "")
|
||||
_ = r
|
||||
})
|
||||
}
|
||||
@@ -2220,7 +2226,7 @@ func TestHashSizesDistributionByRepeatersFiltersRole(t *testing.T) {
|
||||
store := NewPacketStore(db, nil)
|
||||
store.Load()
|
||||
|
||||
result := store.GetAnalyticsHashSizes("")
|
||||
result := store.GetAnalyticsHashSizes("", "")
|
||||
|
||||
// distributionByRepeaters should only count repeater nodes.
|
||||
// Rich test DB: aabbccdd11223344 = repeater (hash size 2), eeff00112233aabb = companion (hash size 3).
|
||||
@@ -2417,13 +2423,13 @@ func TestStoreGetAnalyticsRFCacheHit(t *testing.T) {
|
||||
store.Load()
|
||||
|
||||
// First call — cache miss
|
||||
result1 := store.GetAnalyticsRF("")
|
||||
result1 := store.GetAnalyticsRF("", "")
|
||||
if result1["totalPackets"] == nil {
|
||||
t.Error("expected totalPackets")
|
||||
}
|
||||
|
||||
// Second call — should hit cache
|
||||
result2 := store.GetAnalyticsRF("")
|
||||
result2 := store.GetAnalyticsRF("", "")
|
||||
if result2["totalPackets"] == nil {
|
||||
t.Error("expected cached totalPackets")
|
||||
}
|
||||
@@ -2442,7 +2448,7 @@ func TestStoreGetAnalyticsTopology(t *testing.T) {
|
||||
store := NewPacketStore(db, nil)
|
||||
store.Load()
|
||||
|
||||
result := store.GetAnalyticsTopology("")
|
||||
result := store.GetAnalyticsTopology("", "")
|
||||
if result == nil {
|
||||
t.Error("expected non-nil result")
|
||||
}
|
||||
@@ -2461,7 +2467,7 @@ func TestStoreGetAnalyticsTopology(t *testing.T) {
|
||||
}
|
||||
|
||||
t.Run("with region", func(t *testing.T) {
|
||||
r := store.GetAnalyticsTopology("SJC")
|
||||
r := store.GetAnalyticsTopology("SJC", "")
|
||||
_ = r
|
||||
})
|
||||
}
|
||||
@@ -2472,7 +2478,7 @@ func TestStoreGetAnalyticsChannels(t *testing.T) {
|
||||
store := NewPacketStore(db, nil)
|
||||
store.Load()
|
||||
|
||||
result := store.GetAnalyticsChannels("")
|
||||
result := store.GetAnalyticsChannels("", "")
|
||||
if _, ok := result["activeChannels"]; !ok {
|
||||
t.Error("expected activeChannels")
|
||||
}
|
||||
@@ -2484,7 +2490,7 @@ func TestStoreGetAnalyticsChannels(t *testing.T) {
|
||||
}
|
||||
|
||||
t.Run("with region", func(t *testing.T) {
|
||||
r := store.GetAnalyticsChannels("SJC")
|
||||
r := store.GetAnalyticsChannels("SJC", "")
|
||||
_ = r
|
||||
})
|
||||
}
|
||||
@@ -2518,7 +2524,7 @@ func TestStoreGetAnalyticsChannelsNumericHash(t *testing.T) {
|
||||
|
||||
store := NewPacketStore(db, nil)
|
||||
store.Load()
|
||||
result := store.GetAnalyticsChannels("")
|
||||
result := store.GetAnalyticsChannels("", "")
|
||||
|
||||
channels := result["channels"].([]map[string]interface{})
|
||||
if len(channels) < 3 {
|
||||
@@ -2564,13 +2570,13 @@ func TestStoreGetAnalyticsDistance(t *testing.T) {
|
||||
store := NewPacketStore(db, nil)
|
||||
store.Load()
|
||||
|
||||
result := store.GetAnalyticsDistance("")
|
||||
result := store.GetAnalyticsDistance("", "")
|
||||
if result == nil {
|
||||
t.Error("expected non-nil result")
|
||||
}
|
||||
|
||||
t.Run("with region", func(t *testing.T) {
|
||||
r := store.GetAnalyticsDistance("SJC")
|
||||
r := store.GetAnalyticsDistance("SJC", "")
|
||||
_ = r
|
||||
})
|
||||
}
|
||||
@@ -2945,13 +2951,13 @@ func TestCacheHitTopology(t *testing.T) {
|
||||
store.Load()
|
||||
|
||||
// First call — cache miss
|
||||
r1 := store.GetAnalyticsTopology("")
|
||||
r1 := store.GetAnalyticsTopology("", "")
|
||||
if r1 == nil {
|
||||
t.Fatal("expected topology result")
|
||||
}
|
||||
|
||||
// Second call — cache hit
|
||||
r2 := store.GetAnalyticsTopology("")
|
||||
r2 := store.GetAnalyticsTopology("", "")
|
||||
if r2 == nil {
|
||||
t.Fatal("expected cached topology result")
|
||||
}
|
||||
@@ -2969,12 +2975,12 @@ func TestCacheHitHashSizes(t *testing.T) {
|
||||
store := NewPacketStore(db, nil)
|
||||
store.Load()
|
||||
|
||||
r1 := store.GetAnalyticsHashSizes("")
|
||||
r1 := store.GetAnalyticsHashSizes("", "")
|
||||
if r1 == nil {
|
||||
t.Fatal("expected hash sizes result")
|
||||
}
|
||||
|
||||
r2 := store.GetAnalyticsHashSizes("")
|
||||
r2 := store.GetAnalyticsHashSizes("", "")
|
||||
if r2 == nil {
|
||||
t.Fatal("expected cached hash sizes result")
|
||||
}
|
||||
@@ -2992,12 +2998,12 @@ func TestCacheHitChannels(t *testing.T) {
|
||||
store := NewPacketStore(db, nil)
|
||||
store.Load()
|
||||
|
||||
r1 := store.GetAnalyticsChannels("")
|
||||
r1 := store.GetAnalyticsChannels("", "")
|
||||
if r1 == nil {
|
||||
t.Fatal("expected channels result")
|
||||
}
|
||||
|
||||
r2 := store.GetAnalyticsChannels("")
|
||||
r2 := store.GetAnalyticsChannels("", "")
|
||||
if r2 == nil {
|
||||
t.Fatal("expected cached channels result")
|
||||
}
|
||||
@@ -3392,7 +3398,7 @@ func TestAnalyticsHashSizesZeroHopSkip(t *testing.T) {
|
||||
store := NewPacketStore(db, nil)
|
||||
store.Load()
|
||||
|
||||
result := store.GetAnalyticsHashSizes("")
|
||||
result := store.GetAnalyticsHashSizes("", "")
|
||||
|
||||
// The node should appear in multiByteNodes (hashSize=2 from the flood advert)
|
||||
// If the zero-hop bug is present, hashSize would be 1 and the node would NOT
|
||||
|
||||
+490
-206
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,96 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestGetChannelMessagesPerfLargeChannel asserts that fetching a small page
|
||||
// (limit=50) from a channel with many observations does not scan the full
|
||||
// observation set. Regression guard for issue #1225 where the query loaded
|
||||
// every observation row for the channel and deduped/paginated in Go memory.
|
||||
//
|
||||
// Dataset: 1500 transmissions in #perf, each with 50 observations
|
||||
// (75K obs total). Same shape as staging where #wardriving had ~5.7K tx
|
||||
// and ~275K obs — fewer rows are enough to demonstrate that the broken
|
||||
// impl (which loads/dedups every observation in Go) blows the budget,
|
||||
// while keeping setup fast enough for slower CI runners.
|
||||
//
|
||||
// On the broken implementation this takes multiple seconds (>2s on dev,
|
||||
// ~6–9s on GitHub-hosted CI). With SQL-level pagination over
|
||||
// transmissions it must complete well under the 1.5s budget
|
||||
// (~sub-100ms observed on dev).
|
||||
func TestGetChannelMessagesPerfLargeChannel(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("perf test")
|
||||
}
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
// Seed one observer.
|
||||
if _, err := db.conn.Exec(`INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count)
|
||||
VALUES ('obs_perf', 'PerfObs', 'SJC', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z', 0)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
const numTx = 1500
|
||||
const obsPerTx = 50
|
||||
|
||||
tx, err := db.conn.Begin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
txStmt, err := tx.Prepare(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
|
||||
VALUES (?, ?, ?, 1, 5, ?, '#perf')`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
obsStmt, err := tx.Prepare(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
|
||||
VALUES (?, 1, 10.0, -90, '[]', ?)`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base := time.Now().UTC().Add(-24 * time.Hour)
|
||||
for i := 0; i < numTx; i++ {
|
||||
ts := base.Add(time.Duration(i) * time.Second).Format(time.RFC3339)
|
||||
hash := fmt.Sprintf("perfhash%08d", i)
|
||||
body := fmt.Sprintf(`{"type":"CHAN","channel":"#perf","text":"Sender%d: msg %d","sender":"Sender%d"}`, i%10, i, i%10)
|
||||
res, err := txStmt.Exec(fmt.Sprintf("%04X", i), hash, ts, body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
txID, _ := res.LastInsertId()
|
||||
for o := 0; o < obsPerTx; o++ {
|
||||
if _, err := obsStmt.Exec(txID, base.Unix()+int64(i*100+o)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Warm-up call to amortize first-run prepare cost.
|
||||
if _, _, err := db.GetChannelMessages("#perf", 50, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
msgs, total, err := db.GetChannelMessages("#perf", 50, 0)
|
||||
elapsed := time.Since(start)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != numTx {
|
||||
t.Errorf("total: got %d want %d", total, numTx)
|
||||
}
|
||||
if len(msgs) != 50 {
|
||||
t.Errorf("page size: got %d want 50", len(msgs))
|
||||
}
|
||||
const budget = 1500 * time.Millisecond
|
||||
if elapsed > budget {
|
||||
t.Fatalf("GetChannelMessages too slow for #1225: %v (budget %v) on %d tx × %d obs",
|
||||
elapsed, budget, numTx, obsPerTx)
|
||||
}
|
||||
}
|
||||
@@ -1469,6 +1469,73 @@ func TestOpenDBInvalidPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetectSchemaScopeName verifies that OpenDB sets hasScopeName and
|
||||
// hasDefaultScope via the real detectSchema path when the columns are present.
|
||||
// The existing ScopeStats tests set these flags manually — this test ensures
|
||||
// the flag-setting code itself is covered.
|
||||
func TestDetectSchemaScopeName(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "detect.db")
|
||||
|
||||
// Create file-based DB with the scope_name and default_scope columns.
|
||||
conn, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conn.SetMaxOpenConns(1)
|
||||
if _, err := conn.Exec(`CREATE TABLE transmissions (id INTEGER PRIMARY KEY, hash TEXT, scope_name TEXT)`); err != nil {
|
||||
conn.Close()
|
||||
t.Fatalf("create transmissions: %v", err)
|
||||
}
|
||||
if _, err := conn.Exec(`CREATE TABLE nodes (public_key TEXT PRIMARY KEY, default_scope TEXT)`); err != nil {
|
||||
conn.Close()
|
||||
t.Fatalf("create nodes: %v", err)
|
||||
}
|
||||
if _, err := conn.Exec(`CREATE TABLE observations (id INTEGER PRIMARY KEY)`); err != nil {
|
||||
conn.Close()
|
||||
t.Fatalf("create observations: %v", err)
|
||||
}
|
||||
conn.Close()
|
||||
|
||||
db, err := OpenDB(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenDB: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if !db.hasScopeName {
|
||||
t.Error("hasScopeName should be true when scope_name column exists")
|
||||
}
|
||||
if !db.hasDefaultScope {
|
||||
t.Error("hasDefaultScope should be true when default_scope column exists")
|
||||
}
|
||||
|
||||
// Verify the flags stay false when the columns are absent.
|
||||
dbPath2 := filepath.Join(dir, "detect2.db")
|
||||
conn2, err := sql.Open("sqlite", dbPath2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conn2.SetMaxOpenConns(1)
|
||||
conn2.Exec(`CREATE TABLE transmissions (id INTEGER PRIMARY KEY, hash TEXT)`)
|
||||
conn2.Exec(`CREATE TABLE nodes (public_key TEXT PRIMARY KEY)`)
|
||||
conn2.Exec(`CREATE TABLE observations (id INTEGER PRIMARY KEY)`)
|
||||
conn2.Close()
|
||||
|
||||
db2, err := OpenDB(dbPath2)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenDB2: %v", err)
|
||||
}
|
||||
defer db2.Close()
|
||||
|
||||
if db2.hasScopeName {
|
||||
t.Error("hasScopeName should be false when scope_name column is absent")
|
||||
}
|
||||
if db2.hasDefaultScope {
|
||||
t.Error("hasDefaultScope should be false when default_scope column is absent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetChannelMessagesObserverFallback(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
@@ -2147,3 +2214,98 @@ func TestPerObservationRawHexEnrich(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetScopeStats(t *testing.T) {
|
||||
conn, err := sql.Open("sqlite", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("sql.Open: %v", err)
|
||||
}
|
||||
conn.SetMaxOpenConns(1)
|
||||
db := &DB{conn: conn}
|
||||
defer db.conn.Close()
|
||||
|
||||
// Create minimal schema
|
||||
db.conn.Exec(`CREATE TABLE IF NOT EXISTS transmissions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
raw_hex TEXT, hash TEXT, first_seen TEXT, route_type INTEGER,
|
||||
payload_type INTEGER, payload_version INTEGER, decoded_json TEXT,
|
||||
scope_name TEXT DEFAULT NULL
|
||||
)`)
|
||||
// Manually set hasScopeName since we bypassed the detector
|
||||
db.hasScopeName = true
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
// Transport scoped, known region
|
||||
db.conn.Exec(`INSERT INTO transmissions (hash, first_seen, route_type, scope_name) VALUES ('a', ?, 0, '#belgium')`, now)
|
||||
// Transport scoped, unknown
|
||||
db.conn.Exec(`INSERT INTO transmissions (hash, first_seen, route_type, scope_name) VALUES ('b', ?, 0, '')`, now)
|
||||
// Transport unscoped (NULL)
|
||||
db.conn.Exec(`INSERT INTO transmissions (hash, first_seen, route_type, scope_name) VALUES ('c', ?, 0, NULL)`, now)
|
||||
// Non-transport (should not count)
|
||||
db.conn.Exec(`INSERT INTO transmissions (hash, first_seen, route_type, scope_name) VALUES ('d', ?, 1, NULL)`, now)
|
||||
|
||||
stats, err := db.GetScopeStats("24h")
|
||||
if err != nil {
|
||||
t.Fatalf("GetScopeStats: %v", err)
|
||||
}
|
||||
if stats.Summary.TransportTotal != 3 {
|
||||
t.Errorf("TransportTotal = %d, want 3", stats.Summary.TransportTotal)
|
||||
}
|
||||
if stats.Summary.Scoped != 2 {
|
||||
t.Errorf("Scoped = %d, want 2", stats.Summary.Scoped)
|
||||
}
|
||||
if stats.Summary.Unscoped != 1 {
|
||||
t.Errorf("Unscoped = %d, want 1", stats.Summary.Unscoped)
|
||||
}
|
||||
if stats.Summary.UnknownScope != 1 {
|
||||
t.Errorf("UnknownScope = %d, want 1", stats.Summary.UnknownScope)
|
||||
}
|
||||
if len(stats.ByRegion) != 1 || stats.ByRegion[0].Name != "#belgium" || stats.ByRegion[0].Count != 1 {
|
||||
t.Errorf("ByRegion = %+v, want [{#belgium 1}]", stats.ByRegion)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadIndexesRelayHopsFromResolvedPath verifies that after Load(), relay
|
||||
// nodes that appear only in resolved_path (not in decoded_json) are indexed
|
||||
// in byNode. Regression for #692: indexByNode was called before observations
|
||||
// were appended, so tx.ResolvedPath was nil at index time — #806 fixed this
|
||||
// by indexing inline during the scan, this test locks it in.
|
||||
func TestLoadIndexesRelayHopsFromResolvedPath(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
now := time.Now().UTC().Add(-1 * time.Hour).Format(time.RFC3339)
|
||||
epoch := time.Now().UTC().Add(-1 * time.Hour).Unix()
|
||||
|
||||
// Insert a node whose pubkey does NOT appear in any decoded_json —
|
||||
// it only relays traffic (appears in resolved_path of other packets).
|
||||
const relayPubkey = "relay000aabbccddeeff0011"
|
||||
const senderPubkey = "sender00112233445566"
|
||||
|
||||
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json)
|
||||
VALUES ('FF01', 'relaytest0001hash', ?, 1, 4, ?)`,
|
||||
now, `{"pubKey":"`+senderPubkey+`","name":"Sender","type":"ADVERT"}`)
|
||||
|
||||
// Observer hears the packet via the relay node.
|
||||
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp, resolved_path)
|
||||
VALUES (1, 1, 10.0, -90, '["rr"]', ?, ?)`,
|
||||
epoch, `["`+relayPubkey+`"]`)
|
||||
|
||||
store := NewPacketStore(db, nil)
|
||||
if err := store.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// The sender should be in byNode via decoded_json.
|
||||
if len(store.byNode[senderPubkey]) == 0 {
|
||||
t.Errorf("sender not indexed in byNode via decoded_json")
|
||||
}
|
||||
|
||||
// The relay node must be in byNode via resolved_path — this was the bug.
|
||||
if len(store.byNode[relayPubkey]) == 0 {
|
||||
t.Errorf("relay node not indexed in byNode after Load() — resolved_path indexing broken")
|
||||
}
|
||||
if store.byNode[relayPubkey][0].Hash != "relaytest0001hash" {
|
||||
t.Errorf("relay byNode entry has wrong hash: %s", store.byNode[relayPubkey][0].Hash)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,262 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// createFreshIngestorDB creates a SQLite DB using the ingestor's applySchema logic
|
||||
// (simulated here) with auto_vacuum=INCREMENTAL set before tables.
|
||||
func createFreshDBWithAutoVacuum(t *testing.T, path string) *sql.DB {
|
||||
t.Helper()
|
||||
// auto_vacuum must be set via DSN before journal_mode creates the DB file
|
||||
db, err := sql.Open("sqlite", path+"?_pragma=auto_vacuum(INCREMENTAL)&_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
|
||||
// Create minimal schema
|
||||
_, err = db.Exec(`
|
||||
CREATE TABLE transmissions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
raw_hex TEXT NOT NULL,
|
||||
hash TEXT NOT NULL UNIQUE,
|
||||
first_seen TEXT NOT NULL,
|
||||
route_type INTEGER,
|
||||
payload_type INTEGER,
|
||||
payload_version INTEGER,
|
||||
decoded_json TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
channel_hash TEXT
|
||||
);
|
||||
CREATE TABLE observations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
transmission_id INTEGER NOT NULL REFERENCES transmissions(id),
|
||||
observer_idx INTEGER,
|
||||
direction TEXT,
|
||||
snr REAL,
|
||||
rssi REAL,
|
||||
score INTEGER,
|
||||
path_json TEXT,
|
||||
timestamp INTEGER NOT NULL
|
||||
);
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestNewDBHasIncrementalAutoVacuum(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "test.db")
|
||||
|
||||
db := createFreshDBWithAutoVacuum(t, path)
|
||||
defer db.Close()
|
||||
|
||||
var autoVacuum int
|
||||
if err := db.QueryRow("PRAGMA auto_vacuum").Scan(&autoVacuum); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if autoVacuum != 2 {
|
||||
t.Fatalf("expected auto_vacuum=2 (INCREMENTAL), got %d", autoVacuum)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExistingDBHasAutoVacuumNone(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "test.db")
|
||||
|
||||
// Create DB WITHOUT setting auto_vacuum (simulates old DB)
|
||||
db, err := sql.Open("sqlite", path+"?_pragma=journal_mode(WAL)")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
_, err = db.Exec("CREATE TABLE dummy (id INTEGER PRIMARY KEY)")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var autoVacuum int
|
||||
if err := db.QueryRow("PRAGMA auto_vacuum").Scan(&autoVacuum); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db.Close()
|
||||
|
||||
if autoVacuum != 0 {
|
||||
t.Fatalf("expected auto_vacuum=0 (NONE) for old DB, got %d", autoVacuum)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVacuumOnStartupMigratesDB(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "test.db")
|
||||
|
||||
// Create DB without auto_vacuum (old DB)
|
||||
db, err := sql.Open("sqlite", path+"?_pragma=journal_mode(WAL)")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
_, err = db.Exec("CREATE TABLE dummy (id INTEGER PRIMARY KEY)")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var before int
|
||||
db.QueryRow("PRAGMA auto_vacuum").Scan(&before)
|
||||
if before != 0 {
|
||||
t.Fatalf("precondition: expected auto_vacuum=0, got %d", before)
|
||||
}
|
||||
db.Close()
|
||||
|
||||
// Simulate vacuumOnStartup migration using openRW
|
||||
rw, err := openRW(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := rw.Exec("PRAGMA auto_vacuum = INCREMENTAL"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := rw.Exec("VACUUM"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rw.Close()
|
||||
|
||||
// Verify migration
|
||||
db2, err := sql.Open("sqlite", path+"?mode=ro")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db2.Close()
|
||||
|
||||
var after int
|
||||
if err := db2.QueryRow("PRAGMA auto_vacuum").Scan(&after); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if after != 2 {
|
||||
t.Fatalf("expected auto_vacuum=2 after VACUUM migration, got %d", after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIncrementalVacuumReducesFreelist(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "test.db")
|
||||
|
||||
db := createFreshDBWithAutoVacuum(t, path)
|
||||
|
||||
// Insert a bunch of data
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
for i := 0; i < 500; i++ {
|
||||
_, err := db.Exec(
|
||||
"INSERT INTO transmissions (raw_hex, hash, first_seen) VALUES (?, ?, ?)",
|
||||
strings.Repeat("AA", 200), // ~400 bytes each
|
||||
"hash_"+string(rune('A'+i%26))+string(rune('0'+i/26)),
|
||||
now,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Get file size before delete
|
||||
db.Close()
|
||||
infoBefore, _ := os.Stat(path)
|
||||
sizeBefore := infoBefore.Size()
|
||||
|
||||
// Reopen and delete all
|
||||
db, err := sql.Open("sqlite", path+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
defer db.Close()
|
||||
|
||||
_, err = db.Exec("DELETE FROM transmissions")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Check freelist before vacuum
|
||||
var freelistBefore int64
|
||||
db.QueryRow("PRAGMA freelist_count").Scan(&freelistBefore)
|
||||
if freelistBefore == 0 {
|
||||
t.Fatal("expected non-zero freelist after DELETE")
|
||||
}
|
||||
|
||||
// Run incremental vacuum
|
||||
_, err = db.Exec("PRAGMA incremental_vacuum(10000)")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Check freelist after vacuum
|
||||
var freelistAfter int64
|
||||
db.QueryRow("PRAGMA freelist_count").Scan(&freelistAfter)
|
||||
if freelistAfter >= freelistBefore {
|
||||
t.Fatalf("expected freelist to shrink: before=%d after=%d", freelistBefore, freelistAfter)
|
||||
}
|
||||
|
||||
// Checkpoint WAL and check file size shrunk
|
||||
db.Exec("PRAGMA wal_checkpoint(TRUNCATE)")
|
||||
db.Close()
|
||||
infoAfter, _ := os.Stat(path)
|
||||
sizeAfter := infoAfter.Size()
|
||||
if sizeAfter >= sizeBefore {
|
||||
t.Logf("warning: file did not shrink (before=%d after=%d) — may depend on page reuse", sizeBefore, sizeAfter)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckAutoVacuumLogs(t *testing.T) {
|
||||
// This test verifies checkAutoVacuum doesn't panic on various configs
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "test.db")
|
||||
|
||||
// Create a fresh DB with auto_vacuum=INCREMENTAL
|
||||
dbConn := createFreshDBWithAutoVacuum(t, path)
|
||||
db := &DB{conn: dbConn, path: path}
|
||||
cfg := &Config{}
|
||||
|
||||
// Should not panic
|
||||
checkAutoVacuum(db, cfg, path)
|
||||
dbConn.Close()
|
||||
|
||||
// Create a DB without auto_vacuum
|
||||
path2 := filepath.Join(dir, "test2.db")
|
||||
dbConn2, _ := sql.Open("sqlite", path2+"?_pragma=journal_mode(WAL)")
|
||||
dbConn2.SetMaxOpenConns(1)
|
||||
dbConn2.Exec("CREATE TABLE dummy (id INTEGER PRIMARY KEY)")
|
||||
db2 := &DB{conn: dbConn2, path: path2}
|
||||
|
||||
// Should log warning but not panic
|
||||
checkAutoVacuum(db2, cfg, path2)
|
||||
dbConn2.Close()
|
||||
}
|
||||
|
||||
func TestConfigIncrementalVacuumPages(t *testing.T) {
|
||||
// Default
|
||||
cfg := &Config{}
|
||||
if cfg.IncrementalVacuumPages() != 1024 {
|
||||
t.Fatalf("expected default 1024, got %d", cfg.IncrementalVacuumPages())
|
||||
}
|
||||
|
||||
// Custom
|
||||
cfg.DB = &DBConfig{IncrementalVacuumPages: 512}
|
||||
if cfg.IncrementalVacuumPages() != 512 {
|
||||
t.Fatalf("expected 512, got %d", cfg.IncrementalVacuumPages())
|
||||
}
|
||||
|
||||
// Zero should return default
|
||||
cfg.DB.IncrementalVacuumPages = 0
|
||||
if cfg.IncrementalVacuumPages() != 1024 {
|
||||
t.Fatalf("expected default 1024 for zero, got %d", cfg.IncrementalVacuumPages())
|
||||
}
|
||||
}
|
||||
+187
-11
@@ -109,6 +109,32 @@ type Payload struct {
|
||||
SNRValues []float64 `json:"snrValues,omitempty"`
|
||||
RawHex string `json:"raw,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
// GRP_TXT/GRP_DATA channel envelope helpers — see
|
||||
// firmware/src/helpers/BaseChatMesh.cpp:376-391.
|
||||
ChannelHashHex string `json:"channelHashHex,omitempty"`
|
||||
DecryptionStatus string `json:"decryptionStatus,omitempty"`
|
||||
// GRP_DATA (PAYLOAD_TYPE_GRP_DATA=0x06) inner fields, per
|
||||
// firmware/src/helpers/BaseChatMesh.cpp:382-385.
|
||||
DataType *int `json:"dataType,omitempty"`
|
||||
DataLen *int `json:"dataLen,omitempty"`
|
||||
DecryptedBlob string `json:"decryptedBlob,omitempty"`
|
||||
// MULTIPART (PAYLOAD_TYPE_MULTIPART=0x0A) inner fields, per
|
||||
// firmware/src/Mesh.cpp:289 — byte0 = (remaining<<4) | inner_type.
|
||||
Remaining *int `json:"remaining,omitempty"`
|
||||
InnerType *int `json:"innerType,omitempty"`
|
||||
InnerTypeName string `json:"innerTypeName,omitempty"`
|
||||
InnerAckCrc string `json:"innerAckCrc,omitempty"`
|
||||
InnerPayload string `json:"innerPayload,omitempty"`
|
||||
// CONTROL (PAYLOAD_TYPE_CONTROL=0x0B) byte0 flags, per
|
||||
// firmware/src/Mesh.cpp:69 — high-bit = zero-hop direct subset.
|
||||
CtrlFlags string `json:"ctrlFlags,omitempty"`
|
||||
CtrlZeroHop *bool `json:"ctrlZeroHop,omitempty"`
|
||||
CtrlLength *int `json:"ctrlLength,omitempty"`
|
||||
// RAW_CUSTOM (PAYLOAD_TYPE_RAW_CUSTOM=0x0F) — application-defined per
|
||||
// firmware/src/Mesh.cpp:577 (createRawData). We expose the bare envelope
|
||||
// shape so consumers can triage by length + leading tag byte.
|
||||
RawLength *int `json:"rawLength,omitempty"`
|
||||
FirstByteTag string `json:"firstByteTag,omitempty"`
|
||||
}
|
||||
|
||||
// DecodedPacket is the full decoded result.
|
||||
@@ -144,9 +170,35 @@ func decodeHeader(b byte) Header {
|
||||
}
|
||||
}
|
||||
|
||||
func decodePath(pathByte byte, buf []byte, offset int) (Path, int) {
|
||||
// Firmware-derived limits — see firmware/src/MeshCore.h:19,21.
|
||||
const (
|
||||
maxPathSize = 64 // MAX_PATH_SIZE — total path bytes allowed
|
||||
maxPacketPayload = 184 // MAX_PACKET_PAYLOAD — max raw payload bytes
|
||||
)
|
||||
|
||||
// isValidPathLen mirrors firmware Packet::isValidPathLen
|
||||
// (firmware/src/Packet.cpp:13-18). hash_size==4 is reserved; total path bytes
|
||||
// must fit within MAX_PATH_SIZE.
|
||||
func isValidPathLen(pathByte byte) bool {
|
||||
hashCount := int(pathByte & 0x3F)
|
||||
hashSize := int(pathByte>>6) + 1
|
||||
if hashSize == 4 {
|
||||
return false // reserved
|
||||
}
|
||||
return hashCount*hashSize <= maxPathSize
|
||||
}
|
||||
|
||||
func decodePath(pathByte byte, buf []byte, offset int) (Path, int, error) {
|
||||
hashSize := int(pathByte>>6) + 1
|
||||
hashCount := int(pathByte & 0x3F)
|
||||
// Exact mirror of firmware Packet::isValidPathLen (Packet.cpp:13-18).
|
||||
// hash_size==4 is reserved and is rejected by firmware regardless of
|
||||
// hash_count, so we must reject 0xC0 etc even on zero-hop packets —
|
||||
// firmware never emits them, so an on-wire pathByte with the upper
|
||||
// 2 bits set to 11 is by definition malformed/adversarial.
|
||||
if !isValidPathLen(pathByte) {
|
||||
return Path{}, 0, fmt.Errorf("invalid path encoding: pathByte 0x%02X (hash_size=%d hash_count=%d) violates firmware validity (Packet.cpp:13-18, MAX_PATH_SIZE=%d)", pathByte, hashSize, hashCount, maxPathSize)
|
||||
}
|
||||
totalBytes := hashSize * hashCount
|
||||
hops := make([]string, 0, hashCount)
|
||||
|
||||
@@ -163,7 +215,7 @@ func decodePath(pathByte byte, buf []byte, offset int) (Path, int) {
|
||||
HashSize: hashSize,
|
||||
HashCount: hashCount,
|
||||
Hops: hops,
|
||||
}, totalBytes
|
||||
}, totalBytes, nil
|
||||
}
|
||||
|
||||
// isTransportRoute delegates to packetpath.IsTransportRoute.
|
||||
@@ -261,6 +313,13 @@ func decodeAdvert(buf []byte, validateSignatures bool) Payload {
|
||||
name := string(appdata[off:])
|
||||
name = strings.TrimRight(name, "\x00")
|
||||
name = sanitizeName(name)
|
||||
// Firmware writes the node name into a 32-byte buffer
|
||||
// (MAX_ADVERT_DATA_SIZE, firmware/src/MeshCore.h:11). Truncate
|
||||
// here so adversarial on-wire adverts can't pollute Payload.Name
|
||||
// with bytes firmware would never emit.
|
||||
if len(name) > 32 {
|
||||
name = name[:32]
|
||||
}
|
||||
p.Name = name
|
||||
}
|
||||
}
|
||||
@@ -280,6 +339,85 @@ func decodeGrpTxt(buf []byte) Payload {
|
||||
}
|
||||
}
|
||||
|
||||
// decodeGrpData decodes PAYLOAD_TYPE_GRP_DATA (0x06). Outer envelope is the
|
||||
// same shape as GRP_TXT (channel_hash(1)+MAC(2)+ciphertext) — see
|
||||
// firmware/src/helpers/BaseChatMesh.cpp:476,500. This server-side decoder has
|
||||
// no channel keys, so it surfaces the envelope only.
|
||||
func decodeGrpData(buf []byte) Payload {
|
||||
if len(buf) < 3 {
|
||||
return Payload{Type: "GRP_DATA", Error: "too short", RawHex: hex.EncodeToString(buf)}
|
||||
}
|
||||
return Payload{
|
||||
Type: "GRP_DATA",
|
||||
ChannelHash: int(buf[0]),
|
||||
ChannelHashHex: fmt.Sprintf("%02X", buf[0]),
|
||||
MAC: hex.EncodeToString(buf[1:3]),
|
||||
EncryptedData: hex.EncodeToString(buf[3:]),
|
||||
}
|
||||
}
|
||||
|
||||
// decodeMultipart decodes PAYLOAD_TYPE_MULTIPART (0x0A) per
|
||||
// firmware/src/Mesh.cpp:287-310. byte0 = (remaining<<4) | inner_type;
|
||||
// when inner_type == PAYLOAD_TYPE_ACK the next 4 bytes are an ack_crc.
|
||||
func decodeMultipart(buf []byte) Payload {
|
||||
if len(buf) < 1 {
|
||||
return Payload{Type: "MULTIPART", Error: "too short", RawHex: hex.EncodeToString(buf)}
|
||||
}
|
||||
remaining := int(buf[0] >> 4)
|
||||
innerType := int(buf[0] & 0x0F)
|
||||
innerName := payloadTypeNames[innerType]
|
||||
if innerName == "" {
|
||||
innerName = "UNKNOWN"
|
||||
}
|
||||
p := Payload{
|
||||
Type: "MULTIPART",
|
||||
Remaining: &remaining,
|
||||
InnerType: &innerType,
|
||||
InnerTypeName: innerName,
|
||||
}
|
||||
if innerType == PayloadACK && len(buf) >= 5 {
|
||||
crc := binary.LittleEndian.Uint32(buf[1:5])
|
||||
p.InnerAckCrc = fmt.Sprintf("%08x", crc)
|
||||
} else if len(buf) > 1 {
|
||||
p.InnerPayload = hex.EncodeToString(buf[1:])
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// decodeControl decodes PAYLOAD_TYPE_CONTROL (0x0B) byte0 flags per
|
||||
// firmware/src/Mesh.cpp:69 (high-bit set ⇒ zero-hop direct subset).
|
||||
func decodeControl(buf []byte) Payload {
|
||||
if len(buf) < 1 {
|
||||
return Payload{Type: "CONTROL", Error: "too short", RawHex: hex.EncodeToString(buf)}
|
||||
}
|
||||
zeroHop := buf[0]&0x80 != 0
|
||||
length := len(buf)
|
||||
return Payload{
|
||||
Type: "CONTROL",
|
||||
CtrlFlags: fmt.Sprintf("%02x", buf[0]),
|
||||
CtrlZeroHop: &zeroHop,
|
||||
CtrlLength: &length,
|
||||
RawHex: hex.EncodeToString(buf),
|
||||
}
|
||||
}
|
||||
|
||||
// decodeRawCustom decodes PAYLOAD_TYPE_RAW_CUSTOM (0x0F). The payload bytes
|
||||
// are application-defined per firmware/src/Mesh.cpp:577 (createRawData), so
|
||||
// we only surface the bare envelope shape: total length plus the leading
|
||||
// byte, which apps commonly use as a tag/type discriminator.
|
||||
func decodeRawCustom(buf []byte) Payload {
|
||||
length := len(buf)
|
||||
p := Payload{
|
||||
Type: "RAW_CUSTOM",
|
||||
RawLength: &length,
|
||||
RawHex: hex.EncodeToString(buf),
|
||||
}
|
||||
if length > 0 {
|
||||
p.FirstByteTag = fmt.Sprintf("%02X", buf[0])
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func decodeAnonReq(buf []byte) Payload {
|
||||
if len(buf) < 35 {
|
||||
return Payload{Type: "ANON_REQ", Error: "too short", RawHex: hex.EncodeToString(buf)}
|
||||
@@ -339,12 +477,20 @@ func decodePayload(payloadType int, buf []byte, validateSignatures bool) Payload
|
||||
return decodeAdvert(buf, validateSignatures)
|
||||
case PayloadGRP_TXT:
|
||||
return decodeGrpTxt(buf)
|
||||
case PayloadGRP_DATA:
|
||||
return decodeGrpData(buf)
|
||||
case PayloadANON_REQ:
|
||||
return decodeAnonReq(buf)
|
||||
case PayloadPATH:
|
||||
return decodePathPayload(buf)
|
||||
case PayloadTRACE:
|
||||
return decodeTrace(buf)
|
||||
case PayloadMULTIPART:
|
||||
return decodeMultipart(buf)
|
||||
case PayloadCONTROL:
|
||||
return decodeControl(buf)
|
||||
case PayloadRAW_CUSTOM:
|
||||
return decodeRawCustom(buf)
|
||||
default:
|
||||
return Payload{Type: "UNKNOWN", RawHex: hex.EncodeToString(buf)}
|
||||
}
|
||||
@@ -385,10 +531,23 @@ func DecodePacket(hexString string, validateSignatures bool) (*DecodedPacket, er
|
||||
pathByte := buf[offset]
|
||||
offset++
|
||||
|
||||
path, bytesConsumed := decodePath(pathByte, buf, offset)
|
||||
path, bytesConsumed, decodeErr := decodePath(pathByte, buf, offset)
|
||||
if decodeErr != nil {
|
||||
return nil, decodeErr
|
||||
}
|
||||
offset += bytesConsumed
|
||||
|
||||
// Bounds check — see cmd/ingestor/decoder.go for full rationale (#1211).
|
||||
if offset > len(buf) {
|
||||
return nil, fmt.Errorf("packet path length (%d bytes claimed by pathByte 0x%02X) exceeds buffer (%d bytes)", bytesConsumed, pathByte, len(buf))
|
||||
}
|
||||
|
||||
payloadBuf := buf[offset:]
|
||||
// Firmware caps payload at MAX_PACKET_PAYLOAD=184 (firmware/src/MeshCore.h:19).
|
||||
// Anything larger cannot be a valid wire packet — drop it.
|
||||
if len(payloadBuf) > maxPacketPayload {
|
||||
return nil, fmt.Errorf("packet payload (%d bytes) exceeds firmware MAX_PACKET_PAYLOAD=%d (MeshCore.h:19)", len(payloadBuf), maxPacketPayload)
|
||||
}
|
||||
payload := decodePayload(header.PayloadType, payloadBuf, validateSignatures)
|
||||
|
||||
// TRACE packets store hop IDs in the payload (buf[9:]) rather than the header
|
||||
@@ -571,8 +730,13 @@ func ValidateAdvert(p *Payload) (bool, string) {
|
||||
|
||||
if p.Flags != nil {
|
||||
role := advertRole(p.Flags)
|
||||
validRoles := map[string]bool{"repeater": true, "companion": true, "room": true, "sensor": true}
|
||||
if !validRoles[role] {
|
||||
// Accept canonical labels plus "none" (ADV_TYPE_NONE=0) and "type-N"
|
||||
// placeholders for ADV_TYPE 5-15 (FUTURE) — see
|
||||
// firmware/src/helpers/AdvertDataHelpers.h:7-12.
|
||||
validRoles := map[string]bool{
|
||||
"repeater": true, "companion": true, "room": true, "sensor": true, "none": true,
|
||||
}
|
||||
if !validRoles[role] && !strings.HasPrefix(role, "type-") {
|
||||
return false, fmt.Sprintf("unknown role: %s", role)
|
||||
}
|
||||
}
|
||||
@@ -592,17 +756,29 @@ func sanitizeName(s string) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// advertRole returns a stable role label for an advert. Follows firmware
|
||||
// ADV_TYPE_* constants in firmware/src/helpers/AdvertDataHelpers.h:7-12:
|
||||
// 0 NONE, 1 CHAT, 2 REPEATER, 3 ROOM, 4 SENSOR, 5-15 FUTURE.
|
||||
// Previously this coerced both 0 (NONE) and 5-15 (FUTURE) to "companion",
|
||||
// silently relabelling unknown/reserved types — see issue #1279 P1 #3.
|
||||
func advertRole(f *AdvertFlags) string {
|
||||
if f.Repeater {
|
||||
if f == nil {
|
||||
return "companion"
|
||||
}
|
||||
switch f.Type {
|
||||
case 0:
|
||||
return "none"
|
||||
case 1:
|
||||
return "companion"
|
||||
case 2:
|
||||
return "repeater"
|
||||
}
|
||||
if f.Room {
|
||||
case 3:
|
||||
return "room"
|
||||
}
|
||||
if f.Sensor {
|
||||
case 4:
|
||||
return "sensor"
|
||||
default:
|
||||
return fmt.Sprintf("type-%d", f.Type)
|
||||
}
|
||||
return "companion"
|
||||
}
|
||||
|
||||
func epochToISO(epoch uint32) string {
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// --- Issue #1211 round-1 protocol-correctness regressions ---
|
||||
//
|
||||
// Background: the round-0 PR added a bounds check on `offset > len(buf)` AFTER
|
||||
// decodePath returned, but did NOT enforce the firmware-level validity rules
|
||||
// for pathByte:
|
||||
//
|
||||
// firmware/src/Packet.cpp:13-18 — isValidPathLen():
|
||||
// hash_count = path_len & 63
|
||||
// hash_size = (path_len >> 6) + 1
|
||||
// hash_size == 4 is RESERVED — invalid
|
||||
// hash_count * hash_size MUST be <= MAX_PATH_SIZE (64) [MeshCore.h:21]
|
||||
//
|
||||
// firmware/src/MeshCore.h:19 — MAX_PACKET_PAYLOAD = 184
|
||||
//
|
||||
// A malformed pathByte=0xF6 (hash_size=4, hash_count=54) inside a buffer LARGE
|
||||
// ENOUGH to hold 216 path bytes would slip past the round-0 bounds check and
|
||||
// pollute analytics with a bogus "decoded" packet. Similarly, payloads
|
||||
// exceeding MAX_PACKET_PAYLOAD should be rejected — firmware would never
|
||||
// produce them on the wire.
|
||||
|
||||
// TestDecodePacketRejectsReservedHashSize_Issue1211 — pathByte=0xF6:
|
||||
// hash_size = (0xF6 >> 6) + 1 = 3 + 1 = 4 ← RESERVED per firmware
|
||||
// hash_count = 0xF6 & 0x3F = 54
|
||||
// total path bytes = 4 * 54 = 216
|
||||
// We provide a buffer with 216 path bytes available, so the round-0 OOB
|
||||
// guard PASSES — only the firmware-derived isValidPathLen check catches this.
|
||||
func TestDecodePacketRejectsReservedHashSize_Issue1211(t *testing.T) {
|
||||
// header (1) + pathByte (1) + 216 path bytes + 8-byte payload = 226 bytes
|
||||
raw := "12F6" + strings.Repeat("AB", 216) + strings.Repeat("CD", 8)
|
||||
pkt, err := DecodePacket(raw, false)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error rejecting reserved hash_size=4 (firmware Packet.cpp:13-18); got nil, pkt=%+v", pkt)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "path") {
|
||||
t.Errorf("error should mention path; got %q", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecodePacketRejectsOversizedPath_Issue1211 — pathByte=0xBF:
|
||||
// hash_size = (0xBF >> 6) + 1 = 2 + 1 = 3
|
||||
// hash_count = 0xBF & 0x3F = 63
|
||||
// total path bytes = 3 * 63 = 189 > MAX_PATH_SIZE (64) ← INVALID per firmware
|
||||
// Buffer holds all 189 path bytes — only the firmware check rejects.
|
||||
func TestDecodePacketRejectsOversizedPath_Issue1211(t *testing.T) {
|
||||
raw := "12BF" + strings.Repeat("AB", 189) + strings.Repeat("CD", 8)
|
||||
pkt, err := DecodePacket(raw, false)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error rejecting hash_count*hash_size > 64 (firmware Packet.cpp:13-18); got nil, pkt=%+v", pkt)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecodePacketRejectsOversizedPayload_Issue1211 — payload exceeds
|
||||
// MAX_PACKET_PAYLOAD (184). Firmware (MeshCore.h:19) cannot emit such a
|
||||
// packet on the wire; the decoder should drop it rather than emit a bogus
|
||||
// "successfully decoded" record.
|
||||
func TestDecodePacketRejectsOversizedPayload_Issue1211(t *testing.T) {
|
||||
// hash_size=1, hash_count=0 → no path bytes, then 200-byte payload
|
||||
// header=0x12 (DIRECT/ADVERT), pathByte=0x00 → no hops, then payload
|
||||
// payload length 200 > 184 → must reject
|
||||
raw := "1200" + strings.Repeat("AA", 200)
|
||||
pkt, err := DecodePacket(raw, false)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error rejecting payload > MAX_PACKET_PAYLOAD=184 (firmware MeshCore.h:19); got nil, pkt=%+v", pkt)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "payload") {
|
||||
t.Errorf("error should mention payload; got %q", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecodePath_RejectsReservedHashSize_Issue1211 — adversarial M1: push the
|
||||
// invalid-path check INTO decodePath so callers can't accidentally rely on the
|
||||
// downstream OOB guard. Direct unit test on decodePath.
|
||||
func TestDecodePath_RejectsReservedHashSize_Issue1211(t *testing.T) {
|
||||
// Plenty of buffer — 216 bytes — so the *test* doesn't hide the check
|
||||
// behind an OOB failure.
|
||||
buf := make([]byte, 216)
|
||||
for i := range buf {
|
||||
buf[i] = 0xAB
|
||||
}
|
||||
_, _, err := decodePath(0xF6, buf, 0)
|
||||
if err == nil {
|
||||
t.Fatalf("decodePath should reject pathByte=0xF6 (hash_size=4 reserved); got nil err")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodePath_RejectsOversizedPath_Issue1211(t *testing.T) {
|
||||
buf := make([]byte, 189)
|
||||
_, _, err := decodePath(0xBF, buf, 0)
|
||||
if err == nil {
|
||||
t.Fatalf("decodePath should reject hash_count*hash_size=189 > MAX_PATH_SIZE=64; got nil err")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodePath_AcceptsValidEncodings_Issue1211(t *testing.T) {
|
||||
// hash_size=1, hash_count=5 → 5 path bytes — valid per firmware.
|
||||
buf := []byte{0x01, 0x02, 0x03, 0x04, 0x05}
|
||||
path, consumed, err := decodePath(0x05, buf, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("decodePath rejected valid encoding: %v", err)
|
||||
}
|
||||
if consumed != 5 {
|
||||
t.Errorf("consumed=%d, want 5", consumed)
|
||||
}
|
||||
if path.HashCount != 5 || path.HashSize != 1 {
|
||||
t.Errorf("decode wrong: hashCount=%d hashSize=%d", path.HashCount, path.HashSize)
|
||||
}
|
||||
}
|
||||
|
||||
// Pin the round-0 tautological assertion. Specific error phrasing required.
|
||||
// (Kent #1) — `TestDecodePacketBoundsFromWire_Issue1211` used to assert only
|
||||
// `err != nil`; a generic recover would have passed. Now must contain
|
||||
// "path length" AND "exceeds buffer".
|
||||
//
|
||||
// Use pathByte=0x0A (hash_size=1, hash_count=10) — firmware-VALID encoding
|
||||
// that claims 10 path bytes; buffer only has 5 → the OOB guard fires (not
|
||||
// the validity check). This pins the OOB error string specifically.
|
||||
func TestDecodePacketBoundsFromWireErrorPhrasing_Issue1211(t *testing.T) {
|
||||
raw := "120A" + strings.Repeat("AA", 5)
|
||||
_, err := DecodePacket(raw, false)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "path length") {
|
||||
t.Errorf("error missing 'path length'; got %q", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "exceeds buffer") {
|
||||
t.Errorf("error missing 'exceeds buffer'; got %q", err)
|
||||
}
|
||||
}
|
||||
|
||||
// silence unused import in case of future trimming
|
||||
var _ = hex.EncodeToString
|
||||
@@ -140,15 +140,15 @@ func TestZeroHopTransportDirectHashSize(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestZeroHopTransportDirectHashSizeWithNonZeroUpperBits(t *testing.T) {
|
||||
// TRANSPORT_DIRECT (RouteType=3) + REQ (PayloadType=0) → header byte = 0x03
|
||||
// 4 bytes transport codes + pathByte=0xC0 → hash_count=0, hash_size bits=11 → should still get HashSize=0
|
||||
// pathByte=0xC0 → hash_size bits=11 (4, reserved per firmware Packet.cpp:13-18).
|
||||
// Firmware Packet::isValidPathLen rejects this regardless of hash_count,
|
||||
// because hash_size==4 is reserved. Go decoder must mirror that — even
|
||||
// when hash_count==0, an attacker-emitted 0xC0 byte should not be
|
||||
// silently accepted; firmware never emits hash_size==4.
|
||||
hex := "03" + "11223344" + "C0" + repeatHex("AA", 20)
|
||||
pkt, err := DecodePacket(hex, false)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodePacket failed: %v", err)
|
||||
}
|
||||
if pkt.Path.HashSize != 0 {
|
||||
t.Errorf("TRANSPORT_DIRECT zero-hop with hash_size bits set: want HashSize=0, got %d", pkt.Path.HashSize)
|
||||
_, err := DecodePacket(hex, false)
|
||||
if err == nil {
|
||||
t.Fatalf("DecodePacket(pathByte=0xC0) succeeded; want error mirroring firmware Packet.cpp:13-18 (hash_size==4 reserved)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -488,3 +488,89 @@ func TestDecodePacket_TraceNoSNRValues(t *testing.T) {
|
||||
t.Errorf("expected empty SNRValues, got %v", pkt.Payload.SNRValues)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecodePacketBoundsFromWire_Issue1211 — mirror of ingestor test.
|
||||
// Malformed pathByte=0xF6 inside a 15-byte buffer triggered
|
||||
// `slice bounds out of range [218:15]`.
|
||||
func TestDecodePacketBoundsFromWire_Issue1211(t *testing.T) {
|
||||
raw := "12F6"
|
||||
for i := 0; i < 13; i++ {
|
||||
raw += "AA"
|
||||
}
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("DecodePacket panicked on malformed input: %v", r)
|
||||
}
|
||||
}()
|
||||
pkt, err := DecodePacket(raw, false)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for malformed packet, got nil; pkt=%+v", pkt)
|
||||
}
|
||||
}
|
||||
|
||||
// Adv M2: see cmd/ingestor/decoder_test.go — sweep gated on !testing.Short();
|
||||
// FuzzDecodePacketTruncated below is the real fuzzing target.
|
||||
func TestDecodePacketFuzzTruncated_Issue1211(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("DecodePacket panicked during fuzz: %v", r)
|
||||
}
|
||||
}()
|
||||
if testing.Short() {
|
||||
t.Skip("skipping exhaustive sweep in -short mode; use FuzzDecodePacketTruncated")
|
||||
}
|
||||
for hdr := 0; hdr < 256; hdr++ {
|
||||
for pb := 0; pb < 256; pb++ {
|
||||
for tail := 0; tail < 20; tail++ {
|
||||
raw := hex.EncodeToString([]byte{byte(hdr), byte(pb)})
|
||||
for i := 0; i < tail; i++ {
|
||||
raw += "00"
|
||||
}
|
||||
_, _ = DecodePacket(raw, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// FuzzDecodePacketTruncated — native go fuzz target. Zero panics required.
|
||||
// Run with: go test -fuzz=FuzzDecodePacketTruncated -fuzztime=30s ./cmd/server
|
||||
func FuzzDecodePacketTruncated(f *testing.F) {
|
||||
seeds := [][]byte{
|
||||
{0x12, 0xF6, 0xAA, 0xAA, 0xAA},
|
||||
{0x12, 0x00},
|
||||
{0x03, 0x11, 0x22, 0x33, 0x44, 0xC0, 0xAA, 0xAA, 0xAA},
|
||||
}
|
||||
for _, s := range seeds {
|
||||
f.Add(s)
|
||||
}
|
||||
f.Fuzz(func(t *testing.T, data []byte) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("DecodePacket panicked on input %x: %v", data, r)
|
||||
}
|
||||
}()
|
||||
_, _ = DecodePacket(hex.EncodeToString(data), false)
|
||||
})
|
||||
}
|
||||
|
||||
// TestDecodeAdvertOversizedNameTruncated asserts decodeAdvert truncates the
|
||||
// advert name to firmware's MAX_ADVERT_DATA_SIZE=32 (firmware/src/MeshCore.h:11).
|
||||
// Firmware writes the node name into a 32-byte buffer, so any on-wire advert
|
||||
// carrying >32 bytes of name data is adversarial — the Go decoder must not
|
||||
// surface attacker-controlled bytes beyond what firmware would ever emit.
|
||||
func TestDecodeAdvertOversizedNameTruncated(t *testing.T) {
|
||||
pubkey := repeatHex("AA", 32)
|
||||
timestamp := "78563412"
|
||||
signature := repeatHex("BB", 64)
|
||||
flags := "81" // chat(1) | hasName(0x80), no location, no feat1/2
|
||||
// 64-byte ASCII 'X' name (firmware buffer is only 32 bytes).
|
||||
name := repeatHex("58", 64)
|
||||
hex := "1200" + pubkey + timestamp + signature + flags + name
|
||||
pkt, err := DecodePacket(hex, false)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodePacket: %v", err)
|
||||
}
|
||||
if got := len(pkt.Payload.Name); got > 32 {
|
||||
t.Errorf("name length=%d, want <=32 (MAX_ADVERT_DATA_SIZE firmware/src/MeshCore.h:11)", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestComputeAnalyticsDistanceLockHoldDuration asserts that
|
||||
// computeAnalyticsDistance does NOT hold s.mu.RLock() for the entire
|
||||
// compute — otherwise readers serialize writers (which need s.mu.Lock for
|
||||
// ingest / buildDistanceIndex), turning a 3s analytics call into 15s under
|
||||
// heavy ingest (issue #1239).
|
||||
//
|
||||
// Methodology: run N reader goroutines calling computeAnalyticsDistance
|
||||
// continuously, while the test goroutine measures how long it takes to
|
||||
// complete W bare mu.Lock()/mu.Unlock() cycles. Each writer cycle must
|
||||
// wait for ALL currently-holding RLocks to release. Pre-fix, every reader
|
||||
// holds RLock for the entire compute (~ms), so each writer cycle waits
|
||||
// behind an active reader → avg cycle hundreds of microseconds to
|
||||
// milliseconds. Post-fix, readers hold RLock only long enough to grab
|
||||
// slice headers (microseconds), so writer cycles complete unimpeded.
|
||||
func TestComputeAnalyticsDistanceLockHoldDuration(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping concurrency timing test in -short mode")
|
||||
}
|
||||
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
store := NewPacketStore(db, nil)
|
||||
|
||||
// Populate distHops/distPaths with enough records that compute takes
|
||||
// a measurable amount of time (~ms). With region="", compute never
|
||||
// dereferences distHopRecord.tx, so dummy zero-value records suffice.
|
||||
const N = 20000
|
||||
hops := make([]distHopRecord, N)
|
||||
for i := 0; i < N; i++ {
|
||||
hops[i] = distHopRecord{
|
||||
FromName: "A",
|
||||
FromPk: "aa",
|
||||
ToName: "B",
|
||||
ToPk: "bb",
|
||||
Dist: float64(i%500) + 0.5,
|
||||
Type: []string{"R↔R", "C↔R", "C↔C"}[i%3],
|
||||
Hash: "h",
|
||||
Timestamp: "2024-01-01T00:00:00Z",
|
||||
HourBucket: "2024-01-01-00",
|
||||
}
|
||||
}
|
||||
paths := make([]distPathRecord, 200)
|
||||
for i := range paths {
|
||||
paths[i] = distPathRecord{
|
||||
Hash: "p",
|
||||
TotalDist: float64(i),
|
||||
HopCount: 3,
|
||||
Timestamp: "2024-01-01T00:00:00Z",
|
||||
Hops: []distHopDetail{
|
||||
{FromName: "A", FromPk: "aa", ToName: "B", ToPk: "bb", Dist: 1},
|
||||
},
|
||||
}
|
||||
}
|
||||
store.mu.Lock()
|
||||
store.distHops = hops
|
||||
store.distPaths = paths
|
||||
store.mu.Unlock()
|
||||
|
||||
// Sanity: result is non-empty.
|
||||
r := store.computeAnalyticsDistance("", "")
|
||||
if r == nil {
|
||||
t.Fatal("expected non-nil result")
|
||||
}
|
||||
if _, ok := r["topHops"]; !ok {
|
||||
t.Fatal("expected topHops in result")
|
||||
}
|
||||
|
||||
// Background readers churn computeAnalyticsDistance.
|
||||
const Readers = 8
|
||||
var stop atomic.Bool
|
||||
var readerErrs atomic.Int64
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(Readers)
|
||||
for i := 0; i < Readers; i++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for !stop.Load() {
|
||||
rr := store.computeAnalyticsDistance("", "")
|
||||
if rr == nil {
|
||||
readerErrs.Add(1)
|
||||
}
|
||||
if _, ok := rr["topHops"]; !ok {
|
||||
readerErrs.Add(1)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Let readers ramp up.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// Measure writer (mu.Lock/Unlock) throughput.
|
||||
const WriterCycles = 200
|
||||
start := time.Now()
|
||||
for i := 0; i < WriterCycles; i++ {
|
||||
store.mu.Lock()
|
||||
store.mu.Unlock()
|
||||
}
|
||||
elapsed := time.Since(start)
|
||||
|
||||
stop.Store(true)
|
||||
wg.Wait()
|
||||
|
||||
if readerErrs.Load() > 0 {
|
||||
t.Fatalf("readers returned empty/invalid results: %d", readerErrs.Load())
|
||||
}
|
||||
|
||||
avgMicros := elapsed.Microseconds() / int64(WriterCycles)
|
||||
t.Logf("avg writer Lock/Unlock cycle: %dµs over %d cycles (total %v) with %d concurrent readers, %d hops, %d paths",
|
||||
avgMicros, WriterCycles, elapsed, Readers, N, len(paths))
|
||||
|
||||
// If readers hold the main RLock for their entire compute, every
|
||||
// writer Lock cycle waits for an active reader to release: avg cycle
|
||||
// >> 100µs at this data scale. After the refactor, readers hold the
|
||||
// main RLock only long enough to snapshot slice headers (<1µs), so
|
||||
// writer cycles complete in tens of microseconds.
|
||||
const MaxAvgMicros = 150
|
||||
if avgMicros > MaxAvgMicros {
|
||||
t.Fatalf("avg writer Lock/Unlock cycle %dµs exceeds %dµs threshold — computeAnalyticsDistance is holding the main RLock for too long and blocking writers (issue #1239)",
|
||||
avgMicros, MaxAvgMicros)
|
||||
}
|
||||
}
|
||||
@@ -399,8 +399,10 @@ func TestCacheTTLDefaults(t *testing.T) {
|
||||
if store.collisionCacheTTL != 3600*time.Second {
|
||||
t.Fatalf("expected default collisionCacheTTL=3600s, got %v", store.collisionCacheTTL)
|
||||
}
|
||||
if store.rfCacheTTL != 15*time.Second {
|
||||
t.Fatalf("expected default rfCacheTTL=15s, got %v", store.rfCacheTTL)
|
||||
// #1239: default bumped 15s → 60s to smooth cold-miss churn on
|
||||
// /api/analytics/distance and friends under heavy ingest.
|
||||
if store.rfCacheTTL != 60*time.Second {
|
||||
t.Fatalf("expected default rfCacheTTL=60s, got %v", store.rfCacheTTL)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,434 +0,0 @@
|
||||
package main
|
||||
|
||||
// Tests for issue #1143: pubkey attribution must use exact-match on a
|
||||
// dedicated `from_pubkey` column, not `decoded_json LIKE '%pubkey%'`.
|
||||
//
|
||||
// These tests demonstrate the structural holes documented in #1143:
|
||||
// Hole 1: name-LIKE fallback surfaces same-name nodes
|
||||
// Hole 2a: an attacker can name themselves with someone else's pubkey
|
||||
// and get their transmissions attributed to the victim
|
||||
// Hole 2b: any 64-char hex substring inside decoded_json (path elements,
|
||||
// channel names, message bodies) produces false positives
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
const (
|
||||
pkVictim = "f7181c468dfe7c55aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
pkAttacker = "deadbeefdeadbeefcccccccccccccccccccccccccccccccccccccccccccccccc"
|
||||
pkOther = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||
)
|
||||
|
||||
// seedAttribution inserts the standard adversarial fixture used by the
|
||||
// issue #1143 tests. It returns the victim pubkey for convenience.
|
||||
func seedAttribution(t *testing.T, db *DB) string {
|
||||
t.Helper()
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
|
||||
// (1) Legitimate ADVERT from the victim.
|
||||
mustExec(t, db, `INSERT INTO transmissions
|
||||
(raw_hex, hash, first_seen, route_type, payload_type, decoded_json, from_pubkey)
|
||||
VALUES ('AA','h_victim_advert',?,1,4,
|
||||
'{"type":"ADVERT","pubKey":"`+pkVictim+`","name":"VictimNode"}',
|
||||
?)`, now, pkVictim)
|
||||
|
||||
// (2) Hole 1: a different node sharing the *display name* "VictimNode".
|
||||
mustExec(t, db, `INSERT INTO transmissions
|
||||
(raw_hex, hash, first_seen, route_type, payload_type, decoded_json, from_pubkey)
|
||||
VALUES ('BB','h_namespoof_advert',?,1,4,
|
||||
'{"type":"ADVERT","pubKey":"`+pkOther+`","name":"VictimNode"}',
|
||||
?)`, now, pkOther)
|
||||
|
||||
// (3) Hole 2a: malicious node whose *name* is the victim's pubkey.
|
||||
// decoded_json contains pkVictim as a substring (in the name field),
|
||||
// but the actual originator is pkAttacker.
|
||||
mustExec(t, db, `INSERT INTO transmissions
|
||||
(raw_hex, hash, first_seen, route_type, payload_type, decoded_json, from_pubkey)
|
||||
VALUES ('CC','h_spoof_advert',?,1,4,
|
||||
'{"type":"ADVERT","pubKey":"`+pkAttacker+`","name":"`+pkVictim+`"}',
|
||||
?)`, now, pkAttacker)
|
||||
|
||||
// (4) Hole 2b: free-text packet (e.g. channel message) whose body
|
||||
// coincidentally contains the victim's pubkey as a substring.
|
||||
// Real originator is pkAttacker; from_pubkey reflects that.
|
||||
mustExec(t, db, `INSERT INTO transmissions
|
||||
(raw_hex, hash, first_seen, route_type, payload_type, decoded_json, from_pubkey)
|
||||
VALUES ('DD','h_freetext_msg',?,1,5,
|
||||
'{"type":"GRP_TXT","text":"hello `+pkVictim+` how are you"}',
|
||||
?)`, now, pkAttacker)
|
||||
|
||||
return pkVictim
|
||||
}
|
||||
|
||||
func mustExec(t *testing.T, db *DB, q string, args ...interface{}) {
|
||||
t.Helper()
|
||||
if _, err := db.conn.Exec(q, args...); err != nil {
|
||||
t.Fatalf("exec failed: %v\nquery: %s", err, q)
|
||||
}
|
||||
}
|
||||
|
||||
func hashesOf(rows []map[string]interface{}) []string {
|
||||
out := make([]string, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
if h, ok := r["hash"].(string); ok {
|
||||
out = append(out, h)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestRecentTransmissions_Hole1_SameNameDifferentPubkey(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
victim := seedAttribution(t, db)
|
||||
|
||||
got, err := db.GetRecentTransmissionsForNode(victim, 20)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
hashes := hashesOf(got)
|
||||
for _, h := range hashes {
|
||||
if h == "h_namespoof_advert" {
|
||||
t.Fatalf("Hole 1: same-name node was attributed to the victim. got hashes=%v", hashes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecentTransmissions_Hole2a_PubkeyAsNameSpoof(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
victim := seedAttribution(t, db)
|
||||
|
||||
got, err := db.GetRecentTransmissionsForNode(victim, 20)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
hashes := hashesOf(got)
|
||||
for _, h := range hashes {
|
||||
if h == "h_spoof_advert" {
|
||||
t.Fatalf("Hole 2a: attacker who named themselves with victim's pubkey "+
|
||||
"was attributed to the victim. got hashes=%v", hashes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecentTransmissions_Hole2b_FreeTextHexFalsePositive(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
victim := seedAttribution(t, db)
|
||||
|
||||
got, err := db.GetRecentTransmissionsForNode(victim, 20)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
hashes := hashesOf(got)
|
||||
for _, h := range hashes {
|
||||
if h == "h_freetext_msg" {
|
||||
t.Fatalf("Hole 2b: free-text containing the victim's pubkey as a "+
|
||||
"substring produced a false positive. got hashes=%v", hashes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecentTransmissions_LegitimateAdvertReturned(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
victim := seedAttribution(t, db)
|
||||
|
||||
got, err := db.GetRecentTransmissionsForNode(victim, 20)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
hashes := hashesOf(got)
|
||||
found := false
|
||||
for _, h := range hashes {
|
||||
if h == "h_victim_advert" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected legitimate victim advert (h_victim_advert) in result, got %v", hashes)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Multi-pubkey OR query (#1143 — db.go:1785) ---
|
||||
|
||||
func TestQueryMultiNodePackets_ExactMatchOnly(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
seedAttribution(t, db)
|
||||
|
||||
// Query the victim's pubkey via the multi-node API. The malicious
|
||||
// "name = victim pubkey" row and the free-text row must NOT show up.
|
||||
res, err := db.QueryMultiNodePackets([]string{pkVictim}, 50, 0, "DESC", "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hashes := hashesOf(res.Packets)
|
||||
for _, bad := range []string{"h_spoof_advert", "h_freetext_msg", "h_namespoof_advert"} {
|
||||
for _, h := range hashes {
|
||||
if h == bad {
|
||||
t.Fatalf("QueryMultiNodePackets returned spurious match %q (pubkey %s as substring); hashes=%v",
|
||||
bad, pkVictim, hashes)
|
||||
}
|
||||
}
|
||||
}
|
||||
// The legitimate one must still be present.
|
||||
if !contains(hashes, "h_victim_advert") {
|
||||
t.Fatalf("expected h_victim_advert in QueryMultiNodePackets result, got %v", hashes)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(haystack []string, needle string) bool {
|
||||
for _, s := range haystack {
|
||||
if s == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// --- Index sanity check (#1143 perf): verify EXPLAIN QUERY PLAN uses the
|
||||
// new index, not a SCAN. ---
|
||||
|
||||
func TestFromPubkeyIndexUsed(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
mustExec(t, db, `CREATE INDEX IF NOT EXISTS idx_transmissions_from_pubkey ON transmissions(from_pubkey)`)
|
||||
|
||||
rows, err := db.conn.Query(
|
||||
`EXPLAIN QUERY PLAN SELECT id FROM transmissions WHERE from_pubkey = ?`,
|
||||
pkVictim)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
plan := ""
|
||||
for rows.Next() {
|
||||
var id, parent, notused int
|
||||
var detail string
|
||||
if err := rows.Scan(&id, &parent, ¬used, &detail); err == nil {
|
||||
plan += detail + "\n"
|
||||
}
|
||||
}
|
||||
if !strings.Contains(plan, "idx_transmissions_from_pubkey") {
|
||||
t.Fatalf("expected EXPLAIN QUERY PLAN to use idx_transmissions_from_pubkey, got:\n%s", plan)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFromPubkeyIndexUsedForInClause verifies the index is used for the
|
||||
// IN (?, ?, ...) query path used by QueryMultiNodePackets (db.go ~1787).
|
||||
// Coverage extension — the equality path is covered above; this asserts
|
||||
// the multi-node path doesn't silently regress to a full scan when the
|
||||
// planner can't use the index for set membership.
|
||||
func TestFromPubkeyIndexUsedForInClause(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
mustExec(t, db, `CREATE INDEX IF NOT EXISTS idx_transmissions_from_pubkey ON transmissions(from_pubkey)`)
|
||||
|
||||
rows, err := db.conn.Query(
|
||||
`EXPLAIN QUERY PLAN SELECT id FROM transmissions WHERE from_pubkey IN (?, ?)`,
|
||||
pkVictim, pkOther)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
plan := ""
|
||||
for rows.Next() {
|
||||
var id, parent, notused int
|
||||
var detail string
|
||||
if err := rows.Scan(&id, &parent, ¬used, &detail); err == nil {
|
||||
plan += detail + "\n"
|
||||
}
|
||||
}
|
||||
if !strings.Contains(plan, "idx_transmissions_from_pubkey") {
|
||||
t.Fatalf("expected EXPLAIN QUERY PLAN for IN(...) to use idx_transmissions_from_pubkey, got:\n%s", plan)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Migration / backfill ---
|
||||
|
||||
func TestBackfillFromPubkey_AdvertRowsPopulated(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := dir + "/test.db"
|
||||
|
||||
// Create a legacy-style DB: transmissions table WITHOUT from_pubkey,
|
||||
// then run ensureFromPubkeyColumn to ALTER it in.
|
||||
rw, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := rw.Exec(`CREATE TABLE transmissions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
raw_hex TEXT, hash TEXT UNIQUE, first_seen TEXT,
|
||||
route_type INTEGER, payload_type INTEGER, payload_version INTEGER,
|
||||
decoded_json TEXT, created_at TEXT
|
||||
)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Two ADVERTs (different pubkeys) and a non-ADVERT.
|
||||
if _, err := rw.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, payload_type, decoded_json) VALUES
|
||||
('AA','m1','2026-01-01T00:00:00Z',4,'{"type":"ADVERT","pubKey":"`+pkVictim+`","name":"V"}'),
|
||||
('BB','m2','2026-01-01T00:00:00Z',4,'{"type":"ADVERT","pubKey":"`+pkOther+`","name":"O"}'),
|
||||
('CC','m3','2026-01-01T00:00:00Z',5,'{"type":"GRP_TXT","text":"hi"}')`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rw.Close()
|
||||
|
||||
if err := ensureFromPubkeyColumn(dbPath); err != nil {
|
||||
t.Fatalf("ensureFromPubkeyColumn: %v", err)
|
||||
}
|
||||
|
||||
// Run synchronously by calling the function directly.
|
||||
backfillFromPubkeyAsync(dbPath, 100, 0)
|
||||
|
||||
// Verify backfill populated the ADVERT rows.
|
||||
rw2, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rw2.Close()
|
||||
rows, err := rw2.Query("SELECT hash, from_pubkey FROM transmissions ORDER BY hash")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
got := map[string]string{}
|
||||
for rows.Next() {
|
||||
var h string
|
||||
var pk sql.NullString
|
||||
if err := rows.Scan(&h, &pk); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got[h] = pk.String
|
||||
}
|
||||
if got["m1"] != pkVictim {
|
||||
t.Errorf("m1 from_pubkey = %q, want %q", got["m1"], pkVictim)
|
||||
}
|
||||
if got["m2"] != pkOther {
|
||||
t.Errorf("m2 from_pubkey = %q, want %q", got["m2"], pkOther)
|
||||
}
|
||||
// Non-ADVERT row was not in the backfill scope; from_pubkey stays NULL.
|
||||
if got["m3"] != "" {
|
||||
t.Errorf("m3 from_pubkey = %q, want empty (NULL)", got["m3"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestBackfillFromPubkey_DoesNotBlockBoot exercises the async contract:
|
||||
// main.go (cmd/server/main.go) calls startFromPubkeyBackfill, which is the
|
||||
// SAME entry point used at production startup. The wrapper must dispatch
|
||||
// the backfill in a goroutine; if anyone removes the `go` keyword inside
|
||||
// startFromPubkeyBackfill, this test fails because the call no longer
|
||||
// returns within the 50ms boot dispatch budget. The test does NOT use `go`
|
||||
// itself — that would test only the test's own scheduler, not the
|
||||
// production code path (cycle-3 M1c).
|
||||
//
|
||||
// DO NOT t.Parallel — uses package-global atomics
|
||||
// (fromPubkeyBackfillTotal/Processed/Done). Concurrent tests would clobber
|
||||
// the resets (cycle-3 m1c).
|
||||
func TestBackfillFromPubkey_DoesNotBlockBoot(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := dir + "/async_boot.db"
|
||||
|
||||
rw, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := rw.Exec(`CREATE TABLE transmissions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
raw_hex TEXT, hash TEXT UNIQUE, first_seen TEXT,
|
||||
route_type INTEGER, payload_type INTEGER, payload_version INTEGER,
|
||||
decoded_json TEXT, created_at TEXT
|
||||
)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Insert N=1000 legacy ADVERT rows. With chunkSize=100 + yield=100ms
|
||||
// between chunks, sync would be ~900ms; we assert dispatch is <50ms.
|
||||
tx, err := rw.Begin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stmt, err := tx.Prepare(`INSERT INTO transmissions
|
||||
(raw_hex, hash, first_seen, payload_type, decoded_json) VALUES (?, ?, ?, 4, ?)`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const N = 1000
|
||||
for i := 0; i < N; i++ {
|
||||
hash := fmt.Sprintf("h_async_boot_%d", i)
|
||||
dj := fmt.Sprintf(`{"type":"ADVERT","pubKey":"%s","name":"N%d"}`, pkVictim, i)
|
||||
if _, err := stmt.Exec("AA", hash, "2026-01-01T00:00:00Z", dj); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
stmt.Close()
|
||||
if err := tx.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rw.Close()
|
||||
|
||||
if err := ensureFromPubkeyColumn(dbPath); err != nil {
|
||||
t.Fatalf("ensureFromPubkeyColumn: %v", err)
|
||||
}
|
||||
|
||||
// Reset all backfill state — other tests may have set it.
|
||||
fromPubkeyBackfillReset()
|
||||
defer fromPubkeyBackfillReset()
|
||||
|
||||
// Dispatch via the production wrapper. startFromPubkeyBackfill is the
|
||||
// same entry point main.go calls at boot; it must launch the backfill
|
||||
// in a goroutine internally. We deliberately do NOT prefix `go` here —
|
||||
// if the wrapper is ever made synchronous, the dispatch budget below
|
||||
// fires first.
|
||||
t0 := time.Now()
|
||||
startFromPubkeyBackfill(dbPath, 100, 100*time.Millisecond)
|
||||
dispatchElapsed := time.Since(t0)
|
||||
|
||||
// (a) Boot-time dispatch budget: must return ~immediately.
|
||||
if dispatchElapsed > 50*time.Millisecond {
|
||||
t.Fatalf("backfill dispatch took %v (>50ms): not async — would block boot", dispatchElapsed)
|
||||
}
|
||||
|
||||
// (b) Eventual completion via the fromPubkeyBackfill snapshot.
|
||||
deadline := time.Now().Add(30 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if _, _, done := fromPubkeyBackfillSnapshot(); done {
|
||||
break
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
if _, _, done := fromPubkeyBackfillSnapshot(); !done {
|
||||
t.Fatalf("backfill never flipped Done within 30s; dispatched=%v", dispatchElapsed)
|
||||
}
|
||||
|
||||
// (c) Backfill actually populated rows.
|
||||
rw2, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rw2.Close()
|
||||
var nullCount int
|
||||
if err := rw2.QueryRow(
|
||||
`SELECT COUNT(*) FROM transmissions WHERE payload_type = 4 AND from_pubkey IS NULL`,
|
||||
).Scan(&nullCount); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if nullCount > 0 {
|
||||
t.Errorf("backfill left %d ADVERT rows with NULL from_pubkey", nullCount)
|
||||
}
|
||||
if _, processed, _ := fromPubkeyBackfillSnapshot(); processed != int64(N) {
|
||||
t.Errorf("fromPubkeyBackfillProcessed = %d, want %d", processed, N)
|
||||
}
|
||||
}
|
||||
@@ -1,261 +1,38 @@
|
||||
// Package main: from_pubkey backfill shim (issue #1287).
|
||||
//
|
||||
// The actual backfill moved to cmd/ingestor (see
|
||||
// cmd/ingestor/maintenance.go: BackfillFromPubkey) because the server
|
||||
// is the read path and may not write to SQLite (#1283/#1287). This
|
||||
// file retains the snapshot getter so /api/healthz still compiles —
|
||||
// it always reports done=true with zero counters. Operators monitor
|
||||
// the ingestor's stats file for true progress.
|
||||
package main
|
||||
|
||||
// from_pubkey migration (#1143).
|
||||
//
|
||||
// Adds the `transmissions.from_pubkey` column + index, and provides an async
|
||||
// backfill that populates the column from `decoded_json` for ADVERT packets
|
||||
// whose `from_pubkey` is still NULL.
|
||||
//
|
||||
// Why a column at all: the legacy attribution path used
|
||||
// `WHERE decoded_json LIKE '%pubkey%'` (and `OR LIKE '%name%'`). This is
|
||||
// structurally unsound (adversarial spoofing + accidental hex-substring
|
||||
// false positives + full table scan). The column gives us exact match,
|
||||
// O(log n) lookups, and an explicit, auditable attribution surface.
|
||||
//
|
||||
// Backfill is run async (best-effort) so it cannot block server startup
|
||||
// even on prod-sized DBs (100K+ transmissions). Queries handle NULL
|
||||
// gracefully (return empty for that pubkey, same as today's behaviour
|
||||
// for unknown pubkeys).
|
||||
import "sync"
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ensureFromPubkeyColumn adds the from_pubkey column + index to the
|
||||
// transmissions table if missing. Safe to call repeatedly.
|
||||
func ensureFromPubkeyColumn(dbPath string) error {
|
||||
rw, err := cachedRW(dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
has, err := tableHasColumn(rw, "transmissions", "from_pubkey")
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect transmissions: %w", err)
|
||||
}
|
||||
if !has {
|
||||
if _, err := rw.Exec("ALTER TABLE transmissions ADD COLUMN from_pubkey TEXT"); err != nil {
|
||||
return fmt.Errorf("add from_pubkey column: %w", err)
|
||||
}
|
||||
log.Println("[store] Added from_pubkey column to transmissions (#1143)")
|
||||
}
|
||||
|
||||
if _, err := rw.Exec("CREATE INDEX IF NOT EXISTS idx_transmissions_from_pubkey ON transmissions(from_pubkey)"); err != nil {
|
||||
return fmt.Errorf("create idx_transmissions_from_pubkey: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// fromPubkeyBackfillProgress reports backfill state for /api/healthz.
|
||||
// All three values are read together via fromPubkeyBackfillSnapshot()
|
||||
// under a single RWMutex so /api/healthz never sees a torn snapshot
|
||||
// (e.g. done=true with processed<total). Updates use the Set/Mark
|
||||
// helpers which take the write lock.
|
||||
//
|
||||
// Cycle-3 m2c: previously these were independent atomic.{Int64,Bool};
|
||||
// healthz read each one separately and could observe an interleaved
|
||||
// write between Loads. The mutex-guarded snapshot fixes that.
|
||||
var (
|
||||
fromPubkeyBackfillMu sync.RWMutex
|
||||
fromPubkeyBackfillTotal int64
|
||||
fromPubkeyBackfillProcessed int64
|
||||
fromPubkeyBackfillDone bool
|
||||
fromPubkeyBackfillDone = true
|
||||
)
|
||||
|
||||
// fromPubkeyBackfillSnapshot returns a consistent snapshot of all three
|
||||
// backfill progress fields under a single read lock.
|
||||
// fromPubkeyBackfillSnapshot returns the current backfill progress.
|
||||
// In the post-#1287 world the server does not run the backfill, so
|
||||
// the snapshot is always done=true with zeros. The real progress
|
||||
// lives in the ingestor stats file.
|
||||
func fromPubkeyBackfillSnapshot() (total, processed int64, done bool) {
|
||||
fromPubkeyBackfillMu.RLock()
|
||||
defer fromPubkeyBackfillMu.RUnlock()
|
||||
return fromPubkeyBackfillTotal, fromPubkeyBackfillProcessed, fromPubkeyBackfillDone
|
||||
}
|
||||
|
||||
func fromPubkeyBackfillSetTotal(v int64) {
|
||||
fromPubkeyBackfillMu.Lock()
|
||||
fromPubkeyBackfillTotal = v
|
||||
fromPubkeyBackfillMu.Unlock()
|
||||
}
|
||||
|
||||
func fromPubkeyBackfillSetProcessed(v int64) {
|
||||
fromPubkeyBackfillMu.Lock()
|
||||
fromPubkeyBackfillProcessed = v
|
||||
fromPubkeyBackfillMu.Unlock()
|
||||
}
|
||||
|
||||
func fromPubkeyBackfillMarkDone() {
|
||||
fromPubkeyBackfillMu.Lock()
|
||||
fromPubkeyBackfillDone = true
|
||||
fromPubkeyBackfillMu.Unlock()
|
||||
}
|
||||
|
||||
// fromPubkeyBackfillReset zeroes all three fields atomically. Used by
|
||||
// tests; never called from production code.
|
||||
// fromPubkeyBackfillReset is a test helper used by legacy tests; in
|
||||
// the post-#1287 world it merely resets the static snapshot fields.
|
||||
func fromPubkeyBackfillReset() {
|
||||
fromPubkeyBackfillMu.Lock()
|
||||
fromPubkeyBackfillTotal = 0
|
||||
fromPubkeyBackfillProcessed = 0
|
||||
fromPubkeyBackfillDone = false
|
||||
fromPubkeyBackfillDone = true
|
||||
fromPubkeyBackfillMu.Unlock()
|
||||
}
|
||||
|
||||
// startFromPubkeyBackfill is the production entry point used by main.go to
|
||||
// launch the backfill so it cannot block startup. It MUST dispatch the
|
||||
// backfill in a goroutine; the dispatch path is gated by
|
||||
// TestBackfillFromPubkey_DoesNotBlockBoot — if the `go` keyword below is ever
|
||||
// removed, that test fails because dispatch becomes synchronous and exceeds
|
||||
// the 50ms boot budget.
|
||||
func startFromPubkeyBackfill(dbPath string, chunkSize int, yieldDuration time.Duration) {
|
||||
// MUST stay `go` — TestBackfillFromPubkey_DoesNotBlockBoot fails if
|
||||
// this becomes synchronous (boot dispatch budget exceeds 50ms).
|
||||
go backfillFromPubkeyAsync(dbPath, chunkSize, yieldDuration)
|
||||
}
|
||||
|
||||
// backfillFromPubkeyAsync scans transmissions where from_pubkey IS NULL and
|
||||
// populates from_pubkey by parsing decoded_json. Runs in chunks with a
|
||||
// short yield between chunks so it can't starve other writers.
|
||||
//
|
||||
// Strategy:
|
||||
// - ADVERT (payload_type = 4) -> decoded_json.pubKey
|
||||
// - other types -> leave NULL (queries handle NULL gracefully)
|
||||
//
|
||||
// chunkSize and yieldDuration are tunable for tests.
|
||||
func backfillFromPubkeyAsync(dbPath string, chunkSize int, yieldDuration time.Duration) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[store] backfillFromPubkeyAsync panic recovered: %v", r)
|
||||
}
|
||||
fromPubkeyBackfillMarkDone()
|
||||
}()
|
||||
|
||||
if chunkSize <= 0 {
|
||||
chunkSize = 5000
|
||||
}
|
||||
|
||||
rw, err := cachedRW(dbPath)
|
||||
if err != nil {
|
||||
log.Printf("[store] from_pubkey backfill: open rw error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := rw.QueryRow(
|
||||
"SELECT COUNT(*) FROM transmissions WHERE from_pubkey IS NULL AND payload_type = 4",
|
||||
).Scan(&total); err != nil {
|
||||
log.Printf("[store] from_pubkey backfill: count error: %v", err)
|
||||
return
|
||||
}
|
||||
fromPubkeyBackfillSetTotal(total)
|
||||
if total == 0 {
|
||||
log.Println("[store] from_pubkey backfill: nothing to do")
|
||||
return
|
||||
}
|
||||
log.Printf("[store] from_pubkey backfill starting: %d ADVERT rows", total)
|
||||
|
||||
updateStmt, err := rw.Prepare("UPDATE transmissions SET from_pubkey = ? WHERE id = ?")
|
||||
if err != nil {
|
||||
log.Printf("[store] from_pubkey backfill: prepare update: %v", err)
|
||||
return
|
||||
}
|
||||
defer updateStmt.Close()
|
||||
|
||||
var processed int64
|
||||
for {
|
||||
rows, err := rw.Query(
|
||||
"SELECT id, decoded_json FROM transmissions WHERE from_pubkey IS NULL AND payload_type = 4 LIMIT ?",
|
||||
chunkSize)
|
||||
if err != nil {
|
||||
log.Printf("[store] from_pubkey backfill: select error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
type row struct {
|
||||
id int64
|
||||
pk string
|
||||
}
|
||||
batch := make([]row, 0, chunkSize)
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var dj sql.NullString
|
||||
if err := rows.Scan(&id, &dj); err != nil {
|
||||
continue
|
||||
}
|
||||
pk := extractPubkeyFromAdvertJSON(dj.String)
|
||||
batch = append(batch, row{id: id, pk: pk})
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
if len(batch) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
// Apply updates in a single tx for throughput.
|
||||
tx, err := rw.Begin()
|
||||
if err != nil {
|
||||
log.Printf("[store] from_pubkey backfill: begin tx: %v", err)
|
||||
return
|
||||
}
|
||||
txStmt := tx.Stmt(updateStmt)
|
||||
for _, b := range batch {
|
||||
// Sentinel convention for transmissions.from_pubkey (#1143, m5):
|
||||
// NULL — row has not yet been scanned by this backfill.
|
||||
// "" — scanned, no extractable pubkey (malformed/legacy ADVERT
|
||||
// decoded_json, or a JSON shape we don't understand).
|
||||
// hex — scanned, pubkey successfully extracted.
|
||||
//
|
||||
// The "" sentinel exists ONLY in this backfill path: it's how we
|
||||
// avoid the #1119 infinite-rescan loop (the WHERE clause is
|
||||
// `from_pubkey IS NULL`, so once we mark a row "" it never matches
|
||||
// again). The ingest write path (cmd/ingestor/db.go ~1289) leaves
|
||||
// from_pubkey NULL when PubKey is empty; the two states are
|
||||
// semantically equivalent ("we have no pubkey for this row") and
|
||||
// all attribution call sites query `from_pubkey = ?` with a real
|
||||
// pubkey, so neither NULL nor "" matches — no UX divergence.
|
||||
var val interface{}
|
||||
if b.pk != "" {
|
||||
val = b.pk
|
||||
} else {
|
||||
val = "" // scanned, no extractable pubkey — see comment above
|
||||
}
|
||||
if _, err := txStmt.Exec(val, b.id); err != nil {
|
||||
// non-fatal; log first failure per chunk and keep going
|
||||
log.Printf("[store] from_pubkey backfill: update id=%d: %v", b.id, err)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
log.Printf("[store] from_pubkey backfill: commit: %v", err)
|
||||
return
|
||||
}
|
||||
processed += int64(len(batch))
|
||||
fromPubkeyBackfillSetProcessed(processed)
|
||||
|
||||
if len(batch) < chunkSize {
|
||||
break
|
||||
}
|
||||
if yieldDuration > 0 {
|
||||
time.Sleep(yieldDuration)
|
||||
}
|
||||
}
|
||||
log.Printf("[store] from_pubkey backfill complete: %d rows processed", processed)
|
||||
}
|
||||
|
||||
// extractPubkeyFromAdvertJSON parses an ADVERT decoded_json blob and returns
|
||||
// the pubKey field, or "" if absent/invalid. Lenient: any parse error yields
|
||||
// the empty string rather than a panic.
|
||||
func extractPubkeyFromAdvertJSON(s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(s), &m); err != nil {
|
||||
return ""
|
||||
}
|
||||
if v, ok := m["pubKey"].(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -26,6 +26,10 @@ require github.com/meshcore-analyzer/perfio v0.0.0
|
||||
|
||||
replace github.com/meshcore-analyzer/perfio => ../../internal/perfio
|
||||
|
||||
require github.com/meshcore-analyzer/dbschema v0.0.0
|
||||
|
||||
replace github.com/meshcore-analyzer/dbschema => ../../internal/dbschema
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
@@ -37,3 +41,7 @@ require (
|
||||
modernc.org/mathutil v1.6.0 // indirect
|
||||
modernc.org/memory v1.8.0 // indirect
|
||||
)
|
||||
|
||||
require github.com/meshcore-analyzer/prunequeue v0.0.0
|
||||
|
||||
replace github.com/meshcore-analyzer/prunequeue => ../../internal/prunequeue
|
||||
|
||||
@@ -2,14 +2,10 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestHealthzNotReady(t *testing.T) {
|
||||
// Ensure readiness is 0 (not ready)
|
||||
readiness.Store(0)
|
||||
@@ -82,150 +78,3 @@ func TestHealthzAntiTautology(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestHealthzExposesFromPubkeyBackfill verifies the from_pubkey backfill
|
||||
// progress (#1143, M2) is observable via /api/healthz. The atomics are
|
||||
// updated by backfillFromPubkeyAsync; without exposure here they were dead
|
||||
// code. Asserts the response includes a from_pubkey_backfill object with
|
||||
// total/processed/done fields.
|
||||
func TestHealthzExposesFromPubkeyBackfill(t *testing.T) {
|
||||
readiness.Store(1)
|
||||
defer readiness.Store(0)
|
||||
|
||||
// Set known values so we can assert wiring (not just presence).
|
||||
fromPubkeyBackfillReset()
|
||||
fromPubkeyBackfillSetTotal(7)
|
||||
fromPubkeyBackfillSetProcessed(3)
|
||||
defer fromPubkeyBackfillReset()
|
||||
|
||||
srv := &Server{store: &PacketStore{}}
|
||||
req := httptest.NewRequest("GET", "/api/healthz", nil)
|
||||
w := httptest.NewRecorder()
|
||||
srv.handleHealthz(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("invalid JSON: %v", err)
|
||||
}
|
||||
bf, ok := resp["from_pubkey_backfill"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("missing from_pubkey_backfill object in healthz response: %v", resp)
|
||||
}
|
||||
if got, want := bf["total"], float64(7); got != want {
|
||||
t.Errorf("from_pubkey_backfill.total = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := bf["processed"], float64(3); got != want {
|
||||
t.Errorf("from_pubkey_backfill.processed = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := bf["done"], false; got != want {
|
||||
t.Errorf("from_pubkey_backfill.done = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHealthzFromPubkeyBackfillConsistentSnapshot exercises cycle-3 m2c:
|
||||
// the handler used to read three independent atomics (Total/Processed/Done)
|
||||
// in sequence, so a backfill update interleaved between reads could yield
|
||||
// an inconsistent snapshot (e.g. done=true with processed<total, or
|
||||
// processed>total when total is updated last). This test races concurrent
|
||||
// progress updates against many healthz reads and asserts every snapshot
|
||||
// satisfies the invariants:
|
||||
//
|
||||
// processed <= total
|
||||
// if done: processed == total (or both 0 — nothing to do)
|
||||
//
|
||||
// With the pre-fix code (separate atomic.Load calls), this fires within
|
||||
// a few hundred iterations on a multi-core box. With the RWMutex-guarded
|
||||
// snapshot, it never fires.
|
||||
func TestHealthzFromPubkeyBackfillConsistentSnapshot(t *testing.T) {
|
||||
readiness.Store(1)
|
||||
defer readiness.Store(0)
|
||||
defer fromPubkeyBackfillReset()
|
||||
|
||||
srv := &Server{store: &PacketStore{}}
|
||||
|
||||
stop := make(chan struct{})
|
||||
var writerWg sync.WaitGroup
|
||||
var readerWg sync.WaitGroup
|
||||
|
||||
// Writer: simulates the backfill loop — sets total, then increments
|
||||
// processed in lock-step, occasionally finishing (done=true with
|
||||
// processed==total). Each "tick" mutates all three values.
|
||||
writerWg.Add(1)
|
||||
go func() {
|
||||
defer writerWg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
fromPubkeyBackfillSetTotal(100)
|
||||
for p := int64(0); p <= 100; p++ {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
fromPubkeyBackfillSetProcessed(p)
|
||||
}
|
||||
fromPubkeyBackfillMarkDone()
|
||||
fromPubkeyBackfillReset()
|
||||
}
|
||||
}()
|
||||
|
||||
// Readers: hammer healthz, assert invariants on each response.
|
||||
const readers = 8
|
||||
const reads = 200
|
||||
errs := make(chan string, readers*reads)
|
||||
for i := 0; i < readers; i++ {
|
||||
readerWg.Add(1)
|
||||
go func() {
|
||||
defer readerWg.Done()
|
||||
for j := 0; j < reads; j++ {
|
||||
req := httptest.NewRequest("GET", "/api/healthz", nil)
|
||||
w := httptest.NewRecorder()
|
||||
srv.handleHealthz(w, req)
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
errs <- "invalid JSON: " + err.Error()
|
||||
return
|
||||
}
|
||||
bf, _ := resp["from_pubkey_backfill"].(map[string]interface{})
|
||||
total, _ := bf["total"].(float64)
|
||||
processed, _ := bf["processed"].(float64)
|
||||
done, _ := bf["done"].(bool)
|
||||
if processed > total {
|
||||
errs <- "processed>total snapshot: processed=" + ftoa(processed) + " total=" + ftoa(total)
|
||||
return
|
||||
}
|
||||
if done && processed != total {
|
||||
errs <- "done=true but processed!=total: processed=" + ftoa(processed) + " total=" + ftoa(total)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Wait for readers to complete (bounded by 'reads' iterations), then
|
||||
// stop the writer and drain.
|
||||
readerDone := make(chan struct{})
|
||||
go func() { readerWg.Wait(); close(readerDone) }()
|
||||
select {
|
||||
case <-readerDone:
|
||||
case <-time.After(5 * time.Second):
|
||||
close(stop)
|
||||
writerWg.Wait()
|
||||
t.Fatal("timed out waiting for reader goroutines")
|
||||
}
|
||||
close(stop)
|
||||
writerWg.Wait()
|
||||
|
||||
close(errs)
|
||||
for e := range errs {
|
||||
t.Errorf("inconsistent snapshot: %s", e)
|
||||
}
|
||||
}
|
||||
|
||||
func ftoa(f float64) string { return fmt.Sprintf("%g", f) }
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Issue #1229 (Option C): edge source-diversity confidence weighting.
|
||||
//
|
||||
// The tier-1 affinity scorer must demote edges contributed by a single
|
||||
// observer relative to edges corroborated by multiple distinct observers.
|
||||
// Without this guard, one observer with a chatty link can dominate the
|
||||
// global graph and force resolution to the "wrong" candidate in a region
|
||||
// it doesn't actually cover.
|
||||
//
|
||||
// Fixture (two "8a" candidates from the same anchor's neighborhood):
|
||||
// candX: 25 contributions from 1 observer (single-source, suspect)
|
||||
// candY: 30 contributions from 6 distinct observers (corroborated)
|
||||
//
|
||||
// Raw count score:
|
||||
// candX score ≈ 0.25, candY score ≈ 0.30 — ratio ≈ 1.2× (below 3×, falls
|
||||
// through to tier 2). Without confidence weighting tier 2 would pick
|
||||
// candX because we placed it geo-near the anchor — exactly the
|
||||
// cross-region pollution failure mode described in the issue.
|
||||
//
|
||||
// Confidence-weighted score (multiplier = min(1, |observers|/3)):
|
||||
// candX = 0.25 × (1/3) ≈ 0.083
|
||||
// candY = 0.30 × 1.0 = 0.30
|
||||
// ratio ≈ 3.6× — clears affinityConfidenceRatio, tier-1 returns candY
|
||||
// with method "neighbor_affinity".
|
||||
|
||||
func seedAffinityFromObservers(g *NeighborGraph, anchor, candPK, prefix string, observers []string, perObserver int) {
|
||||
now := time.Now()
|
||||
step := 0
|
||||
for _, obs := range observers {
|
||||
for i := 0; i < perObserver; i++ {
|
||||
g.upsertEdge(anchor, candPK, prefix, obs, nil, now.Add(-time.Duration(step)*time.Minute))
|
||||
step++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWithContext_Tier1_ConfidencePrefersMultiObserverEdge(t *testing.T) {
|
||||
nodes := []nodeInfo{
|
||||
// candX: placed near the anchor so tier-2 (geo) would pick it.
|
||||
{PublicKey: "8aaaaaaaaaaa", Role: "repeater", Name: "candX", HasGPS: true, Lat: 34.06, Lon: -118.26},
|
||||
// candY: far from anchor; only source-diversity confidence rescues it.
|
||||
{PublicKey: "8abbbbbbbbbb", Role: "repeater", Name: "candY", HasGPS: true, Lat: 47.6, Lon: -122.3},
|
||||
{PublicKey: "ffeeeeeeeeee", Role: "repeater", Name: "anchor", HasGPS: true, Lat: 34.05, Lon: -118.25},
|
||||
}
|
||||
anchor := "ffeeeeeeeeee"
|
||||
|
||||
g := NewNeighborGraph()
|
||||
// candX: 1 observer × 25 obs → single-source, demoted to 1/3 weight.
|
||||
seedAffinityFromObservers(g, anchor, "8aaaaaaaaaaa", "8a",
|
||||
[]string{"obs1"}, 25)
|
||||
// candY: 6 distinct observers × 5 obs each = 30 obs → full weight.
|
||||
seedAffinityFromObservers(g, anchor, "8abbbbbbbbbb", "8a",
|
||||
[]string{"obs1", "obs2", "obs3", "obs4", "obs5", "obs6"}, 5)
|
||||
|
||||
pm := buildPrefixMap(nodes)
|
||||
r, method, score := pm.resolveWithContext("8a", []string{anchor}, g)
|
||||
if r == nil {
|
||||
t.Fatal("expected non-nil candidate")
|
||||
}
|
||||
if r.Name != "candY" {
|
||||
t.Fatalf("want candY (corroborated by 6 observers); got %s via %s score=%v",
|
||||
r.Name, method, score)
|
||||
}
|
||||
if method != "neighbor_affinity" {
|
||||
t.Fatalf("want method=neighbor_affinity (confidence-weighted tier 1); got %s", method)
|
||||
}
|
||||
}
|
||||
|
||||
// Sanity gate on the source-diversity counter itself: repeated contributions
|
||||
// from the same observer must NOT inflate the observer-set count, but
|
||||
// contributions from new observers must increment it.
|
||||
func TestNeighborEdge_ObserverSetIsDistinct(t *testing.T) {
|
||||
g := NewNeighborGraph()
|
||||
now := time.Now()
|
||||
// 10 contributions from obs1 — set size must stay 1.
|
||||
for i := 0; i < 10; i++ {
|
||||
g.upsertEdge("aa11", "bb22", "bb", "obs1", nil, now)
|
||||
}
|
||||
// 1 contribution each from obs2..obs4 — set size grows to 4.
|
||||
g.upsertEdge("aa11", "bb22", "bb", "obs2", nil, now)
|
||||
g.upsertEdge("aa11", "bb22", "bb", "obs3", nil, now)
|
||||
g.upsertEdge("aa11", "bb22", "bb", "obs4", nil, now)
|
||||
|
||||
edges := g.Neighbors("aa11")
|
||||
if len(edges) != 1 {
|
||||
t.Fatalf("expected 1 edge; got %d", len(edges))
|
||||
}
|
||||
e := edges[0]
|
||||
if len(e.Observers) != 4 {
|
||||
t.Fatalf("expected 4 distinct observers; got %d (%v)", len(e.Observers), e.Observers)
|
||||
}
|
||||
if e.Count != 13 {
|
||||
t.Fatalf("expected count=13 (10+3); got %d", e.Count)
|
||||
}
|
||||
if got := e.Confidence(); got != 1.0 {
|
||||
t.Fatalf("Confidence() with 4 observers: want 1.0 (saturated); got %v", got)
|
||||
}
|
||||
// Single-observer edge must report degraded confidence.
|
||||
g.upsertEdge("aa11", "cc33", "cc", "obs1", nil, now)
|
||||
for _, ee := range g.Neighbors("aa11") {
|
||||
if ee.NodeA == "cc33" || ee.NodeB == "cc33" {
|
||||
if got := ee.Confidence(); got >= 1.0 || got <= 0 {
|
||||
t.Fatalf("Confidence() single-observer: want in (0,1); got %v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -166,7 +166,7 @@ func TestTopHopsRespectsContextAcrossAllCallSites(t *testing.T) {
|
||||
for i := 0; i < 2; i++ {
|
||||
g.upsertEdge(t1201Sender, t1201_72dd, "72", t1201Observer, nil, now.Add(-time.Duration(i)*time.Hour))
|
||||
}
|
||||
store.graph = g
|
||||
store.graph.Store(g)
|
||||
|
||||
if err := store.Load(); err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestHotStartup_loadChunk_IndexSliceConsistency guards against regression
|
||||
// of PR #1187 r3 MUST-FIX 1: the batched merge in loadChunk used to
|
||||
// (1) prepend localPackets to s.packets under one critical section, then
|
||||
// (2) populate s.byHash/s.byTxID/s.byObsID/s.byNode/s.byPayloadType in
|
||||
// separate per-batch critical sections. Readers that acquired RLock
|
||||
// between the slice update and the index updates observed packets that
|
||||
// were in the slice but missing from byHash — causing GetPacketByHash to
|
||||
// return nil and QueryPackets hash/node fast-paths to silently miss data
|
||||
// during background load.
|
||||
//
|
||||
// The invariant under test: for any RLock-held snapshot, every tx in
|
||||
// s.packets must also be present in s.byHash[tx.Hash]. Violation = silent
|
||||
// partial data loss.
|
||||
func TestHotStartup_loadChunk_IndexSliceConsistency(t *testing.T) {
|
||||
// 10 recent + 1200 old: 1200 > 2 * mergeBatchSize(500) so the merge
|
||||
// spans 3 batches, widening the inconsistency window for the reader.
|
||||
dbPath := createTestDBWithAgedPackets(t, 10, 1200)
|
||||
|
||||
db, err := OpenDB(dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.conn.Close()
|
||||
|
||||
store := NewPacketStore(db, &PacketStoreConfig{
|
||||
RetentionHours: 72,
|
||||
HotStartupHours: 1,
|
||||
})
|
||||
if err := store.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(store.packets) != 10 {
|
||||
t.Fatalf("setup: expected 10 packets after hot Load, got %d", len(store.packets))
|
||||
}
|
||||
|
||||
var stop atomic.Bool
|
||||
var violations atomic.Int64
|
||||
var checks atomic.Int64
|
||||
|
||||
var wg sync.WaitGroup
|
||||
// Reader: repeatedly snapshot under RLock. For each tx in s.packets,
|
||||
// assert s.byHash[tx.Hash] is non-nil. Any miss = consistency violation.
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for !stop.Load() {
|
||||
store.mu.RLock()
|
||||
for _, tx := range store.packets {
|
||||
if tx == nil || tx.Hash == "" {
|
||||
continue
|
||||
}
|
||||
checks.Add(1)
|
||||
if store.byHash[tx.Hash] == nil {
|
||||
violations.Add(1)
|
||||
}
|
||||
// Also: byTxID for this tx must be populated
|
||||
if store.byTxID[tx.ID] == nil {
|
||||
violations.Add(1)
|
||||
}
|
||||
}
|
||||
store.mu.RUnlock()
|
||||
}
|
||||
}()
|
||||
|
||||
// Give the reader a moment to start the loop.
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
|
||||
// Trigger the batched merge.
|
||||
chunkEnd := time.Now().UTC().Add(-1 * time.Hour)
|
||||
chunkStart := time.Now().UTC().Add(-72 * time.Hour)
|
||||
if err := store.loadChunk(chunkStart, chunkEnd); err != nil {
|
||||
stop.Store(true)
|
||||
wg.Wait()
|
||||
t.Fatalf("loadChunk failed: %v", err)
|
||||
}
|
||||
|
||||
// Let reader observe a few iterations after merge completes.
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
stop.Store(true)
|
||||
wg.Wait()
|
||||
|
||||
if v := violations.Load(); v > 0 {
|
||||
t.Fatalf("index↔slice consistency violated %d times across %d checks: "+
|
||||
"packets observed in s.packets that were missing from s.byHash/s.byTxID. "+
|
||||
"This is the silent-partial-data-loss regression from R2 #6 (commit 2ec762aa).",
|
||||
v, checks.Load())
|
||||
}
|
||||
|
||||
// Post-condition sanity: final state must be fully consistent.
|
||||
store.mu.RLock()
|
||||
defer store.mu.RUnlock()
|
||||
if len(store.packets) != 1210 {
|
||||
t.Errorf("expected 1210 packets after merge, got %d", len(store.packets))
|
||||
}
|
||||
for _, tx := range store.packets {
|
||||
if store.byHash[tx.Hash] == nil {
|
||||
t.Errorf("post-merge: tx %s missing from byHash", tx.Hash)
|
||||
break
|
||||
}
|
||||
}
|
||||
// Spot check: an old packet hash must be retrievable via GetPacketByHash.
|
||||
// (Drop the RLock first to avoid deadlock; GetPacketByHash takes RLock.)
|
||||
_ = strings.ToLower
|
||||
}
|
||||
@@ -0,0 +1,608 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// createTestDBMultiDay creates a test DB with packets spread across numDays days.
|
||||
// txPerDay transmissions are inserted per day, oldest day first.
|
||||
// Packets within each day are spaced 1 minute apart.
|
||||
func createTestDBMultiDay(t *testing.T, numDays, txPerDay int) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "test.db")
|
||||
|
||||
conn, err := sql.Open("sqlite", dbPath+"?_journal_mode=WAL")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
execOrFail := func(s string) {
|
||||
if _, err := conn.Exec(s); err != nil {
|
||||
t.Fatalf("createTestDBMultiDay setup: %v", err)
|
||||
}
|
||||
}
|
||||
execOrFail(`CREATE TABLE transmissions (id INTEGER PRIMARY KEY, raw_hex TEXT, hash TEXT, first_seen TEXT, route_type INTEGER, payload_type INTEGER, payload_version INTEGER, decoded_json TEXT)`)
|
||||
execOrFail(`CREATE TABLE observations (id INTEGER PRIMARY KEY, transmission_id INTEGER, observer_id TEXT, observer_name TEXT, direction TEXT, snr REAL, rssi REAL, score INTEGER, path_json TEXT, timestamp TEXT, raw_hex TEXT)`)
|
||||
execOrFail(`CREATE TABLE observers (rowid INTEGER PRIMARY KEY, id TEXT, name TEXT, iata TEXT)`)
|
||||
execOrFail(`CREATE TABLE nodes (pubkey TEXT PRIMARY KEY, name TEXT, role TEXT, lat REAL, lon REAL, last_seen TEXT, first_seen TEXT, frequency REAL)`)
|
||||
execOrFail(`CREATE TABLE schema_version (version INTEGER)`)
|
||||
execOrFail(`INSERT INTO schema_version (version) VALUES (1)`)
|
||||
execOrFail(`CREATE INDEX idx_tx_first_seen ON transmissions(first_seen)`)
|
||||
|
||||
id := 1
|
||||
now := time.Now().UTC()
|
||||
for day := numDays; day >= 1; day-- {
|
||||
// Offset by +30 minutes so day boundaries don't coincide exactly with
|
||||
// hotStartupHours/retentionHours cutoffs, preventing timing-boundary flakiness.
|
||||
// E.g. for numDays=3: day3 starts at now-71.5h, day2 at now-47.5h, day1 at now-23.5h.
|
||||
base := now.Add(-time.Duration(day)*24*time.Hour + 30*time.Minute)
|
||||
for i := 0; i < txPerDay; i++ {
|
||||
ts := base.Add(time.Duration(i) * time.Minute).Format(time.RFC3339)
|
||||
hash := fmt.Sprintf("hash%06d", id)
|
||||
if _, err := conn.Exec("INSERT INTO transmissions VALUES (?,?,?,?,0,4,1,?)", id, "aa", hash, ts, `{}`); err != nil {
|
||||
t.Fatalf("createTestDBMultiDay insert tx: %v", err)
|
||||
}
|
||||
if _, err := conn.Exec("INSERT INTO observations VALUES (?,?,?,?,?,?,?,?,?,?,?)", id, id, "obs1", "Obs1", "RX", -10.0, -80.0, 5, `[]`, ts, ""); err != nil {
|
||||
t.Fatalf("createTestDBMultiDay insert obs: %v", err)
|
||||
}
|
||||
id++
|
||||
}
|
||||
}
|
||||
return dbPath
|
||||
}
|
||||
|
||||
// waitForBackgroundLoad polls backgroundLoadDone until true or timeout.
|
||||
func waitForBackgroundLoad(t *testing.T, store *PacketStore, timeout time.Duration) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
if store.backgroundLoadDone.Load() {
|
||||
return
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("background load did not complete within %v", timeout)
|
||||
}
|
||||
|
||||
func TestHotStartupConfig_Clamp(t *testing.T) {
|
||||
dbPath := createTestDB(t, 10)
|
||||
|
||||
db, err := OpenDB(dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.conn.Close()
|
||||
|
||||
// hotStartupHours > retentionHours → must be clamped
|
||||
store := NewPacketStore(db, &PacketStoreConfig{
|
||||
RetentionHours: 24,
|
||||
HotStartupHours: 48,
|
||||
})
|
||||
if store.hotStartupHours != 24 {
|
||||
t.Errorf("expected hotStartupHours clamped to retentionHours=24, got %f", store.hotStartupHours)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHotStartupConfig_ZeroIsDisabled(t *testing.T) {
|
||||
dbPath := createTestDB(t, 10)
|
||||
|
||||
db, err := OpenDB(dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.conn.Close()
|
||||
|
||||
store := NewPacketStore(db, &PacketStoreConfig{
|
||||
RetentionHours: 24,
|
||||
HotStartupHours: 0,
|
||||
})
|
||||
if store.hotStartupHours != 0 {
|
||||
t.Errorf("expected hotStartupHours=0, got %f", store.hotStartupHours)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHotStartup_LoadsOnlyHotWindow(t *testing.T) {
|
||||
// 50 old packets (48h ago), 10 recent (30min ago)
|
||||
dbPath := createTestDBWithAgedPackets(t, 10, 50)
|
||||
|
||||
db, err := OpenDB(dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.conn.Close()
|
||||
|
||||
store := NewPacketStore(db, &PacketStoreConfig{
|
||||
RetentionHours: 72,
|
||||
HotStartupHours: 1, // load only last 1 hour
|
||||
})
|
||||
if err := store.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Only the 10 recent packets should be in memory
|
||||
if len(store.packets) != 10 {
|
||||
t.Errorf("expected 10 recent packets in hot window, got %d", len(store.packets))
|
||||
}
|
||||
// oldestLoaded should be ~1h ago
|
||||
if store.oldestLoaded == "" {
|
||||
t.Fatal("oldestLoaded must be set after Load()")
|
||||
}
|
||||
oldest, _ := time.Parse(time.RFC3339, store.oldestLoaded)
|
||||
diff := time.Since(oldest)
|
||||
if diff < 30*time.Minute || diff > 90*time.Minute {
|
||||
t.Errorf("oldestLoaded %s should be ~1h ago, got diff=%v", store.oldestLoaded, diff)
|
||||
}
|
||||
// backgroundLoadDone must not be set by Load() itself
|
||||
if store.backgroundLoadDone.Load() {
|
||||
t.Error("backgroundLoadDone must not be true after Load()")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHotStartup_DisabledWhenZero(t *testing.T) {
|
||||
// 50 old (48h ago), 10 recent (30min ago) — all within 72h retention
|
||||
dbPath := createTestDBWithAgedPackets(t, 10, 50)
|
||||
|
||||
db, err := OpenDB(dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.conn.Close()
|
||||
|
||||
store := NewPacketStore(db, &PacketStoreConfig{
|
||||
RetentionHours: 72,
|
||||
HotStartupHours: 0, // disabled → load all retentionHours as before
|
||||
})
|
||||
if err := store.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// All 60 packets should be loaded (both old and recent within 72h)
|
||||
if len(store.packets) != 60 {
|
||||
t.Errorf("expected 60 packets with hotStartupHours=0, got %d", len(store.packets))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHotStartup_loadChunk_AddsOlderData(t *testing.T) {
|
||||
// 50 old packets (48h ago), 10 recent (30min ago)
|
||||
dbPath := createTestDBWithAgedPackets(t, 10, 50)
|
||||
|
||||
db, err := OpenDB(dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.conn.Close()
|
||||
|
||||
store := NewPacketStore(db, &PacketStoreConfig{
|
||||
RetentionHours: 72,
|
||||
HotStartupHours: 1,
|
||||
})
|
||||
if err := store.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(store.packets) != 10 {
|
||||
t.Fatalf("setup: expected 10 packets after hot Load, got %d", len(store.packets))
|
||||
}
|
||||
|
||||
// Load the old chunk (covers the 50 old packets at ~48h ago)
|
||||
chunkEnd := time.Now().UTC().Add(-1 * time.Hour)
|
||||
chunkStart := time.Now().UTC().Add(-72 * time.Hour)
|
||||
if err := store.loadChunk(chunkStart, chunkEnd); err != nil {
|
||||
t.Fatalf("loadChunk failed: %v", err)
|
||||
}
|
||||
|
||||
// Should have 10 recent + 50 old
|
||||
if len(store.packets) != 60 {
|
||||
t.Errorf("expected 60 packets after loadChunk, got %d", len(store.packets))
|
||||
}
|
||||
// Packets must remain sorted ASC by first_seen
|
||||
for i := 1; i < len(store.packets); i++ {
|
||||
if store.packets[i].FirstSeen < store.packets[i-1].FirstSeen {
|
||||
t.Fatalf("packets not in ASC order at index %d: %s < %s",
|
||||
i, store.packets[i].FirstSeen, store.packets[i-1].FirstSeen)
|
||||
}
|
||||
}
|
||||
// byHash must include the old packets
|
||||
if len(store.byHash) != 60 {
|
||||
t.Errorf("expected byHash len=60, got %d", len(store.byHash))
|
||||
}
|
||||
// byObserver must reflect all 60 observations for obs1
|
||||
if len(store.byObserver["obs1"]) != 60 {
|
||||
t.Errorf("expected byObserver[obs1] len=60, got %d", len(store.byObserver["obs1"]))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHotStartup_BackgroundFillsToRetention(t *testing.T) {
|
||||
// 3 days × 50 tx/day = 150 total
|
||||
dbPath := createTestDBMultiDay(t, 3, 50)
|
||||
|
||||
db, err := OpenDB(dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.conn.Close()
|
||||
|
||||
store := NewPacketStore(db, &PacketStoreConfig{
|
||||
RetentionHours: 72,
|
||||
HotStartupHours: 24,
|
||||
})
|
||||
if err := store.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// After hot Load: only ~50 packets (day 1 = last 24h)
|
||||
afterHot := len(store.packets)
|
||||
if afterHot < 1 || afterHot > 60 {
|
||||
t.Errorf("expected ~50 packets after hot Load, got %d", afterHot)
|
||||
}
|
||||
|
||||
// Start background fill
|
||||
go store.loadBackgroundChunks()
|
||||
waitForBackgroundLoad(t, store, 15*time.Second)
|
||||
|
||||
// After background fill: all 150 packets should be loaded
|
||||
store.mu.RLock()
|
||||
total := len(store.packets)
|
||||
store.mu.RUnlock()
|
||||
|
||||
if total != 150 {
|
||||
t.Errorf("expected 150 packets after background load, got %d", total)
|
||||
}
|
||||
if !store.backgroundLoadDone.Load() {
|
||||
t.Error("backgroundLoadDone must be true after loadBackgroundChunks returns")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHotStartup_ChunkErrorRecovery(t *testing.T) {
|
||||
dbPath := createTestDBWithAgedPackets(t, 10, 50)
|
||||
|
||||
db, err := OpenDB(dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
store := NewPacketStore(db, &PacketStoreConfig{
|
||||
RetentionHours: 72,
|
||||
HotStartupHours: 1,
|
||||
})
|
||||
if err := store.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// intentional: closed early to simulate chunk-load failures; no defer
|
||||
db.conn.Close()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
store.loadBackgroundChunks()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
// Good — completed without hanging.
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("loadBackgroundChunks hung after DB close")
|
||||
}
|
||||
|
||||
if !store.backgroundLoadDone.Load() {
|
||||
t.Error("backgroundLoadDone must be set even when all chunks fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHotStartup_SQLFallback_TriggeredForOldDate(t *testing.T) {
|
||||
// 50 old packets (48h ago), 10 recent (30min ago)
|
||||
dbPath := createTestDBWithAgedPackets(t, 10, 50)
|
||||
|
||||
db, err := OpenDB(dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.conn.Close()
|
||||
|
||||
// Hot load: only last 1h → 10 recent packets in memory
|
||||
store := NewPacketStore(db, &PacketStoreConfig{
|
||||
RetentionHours: 72,
|
||||
HotStartupHours: 1,
|
||||
})
|
||||
if err := store.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(store.packets) != 10 {
|
||||
t.Fatalf("setup: expected 10 in-memory packets, got %d", len(store.packets))
|
||||
}
|
||||
|
||||
// Query with Since = 49h ago (before oldestLoaded ~1h ago) → SQL fallback
|
||||
since49h := time.Now().UTC().Add(-49 * time.Hour).Format(time.RFC3339)
|
||||
result := store.QueryPackets(PacketQuery{Since: since49h, Limit: 100, Order: "ASC"})
|
||||
|
||||
// SQL fallback returns all packets newer than Since: 50 old (48h ago) + 10 recent (30min ago) = 60
|
||||
if result.Total != 60 {
|
||||
t.Errorf("expected SQL fallback to return 60 packets for Since=49h ago, got %d", result.Total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHotStartup_PerfStats(t *testing.T) {
|
||||
dbPath := createTestDBWithAgedPackets(t, 10, 50)
|
||||
|
||||
db, err := OpenDB(dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.conn.Close()
|
||||
|
||||
store := NewPacketStore(db, &PacketStoreConfig{
|
||||
RetentionHours: 72,
|
||||
HotStartupHours: 1,
|
||||
})
|
||||
if err := store.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
stats := store.GetPerfStoreStats()
|
||||
|
||||
if v, ok := stats["hotStartupHours"]; !ok || v.(float64) != 1 {
|
||||
t.Errorf("expected hotStartupHours=1 in stats, got %v", v)
|
||||
}
|
||||
if v, ok := stats["backgroundLoadComplete"]; !ok || v.(bool) != false {
|
||||
t.Errorf("expected backgroundLoadComplete=false in stats, got %v", v)
|
||||
}
|
||||
if _, ok := stats["backgroundLoadProgress"]; !ok {
|
||||
t.Error("expected backgroundLoadProgress in stats")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHotStartup_SQLFallback_NotTriggeredForRecentDate(t *testing.T) {
|
||||
// 50 old packets (48h ago), 10 recent (30min ago)
|
||||
dbPath := createTestDBWithAgedPackets(t, 10, 50)
|
||||
|
||||
db, err := OpenDB(dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.conn.Close()
|
||||
|
||||
// Hot load: last 1h → 10 recent packets in memory
|
||||
store := NewPacketStore(db, &PacketStoreConfig{
|
||||
RetentionHours: 72,
|
||||
HotStartupHours: 1,
|
||||
})
|
||||
if err := store.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Query with Since = 45min ago (after oldestLoaded ~1h ago) → in-memory path
|
||||
since45m := time.Now().UTC().Add(-45 * time.Minute).Format(time.RFC3339)
|
||||
result := store.QueryPackets(PacketQuery{Since: since45m, Limit: 100, Order: "ASC"})
|
||||
|
||||
// In-memory path: returns only the 10 recent packets (all within last 30min)
|
||||
if result.Total != 10 {
|
||||
t.Errorf("expected 10 in-memory packets for recent Since query, got %d", result.Total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHotStartup_SQLFallback_Until(t *testing.T) {
|
||||
// 50 old packets (48h ago), 10 recent (30min ago)
|
||||
dbPath := createTestDBWithAgedPackets(t, 10, 50)
|
||||
|
||||
db, err := OpenDB(dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.conn.Close()
|
||||
|
||||
// Hot load: only last 1h → 10 recent in memory, oldestLoaded ~1h ago
|
||||
store := NewPacketStore(db, &PacketStoreConfig{
|
||||
RetentionHours: 72,
|
||||
HotStartupHours: 1,
|
||||
})
|
||||
if err := store.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(store.packets) != 10 {
|
||||
t.Fatalf("setup: expected 10 in-memory packets, got %d", len(store.packets))
|
||||
}
|
||||
|
||||
// Until = 2h ago (before oldestLoaded ~1h ago) → SQL fallback
|
||||
until2h := time.Now().UTC().Add(-2 * time.Hour).Format(time.RFC3339)
|
||||
result := store.QueryPackets(PacketQuery{Until: until2h, Limit: 100, Order: "ASC"})
|
||||
|
||||
// SQL fallback returns the 50 old packets (stored at ~48h ago, all before Until)
|
||||
if result.Total != 50 {
|
||||
t.Errorf("expected SQL fallback to return 50 old packets for Until before oldestLoaded, got %d", result.Total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHotStartup_PerfStoreHTTP(t *testing.T) {
|
||||
dbPath := createTestDBWithAgedPackets(t, 10, 50)
|
||||
|
||||
db, err := OpenDB(dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.conn.Close()
|
||||
|
||||
store := NewPacketStore(db, &PacketStoreConfig{
|
||||
RetentionHours: 72,
|
||||
HotStartupHours: 1,
|
||||
})
|
||||
if err := store.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
srv := NewServer(db, &Config{Port: 3000}, NewHub())
|
||||
srv.store = store
|
||||
router := mux.NewRouter()
|
||||
srv.RegisterRoutes(router)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/perf", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("invalid JSON: %v", err)
|
||||
}
|
||||
ps, ok := body["packetStore"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("missing packetStore in /api/perf response")
|
||||
}
|
||||
for _, field := range []string{"hotStartupHours", "backgroundLoadComplete", "backgroundLoadProgress"} {
|
||||
if _, ok := ps[field]; !ok {
|
||||
t.Errorf("missing field %q in packetStore", field)
|
||||
}
|
||||
}
|
||||
if v, ok := ps["hotStartupHours"].(float64); !ok || v != 1 {
|
||||
t.Errorf("expected hotStartupHours=1, got %v", ps["hotStartupHours"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHotStartup_ConcurrentQueryDuringBackgroundLoad(t *testing.T) {
|
||||
// 5 days × 200 tx/day = 1000 total — small enough to run in CI fast,
|
||||
// large enough to give pollers >=1 query during the background fill.
|
||||
dbPath := createTestDBMultiDay(t, 5, 200)
|
||||
|
||||
db, err := OpenDB(dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.conn.Close()
|
||||
|
||||
// Hot load: only last 24h → ~200 packets in memory
|
||||
store := NewPacketStore(db, &PacketStoreConfig{
|
||||
RetentionHours: 120,
|
||||
HotStartupHours: 24,
|
||||
})
|
||||
if err := store.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
preLen := len(store.packets)
|
||||
|
||||
// Real invariant (Munger r2 #5): while background fill is running,
|
||||
// the result set for a fixed [since, until] window must be monotonic
|
||||
// in TIME — rows only appear, never disappear. The query window must
|
||||
// straddle the moving oldestLoaded boundary so we exercise both the
|
||||
// SQL fallback (since < oldestLoaded) and the in-memory path
|
||||
// (oldestLoaded shrinks below since as chunks merge).
|
||||
//
|
||||
// since=200h ago covers everything; as oldestLoaded retreats from
|
||||
// 24h ago to 120h ago, the answer source switches from SQL fallback
|
||||
// to in-memory; Total must never decrease across that switch.
|
||||
since := time.Now().UTC().Add(-200 * time.Hour).Format(time.RFC3339)
|
||||
q := PacketQuery{Since: since, Limit: 5000, Order: "ASC"}
|
||||
|
||||
// Start background fill.
|
||||
go store.loadBackgroundChunks()
|
||||
|
||||
// Pollers: each goroutine keeps querying until the loader is done,
|
||||
// asserting that within its own series Total only grows or stays equal.
|
||||
// A shrink — even by one row — is a real-invariant violation that
|
||||
// the trivial Total>=0 / postLen>=preLen tests could not catch.
|
||||
var wg sync.WaitGroup
|
||||
pollers := 8
|
||||
totalSamples := atomicSamples{}
|
||||
for i := 0; i < pollers; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
lastTotal := -1
|
||||
for !store.backgroundLoadDone.Load() {
|
||||
r := store.QueryPackets(q)
|
||||
if r == nil {
|
||||
continue
|
||||
}
|
||||
if lastTotal >= 0 && r.Total < lastTotal {
|
||||
t.Errorf("poller %d: result set shrank (%d → %d) — non-monotonic across moving oldestLoaded boundary",
|
||||
i, lastTotal, r.Total)
|
||||
}
|
||||
lastTotal = r.Total
|
||||
totalSamples.inc()
|
||||
}
|
||||
r := store.QueryPackets(q)
|
||||
if r != nil {
|
||||
if lastTotal >= 0 && r.Total < lastTotal {
|
||||
t.Errorf("poller %d: post-load result set shrank (%d → %d)", i, lastTotal, r.Total)
|
||||
}
|
||||
totalSamples.inc()
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
waitForBackgroundLoad(t, store, 60*time.Second)
|
||||
|
||||
store.mu.RLock()
|
||||
postLen := len(store.packets)
|
||||
store.mu.RUnlock()
|
||||
|
||||
if postLen < preLen {
|
||||
t.Errorf("expected packet count after background load (%d) >= pre-background (%d)", postLen, preLen)
|
||||
}
|
||||
if totalSamples.get() == 0 {
|
||||
t.Error("pollers observed zero samples — test did not actually exercise the invariant")
|
||||
}
|
||||
}
|
||||
|
||||
type atomicSamples struct {
|
||||
n int64
|
||||
}
|
||||
|
||||
func (a *atomicSamples) inc() { atomic.AddInt64(&a.n, 1) }
|
||||
func (a *atomicSamples) get() int64 {
|
||||
return atomic.LoadInt64(&a.n)
|
||||
}
|
||||
|
||||
// TestHotStartup_BackgroundLoadFailureSurfacesInPerf asserts that when every
|
||||
// background chunk errors, the store does NOT report backgroundLoadComplete=true
|
||||
// — instead it surfaces backgroundLoadFailed=true via GetPerfStoreStats so
|
||||
// operators see a visible failure rather than silent data loss. Munger r2 #3.
|
||||
func TestHotStartup_BackgroundLoadFailureSurfacesInPerf(t *testing.T) {
|
||||
dbPath := createTestDBMultiDay(t, 3, 50)
|
||||
|
||||
db, err := OpenDB(dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store := NewPacketStore(db, &PacketStoreConfig{
|
||||
RetentionHours: 72,
|
||||
HotStartupHours: 24,
|
||||
})
|
||||
if err := store.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Force every loadChunk call to fail by closing the read connection.
|
||||
// loadBackgroundChunks must then NOT report "complete" — it must report failed.
|
||||
if err := db.conn.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
store.loadBackgroundChunks()
|
||||
|
||||
perf := store.GetPerfStoreStats()
|
||||
failed, hasFailedKey := perf["backgroundLoadFailed"].(bool)
|
||||
|
||||
if !hasFailedKey {
|
||||
t.Fatalf("expected backgroundLoadFailed key in /api/perf payload, got keys: %v", perf)
|
||||
}
|
||||
if !failed {
|
||||
t.Errorf("expected backgroundLoadFailed=true after every chunk errored, got false (observability lying)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestQueryGroupedPacketsReturnsDistinctIATAs (#1189 R2):
|
||||
// The default collapsed grouped view must already expose the DISTINCT set
|
||||
// of observer IATA codes for each transmission — frontend can't compute it
|
||||
// because p._children is empty until the user expands the row (or applies a
|
||||
// non-default sort). Previously the cell showed a single IATA + "+N" of
|
||||
// observer count, which conflates SAME-region redundancy with CROSS-region
|
||||
// reception. R1 added a frontend helper but it only fired on the expanded
|
||||
// view; this test gates the server-side fix.
|
||||
//
|
||||
// Seeds one transmission with observations from two IATAs (SJC, SFO) and
|
||||
// asserts the grouped row carries distinct_iatas containing both codes.
|
||||
func TestQueryGroupedPacketsReturnsDistinctIATAs(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
now := time.Now().UTC()
|
||||
recentEpoch := now.Add(-1 * time.Hour).Unix()
|
||||
|
||||
// Observers: SJC + SFO + a third with no IATA (should be excluded).
|
||||
db.conn.Exec(`INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count)
|
||||
VALUES ('obsA', 'A', 'SJC', ?, '2026-01-01T00:00:00Z', 10)`, now.Format(time.RFC3339))
|
||||
db.conn.Exec(`INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count)
|
||||
VALUES ('obsB', 'B', 'SFO', ?, '2026-01-01T00:00:00Z', 10)`, now.Format(time.RFC3339))
|
||||
db.conn.Exec(`INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count)
|
||||
VALUES ('obsC', 'C', '', ?, '2026-01-01T00:00:00Z', 10)`, now.Format(time.RFC3339))
|
||||
|
||||
// One transmission with 3 observations (SJC, SFO, no-IATA).
|
||||
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json)
|
||||
VALUES ('AABB', 'deadbeefcafef00d', ?, 1, 4, '{}')`, now.Format(time.RFC3339))
|
||||
// v3 schema: observer_idx = observers.rowid (auto-assigned 1,2,3 in insert order).
|
||||
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
|
||||
VALUES (1, 1, 12.0, -80, '["aa"]', ?)`, recentEpoch)
|
||||
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
|
||||
VALUES (1, 2, 8.0, -90, '["aa"]', ?)`, recentEpoch-30)
|
||||
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
|
||||
VALUES (1, 3, 5.0, -95, '["aa"]', ?)`, recentEpoch-60)
|
||||
|
||||
result, err := db.QueryGroupedPackets(PacketQuery{Limit: 50})
|
||||
if err != nil {
|
||||
t.Fatalf("QueryGroupedPackets: %v", err)
|
||||
}
|
||||
if result.Total != 1 {
|
||||
t.Fatalf("expected 1 grouped tx, got %d", result.Total)
|
||||
}
|
||||
|
||||
row := result.Packets[0]
|
||||
raw, ok := row["distinct_iatas"]
|
||||
if !ok {
|
||||
t.Fatalf("expected distinct_iatas key in grouped row, got: %#v", row)
|
||||
}
|
||||
iatas, ok := raw.([]string)
|
||||
if !ok {
|
||||
t.Fatalf("expected distinct_iatas to be []string, got %T (%v)", raw, raw)
|
||||
}
|
||||
sort.Strings(iatas)
|
||||
want := []string{"SFO", "SJC"}
|
||||
if strings.Join(iatas, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("distinct_iatas = %v, want %v (must exclude empty-IATA observers, dedupe)", iatas, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueryGroupedPacketsDistinctIATAsEmptyWhenNoIATA (#1189 R2):
|
||||
// Group whose observers all have no IATA → distinct_iatas should be empty
|
||||
// (or absent / empty slice) — must NOT carry stale data from another group.
|
||||
func TestQueryGroupedPacketsDistinctIATAsEmptyWhenNoIATA(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
now := time.Now().UTC()
|
||||
recentEpoch := now.Add(-1 * time.Hour).Unix()
|
||||
|
||||
db.conn.Exec(`INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count)
|
||||
VALUES ('obsX', 'X', '', ?, '2026-01-01T00:00:00Z', 1)`, now.Format(time.RFC3339))
|
||||
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json)
|
||||
VALUES ('AA', '1111222233334444', ?, 1, 4, '{}')`, now.Format(time.RFC3339))
|
||||
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
|
||||
VALUES (1, 1, 10.0, -85, '[]', ?)`, recentEpoch)
|
||||
|
||||
result, err := db.QueryGroupedPackets(PacketQuery{Limit: 50})
|
||||
if err != nil {
|
||||
t.Fatalf("QueryGroupedPackets: %v", err)
|
||||
}
|
||||
if result.Total != 1 {
|
||||
t.Fatalf("expected 1 grouped tx, got %d", result.Total)
|
||||
}
|
||||
row := result.Packets[0]
|
||||
raw, ok := row["distinct_iatas"]
|
||||
if !ok {
|
||||
// absent key acceptable — treat as empty
|
||||
return
|
||||
}
|
||||
iatas, ok := raw.([]string)
|
||||
if !ok {
|
||||
t.Fatalf("distinct_iatas should be []string, got %T", raw)
|
||||
}
|
||||
if len(iatas) != 0 {
|
||||
t.Fatalf("distinct_iatas should be empty for no-IATA group, got %v", iatas)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package main
|
||||
|
||||
// Tests for issue #1279 P2 items:
|
||||
// - Item 1: payloadTypeNames map must include ALL 13 firmware payload types.
|
||||
// - Item 5: RAW_CUSTOM (0x0F) decoder must expose rawLength + firstByteTag.
|
||||
//
|
||||
// Firmware refs:
|
||||
// - firmware/src/Packet.h:19-32 (PAYLOAD_TYPE_*) — 0..0xB plus 0xF (RAW_CUSTOM)
|
||||
// - firmware/src/Mesh.cpp:577 (createRawData) — application-defined payload
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPayloadTypeNamesAll13(t *testing.T) {
|
||||
// Firmware-defined: 0..9, 0x0A, 0x0B, 0x0F — 13 total.
|
||||
want := map[int]string{
|
||||
0x00: "REQ", 0x01: "RESPONSE", 0x02: "TXT_MSG", 0x03: "ACK",
|
||||
0x04: "ADVERT", 0x05: "GRP_TXT", 0x06: "GRP_DATA", 0x07: "ANON_REQ",
|
||||
0x08: "PATH", 0x09: "TRACE", 0x0A: "MULTIPART", 0x0B: "CONTROL",
|
||||
0x0F: "RAW_CUSTOM",
|
||||
}
|
||||
if len(payloadTypeNames) != len(want) {
|
||||
t.Errorf("payloadTypeNames has %d entries, want %d", len(payloadTypeNames), len(want))
|
||||
}
|
||||
for code, name := range want {
|
||||
got, ok := payloadTypeNames[code]
|
||||
if !ok {
|
||||
t.Errorf("payloadTypeNames missing 0x%02X (%s)", code, name)
|
||||
continue
|
||||
}
|
||||
if got != name {
|
||||
t.Errorf("payloadTypeNames[0x%02X] = %q, want %q", code, got, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeRawCustomExposesLengthAndTag(t *testing.T) {
|
||||
// Build a RAW_CUSTOM packet: header byte = (route<<6 | type<<2 | ver),
|
||||
// type=0x0F. Route FLOOD (1), version 1: (1<<6)|(0x0F<<2)|1 = 0x7D.
|
||||
// Path byte: 0 hops, hash_size=1 → upper bits 0, lower 0 → 0x00.
|
||||
// Payload: first byte tag 0xA5, then arbitrary data.
|
||||
hexStr := "7D00A5DEADBEEF"
|
||||
pkt, err := DecodePacket(hexStr, false)
|
||||
if err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if pkt.Payload.Type != "RAW_CUSTOM" {
|
||||
t.Fatalf("payload type = %q, want RAW_CUSTOM", pkt.Payload.Type)
|
||||
}
|
||||
if pkt.Payload.RawLength == nil {
|
||||
t.Fatal("RawLength should be set for RAW_CUSTOM")
|
||||
}
|
||||
// payload = 4 bytes (A5 DE AD BE EF) — wait, A5 DE AD BE EF = 5 bytes.
|
||||
if *pkt.Payload.RawLength != 5 {
|
||||
t.Errorf("RawLength=%d, want 5", *pkt.Payload.RawLength)
|
||||
}
|
||||
if !strings.EqualFold(pkt.Payload.FirstByteTag, "A5") {
|
||||
t.Errorf("FirstByteTag=%q, want A5", pkt.Payload.FirstByteTag)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package main
|
||||
|
||||
// Tests for issue #1279 P0+P1 decoder additions (server-side).
|
||||
//
|
||||
// Wire-vector citations identical to the ingestor counterpart:
|
||||
// - GRP_DATA outer: firmware/src/helpers/BaseChatMesh.cpp:500
|
||||
// - MULTIPART byte0: firmware/src/Mesh.cpp:289
|
||||
// - MULTIPART ACK inner: firmware/src/Mesh.cpp:292-307
|
||||
// - CONTROL byte0 flags: firmware/src/Mesh.cpp:69 + Mesh.cpp:609
|
||||
// - advertRole label rules: firmware/src/helpers/AdvertDataHelpers.h:7-12
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDecodeGrpDataEnvelopeServer(t *testing.T) {
|
||||
// Server-side decoder has no channel keys: envelope only.
|
||||
buf := []byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x11}
|
||||
p := decodeGrpData(buf)
|
||||
if p.Type != "GRP_DATA" {
|
||||
t.Fatalf("type=%q want GRP_DATA", p.Type)
|
||||
}
|
||||
if p.ChannelHash != 0xAA {
|
||||
t.Errorf("channelHash=%d want 170", p.ChannelHash)
|
||||
}
|
||||
if p.ChannelHashHex != "AA" {
|
||||
t.Errorf("channelHashHex=%q want AA", p.ChannelHashHex)
|
||||
}
|
||||
if p.MAC != "bbcc" {
|
||||
t.Errorf("mac=%q want bbcc", p.MAC)
|
||||
}
|
||||
if p.EncryptedData != "ddeeff11" {
|
||||
t.Errorf("encryptedData=%q want ddeeff11", p.EncryptedData)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeMultipartAckServer(t *testing.T) {
|
||||
buf := []byte{0x33, 0xEF, 0xBE, 0xAD, 0xDE}
|
||||
p := decodeMultipart(buf)
|
||||
if p.Type != "MULTIPART" {
|
||||
t.Fatalf("type=%q want MULTIPART", p.Type)
|
||||
}
|
||||
if p.Remaining == nil || *p.Remaining != 3 {
|
||||
t.Errorf("remaining=%v want 3", p.Remaining)
|
||||
}
|
||||
if p.InnerType == nil || *p.InnerType != 0x03 {
|
||||
t.Errorf("innerType=%v want 3", p.InnerType)
|
||||
}
|
||||
if p.InnerTypeName != "ACK" {
|
||||
t.Errorf("innerTypeName=%q want ACK", p.InnerTypeName)
|
||||
}
|
||||
if p.InnerAckCrc != "deadbeef" {
|
||||
t.Errorf("innerAckCrc=%q want deadbeef", p.InnerAckCrc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeMultipartNonAckServer(t *testing.T) {
|
||||
buf := []byte{0x22, 0x01, 0x02, 0x03}
|
||||
p := decodeMultipart(buf)
|
||||
if p.Remaining == nil || *p.Remaining != 2 {
|
||||
t.Errorf("remaining=%v want 2", p.Remaining)
|
||||
}
|
||||
if p.InnerType == nil || *p.InnerType != 0x02 {
|
||||
t.Errorf("innerType=%v want 2", p.InnerType)
|
||||
}
|
||||
if p.InnerTypeName != "TXT_MSG" {
|
||||
t.Errorf("innerTypeName=%q want TXT_MSG", p.InnerTypeName)
|
||||
}
|
||||
if p.InnerPayload != "010203" {
|
||||
t.Errorf("innerPayload=%q want 010203", p.InnerPayload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdvertRoleLabelsRawTypeServer(t *testing.T) {
|
||||
cases := []struct {
|
||||
typ int
|
||||
want string
|
||||
}{
|
||||
{0, "none"},
|
||||
{1, "companion"},
|
||||
{2, "repeater"},
|
||||
{3, "room"},
|
||||
{4, "sensor"},
|
||||
{5, "type-5"},
|
||||
{15, "type-15"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := advertRole(&AdvertFlags{Type: tc.typ, Repeater: tc.typ == 2, Room: tc.typ == 3, Sensor: tc.typ == 4})
|
||||
if got != tc.want {
|
||||
t.Errorf("advertRole(type=%d) = %q, want %q", tc.typ, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeControlZeroHopServer(t *testing.T) {
|
||||
buf := []byte{0x81, 0xAA, 0xBB, 0xCC}
|
||||
p := decodeControl(buf)
|
||||
if p.Type != "CONTROL" {
|
||||
t.Fatalf("type=%q want CONTROL", p.Type)
|
||||
}
|
||||
if p.CtrlFlags != "81" {
|
||||
t.Errorf("ctrlFlags=%q want 81", p.CtrlFlags)
|
||||
}
|
||||
if p.CtrlZeroHop == nil || !*p.CtrlZeroHop {
|
||||
t.Errorf("ctrlZeroHop=%v want true", p.CtrlZeroHop)
|
||||
}
|
||||
if p.CtrlLength == nil || *p.CtrlLength != 4 {
|
||||
t.Errorf("ctrlLength=%v want 4", p.CtrlLength)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeControlMultiHopServer(t *testing.T) {
|
||||
buf := []byte{0x01, 0x42}
|
||||
p := decodeControl(buf)
|
||||
if p.CtrlFlags != "01" {
|
||||
t.Errorf("ctrlFlags=%q want 01", p.CtrlFlags)
|
||||
}
|
||||
if p.CtrlZeroHop == nil || *p.CtrlZeroHop {
|
||||
t.Errorf("ctrlZeroHop=%v want false", p.CtrlZeroHop)
|
||||
}
|
||||
if p.CtrlLength == nil || *p.CtrlLength != 2 {
|
||||
t.Errorf("ctrlLength=%v want 2", p.CtrlLength)
|
||||
}
|
||||
}
|
||||
@@ -86,7 +86,7 @@ func TestIssue804_AnalyticsAttributesByRepeaterRegion(t *testing.T) {
|
||||
store.Load()
|
||||
|
||||
t.Run("region=SJC excludes PDX-Repeater (heard but not home)", func(t *testing.T) {
|
||||
result := store.GetAnalyticsHashSizes("SJC")
|
||||
result := store.GetAnalyticsHashSizes("SJC", "")
|
||||
|
||||
mb, ok := result["multiByteNodes"].([]map[string]interface{})
|
||||
if !ok {
|
||||
@@ -113,7 +113,7 @@ func TestIssue804_AnalyticsAttributesByRepeaterRegion(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("API exposes attributionMethod", func(t *testing.T) {
|
||||
result := store.GetAnalyticsHashSizes("SJC")
|
||||
result := store.GetAnalyticsHashSizes("SJC", "")
|
||||
method, ok := result["attributionMethod"].(string)
|
||||
if !ok {
|
||||
t.Fatal("expected attributionMethod string field on result")
|
||||
@@ -124,7 +124,7 @@ func TestIssue804_AnalyticsAttributesByRepeaterRegion(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("region=PDX excludes SJC-Repeater", func(t *testing.T) {
|
||||
result := store.GetAnalyticsHashSizes("PDX")
|
||||
result := store.GetAnalyticsHashSizes("PDX", "")
|
||||
mb, _ := result["multiByteNodes"].([]map[string]interface{})
|
||||
|
||||
var foundPDX, foundSJC bool
|
||||
|
||||
+106
-228
@@ -18,6 +18,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/meshcore-analyzer/dbschema"
|
||||
)
|
||||
|
||||
// Set via -ldflags at build time
|
||||
@@ -167,74 +168,52 @@ func main() {
|
||||
stats.TotalTransmissions, stats.TotalObservations, stats.TotalNodes, stats.TotalObservers)
|
||||
}
|
||||
|
||||
// Check auto_vacuum mode and optionally migrate (#919)
|
||||
checkAutoVacuum(database, cfg, resolvedDB)
|
||||
// auto_vacuum is checked + migrated by the ingestor (#1283). The
|
||||
// server is read-only and must not race the writer for the lock.
|
||||
|
||||
// Assert all schema migrations the ingestor owns have already run
|
||||
// (#1287). The server NEVER migrates — it only reads. If a required
|
||||
// column/index/table is missing, the operator must restart the
|
||||
// ingestor (which owns dbschema.Apply) before this server can start.
|
||||
if err := dbschema.AssertReady(database.conn); err != nil {
|
||||
log.Fatalf("[db] schema not ready (ingestor must run migrations first): %v", err)
|
||||
}
|
||||
|
||||
// In-memory packet store
|
||||
store := NewPacketStore(database, cfg.PacketStore, cfg.CacheTTL)
|
||||
store.config = cfg
|
||||
if err := store.Load(); err != nil {
|
||||
log.Fatalf("[store] failed to load: %v", err)
|
||||
}
|
||||
if store.hotStartupHours > 0 {
|
||||
log.Printf("[store] starting background load: filling retentionHours=%gh from hotStartupHours=%gh",
|
||||
store.retentionHours, store.hotStartupHours)
|
||||
go store.loadBackgroundChunks()
|
||||
}
|
||||
|
||||
// Initialize persisted neighbor graph
|
||||
// Initialize persisted neighbor graph.
|
||||
// Per #1287, schema migrations all live in the ingestor (see
|
||||
// dbschema.Apply). The server merely loads the snapshot here and
|
||||
// then refreshes it via the recompNeighborGraph slot every 60s.
|
||||
dbPath = database.path
|
||||
if err := ensureNeighborEdgesTable(dbPath); err != nil {
|
||||
log.Printf("[neighbor] warning: could not create neighbor_edges table: %v", err)
|
||||
}
|
||||
// Add resolved_path column if missing.
|
||||
// NOTE on startup ordering (review item #10): ensureResolvedPathColumn runs AFTER
|
||||
// OpenDB/detectSchema, so db.hasResolvedPath will be false on first run with a
|
||||
// pre-existing DB. This means Load() won't SELECT resolved_path from SQLite.
|
||||
// Async backfill runs after HTTP starts (see backfillResolvedPathsAsync below)
|
||||
// AND to SQLite. On next restart, detectSchema finds the column and Load() reads it.
|
||||
if err := ensureResolvedPathColumn(dbPath); err != nil {
|
||||
log.Printf("[store] warning: could not add resolved_path column: %v", err)
|
||||
} else {
|
||||
database.hasResolvedPath = true // detectSchema ran before column was added; fix the flag
|
||||
}
|
||||
|
||||
// Ensure observers.inactive column exists (PR #954 filters on it; ingestor migration
|
||||
// adds it but server may run against DBs ingestor never touched, e.g. e2e fixture).
|
||||
if err := ensureObserverInactiveColumn(dbPath); err != nil {
|
||||
log.Printf("[store] warning: could not add observers.inactive column: %v", err)
|
||||
}
|
||||
|
||||
// Ensure observers.last_packet_at column exists (PR #905 reads it; ingestor migration
|
||||
// adds it but server may run against DBs ingestor never touched, e.g. e2e fixture).
|
||||
if err := ensureLastPacketAtColumn(dbPath); err != nil {
|
||||
log.Printf("[store] warning: could not add observers.last_packet_at column: %v", err)
|
||||
}
|
||||
|
||||
// Ensure nodes.foreign_advert column exists (#730 reads it on every /api/nodes
|
||||
// scan; ingestor migration foreign_advert_v1 adds it but server may run against
|
||||
// DBs ingestor never touched, e.g. e2e fixture).
|
||||
if err := ensureForeignAdvertColumn(dbPath); err != nil {
|
||||
log.Printf("[store] warning: could not add nodes.foreign_advert column: %v", err)
|
||||
}
|
||||
|
||||
// Ensure transmissions.from_pubkey column + index exists (#1143). Backfill
|
||||
// for legacy NULL rows runs async after HTTP starts so it can't block boot
|
||||
// even on prod-sized DBs (100K+ transmissions).
|
||||
if err := ensureFromPubkeyColumn(dbPath); err != nil {
|
||||
log.Printf("[store] warning: could not add transmissions.from_pubkey column: %v", err)
|
||||
}
|
||||
|
||||
// Soft-delete observers that are in the blacklist (mark inactive=1) so
|
||||
// historical data from a prior unblocked window is hidden too.
|
||||
if len(cfg.ObserverBlacklist) > 0 {
|
||||
softDeleteBlacklistedObservers(dbPath, cfg.ObserverBlacklist)
|
||||
}
|
||||
database.hasResolvedPath = true // dbschema.AssertReady above already verified observations.resolved_path exists
|
||||
|
||||
// WaitGroup for background init steps that gate /api/healthz readiness.
|
||||
var initWg sync.WaitGroup
|
||||
|
||||
// Load or build neighbor graph
|
||||
if neighborEdgesTableExists(database.conn) {
|
||||
store.graph = loadNeighborEdgesFromDB(database.conn)
|
||||
store.graph.Store(loadNeighborEdgesFromDB(database.conn))
|
||||
log.Printf("[neighbor] loaded persisted neighbor graph")
|
||||
} else {
|
||||
log.Printf("[neighbor] no persisted edges found, will build in background...")
|
||||
store.graph = NewNeighborGraph() // empty graph — gets populated by background goroutine
|
||||
// No persisted snapshot yet (e.g. fresh DB before the ingestor
|
||||
// has run its first edge-build cycle). Build an in-memory graph
|
||||
// from the packets we already have so reads aren't empty. We
|
||||
// do NOT persist — the ingestor owns neighbor_edges writes per
|
||||
// #1287; the recompNeighborGraph recomputer will pick up the
|
||||
// real snapshot as soon as the ingestor populates it.
|
||||
log.Printf("[neighbor] no persisted edges found, will build in-memory in background...")
|
||||
store.graph.Store(NewNeighborGraph())
|
||||
initWg.Add(1)
|
||||
go func() {
|
||||
defer initWg.Done()
|
||||
@@ -243,16 +222,9 @@ func main() {
|
||||
log.Printf("[neighbor] graph build panic recovered: %v", r)
|
||||
}
|
||||
}()
|
||||
rw, rwErr := cachedRW(dbPath)
|
||||
if rwErr == nil {
|
||||
edgeCount := buildAndPersistEdges(store, rw)
|
||||
log.Printf("[neighbor] persisted %d edges", edgeCount)
|
||||
}
|
||||
built := BuildFromStore(store)
|
||||
store.mu.Lock()
|
||||
store.graph = built
|
||||
store.mu.Unlock()
|
||||
log.Printf("[neighbor] graph build complete")
|
||||
store.graph.Store(built)
|
||||
log.Printf("[neighbor] in-memory graph build complete")
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -299,9 +271,11 @@ func main() {
|
||||
|
||||
// WebSocket hub
|
||||
hub := NewHub()
|
||||
hub.upgrader.EnableCompression = cfg.WSCompressionEnabled()
|
||||
|
||||
// HTTP server
|
||||
srv := NewServer(database, cfg, hub)
|
||||
srv.configDir = configDir
|
||||
srv.store = store
|
||||
router := mux.NewRouter()
|
||||
srv.RegisterRoutes(router)
|
||||
@@ -332,161 +306,72 @@ func main() {
|
||||
stopEviction := store.StartEvictionTicker()
|
||||
defer stopEviction()
|
||||
|
||||
// Auto-prune old packets if retention.packetDays is configured
|
||||
vacuumPages := cfg.IncrementalVacuumPages()
|
||||
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() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[prune] panic recovered: %v", r)
|
||||
}
|
||||
}()
|
||||
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)
|
||||
if n > 0 {
|
||||
runIncrementalVacuum(resolvedDB, vacuumPages)
|
||||
}
|
||||
}
|
||||
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)
|
||||
if n > 0 {
|
||||
runIncrementalVacuum(resolvedDB, vacuumPages)
|
||||
}
|
||||
}
|
||||
case <-pruneDone:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
log.Printf("[prune] auto-prune enabled: packets older than %d days will be removed daily", days)
|
||||
}
|
||||
// Steady-state analytics recomputers (issue #1240). Replaces the
|
||||
// on-request compute-then-cache pattern for the default (region="",
|
||||
// zero-window) analytics queries with a background refresh loop so
|
||||
// reads always hit cache in <1ms.
|
||||
stopAnalyticsRecomp := store.StartAnalyticsRecomputers(
|
||||
cfg.AnalyticsDefaultRecomputeInterval(),
|
||||
cfg.AnalyticsRecomputeIntervals(),
|
||||
)
|
||||
defer stopAnalyticsRecomp()
|
||||
log.Printf("[analytics-recompute] background recompute enabled (default=%s)", cfg.AnalyticsDefaultRecomputeInterval())
|
||||
|
||||
// Auto-prune old metrics
|
||||
var stopMetricsPrune func()
|
||||
{
|
||||
metricsDays := cfg.MetricsRetentionDays()
|
||||
metricsPruneTicker := time.NewTicker(24 * time.Hour)
|
||||
metricsPruneDone := make(chan struct{})
|
||||
stopMetricsPrune = func() {
|
||||
metricsPruneTicker.Stop()
|
||||
close(metricsPruneDone)
|
||||
}
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[metrics-prune] panic recovered: %v", r)
|
||||
}
|
||||
}()
|
||||
time.Sleep(2 * time.Minute) // stagger after packet prune
|
||||
database.PruneOldMetrics(metricsDays)
|
||||
runIncrementalVacuum(resolvedDB, vacuumPages)
|
||||
for {
|
||||
select {
|
||||
case <-metricsPruneTicker.C:
|
||||
database.PruneOldMetrics(metricsDays)
|
||||
runIncrementalVacuum(resolvedDB, vacuumPages)
|
||||
case <-metricsPruneDone:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
log.Printf("[metrics-prune] auto-prune enabled: metrics older than %d days", metricsDays)
|
||||
}
|
||||
// Steady-state repeater-enrichment recomputer (issue #1262).
|
||||
// Prewarms the bulk caches feeding handleNodes so the very first
|
||||
// /api/nodes?limit=2000 from live.js's SPA bootstrap hits a
|
||||
// populated cache instead of paying a 15.7s on-thread rebuild.
|
||||
// Uses the configured RelayActiveHours window and the same
|
||||
// default recompute interval as the other analytics caches.
|
||||
relayWindowHours := cfg.GetHealthThresholds().RelayActiveHours
|
||||
stopRepeaterEnrichRecomp := store.StartRepeaterEnrichmentRecomputer(
|
||||
relayWindowHours,
|
||||
cfg.AnalyticsDefaultRecomputeInterval(),
|
||||
)
|
||||
defer stopRepeaterEnrichRecomp()
|
||||
log.Printf("[repeater-enrich-recompute] background recompute enabled (window=%.1fh, interval=%s)",
|
||||
relayWindowHours, cfg.AnalyticsDefaultRecomputeInterval())
|
||||
|
||||
// Auto-prune stale observers
|
||||
var stopObserverPrune func()
|
||||
{
|
||||
observerDays := cfg.ObserverDaysOrDefault()
|
||||
if observerDays <= -1 {
|
||||
// -1 means keep forever, skip
|
||||
} else {
|
||||
observerPruneTicker := time.NewTicker(24 * time.Hour)
|
||||
observerPruneDone := make(chan struct{})
|
||||
stopObserverPrune = func() {
|
||||
observerPruneTicker.Stop()
|
||||
close(observerPruneDone)
|
||||
}
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[observer-prune] panic recovered: %v", r)
|
||||
}
|
||||
}()
|
||||
time.Sleep(3 * time.Minute) // stagger after metrics prune
|
||||
database.RemoveStaleObservers(observerDays)
|
||||
runIncrementalVacuum(resolvedDB, vacuumPages)
|
||||
for {
|
||||
select {
|
||||
case <-observerPruneTicker.C:
|
||||
database.RemoveStaleObservers(observerDays)
|
||||
runIncrementalVacuum(resolvedDB, vacuumPages)
|
||||
case <-observerPruneDone:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
log.Printf("[observer-prune] auto-prune enabled: observers not seen in %d days will be removed", observerDays)
|
||||
}
|
||||
}
|
||||
// Steady-state bridge-centrality recomputer (issue #672 axis 2).
|
||||
// Computes betweenness centrality over the in-memory neighbor
|
||||
// graph and stores the per-pubkey score map atomically. Read by
|
||||
// handleNodes via a single atomic load.
|
||||
stopBridgeRecomp := store.StartBridgeScoreRecomputer(
|
||||
cfg.AnalyticsDefaultRecomputeInterval(),
|
||||
)
|
||||
defer stopBridgeRecomp()
|
||||
log.Printf("[bridge-recompute] background recompute enabled (interval=%s)",
|
||||
cfg.AnalyticsDefaultRecomputeInterval())
|
||||
|
||||
// Auto-prune old neighbor edges
|
||||
var stopEdgePrune func()
|
||||
{
|
||||
maxAgeDays := cfg.NeighborMaxAgeDays()
|
||||
edgePruneTicker := time.NewTicker(24 * time.Hour)
|
||||
edgePruneDone := make(chan struct{})
|
||||
stopEdgePrune = func() {
|
||||
edgePruneTicker.Stop()
|
||||
close(edgePruneDone)
|
||||
}
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[neighbor-prune] panic recovered: %v", r)
|
||||
}
|
||||
}()
|
||||
time.Sleep(4 * time.Minute) // stagger after metrics prune
|
||||
store.mu.RLock()
|
||||
g := store.graph
|
||||
store.mu.RUnlock()
|
||||
PruneNeighborEdges(dbPath, g, maxAgeDays)
|
||||
runIncrementalVacuum(resolvedDB, vacuumPages)
|
||||
for {
|
||||
select {
|
||||
case <-edgePruneTicker.C:
|
||||
store.mu.RLock()
|
||||
g := store.graph
|
||||
store.mu.RUnlock()
|
||||
PruneNeighborEdges(dbPath, g, maxAgeDays)
|
||||
runIncrementalVacuum(resolvedDB, vacuumPages)
|
||||
case <-edgePruneDone:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
log.Printf("[neighbor-prune] auto-prune enabled: edges older than %d days", maxAgeDays)
|
||||
}
|
||||
// Steady-state neighbor-graph snapshot recomputer (issue #1287).
|
||||
// Per Option 4: the ingestor owns neighbor_edges; the server
|
||||
// READS the snapshot every 60s and atomic-swaps it into s.graph.
|
||||
// This is the ONLY path that updates s.graph at steady state.
|
||||
stopNeighborRecomp := store.StartNeighborGraphRecomputer(NeighborGraphRecomputerDefaultInterval)
|
||||
defer stopNeighborRecomp()
|
||||
log.Printf("[neighbor-recompute] snapshot reload enabled (interval=%s)",
|
||||
NeighborGraphRecomputerDefaultInterval)
|
||||
|
||||
// Packet / metrics / observer retention moved to the ingestor in
|
||||
// #1283 (writes only belong on the writer process). Neighbor-edge
|
||||
// pruning moved to the ingestor in #1287 for the same reason. The
|
||||
// server no longer schedules any of these; the ingestor's tickers
|
||||
// handle them.
|
||||
_ = cfg.IncrementalVacuumPages() // kept reachable for config validation; not used here
|
||||
_ = cfg.NeighborMaxAgeDays() // ditto — owned by ingestor now
|
||||
|
||||
// Graceful shutdown
|
||||
var handler http.Handler = router
|
||||
if cfg.GZipEnabled() {
|
||||
handler = gzipMiddlewareWithConfig(cfg.Compression, router)
|
||||
log.Printf("[server] HTTP gzip compression enabled")
|
||||
}
|
||||
if cfg.WSCompressionEnabled() {
|
||||
log.Printf("[server] WebSocket permessage-deflate compression enabled")
|
||||
}
|
||||
httpServer := &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", cfg.Port),
|
||||
Handler: router,
|
||||
Handler: handler,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 60 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
@@ -501,18 +386,14 @@ func main() {
|
||||
// 1. Stop accepting new WebSocket/poll data
|
||||
poller.Stop()
|
||||
|
||||
// 1b. Stop auto-prune ticker
|
||||
if stopPrune != nil {
|
||||
stopPrune()
|
||||
}
|
||||
if stopMetricsPrune != nil {
|
||||
stopMetricsPrune()
|
||||
}
|
||||
if stopObserverPrune != nil {
|
||||
stopObserverPrune()
|
||||
}
|
||||
if stopEdgePrune != nil {
|
||||
stopEdgePrune()
|
||||
// 1b. Auto-prune tickers were all relocated to the ingestor in
|
||||
// #1283/#1287 — nothing to stop here.
|
||||
|
||||
// 1c. Stop steady-state analytics recomputers (issue #1240).
|
||||
// Must happen before dbClose so any in-flight compute that
|
||||
// reaches into SQLite has finished.
|
||||
if stopAnalyticsRecomp != nil {
|
||||
stopAnalyticsRecomp()
|
||||
}
|
||||
|
||||
// 2. Gracefully drain HTTP connections (up to 15s)
|
||||
@@ -534,13 +415,10 @@ func main() {
|
||||
|
||||
log.Printf("[server] CoreScope (Go) listening on http://localhost:%d", cfg.Port)
|
||||
|
||||
// Start async backfill in background — HTTP is now available.
|
||||
go backfillResolvedPathsAsync(store, dbPath, 5000, 100*time.Millisecond, cfg.BackfillHours())
|
||||
// #1143: backfill from_pubkey for legacy ADVERT rows. Async so even
|
||||
// 100K+ rows can't block boot; queries handle NULL gracefully.
|
||||
// startFromPubkeyBackfill wraps the goroutine dispatch so the async
|
||||
// contract is testable (see TestBackfillFromPubkey_DoesNotBlockBoot).
|
||||
startFromPubkeyBackfill(dbPath, 5000, 100*time.Millisecond)
|
||||
// Backfills (resolved_path, from_pubkey) moved to the ingestor in
|
||||
// #1287 — they are write operations and belong on the writer
|
||||
// process. The server reads the results via the periodic
|
||||
// recompNeighborGraph / fetchResolvedPathForObs paths.
|
||||
|
||||
// Migrate old content hashes in background (one-time, idempotent).
|
||||
go migrateContentHashesAsync(store, 5000, 100*time.Millisecond)
|
||||
|
||||
@@ -66,14 +66,14 @@ func TestMultiByteCapability_RegionFiltered_PreservesConfirmedStatus(t *testing.
|
||||
store.Load()
|
||||
|
||||
// Sanity: unfiltered view exposes the field.
|
||||
unfiltered := store.GetAnalyticsHashSizes("")
|
||||
unfiltered := store.GetAnalyticsHashSizes("", "")
|
||||
if _, ok := unfiltered["multiByteCapability"]; !ok {
|
||||
t.Fatal("unfiltered result missing multiByteCapability — test setup is wrong")
|
||||
}
|
||||
|
||||
// The actual assertion: region-filtered view MUST also expose the field
|
||||
// AND must report Node A as "confirmed", not "unknown".
|
||||
result := store.GetAnalyticsHashSizes("JKG")
|
||||
result := store.GetAnalyticsHashSizes("JKG", "")
|
||||
capsRaw, ok := result["multiByteCapability"]
|
||||
if !ok {
|
||||
t.Fatalf("expected multiByteCapability in region=JKG result, got keys: %v", keysOf(result))
|
||||
|
||||
+17
-10
@@ -6,6 +6,7 @@ import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
@@ -66,10 +67,11 @@ type GraphEdge struct {
|
||||
}
|
||||
|
||||
type GraphStats struct {
|
||||
TotalNodes int `json:"total_nodes"`
|
||||
TotalEdges int `json:"total_edges"`
|
||||
AmbiguousEdges int `json:"ambiguous_edges"`
|
||||
AvgClusterSize float64 `json:"avg_cluster_size"`
|
||||
TotalNodes int `json:"total_nodes"`
|
||||
TotalEdges int `json:"total_edges"`
|
||||
AmbiguousEdges int `json:"ambiguous_edges"`
|
||||
AvgClusterSize float64 `json:"avg_cluster_size"`
|
||||
RejectedEdgesGeoFar uint64 `json:"rejected_edges_geo_far"` // edges dropped at build time by the geo-implausibility filter (#1228)
|
||||
}
|
||||
|
||||
// ─── Graph accessor on Server ──────────────────────────────────────────────────
|
||||
@@ -81,8 +83,12 @@ func (s *Server) getNeighborGraph() *NeighborGraph {
|
||||
|
||||
if s.neighborGraph == nil || s.neighborGraph.IsStale() {
|
||||
if s.store != nil {
|
||||
debugLog := s.cfg != nil && s.cfg.DebugAffinity
|
||||
s.neighborGraph = BuildFromStoreWithLog(s.store, debugLog)
|
||||
opts := BuildOptions{MaxEdgeKm: DefaultMaxEdgeKm}
|
||||
if s.cfg != nil {
|
||||
opts.EnableLog = s.cfg.DebugAffinity
|
||||
opts.MaxEdgeKm = s.cfg.NeighborMaxEdgeKm()
|
||||
}
|
||||
s.neighborGraph = BuildFromStoreWithOptions(s.store, opts)
|
||||
} else {
|
||||
s.neighborGraph = NewNeighborGraph()
|
||||
}
|
||||
@@ -347,10 +353,11 @@ func (s *Server) handleNeighborGraph(w http.ResponseWriter, r *http.Request) {
|
||||
Nodes: nodes,
|
||||
Edges: filteredEdges,
|
||||
Stats: GraphStats{
|
||||
TotalNodes: len(nodes),
|
||||
TotalEdges: len(filteredEdges),
|
||||
AmbiguousEdges: ambiguousCount,
|
||||
AvgClusterSize: avgCluster,
|
||||
TotalNodes: len(nodes),
|
||||
TotalEdges: len(filteredEdges),
|
||||
AmbiguousEdges: ambiguousCount,
|
||||
AvgClusterSize: avgCluster,
|
||||
RejectedEdgesGeoFar: atomic.LoadUint64(&graph.RejectedEdgesGeoFar),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -476,10 +476,10 @@ func TestBuildNodeInfoMap_ObserverEnrichment(t *testing.T) {
|
||||
// Create tables
|
||||
for _, stmt := range []string{
|
||||
"CREATE TABLE nodes (public_key TEXT, name TEXT, role TEXT, lat REAL, lon REAL)",
|
||||
"CREATE TABLE observers (id TEXT, name TEXT)",
|
||||
"CREATE TABLE observers (id TEXT, name TEXT, iata TEXT)",
|
||||
"INSERT INTO nodes VALUES ('AAAA1111', 'Repeater-1', 'repeater', 0, 0)",
|
||||
"INSERT INTO observers VALUES ('BBBB2222', 'Observer-Alpha')",
|
||||
"INSERT INTO observers VALUES ('AAAA1111', 'Obs-also-repeater')",
|
||||
"INSERT INTO observers VALUES ('BBBB2222', 'Observer-Alpha', '')",
|
||||
"INSERT INTO observers VALUES ('AAAA1111', 'Obs-also-repeater', '')",
|
||||
} {
|
||||
if _, err := conn.Exec(stmt); err != nil {
|
||||
t.Fatalf("exec %q: %v", stmt, err)
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"math"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -23,6 +24,10 @@ const (
|
||||
affinityConfidenceRatio = 3.0
|
||||
// Minimum observation count to auto-resolve.
|
||||
affinityMinObservations = 3
|
||||
// Source-diversity saturation: edges contributed by this many distinct
|
||||
// observers (or more) earn full confidence weight (multiplier 1.0).
|
||||
// Fewer observers earn a proportional fraction. Issue #1229 (Option C).
|
||||
affinityObserverSaturation = 3.0
|
||||
)
|
||||
|
||||
// affinityLambda = ln(2) / half-life-hours, precomputed.
|
||||
@@ -70,6 +75,29 @@ func (e *NeighborEdge) Score(now time.Time) float64 {
|
||||
return countFactor * decay
|
||||
}
|
||||
|
||||
// Confidence returns a source-diversity multiplier in (0, 1] derived from the
|
||||
// number of distinct observers that have contributed to this edge. Issue #1229
|
||||
// (Option C): edges corroborated by multiple independent observers should
|
||||
// outrank edges seen by a single observer at the same raw score.
|
||||
//
|
||||
// Formula: min(1.0, max(1, |Observers|) / affinityObserverSaturation).
|
||||
// With saturation=3, a single observer yields 1/3, two observers 2/3, and
|
||||
// three-or-more observers saturate at 1.0 — full historical weight. Edges
|
||||
// with an empty observer set (legacy persisted rows lacking the column;
|
||||
// see neighbor_persist.go backward-compat) default to a count of 1 so they
|
||||
// behave like single-observer edges rather than disappearing — defensive.
|
||||
func (e *NeighborEdge) Confidence() float64 {
|
||||
n := float64(len(e.Observers))
|
||||
if n < 1 {
|
||||
n = 1
|
||||
}
|
||||
c := n / affinityObserverSaturation
|
||||
if c > 1.0 {
|
||||
c = 1.0
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// AvgSNR returns the average SNR, or 0 if no samples.
|
||||
func (e *NeighborEdge) AvgSNR() float64 {
|
||||
if e.SNRCount == 0 {
|
||||
@@ -87,6 +115,27 @@ type NeighborGraph struct {
|
||||
byNode map[string][]*NeighborEdge // pubkey → edges involving this node
|
||||
builtAt time.Time
|
||||
logFn func(prefix, msg string) // optional structured logging callback
|
||||
|
||||
// RejectedEdgesGeoFar counts edges dropped at build time because both
|
||||
// endpoints had GPS and their haversine distance exceeded the
|
||||
// configurable threshold (NeighborGraphConfig.MaxEdgeKm, default 500).
|
||||
// Accessed via sync/atomic. See issue #1228.
|
||||
RejectedEdgesGeoFar uint64
|
||||
|
||||
// maxEdgeKm is the geo-sanity threshold copied from config at build
|
||||
// time. 0 means "no limit" / filter disabled.
|
||||
maxEdgeKm float64
|
||||
|
||||
// nodeGeo maps lowercased pubkey → (lat, lon, hasGPS) for geo-sanity
|
||||
// checks during upsertEdge. Populated by the builder; empty for graphs
|
||||
// constructed via NewNeighborGraph directly (geo filter inert).
|
||||
nodeGeo map[string]nodeGeoInfo
|
||||
}
|
||||
|
||||
// nodeGeoInfo is the minimal geo slice cached on the graph for upsertEdge.
|
||||
type nodeGeoInfo struct {
|
||||
Lat, Lon float64
|
||||
HasGPS bool
|
||||
}
|
||||
|
||||
// NewNeighborGraph creates an empty graph.
|
||||
@@ -148,9 +197,20 @@ func (g *NeighborGraph) IsStale() bool {
|
||||
// BuildFromStore constructs the neighbor graph from all packets in the store.
|
||||
// The store's read-lock must NOT be held by the caller.
|
||||
func BuildFromStore(store *PacketStore) *NeighborGraph {
|
||||
return BuildFromStoreWithLog(store, false)
|
||||
return BuildFromStoreWithOptions(store, BuildOptions{MaxEdgeKm: DefaultMaxEdgeKm})
|
||||
}
|
||||
|
||||
// BuildOptions controls optional behavior of BuildFromStoreWithOptions.
|
||||
type BuildOptions struct {
|
||||
EnableLog bool // structured disambiguation logging
|
||||
MaxEdgeKm float64 // geo-sanity threshold; 0 disables the filter
|
||||
}
|
||||
|
||||
// DefaultMaxEdgeKm is the conservative built-in cap for the
|
||||
// geo-implausibility filter (issue #1228). 500 km is comfortably above any
|
||||
// plausible terrestrial LoRa hop (including satellite-relayed cases).
|
||||
const DefaultMaxEdgeKm = 500.0
|
||||
|
||||
// cachedToLower returns strings.ToLower(s), caching results to avoid
|
||||
// repeated allocations for the same pubkey string.
|
||||
func cachedToLower(cache map[string]string, s string) string {
|
||||
@@ -163,9 +223,16 @@ func cachedToLower(cache map[string]string, s string) string {
|
||||
}
|
||||
|
||||
// BuildFromStoreWithLog constructs the neighbor graph, optionally logging disambiguation decisions.
|
||||
// Kept for backward compatibility; new callers should use BuildFromStoreWithOptions.
|
||||
func BuildFromStoreWithLog(store *PacketStore, enableLog bool) *NeighborGraph {
|
||||
return BuildFromStoreWithOptions(store, BuildOptions{EnableLog: enableLog, MaxEdgeKm: DefaultMaxEdgeKm})
|
||||
}
|
||||
|
||||
// BuildFromStoreWithOptions constructs the neighbor graph with explicit options.
|
||||
func BuildFromStoreWithOptions(store *PacketStore, opts BuildOptions) *NeighborGraph {
|
||||
g := NewNeighborGraph()
|
||||
if enableLog {
|
||||
g.maxEdgeKm = opts.MaxEdgeKm
|
||||
if opts.EnableLog {
|
||||
g.logFn = func(prefix, msg string) {
|
||||
log.Printf("[affinity] resolve %s: %s", prefix, msg)
|
||||
}
|
||||
@@ -179,7 +246,16 @@ func BuildFromStoreWithLog(store *PacketStore, enableLog bool) *NeighborGraph {
|
||||
|
||||
// Build prefix map for candidate resolution.
|
||||
// Use cached nodes+PM (avoids DB call if cache is fresh).
|
||||
_, pm := store.getCachedNodesAndPM()
|
||||
allNodes, pm := store.getCachedNodesAndPM()
|
||||
|
||||
// Index node geo for upsertEdge geo-sanity checks (issue #1228).
|
||||
geo := make(map[string]nodeGeoInfo, len(allNodes))
|
||||
for _, n := range allNodes {
|
||||
geo[strings.ToLower(n.PublicKey)] = nodeGeoInfo{Lat: n.Lat, Lon: n.Lon, HasGPS: n.HasGPS}
|
||||
}
|
||||
g.mu.Lock()
|
||||
g.nodeGeo = geo
|
||||
g.mu.Unlock()
|
||||
|
||||
// Local cache for strings.ToLower — pubkeys are immutable and repeat
|
||||
// across hundreds of thousands of observations.
|
||||
@@ -267,6 +343,13 @@ func jsonUnmarshalFast(data string, v interface{}) error {
|
||||
|
||||
// upsertEdge adds/updates an edge between two fully-known pubkeys.
|
||||
func (g *NeighborGraph) upsertEdge(pubkeyA, pubkeyB, prefix, observer string, snr *float64, ts time.Time) {
|
||||
// Geo-sanity guard (issue #1228): if both endpoints have known GPS and
|
||||
// the haversine distance exceeds the configured threshold, drop the
|
||||
// edge. When either lacks GPS we have no signal and accept.
|
||||
if g.shouldRejectGeoFar(pubkeyA, pubkeyB) {
|
||||
atomic.AddUint64(&g.RejectedEdgesGeoFar, 1)
|
||||
return
|
||||
}
|
||||
key := makeEdgeKey(pubkeyA, pubkeyB)
|
||||
|
||||
g.mu.Lock()
|
||||
@@ -652,3 +735,40 @@ func (g *NeighborGraph) PruneOlderThan(cutoff time.Time) int {
|
||||
}
|
||||
return pruned
|
||||
}
|
||||
|
||||
// shouldRejectGeoFar reports whether the edge (a, b) is geographically
|
||||
// implausible under the configured threshold. Both endpoints must have known
|
||||
// GPS to trigger a rejection; if either lacks GPS the edge is accepted
|
||||
// (issue #1228 — "no signal to reject").
|
||||
//
|
||||
// All log output is PII-truncated to the first 8 hex chars of each pubkey.
|
||||
func (g *NeighborGraph) shouldRejectGeoFar(a, b string) bool {
|
||||
if g == nil || g.maxEdgeKm <= 0 || g.nodeGeo == nil {
|
||||
return false
|
||||
}
|
||||
if strings.HasPrefix(a, "prefix:") || strings.HasPrefix(b, "prefix:") {
|
||||
return false
|
||||
}
|
||||
ga, oka := g.nodeGeo[a]
|
||||
gb, okb := g.nodeGeo[b]
|
||||
if !oka || !okb || !ga.HasGPS || !gb.HasGPS {
|
||||
return false
|
||||
}
|
||||
d := haversineKm(ga.Lat, ga.Lon, gb.Lat, gb.Lon)
|
||||
if d <= g.maxEdgeKm {
|
||||
return false
|
||||
}
|
||||
// PII-truncated INFO log (8-char prefix max).
|
||||
log.Printf("[neighbor-graph] reject geo-far edge %s↔%s distance=%.0fkm threshold=%.0fkm",
|
||||
piiTruncPubkey(a), piiTruncPubkey(b), d, g.maxEdgeKm)
|
||||
return true
|
||||
}
|
||||
|
||||
// piiTruncPubkey returns at most the first 8 hex chars of a pubkey for log
|
||||
// output. The repo is public and observer/node pubkeys are PII-adjacent.
|
||||
func piiTruncPubkey(pk string) string {
|
||||
if len(pk) <= 8 {
|
||||
return pk
|
||||
}
|
||||
return pk[:8]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestBuildNeighborGraph_RejectsGeoFarEdge — RED test for #1228.
|
||||
//
|
||||
// Synthetic advert produces an edge between A (Bay Area) and B (Berlin).
|
||||
// Distance ≈ 9 100 km, well above any plausible terrestrial LoRa hop.
|
||||
// The geo-sanity filter must reject the edge at build time so the
|
||||
// affinity graph cannot self-reinforce a wrong disambiguation.
|
||||
func TestBuildNeighborGraph_RejectsGeoFarEdge(t *testing.T) {
|
||||
nodes := []nodeInfo{
|
||||
// A: San Francisco
|
||||
{Role: "repeater", PublicKey: "aaaa1111", Name: "A_SF", Lat: 37.77, Lon: -122.41, HasGPS: true},
|
||||
// B: Berlin
|
||||
{Role: "repeater", PublicKey: "bbbb2222", Name: "B_BE", Lat: 52.52, Lon: 13.40, HasGPS: true},
|
||||
// Observer with GPS at SF (won't affect A↔B edge under test)
|
||||
{Role: "repeater", PublicKey: "obs00001", Name: "Observer", Lat: 37.77, Lon: -122.41, HasGPS: true},
|
||||
}
|
||||
// ADVERT originated by A, path=["bbbb"] → builder creates edge A↔B
|
||||
// (originator ↔ path[0]). With geo sanity ON this edge must be dropped.
|
||||
tx := ngMakeTx(1, 4, ngFromNodeJSON("aaaa1111"), []*StoreObs{
|
||||
ngMakeObs("obs00001", `["bbbb"]`, nowStr, ngFloatPtr(-10)),
|
||||
})
|
||||
store := ngTestStore(nodes, []*StoreTx{tx})
|
||||
g := BuildFromStore(store)
|
||||
|
||||
for _, e := range g.AllEdges() {
|
||||
if (e.NodeA == "aaaa1111" && e.NodeB == "bbbb2222") ||
|
||||
(e.NodeA == "bbbb2222" && e.NodeB == "aaaa1111") {
|
||||
t.Fatalf("geo-implausible edge A(SF)↔B(Berlin) was not rejected: %+v", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildNeighborGraph_AcceptsLocalEdge — A↔C within plausible LoRa range
|
||||
// (both in CA, ~100 km apart) must remain in the graph.
|
||||
func TestBuildNeighborGraph_AcceptsLocalEdge(t *testing.T) {
|
||||
nodes := []nodeInfo{
|
||||
{Role: "repeater", PublicKey: "aaaa1111", Name: "A_SF", Lat: 37.77, Lon: -122.41, HasGPS: true},
|
||||
{Role: "repeater", PublicKey: "cccc3333", Name: "C_SJ", Lat: 37.34, Lon: -121.89, HasGPS: true},
|
||||
{Role: "repeater", PublicKey: "obs00001", Name: "Observer", Lat: 37.77, Lon: -122.41, HasGPS: true},
|
||||
}
|
||||
tx := ngMakeTx(1, 4, ngFromNodeJSON("aaaa1111"), []*StoreObs{
|
||||
ngMakeObs("obs00001", `["cccc"]`, nowStr, ngFloatPtr(-10)),
|
||||
})
|
||||
store := ngTestStore(nodes, []*StoreTx{tx})
|
||||
g := BuildFromStore(store)
|
||||
|
||||
found := false
|
||||
for _, e := range g.AllEdges() {
|
||||
if (e.NodeA == "aaaa1111" && e.NodeB == "cccc3333") ||
|
||||
(e.NodeA == "cccc3333" && e.NodeB == "aaaa1111") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("local A↔C edge (~50km) must be accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildNeighborGraph_AcceptsEdgeWhenNoGPS — if either endpoint lacks GPS,
|
||||
// we have no signal to reject; the edge is accepted.
|
||||
func TestBuildNeighborGraph_AcceptsEdgeWhenNoGPS(t *testing.T) {
|
||||
nodes := []nodeInfo{
|
||||
// A has GPS (Berlin)
|
||||
{Role: "repeater", PublicKey: "aaaa1111", Name: "A", Lat: 52.52, Lon: 13.40, HasGPS: true},
|
||||
// D has no GPS
|
||||
{Role: "repeater", PublicKey: "dddd4444", Name: "D"}, // HasGPS = false
|
||||
{Role: "repeater", PublicKey: "obs00001", Name: "Observer"},
|
||||
}
|
||||
tx := ngMakeTx(1, 4, ngFromNodeJSON("aaaa1111"), []*StoreObs{
|
||||
ngMakeObs("obs00001", `["dddd"]`, nowStr, nil),
|
||||
})
|
||||
store := ngTestStore(nodes, []*StoreTx{tx})
|
||||
g := BuildFromStore(store)
|
||||
|
||||
found := false
|
||||
for _, e := range g.AllEdges() {
|
||||
if (e.NodeA == "aaaa1111" && e.NodeB == "dddd4444") ||
|
||||
(e.NodeA == "dddd4444" && e.NodeB == "aaaa1111") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("A(GPS)↔D(no-GPS) edge must be accepted (no signal to reject)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildNeighborGraph_RejectedCounterIncrements — every dropped edge bumps
|
||||
// the atomic counter surfaced by /api/analytics/neighbor-graph stats.
|
||||
func TestBuildNeighborGraph_RejectedCounterIncrements(t *testing.T) {
|
||||
nodes := []nodeInfo{
|
||||
{Role: "repeater", PublicKey: "aaaa1111", Name: "A_SF", Lat: 37.77, Lon: -122.41, HasGPS: true},
|
||||
{Role: "repeater", PublicKey: "bbbb2222", Name: "B_BE", Lat: 52.52, Lon: 13.40, HasGPS: true},
|
||||
{Role: "repeater", PublicKey: "obs00001", Name: "Observer", Lat: 37.77, Lon: -122.41, HasGPS: true},
|
||||
}
|
||||
// Two adverts each producing the far A↔B edge attempt → counter ≥ 2.
|
||||
txs := []*StoreTx{
|
||||
ngMakeTx(1, 4, ngFromNodeJSON("aaaa1111"), []*StoreObs{
|
||||
ngMakeObs("obs00001", `["bbbb"]`, nowStr, nil),
|
||||
}),
|
||||
ngMakeTx(2, 4, ngFromNodeJSON("aaaa1111"), []*StoreObs{
|
||||
ngMakeObs("obs00001", `["bbbb"]`, nowStr, nil),
|
||||
}),
|
||||
}
|
||||
store := ngTestStore(nodes, txs)
|
||||
g := BuildFromStore(store)
|
||||
got := atomic.LoadUint64(&g.RejectedEdgesGeoFar)
|
||||
if got < 2 {
|
||||
t.Fatalf("RejectedEdgesGeoFar = %d, want >= 2", got)
|
||||
}
|
||||
}
|
||||
+38
-631
@@ -1,42 +1,33 @@
|
||||
// Package main: read-only neighbor-edges loader.
|
||||
//
|
||||
// Per issue #1287 (followup to #1283), cmd/server is the read path: it
|
||||
// LOADS the in-memory neighbor graph from the SQLite snapshot the
|
||||
// ingestor maintains, but never writes to it. The previous write-side
|
||||
// helpers in this file (buildAndPersistEdges, asyncPersistResolvedPaths
|
||||
// AndEdges, ensure*Column, softDeleteBlacklistedObservers,
|
||||
// PruneNeighborEdges, openRW) all moved to cmd/ingestor; cmd/ingestor
|
||||
// owns CREATE/ALTER/INSERT/UPDATE/DELETE on neighbor_edges and the
|
||||
// observations/resolved_path column.
|
||||
//
|
||||
// Server now refreshes its in-memory copy of the graph via the
|
||||
// recompNeighborGraph slot in analytics_recomputer.go: every 60s it
|
||||
// re-reads neighbor_edges and atomic-swaps the resulting NeighborGraph
|
||||
// into s.graph.
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// persistSem limits concurrent async persistence goroutines to 1.
|
||||
// Without this, each ingest cycle spawns a goroutine that opens a new
|
||||
// SQLite RW connection; under sustained load goroutines pile up with
|
||||
// no backpressure, causing contention and busy-timeout cascades.
|
||||
var persistSem = make(chan struct{}, 1)
|
||||
|
||||
// ─── neighbor_edges table ──────────────────────────────────────────────────────
|
||||
|
||||
// ensureNeighborEdgesTable creates the neighbor_edges table if it doesn't exist.
|
||||
// Uses a separate read-write connection since the main DB is read-only.
|
||||
func ensureNeighborEdgesTable(dbPath string) error {
|
||||
rw, err := cachedRW(dbPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open rw for neighbor_edges: %w", err)
|
||||
}
|
||||
|
||||
_, err = rw.Exec(`CREATE TABLE IF NOT EXISTS neighbor_edges (
|
||||
node_a TEXT NOT NULL,
|
||||
node_b TEXT NOT NULL,
|
||||
count INTEGER DEFAULT 1,
|
||||
last_seen TEXT,
|
||||
PRIMARY KEY (node_a, node_b)
|
||||
)`)
|
||||
return err
|
||||
}
|
||||
// ─── neighbor_edges loader (read-only) ─────────────────────────────────────────
|
||||
|
||||
// loadNeighborEdgesFromDB loads all edges from the neighbor_edges table
|
||||
// and builds an in-memory NeighborGraph.
|
||||
// and builds an in-memory NeighborGraph. Called on server startup and
|
||||
// from the recompNeighborGraph background recomputer (#1287).
|
||||
func loadNeighborEdgesFromDB(conn *sql.DB) *NeighborGraph {
|
||||
g := NewNeighborGraph()
|
||||
|
||||
@@ -59,7 +50,6 @@ func loadNeighborEdgesFromDB(conn *sql.DB) *NeighborGraph {
|
||||
if lastSeen.Valid {
|
||||
ts = parseTimestamp(lastSeen.String)
|
||||
}
|
||||
// Build edge directly (both nodes are full pubkeys from persisted data)
|
||||
key := makeEdgeKey(a, b)
|
||||
g.mu.Lock()
|
||||
e, exists := g.edges[key]
|
||||
@@ -95,353 +85,28 @@ func loadNeighborEdgesFromDB(conn *sql.DB) *NeighborGraph {
|
||||
return g
|
||||
}
|
||||
|
||||
// ─── shared async persistence helper ───────────────────────────────────────────
|
||||
|
||||
// persistObsUpdate holds data for a resolved_path SQLite update.
|
||||
type persistObsUpdate struct {
|
||||
obsID int
|
||||
resolvedPath string
|
||||
}
|
||||
|
||||
// persistEdgeUpdate holds data for a neighbor_edges SQLite upsert.
|
||||
type persistEdgeUpdate struct {
|
||||
a, b, ts string
|
||||
}
|
||||
|
||||
// asyncPersistResolvedPathsAndEdges writes resolved_path updates and neighbor
|
||||
// edge upserts to SQLite in a background goroutine. Shared between
|
||||
// IngestNewFromDB and IngestNewObservations to avoid DRY violation.
|
||||
func asyncPersistResolvedPathsAndEdges(dbPath string, obsUpdates []persistObsUpdate, edgeUpdates []persistEdgeUpdate, logPrefix string) {
|
||||
if len(obsUpdates) == 0 && len(edgeUpdates) == 0 {
|
||||
return
|
||||
}
|
||||
// Try-acquire semaphore BEFORE spawning goroutine. If another
|
||||
// persistence operation is already running, drop this batch —
|
||||
// data lives in memory and will be backfilled on restart.
|
||||
select {
|
||||
case persistSem <- struct{}{}:
|
||||
// Acquired — spawn goroutine to do the work.
|
||||
default:
|
||||
log.Printf("[store] %s skipped: persistence already in progress", logPrefix)
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
defer func() { <-persistSem }()
|
||||
|
||||
rw, err := cachedRW(dbPath)
|
||||
if err != nil {
|
||||
log.Printf("[store] %s rw open error: %v", logPrefix, err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(obsUpdates) > 0 {
|
||||
sqlTx, err := rw.Begin()
|
||||
if err == nil {
|
||||
stmt, err := sqlTx.Prepare("UPDATE observations SET resolved_path = ? WHERE id = ?")
|
||||
if err == nil {
|
||||
var firstErr error
|
||||
for _, u := range obsUpdates {
|
||||
if _, err := stmt.Exec(u.resolvedPath, u.obsID); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
stmt.Close()
|
||||
if firstErr != nil {
|
||||
log.Printf("[store] %s resolved_path error (first): %v", logPrefix, firstErr)
|
||||
}
|
||||
} else {
|
||||
log.Printf("[store] %s resolved_path prepare error: %v", logPrefix, err)
|
||||
}
|
||||
sqlTx.Commit()
|
||||
}
|
||||
}
|
||||
|
||||
if len(edgeUpdates) > 0 {
|
||||
sqlTx, err := rw.Begin()
|
||||
if err == nil {
|
||||
stmt, err := sqlTx.Prepare(`INSERT INTO neighbor_edges (node_a, node_b, count, last_seen)
|
||||
VALUES (?, ?, 1, ?)
|
||||
ON CONFLICT(node_a, node_b) DO UPDATE SET
|
||||
count = count + 1, last_seen = MAX(last_seen, excluded.last_seen)`)
|
||||
if err == nil {
|
||||
var firstErr error
|
||||
for _, e := range edgeUpdates {
|
||||
if _, err := stmt.Exec(e.a, e.b, e.ts); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
stmt.Close()
|
||||
if firstErr != nil {
|
||||
log.Printf("[store] %s edge error (first): %v", logPrefix, firstErr)
|
||||
}
|
||||
} else {
|
||||
log.Printf("[store] %s edge prepare error: %v", logPrefix, err)
|
||||
}
|
||||
sqlTx.Commit()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// neighborEdgesTableExists checks if the neighbor_edges table has any data.
|
||||
// neighborEdgesTableExists returns true when neighbor_edges contains at
|
||||
// least one row. Used by main.go to decide between "load snapshot" and
|
||||
// "start with empty graph and wait for the ingestor to populate it".
|
||||
func neighborEdgesTableExists(conn *sql.DB) bool {
|
||||
var cnt int
|
||||
err := conn.QueryRow("SELECT COUNT(*) FROM neighbor_edges").Scan(&cnt)
|
||||
if err != nil {
|
||||
return false // table doesn't exist
|
||||
return false
|
||||
}
|
||||
return cnt > 0
|
||||
}
|
||||
|
||||
// buildAndPersistEdges scans all packets in the store, extracts edges per
|
||||
// ADVERT/non-ADVERT rules, and persists them to SQLite.
|
||||
func buildAndPersistEdges(store *PacketStore, rw *sql.DB) int {
|
||||
store.mu.RLock()
|
||||
packets := make([]*StoreTx, len(store.packets))
|
||||
copy(packets, store.packets)
|
||||
store.mu.RUnlock()
|
||||
// ─── resolved_path helpers (read-only / in-memory only) ────────────────────────
|
||||
|
||||
_, pm := store.getCachedNodesAndPM()
|
||||
|
||||
tx, err := rw.Begin()
|
||||
if err != nil {
|
||||
log.Printf("[neighbor] begin tx error: %v", err)
|
||||
return 0
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
stmt, err := tx.Prepare(`INSERT INTO neighbor_edges (node_a, node_b, count, last_seen)
|
||||
VALUES (?, ?, 1, ?)
|
||||
ON CONFLICT(node_a, node_b) DO UPDATE SET
|
||||
count = count + 1, last_seen = MAX(last_seen, excluded.last_seen)`)
|
||||
if err != nil {
|
||||
log.Printf("[neighbor] prepare stmt error: %v", err)
|
||||
return 0
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
edgeCount := 0
|
||||
var firstErr error
|
||||
for _, pkt := range packets {
|
||||
for _, obs := range pkt.Observations {
|
||||
for _, ec := range extractEdgesFromObs(obs, pkt, pm) {
|
||||
if _, err := stmt.Exec(ec.A, ec.B, ec.Timestamp); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
edgeCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
if firstErr != nil {
|
||||
log.Printf("[neighbor] edge exec error (first): %v", firstErr)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
log.Printf("[neighbor] commit error: %v", err)
|
||||
return 0
|
||||
}
|
||||
return edgeCount
|
||||
}
|
||||
|
||||
// ─── resolved_path column ──────────────────────────────────────────────────────
|
||||
|
||||
// ensureResolvedPathColumn adds the resolved_path column to observations if missing.
|
||||
func ensureResolvedPathColumn(dbPath string) error {
|
||||
rw, err := cachedRW(dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if column already exists
|
||||
rows, err := rw.Query("PRAGMA table_info(observations)")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var colName string
|
||||
var colType sql.NullString
|
||||
var notNull, pk int
|
||||
var dflt sql.NullString
|
||||
if rows.Scan(&cid, &colName, &colType, ¬Null, &dflt, &pk) == nil && colName == "resolved_path" {
|
||||
return nil // already exists
|
||||
}
|
||||
}
|
||||
|
||||
_, err = rw.Exec("ALTER TABLE observations ADD COLUMN resolved_path TEXT")
|
||||
if err != nil {
|
||||
return fmt.Errorf("add resolved_path column: %w", err)
|
||||
}
|
||||
log.Println("[store] Added resolved_path column to observations")
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureObserverInactiveColumn adds the inactive column to observers if missing.
|
||||
// The column was originally added by ingestor migration (cmd/ingestor/db.go:344) to
|
||||
// support soft-delete via RemoveStaleObservers + filtered reads (PR #954). When the
|
||||
// server starts against a DB that was never touched by the ingestor (e.g. the e2e
|
||||
// fixture), the column is missing and read queries that filter on it (GetObservers,
|
||||
// GetStats) silently fail with "no such column: inactive" — leaving /api/observers
|
||||
// returning empty.
|
||||
func ensureObserverInactiveColumn(dbPath string) error {
|
||||
rw, err := cachedRW(dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rows, err := rw.Query("PRAGMA table_info(observers)")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var colName string
|
||||
var colType sql.NullString
|
||||
var notNull, pk int
|
||||
var dflt sql.NullString
|
||||
if rows.Scan(&cid, &colName, &colType, ¬Null, &dflt, &pk) == nil && colName == "inactive" {
|
||||
return nil // already exists
|
||||
}
|
||||
}
|
||||
|
||||
_, err = rw.Exec("ALTER TABLE observers ADD COLUMN inactive INTEGER DEFAULT 0")
|
||||
if err != nil {
|
||||
return fmt.Errorf("add inactive column: %w", err)
|
||||
}
|
||||
log.Println("[store] Added inactive column to observers")
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureLastPacketAtColumn adds the last_packet_at column to observers if missing.
|
||||
// The column was originally added by ingestor migration (observers_last_packet_at_v1)
|
||||
// to track the most recent packet observation time separately from status updates.
|
||||
// When the server starts against a DB that was never touched by the ingestor (e.g.
|
||||
// the e2e fixture), the column is missing and read queries that reference it
|
||||
// (GetObservers, GetObserverByID) fail with "no such column: last_packet_at".
|
||||
func ensureLastPacketAtColumn(dbPath string) error {
|
||||
rw, err := cachedRW(dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rows, err := rw.Query("PRAGMA table_info(observers)")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var colName string
|
||||
var colType sql.NullString
|
||||
var notNull, pk int
|
||||
var dflt sql.NullString
|
||||
if rows.Scan(&cid, &colName, &colType, ¬Null, &dflt, &pk) == nil && colName == "last_packet_at" {
|
||||
return nil // already exists
|
||||
}
|
||||
}
|
||||
|
||||
_, err = rw.Exec("ALTER TABLE observers ADD COLUMN last_packet_at TEXT")
|
||||
if err != nil {
|
||||
return fmt.Errorf("add last_packet_at column: %w", err)
|
||||
}
|
||||
log.Println("[store] Added last_packet_at column to observers")
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureForeignAdvertColumn adds the foreign_advert column to nodes/inactive_nodes
|
||||
// if missing (#730). The column is added by the ingestor migration foreign_advert_v1
|
||||
// — but the server may run against a DB the ingestor has never touched (e2e fixture,
|
||||
// fresh installs where the server boots first), in which case scanNodeRow fails
|
||||
// with "no such column: foreign_advert" and /api/nodes silently returns nothing.
|
||||
func ensureForeignAdvertColumn(dbPath string) error {
|
||||
rw, err := cachedRW(dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, table := range []string{"nodes", "inactive_nodes"} {
|
||||
has, err := tableHasColumn(rw, table, "foreign_advert")
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect %s: %w", table, err)
|
||||
}
|
||||
if has {
|
||||
continue
|
||||
}
|
||||
if _, err := rw.Exec(fmt.Sprintf("ALTER TABLE %s ADD COLUMN foreign_advert INTEGER DEFAULT 0", table)); err != nil {
|
||||
return fmt.Errorf("add foreign_advert to %s: %w", table, err)
|
||||
}
|
||||
log.Printf("[store] Added foreign_advert column to %s", table)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// tableHasColumn reports whether the named table has the named column.
|
||||
func tableHasColumn(rw *sql.DB, table, column string) (bool, error) {
|
||||
rows, err := rw.Query(fmt.Sprintf("PRAGMA table_info(%s)", table))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var colName string
|
||||
var colType sql.NullString
|
||||
var notNull, pk int
|
||||
var dflt sql.NullString
|
||||
if rows.Scan(&cid, &colName, &colType, ¬Null, &dflt, &pk) == nil && colName == column {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// softDeleteBlacklistedObservers marks observers matching the blacklist as
|
||||
// inactive=1 so they are hidden from API responses. Runs once at startup.
|
||||
func softDeleteBlacklistedObservers(dbPath string, blacklist []string) {
|
||||
rw, err := cachedRW(dbPath)
|
||||
if err != nil {
|
||||
log.Printf("[observer-blacklist] warning: could not open DB for soft-delete: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
placeholders := make([]string, 0, len(blacklist))
|
||||
args := make([]interface{}, 0, len(blacklist))
|
||||
for _, pk := range blacklist {
|
||||
trimmed := strings.TrimSpace(pk)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
placeholders = append(placeholders, "LOWER(?)")
|
||||
args = append(args, trimmed)
|
||||
}
|
||||
if len(placeholders) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
query := "UPDATE observers SET inactive = 1 WHERE LOWER(id) IN (" + strings.Join(placeholders, ",") + ") AND (inactive IS NULL OR inactive = 0)"
|
||||
result, err := rw.Exec(query, args...)
|
||||
if err != nil {
|
||||
log.Printf("[observer-blacklist] warning: soft-delete failed: %v", err)
|
||||
return
|
||||
}
|
||||
if n, _ := result.RowsAffected(); n > 0 {
|
||||
log.Printf("[observer-blacklist] soft-deleted %d blacklisted observer(s)", n)
|
||||
}
|
||||
}
|
||||
|
||||
// resolvePathForObs resolves hop prefixes to full pubkeys for an observation.
|
||||
// Returns nil if path is empty.
|
||||
// resolvePathForObs resolves hop prefixes to full pubkeys for an
|
||||
// observation. Pure compute — does NOT persist (the ingestor owns
|
||||
// writes to observations.resolved_path).
|
||||
func resolvePathForObs(pathJSON, observerID string, tx *StoreTx, pm *prefixMap, graph *NeighborGraph) []*string {
|
||||
hops := parsePathJSON(pathJSON)
|
||||
if len(hops) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build context pubkeys: observer + originator (if known)
|
||||
contextPKs := make([]string, 0, 3)
|
||||
if observerID != "" {
|
||||
contextPKs = append(contextPKs, strings.ToLower(observerID))
|
||||
@@ -450,28 +115,23 @@ func resolvePathForObs(pathJSON, observerID string, tx *StoreTx, pm *prefixMap,
|
||||
if fromNode != "" {
|
||||
contextPKs = append(contextPKs, strings.ToLower(fromNode))
|
||||
}
|
||||
|
||||
resolved := make([]*string, len(hops))
|
||||
for i, hop := range hops {
|
||||
// Add adjacent hops as context for disambiguation
|
||||
ctx := make([]string, len(contextPKs), len(contextPKs)+2)
|
||||
copy(ctx, contextPKs)
|
||||
// Add previously resolved hops as context
|
||||
if i > 0 && resolved[i-1] != nil {
|
||||
ctx = append(ctx, *resolved[i-1])
|
||||
}
|
||||
|
||||
node, _, _ := pm.resolveWithContext(hop, ctx, graph)
|
||||
if node != nil {
|
||||
pk := strings.ToLower(node.PublicKey)
|
||||
resolved[i] = &pk
|
||||
}
|
||||
}
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
// marshalResolvedPath converts []*string to JSON for storage.
|
||||
// marshalResolvedPath converts []*string to JSON for in-memory caching.
|
||||
func marshalResolvedPath(rp []*string) string {
|
||||
if len(rp) == 0 {
|
||||
return ""
|
||||
@@ -495,226 +155,22 @@ func unmarshalResolvedPath(s string) []*string {
|
||||
return result
|
||||
}
|
||||
|
||||
// ─── Shared edge-extraction helper (used by ingestor + tests) ──────────────────
|
||||
|
||||
// backfillResolvedPathsAsync processes observations with NULL resolved_path in
|
||||
// chunks, yielding between batches so HTTP handlers remain responsive. It sets
|
||||
// store.backfillComplete when finished and re-picks best observations for any
|
||||
// transmissions affected by newly resolved paths.
|
||||
func backfillResolvedPathsAsync(store *PacketStore, dbPath string, chunkSize int, yieldDuration time.Duration, backfillHours int) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[store] backfillResolvedPathsAsync panic recovered: %v", r)
|
||||
}
|
||||
}()
|
||||
// Collect ALL pending obs refs upfront in one pass under a single RLock (fix A).
|
||||
type obsRef struct {
|
||||
obsID int
|
||||
pathJSON string
|
||||
observerID string
|
||||
txJSON string
|
||||
payloadType *int
|
||||
txHash string // to re-pick best obs
|
||||
}
|
||||
|
||||
cutoff := time.Now().UTC().Add(-time.Duration(backfillHours) * time.Hour)
|
||||
|
||||
store.mu.RLock()
|
||||
pm := store.nodePM
|
||||
var allPending []obsRef
|
||||
for _, tx := range store.packets {
|
||||
// Skip transmissions older than the backfill window.
|
||||
if tx.FirstSeen != "" {
|
||||
if ts, err := time.Parse(time.RFC3339Nano, tx.FirstSeen); err == nil && ts.Before(cutoff) {
|
||||
continue
|
||||
}
|
||||
// Also try the common SQLite format
|
||||
if ts, err := time.Parse("2006-01-02 15:04:05", tx.FirstSeen); err == nil && ts.Before(cutoff) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
for _, obs := range tx.Observations {
|
||||
// Check if this observation has been resolved: look up in the index.
|
||||
// If the tx has no reverse-map entries AND path is non-empty, it needs backfill.
|
||||
hasRP := false
|
||||
if _, ok := store.resolvedPubkeyReverse[tx.ID]; ok {
|
||||
hasRP = true
|
||||
}
|
||||
if !hasRP && obs.PathJSON != "" && obs.PathJSON != "[]" {
|
||||
allPending = append(allPending, obsRef{
|
||||
obsID: obs.ID,
|
||||
pathJSON: obs.PathJSON,
|
||||
observerID: obs.ObserverID,
|
||||
txJSON: tx.DecodedJSON,
|
||||
payloadType: tx.PayloadType,
|
||||
txHash: tx.Hash,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
store.mu.RUnlock()
|
||||
|
||||
totalPending := len(allPending)
|
||||
if totalPending == 0 || pm == nil {
|
||||
store.backfillComplete.Store(true)
|
||||
log.Printf("[store] async resolved_path backfill: nothing to do")
|
||||
return
|
||||
}
|
||||
|
||||
store.backfillTotal.Store(int64(totalPending))
|
||||
store.backfillProcessed.Store(0)
|
||||
log.Printf("[store] async resolved_path backfill starting: %d observations", totalPending)
|
||||
|
||||
// Open RW connection once before the chunk loop (fix B).
|
||||
var rw *sql.DB
|
||||
if dbPath != "" {
|
||||
var err error
|
||||
rw, err = cachedRW(dbPath)
|
||||
if err != nil {
|
||||
log.Printf("[store] async backfill: open rw error: %v", err)
|
||||
}
|
||||
}
|
||||
// rw is cached process-wide; do not close
|
||||
|
||||
totalProcessed := 0
|
||||
for totalProcessed < totalPending {
|
||||
end := totalProcessed + chunkSize
|
||||
if end > totalPending {
|
||||
end = totalPending
|
||||
}
|
||||
chunk := allPending[totalProcessed:end]
|
||||
|
||||
// Re-read graph under RLock at the start of each chunk so we pick up
|
||||
// a freshly-built graph once the background build goroutine completes,
|
||||
// instead of using the potentially-empty graph captured at cold start.
|
||||
store.mu.RLock()
|
||||
graph := store.graph
|
||||
store.mu.RUnlock()
|
||||
|
||||
// Resolve paths outside any lock.
|
||||
type resolved struct {
|
||||
obsID int
|
||||
rp []*string
|
||||
rpJSON string
|
||||
txHash string
|
||||
}
|
||||
var results []resolved
|
||||
for _, ref := range chunk {
|
||||
fakeTx := &StoreTx{DecodedJSON: ref.txJSON, PayloadType: ref.payloadType}
|
||||
rp := resolvePathForObs(ref.pathJSON, ref.observerID, fakeTx, pm, graph)
|
||||
if len(rp) > 0 {
|
||||
rpJSON := marshalResolvedPath(rp)
|
||||
if rpJSON != "" {
|
||||
results = append(results, resolved{ref.obsID, rp, rpJSON, ref.txHash})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Persist to SQLite using the shared connection.
|
||||
if len(results) > 0 && rw != nil {
|
||||
sqlTx, err := rw.Begin()
|
||||
if err != nil {
|
||||
log.Printf("[store] async backfill: begin tx error: %v", err)
|
||||
} else {
|
||||
stmt, err := sqlTx.Prepare("UPDATE observations SET resolved_path = ? WHERE id = ?")
|
||||
if err != nil {
|
||||
log.Printf("[store] async backfill: prepare error: %v", err)
|
||||
sqlTx.Rollback()
|
||||
} else {
|
||||
var execErr error
|
||||
for _, r := range results {
|
||||
if _, e := stmt.Exec(r.rpJSON, r.obsID); e != nil && execErr == nil {
|
||||
execErr = e
|
||||
}
|
||||
}
|
||||
if execErr != nil {
|
||||
log.Printf("[store] async backfill: exec error (first): %v", execErr)
|
||||
}
|
||||
stmt.Close()
|
||||
if err := sqlTx.Commit(); err != nil {
|
||||
log.Printf("[store] async backfill: commit error: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update in-memory state: update resolved pubkey index, re-pick best observation,
|
||||
// and invalidate LRU cache entries for backfilled observations (#800).
|
||||
//
|
||||
// Lock ordering: always take s.mu BEFORE lruMu. The read path
|
||||
// (fetchResolvedPathForObs) takes lruMu independently of s.mu,
|
||||
// so we must NOT hold s.mu while taking lruMu. Instead, collect
|
||||
// obsIDs to invalidate under s.mu, release it, then take lruMu.
|
||||
store.mu.Lock()
|
||||
affectedSet := make(map[string]bool)
|
||||
lruInvalidate := make([]int, 0, len(results))
|
||||
for _, r := range results {
|
||||
// Remove old index entries for this tx, then re-add with new pubkeys
|
||||
if !affectedSet[r.txHash] {
|
||||
affectedSet[r.txHash] = true
|
||||
if tx, ok := store.byHash[r.txHash]; ok {
|
||||
store.removeFromResolvedPubkeyIndex(tx.ID)
|
||||
}
|
||||
}
|
||||
// Add new resolved pubkeys to index
|
||||
if tx, ok := store.byHash[r.txHash]; ok {
|
||||
pks := extractResolvedPubkeys(r.rp)
|
||||
store.addToResolvedPubkeyIndex(tx.ID, pks)
|
||||
// Update byNode for relay nodes
|
||||
for _, pk := range pks {
|
||||
store.addToByNode(tx, pk)
|
||||
}
|
||||
// Update byPathHop resolved-key entries
|
||||
hopsSeen := make(map[string]bool)
|
||||
for _, hop := range txGetParsedPath(tx) {
|
||||
hopsSeen[strings.ToLower(hop)] = true
|
||||
}
|
||||
for _, pk := range pks {
|
||||
if !hopsSeen[pk] {
|
||||
hopsSeen[pk] = true
|
||||
store.byPathHop[pk] = append(store.byPathHop[pk], tx)
|
||||
}
|
||||
}
|
||||
}
|
||||
lruInvalidate = append(lruInvalidate, r.obsID)
|
||||
}
|
||||
// Re-pick best observation for affected transmissions
|
||||
for txHash := range affectedSet {
|
||||
if tx, ok := store.byHash[txHash]; ok {
|
||||
pickBestObservation(tx)
|
||||
}
|
||||
}
|
||||
store.mu.Unlock()
|
||||
|
||||
// Invalidate LRU entries AFTER releasing s.mu to maintain lock
|
||||
// ordering (lruMu must never be taken while s.mu is held).
|
||||
store.lruMu.Lock()
|
||||
for _, obsID := range lruInvalidate {
|
||||
store.lruDelete(obsID)
|
||||
}
|
||||
store.lruMu.Unlock()
|
||||
}
|
||||
|
||||
totalProcessed += len(chunk)
|
||||
store.backfillProcessed.Store(int64(totalProcessed))
|
||||
pct := float64(totalProcessed) / float64(totalPending) * 100
|
||||
log.Printf("[store] backfill progress: %d/%d observations (%.1f%%)", totalProcessed, totalPending, pct)
|
||||
|
||||
time.Sleep(yieldDuration)
|
||||
}
|
||||
|
||||
store.backfillComplete.Store(true)
|
||||
log.Printf("[store] async resolved_path backfill complete: %d observations processed", totalProcessed)
|
||||
}
|
||||
|
||||
// ─── Shared helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
// edgeCandidate represents an extracted edge to be persisted.
|
||||
// edgeCandidate represents an extracted edge. The ingestor uses the
|
||||
// same logic when computing edges from observations.
|
||||
type edgeCandidate struct {
|
||||
A, B, Timestamp string
|
||||
}
|
||||
|
||||
// extractEdgesFromObs extracts neighbor edge candidates from a single observation.
|
||||
// For ADVERTs: originator↔path[0] (if unambiguous). For ALL types: observer↔path[last] (if unambiguous).
|
||||
// Also handles zero-hop ADVERTs (originator↔observer direct link).
|
||||
// extractEdgesFromObs extracts neighbor edge candidates from a single
|
||||
// observation. For ADVERTs: originator↔path[0] (if unambiguous). For
|
||||
// ALL types: observer↔path[last] (if unambiguous). Also handles
|
||||
// zero-hop ADVERTs (originator↔observer direct link).
|
||||
//
|
||||
// Kept in cmd/server because the in-memory graph builder
|
||||
// (neighbor_graph.go) also calls it; it is pure compute and does not
|
||||
// touch the DB.
|
||||
func extractEdgesFromObs(obs *StoreObs, tx *StoreTx, pm *prefixMap) []edgeCandidate {
|
||||
isAdvert := tx.PayloadType != nil && *tx.PayloadType == PayloadADVERT
|
||||
fromNode := extractFromNode(tx)
|
||||
@@ -737,7 +193,6 @@ func extractEdgesFromObs(obs *StoreObs, tx *StoreTx, pm *prefixMap) []edgeCandid
|
||||
return edges
|
||||
}
|
||||
|
||||
// Edge 1: originator ↔ path[0] — ADVERTs only (resolve prefix to full pubkey)
|
||||
if isAdvert && fromNode != "" && pm != nil {
|
||||
firstHop := strings.ToLower(path[0])
|
||||
fromLower := strings.ToLower(fromNode)
|
||||
@@ -754,7 +209,6 @@ func extractEdgesFromObs(obs *StoreObs, tx *StoreTx, pm *prefixMap) []edgeCandid
|
||||
}
|
||||
}
|
||||
|
||||
// Edge 2: observer ↔ path[last] — ALL packet types
|
||||
if pm != nil {
|
||||
lastHop := strings.ToLower(path[len(path)-1])
|
||||
candidates := pm.m[lastHop]
|
||||
@@ -772,50 +226,3 @@ func extractEdgesFromObs(obs *StoreObs, tx *StoreTx, pm *prefixMap) []edgeCandid
|
||||
|
||||
return edges
|
||||
}
|
||||
|
||||
// openRW opens a read-write SQLite connection (same pattern as PruneOldPackets).
|
||||
func openRW(dbPath string) (*sql.DB, error) {
|
||||
dsn := fmt.Sprintf("file:%s?_journal_mode=WAL", dbPath)
|
||||
rw, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rw.SetMaxOpenConns(1)
|
||||
// DSN _busy_timeout may not be honored by all drivers; set via PRAGMA
|
||||
// to guarantee SQLite retries for up to 5s before returning SQLITE_BUSY.
|
||||
if _, err := rw.Exec("PRAGMA busy_timeout = 5000"); err != nil {
|
||||
rw.Close()
|
||||
return nil, fmt.Errorf("set busy_timeout: %w", err)
|
||||
}
|
||||
return rw, nil
|
||||
}
|
||||
|
||||
// PruneNeighborEdges removes edges older than maxAgeDays from both SQLite and
|
||||
// the in-memory graph. Uses openRW internally because the shared database.conn
|
||||
// is opened with mode=ro and DELETEs would silently fail.
|
||||
func PruneNeighborEdges(dbPath string, graph *NeighborGraph, maxAgeDays int) (int, error) {
|
||||
cutoff := time.Now().UTC().Add(-time.Duration(maxAgeDays) * 24 * time.Hour)
|
||||
|
||||
// 1. Prune from SQLite using a read-write connection
|
||||
var dbPruned int64
|
||||
rw, err := cachedRW(dbPath)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("prune neighbor_edges: open rw: %w", err)
|
||||
}
|
||||
res, err := rw.Exec("DELETE FROM neighbor_edges WHERE last_seen < ?", cutoff.Format(time.RFC3339))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("prune neighbor_edges: %w", err)
|
||||
}
|
||||
dbPruned, _ = res.RowsAffected()
|
||||
|
||||
// 2. Prune from in-memory graph
|
||||
memPruned := 0
|
||||
if graph != nil {
|
||||
memPruned = graph.PruneOlderThan(cutoff)
|
||||
}
|
||||
|
||||
if dbPruned > 0 || memPruned > 0 {
|
||||
log.Printf("[neighbor-prune] removed %d DB rows, %d in-memory edges older than %d days", dbPruned, memPruned, maxAgeDays)
|
||||
}
|
||||
return int(dbPruned), nil
|
||||
}
|
||||
|
||||
@@ -1,599 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// createTestDBWithSchema creates a temp SQLite DB with the standard schema + resolved_path column.
|
||||
func createTestDBWithSchema(t *testing.T) (*DB, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "test.db")
|
||||
|
||||
conn, err := sql.Open("sqlite", "file:"+dbPath+"?_journal_mode=WAL")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create tables
|
||||
conn.Exec(`CREATE TABLE transmissions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
raw_hex TEXT, hash TEXT UNIQUE, first_seen TEXT,
|
||||
route_type INTEGER, payload_type INTEGER, payload_version INTEGER,
|
||||
decoded_json TEXT, channel_hash TEXT DEFAULT NULL
|
||||
)`)
|
||||
conn.Exec(`CREATE TABLE observers (
|
||||
id TEXT PRIMARY KEY, name TEXT, iata TEXT
|
||||
)`)
|
||||
conn.Exec(`CREATE TABLE observations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
transmission_id INTEGER NOT NULL REFERENCES transmissions(id),
|
||||
observer_id TEXT, observer_name TEXT, direction TEXT,
|
||||
snr REAL, rssi REAL, score INTEGER,
|
||||
path_json TEXT, timestamp TEXT,
|
||||
resolved_path TEXT, raw_hex TEXT
|
||||
)`)
|
||||
conn.Exec(`CREATE TABLE nodes (
|
||||
public_key TEXT PRIMARY KEY, name TEXT, role TEXT,
|
||||
lat REAL, lon REAL, last_seen TEXT, first_seen TEXT,
|
||||
advert_count INTEGER DEFAULT 0
|
||||
)`)
|
||||
|
||||
conn.Close()
|
||||
|
||||
db, err := OpenDB(dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return db, dbPath
|
||||
}
|
||||
|
||||
func TestResolvePathForObs(t *testing.T) {
|
||||
// Build a prefix map with known nodes
|
||||
nodes := []nodeInfo{
|
||||
{Role: "repeater", PublicKey: "aabbccddee1234567890aabbccddee1234567890aabbccddee1234567890aabb", Name: "Node-AA"},
|
||||
{Role: "repeater", PublicKey: "bbccddee1234567890aabbccddee1234567890aabbccddee1234567890aabb11", Name: "Node-BB"},
|
||||
}
|
||||
pm := buildPrefixMap(nodes)
|
||||
graph := NewNeighborGraph()
|
||||
|
||||
tx := &StoreTx{
|
||||
DecodedJSON: `{"pubKey": "originator1234567890"}`,
|
||||
PayloadType: intPtr(4),
|
||||
}
|
||||
|
||||
// Unambiguous prefixes should resolve
|
||||
rp := resolvePathForObs(`["aa","bb"]`, "observer1", tx, pm, graph)
|
||||
if len(rp) != 2 {
|
||||
t.Fatalf("expected 2 resolved hops, got %d", len(rp))
|
||||
}
|
||||
if rp[0] == nil || !strings.HasPrefix(*rp[0], "aabbcc") {
|
||||
t.Errorf("expected first hop to resolve to Node-AA, got %v", rp[0])
|
||||
}
|
||||
if rp[1] == nil || !strings.HasPrefix(*rp[1], "bbccdd") {
|
||||
t.Errorf("expected second hop to resolve to Node-BB, got %v", rp[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePathForObs_EmptyPath(t *testing.T) {
|
||||
pm := buildPrefixMap(nil)
|
||||
rp := resolvePathForObs(`[]`, "", &StoreTx{}, pm, nil)
|
||||
if rp != nil {
|
||||
t.Errorf("expected nil for empty path, got %v", rp)
|
||||
}
|
||||
|
||||
rp = resolvePathForObs("", "", &StoreTx{}, pm, nil)
|
||||
if rp != nil {
|
||||
t.Errorf("expected nil for empty string, got %v", rp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePathForObs_Unresolvable(t *testing.T) {
|
||||
nodes := []nodeInfo{
|
||||
{Role: "repeater", PublicKey: "aabbccddee1234567890aabbccddee1234567890aabbccddee1234567890aabb", Name: "Node-AA"},
|
||||
}
|
||||
pm := buildPrefixMap(nodes)
|
||||
|
||||
// "zz" prefix doesn't match any node
|
||||
rp := resolvePathForObs(`["zz"]`, "", &StoreTx{}, pm, nil)
|
||||
if len(rp) != 1 {
|
||||
t.Fatalf("expected 1 hop, got %d", len(rp))
|
||||
}
|
||||
if rp[0] != nil {
|
||||
t.Errorf("expected nil for unresolvable hop, got %v", *rp[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalUnmarshalResolvedPath(t *testing.T) {
|
||||
pk1 := "aabbccdd"
|
||||
var rp []*string
|
||||
rp = append(rp, &pk1, nil)
|
||||
|
||||
j := marshalResolvedPath(rp)
|
||||
if j == "" {
|
||||
t.Fatal("expected non-empty JSON")
|
||||
}
|
||||
|
||||
parsed := unmarshalResolvedPath(j)
|
||||
if len(parsed) != 2 {
|
||||
t.Fatalf("expected 2 elements, got %d", len(parsed))
|
||||
}
|
||||
if parsed[0] == nil || *parsed[0] != "aabbccdd" {
|
||||
t.Errorf("first element wrong: %v", parsed[0])
|
||||
}
|
||||
if parsed[1] != nil {
|
||||
t.Errorf("second element should be nil, got %v", *parsed[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalResolvedPath_Empty(t *testing.T) {
|
||||
if marshalResolvedPath(nil) != "" {
|
||||
t.Error("expected empty for nil")
|
||||
}
|
||||
if marshalResolvedPath([]*string{}) != "" {
|
||||
t.Error("expected empty for empty slice")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshalResolvedPath_Invalid(t *testing.T) {
|
||||
if unmarshalResolvedPath("") != nil {
|
||||
t.Error("expected nil for empty string")
|
||||
}
|
||||
if unmarshalResolvedPath("not json") != nil {
|
||||
t.Error("expected nil for invalid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureNeighborEdgesTable(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "test.db")
|
||||
|
||||
// Create initial DB
|
||||
conn, _ := sql.Open("sqlite", "file:"+dbPath+"?_journal_mode=WAL")
|
||||
conn.Exec("CREATE TABLE test (id INTEGER PRIMARY KEY)")
|
||||
conn.Close()
|
||||
|
||||
if err := ensureNeighborEdgesTable(dbPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Verify table exists
|
||||
conn, _ = sql.Open("sqlite", "file:"+dbPath+"?mode=ro")
|
||||
defer conn.Close()
|
||||
var cnt int
|
||||
if err := conn.QueryRow("SELECT COUNT(*) FROM neighbor_edges").Scan(&cnt); err != nil {
|
||||
t.Fatalf("neighbor_edges table not created: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadNeighborEdgesFromDB(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "test.db")
|
||||
|
||||
conn, _ := sql.Open("sqlite", "file:"+dbPath+"?_journal_mode=WAL")
|
||||
conn.Exec(`CREATE TABLE neighbor_edges (
|
||||
node_a TEXT NOT NULL, node_b TEXT NOT NULL,
|
||||
count INTEGER DEFAULT 1, last_seen TEXT,
|
||||
PRIMARY KEY (node_a, node_b)
|
||||
)`)
|
||||
conn.Exec("INSERT INTO neighbor_edges VALUES ('aaa', 'bbb', 5, '2024-01-01T00:00:00Z')")
|
||||
conn.Exec("INSERT INTO neighbor_edges VALUES ('ccc', 'ddd', 3, '2024-01-02T00:00:00Z')")
|
||||
|
||||
g := loadNeighborEdgesFromDB(conn)
|
||||
conn.Close()
|
||||
|
||||
// Should have 2 edges
|
||||
edges := g.AllEdges()
|
||||
if len(edges) != 2 {
|
||||
t.Errorf("expected 2 edges, got %d", len(edges))
|
||||
}
|
||||
|
||||
// Check neighbors
|
||||
n := g.Neighbors("aaa")
|
||||
if len(n) != 1 {
|
||||
t.Errorf("expected 1 neighbor for aaa, got %d", len(n))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreObsResolvedPathInBroadcast(t *testing.T) {
|
||||
// After #800 refactor, resolved_path is no longer stored on StoreTx/StoreObs structs.
|
||||
// Broadcast maps carry resolved_path from the decode-window, not from struct fields.
|
||||
// This test verifies pickBestObservation no longer sets ResolvedPath on tx.
|
||||
obs := &StoreObs{
|
||||
ID: 1,
|
||||
ObserverID: "obs1",
|
||||
ObserverName: "Observer 1",
|
||||
PathJSON: `["aa"]`,
|
||||
Timestamp: "2024-01-01T00:00:00Z",
|
||||
}
|
||||
|
||||
tx := &StoreTx{
|
||||
ID: 1,
|
||||
Hash: "abc123",
|
||||
Observations: []*StoreObs{obs},
|
||||
}
|
||||
pickBestObservation(tx)
|
||||
|
||||
// tx should NOT have a ResolvedPath field anymore (compile-time guard)
|
||||
// Verify the best observation's fields are propagated correctly
|
||||
if tx.ObserverID != "obs1" {
|
||||
t.Errorf("expected ObserverID=obs1, got %s", tx.ObserverID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvedPathInTxToMap(t *testing.T) {
|
||||
// After #800, txToMap no longer includes resolved_path from the struct.
|
||||
// resolved_path is only available via on-demand SQL fetch (txToMapWithRP).
|
||||
tx := &StoreTx{
|
||||
ID: 1,
|
||||
Hash: "abc123",
|
||||
PathJSON: `["aa"]`,
|
||||
obsKeys: make(map[string]bool),
|
||||
}
|
||||
|
||||
m := txToMap(tx)
|
||||
if _, ok := m["resolved_path"]; ok {
|
||||
t.Error("resolved_path should not be in txToMap output (removed in #800)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvedPathOmittedWhenNil(t *testing.T) {
|
||||
tx := &StoreTx{
|
||||
ID: 1,
|
||||
Hash: "abc123",
|
||||
obsKeys: make(map[string]bool),
|
||||
}
|
||||
|
||||
m := txToMap(tx)
|
||||
if _, ok := m["resolved_path"]; ok {
|
||||
t.Error("resolved_path should not be in map when nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureResolvedPathColumn(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "test.db")
|
||||
|
||||
conn, _ := sql.Open("sqlite", "file:"+dbPath+"?_journal_mode=WAL")
|
||||
conn.Exec(`CREATE TABLE observations (
|
||||
id INTEGER PRIMARY KEY, transmission_id INTEGER,
|
||||
observer_id TEXT, path_json TEXT, timestamp TEXT, raw_hex TEXT
|
||||
)`)
|
||||
conn.Close()
|
||||
|
||||
if err := ensureResolvedPathColumn(dbPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Verify column exists
|
||||
conn, _ = sql.Open("sqlite", "file:"+dbPath+"?mode=ro")
|
||||
defer conn.Close()
|
||||
rows, _ := conn.Query("PRAGMA table_info(observations)")
|
||||
found := false
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var colName string
|
||||
var colType sql.NullString
|
||||
var notNull, pk int
|
||||
var dflt sql.NullString
|
||||
rows.Scan(&cid, &colName, &colType, ¬Null, &dflt, &pk)
|
||||
if colName == "resolved_path" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
if !found {
|
||||
t.Error("resolved_path column not added")
|
||||
}
|
||||
|
||||
// Running again should be idempotent
|
||||
if err := ensureResolvedPathColumn(dbPath); err != nil {
|
||||
t.Fatal("second call should be idempotent:", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDBDetectsResolvedPathColumn(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "test.db")
|
||||
|
||||
// Create DB without resolved_path
|
||||
conn, _ := sql.Open("sqlite", "file:"+dbPath+"?_journal_mode=WAL")
|
||||
conn.Exec(`CREATE TABLE observations (id INTEGER PRIMARY KEY, observer_idx INTEGER)`)
|
||||
conn.Exec(`CREATE TABLE transmissions (id INTEGER PRIMARY KEY)`)
|
||||
conn.Close()
|
||||
|
||||
db, err := OpenDB(dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if db.hasResolvedPath {
|
||||
t.Error("should not detect resolved_path when column missing")
|
||||
}
|
||||
db.Close()
|
||||
|
||||
// Add resolved_path column
|
||||
conn, _ = sql.Open("sqlite", "file:"+dbPath+"?_journal_mode=WAL")
|
||||
conn.Exec("ALTER TABLE observations ADD COLUMN resolved_path TEXT")
|
||||
conn.Close()
|
||||
|
||||
db, err = OpenDB(dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !db.hasResolvedPath {
|
||||
t.Error("should detect resolved_path when column exists")
|
||||
}
|
||||
db.Close()
|
||||
}
|
||||
|
||||
func TestLoadWithResolvedPath(t *testing.T) {
|
||||
db, dbPath := createTestDBWithSchema(t)
|
||||
defer db.Close()
|
||||
|
||||
// Insert test data
|
||||
rw, _ := openRW(dbPath)
|
||||
rw.Exec(`INSERT INTO transmissions (id, hash, first_seen, payload_type, decoded_json)
|
||||
VALUES (1, 'hash1', '2024-01-01T00:00:00Z', 4, '{"pubKey":"origpk"}')`)
|
||||
rw.Exec(`INSERT INTO observations (id, transmission_id, observer_id, observer_name, path_json, timestamp, resolved_path)
|
||||
VALUES (1, 1, 'obs1', 'Observer1', '["aa"]', '2024-01-01T00:00:00Z', '["aabbccdd"]')`)
|
||||
rw.Close()
|
||||
|
||||
store := NewPacketStore(db, nil)
|
||||
if err := store.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(store.packets) != 1 {
|
||||
t.Fatalf("expected 1 packet, got %d", len(store.packets))
|
||||
}
|
||||
|
||||
tx := store.packets[0]
|
||||
if len(tx.Observations) != 1 {
|
||||
t.Fatalf("expected 1 observation, got %d", len(tx.Observations))
|
||||
}
|
||||
|
||||
// After #800, ResolvedPath is not stored on StoreObs struct.
|
||||
// Instead, resolved pubkeys are in the membership index.
|
||||
_ = tx.Observations[0] // obs exists
|
||||
h := resolvedPubkeyHash("aabbccdd")
|
||||
if len(store.resolvedPubkeyIndex[h]) != 1 {
|
||||
t.Fatal("expected resolved pubkey to be indexed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvedPathInAPIResponse(t *testing.T) {
|
||||
// After #800, TransmissionResp no longer has ResolvedPath field.
|
||||
// resolved_path is included dynamically in map-based API responses.
|
||||
resp := TransmissionResp{
|
||||
ID: 1,
|
||||
Hash: "test",
|
||||
}
|
||||
|
||||
data, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var m map[string]interface{}
|
||||
json.Unmarshal(data, &m)
|
||||
|
||||
// resolved_path should NOT be in the marshaled JSON
|
||||
if _, ok := m["resolved_path"]; ok {
|
||||
t.Error("resolved_path should not be in TransmissionResp JSON (#800)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvedPathOmittedWhenEmpty(t *testing.T) {
|
||||
resp := TransmissionResp{
|
||||
ID: 1,
|
||||
Hash: "test",
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(resp)
|
||||
var m map[string]interface{}
|
||||
json.Unmarshal(data, &m)
|
||||
|
||||
if _, ok := m["resolved_path"]; ok {
|
||||
t.Error("resolved_path should be omitted when nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractEdgesFromObs_AdvertNoPath(t *testing.T) {
|
||||
tx := &StoreTx{
|
||||
DecodedJSON: `{"pubKey":"aaaa1111"}`,
|
||||
PayloadType: intPtr(4),
|
||||
}
|
||||
obs := &StoreObs{
|
||||
ObserverID: "bbbb2222",
|
||||
PathJSON: "",
|
||||
Timestamp: "2024-01-01T00:00:00Z",
|
||||
}
|
||||
|
||||
edges := extractEdgesFromObs(obs, tx, nil)
|
||||
if len(edges) != 1 {
|
||||
t.Fatalf("expected 1 edge for zero-hop advert, got %d", len(edges))
|
||||
}
|
||||
// Canonical ordering: aaaa < bbbb
|
||||
if edges[0].A != "aaaa1111" || edges[0].B != "bbbb2222" {
|
||||
t.Errorf("unexpected edge: %+v", edges[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractEdgesFromObs_NonAdvertNoPath(t *testing.T) {
|
||||
tx := &StoreTx{PayloadType: intPtr(1)}
|
||||
obs := &StoreObs{ObserverID: "obs1", PathJSON: ""}
|
||||
edges := extractEdgesFromObs(obs, tx, nil)
|
||||
if len(edges) != 0 {
|
||||
t.Errorf("expected 0 edges for non-advert without path, got %d", len(edges))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractEdgesFromObs_WithPath(t *testing.T) {
|
||||
nodes := []nodeInfo{
|
||||
{Role: "repeater", PublicKey: "aabbccddee1234567890aabbccddee1234567890aabbccddee1234567890aabb", Name: "Node-AA"},
|
||||
{Role: "repeater", PublicKey: "ffgghhii1234567890aabbccddee1234567890aabbccddee1234567890aabb11", Name: "Node-FF"},
|
||||
}
|
||||
pm := buildPrefixMap(nodes)
|
||||
|
||||
tx := &StoreTx{
|
||||
DecodedJSON: `{"pubKey":"originator00"}`,
|
||||
PayloadType: intPtr(4),
|
||||
}
|
||||
obs := &StoreObs{
|
||||
ObserverID: "observer00",
|
||||
PathJSON: `["aa","ff"]`,
|
||||
Timestamp: "2024-01-01T00:00:00Z",
|
||||
}
|
||||
|
||||
edges := extractEdgesFromObs(obs, tx, pm)
|
||||
// Should get: originator↔aa (advert), observer↔ff (last hop)
|
||||
if len(edges) != 2 {
|
||||
t.Fatalf("expected 2 edges, got %d", len(edges))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractEdgesFromObs_SameNodeNoEdge(t *testing.T) {
|
||||
tx := &StoreTx{
|
||||
DecodedJSON: `{"pubKey":"same1234"}`,
|
||||
PayloadType: intPtr(4),
|
||||
}
|
||||
obs := &StoreObs{
|
||||
ObserverID: "same1234",
|
||||
PathJSON: "",
|
||||
Timestamp: "2024-01-01T00:00:00Z",
|
||||
}
|
||||
edges := extractEdgesFromObs(obs, tx, nil)
|
||||
if len(edges) != 0 {
|
||||
t.Errorf("expected 0 edges when originator == observer, got %d", len(edges))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
func TestPersistSemaphoreTryAcquireSkipsBatch(t *testing.T) {
|
||||
// Verify that persistSem is a buffered channel of size 1.
|
||||
if cap(persistSem) != 1 {
|
||||
t.Errorf("persistSem capacity = %d, want 1", cap(persistSem))
|
||||
}
|
||||
// Acquire the semaphore to simulate an in-progress persistence.
|
||||
persistSem <- struct{}{}
|
||||
|
||||
// asyncPersistResolvedPathsAndEdges should skip (not block, not
|
||||
// spawn a goroutine) when the semaphore is already held.
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
asyncPersistResolvedPathsAndEdges(
|
||||
"/nonexistent/path.db",
|
||||
[]persistObsUpdate{{obsID: 1, resolvedPath: "x"}},
|
||||
nil,
|
||||
"test",
|
||||
)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
// If the function blocks on the semaphore instead of skipping,
|
||||
// this select will hit the timeout.
|
||||
select {
|
||||
case <-done:
|
||||
// Expected: returned immediately because semaphore was busy.
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
<-persistSem
|
||||
t.Fatal("asyncPersistResolvedPathsAndEdges blocked instead of skipping when semaphore was held")
|
||||
}
|
||||
|
||||
<-persistSem // release
|
||||
}
|
||||
|
||||
func TestOpenRW_BusyTimeout(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "test.db")
|
||||
|
||||
// Create the DB file first
|
||||
db, err := sql.Open("sqlite", "file:"+dbPath+"?_journal_mode=WAL")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db.Exec("CREATE TABLE dummy (id INTEGER)")
|
||||
db.Close()
|
||||
|
||||
// Open via openRW and verify busy_timeout is set
|
||||
rw, err := openRW(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("openRW failed: %v", err)
|
||||
}
|
||||
defer rw.Close()
|
||||
|
||||
var timeout int
|
||||
if err := rw.QueryRow("PRAGMA busy_timeout").Scan(&timeout); err != nil {
|
||||
t.Fatalf("query busy_timeout: %v", err)
|
||||
}
|
||||
if timeout != 5000 {
|
||||
t.Errorf("expected busy_timeout=5000, got %d", timeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureLastPacketAtColumn(t *testing.T) {
|
||||
// Create a temp DB with observers table missing last_packet_at
|
||||
dir := t.TempDir()
|
||||
dbPath := dir + "/test.db"
|
||||
db, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = db.Exec(`CREATE TABLE observers (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT,
|
||||
last_seen TEXT,
|
||||
lat REAL,
|
||||
lon REAL,
|
||||
inactive INTEGER DEFAULT 0
|
||||
)`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db.Close()
|
||||
|
||||
// First call: should add the column
|
||||
if err := ensureLastPacketAtColumn(dbPath); err != nil {
|
||||
t.Fatalf("first call failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify column exists
|
||||
db2, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db2.Close()
|
||||
|
||||
var found bool
|
||||
rows, err := db2.Query("PRAGMA table_info(observers)")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var colName string
|
||||
var colType sql.NullString
|
||||
var notNull, pk int
|
||||
var dflt sql.NullString
|
||||
if rows.Scan(&cid, &colName, &colType, ¬Null, &dflt, &pk) == nil && colName == "last_packet_at" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("last_packet_at column not found after migration")
|
||||
}
|
||||
|
||||
// Idempotency: second call should succeed without error
|
||||
if err := ensureLastPacketAtColumn(dbPath); err != nil {
|
||||
t.Fatalf("idempotent call failed: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// Package main: neighbor-graph snapshot recomputer (issue #1287).
|
||||
//
|
||||
// Per #1287 Option 4: the ingestor owns the neighbor_edges table —
|
||||
// it computes the graph from observations it ingests and persists
|
||||
// snapshots there. The server READS the snapshot and atomic-swaps
|
||||
// it into s.graph; that swap is exactly what this recomputer does.
|
||||
//
|
||||
// Cadence: 60s default. Staleness budget matches the existing
|
||||
// analytics recomputer (#1240) — operators already accept that
|
||||
// derived analytics lag the wire by tens of seconds.
|
||||
package main
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// NeighborGraphRecomputerDefaultInterval is how often the server
|
||||
// re-reads the neighbor_edges snapshot. 60s is the standard
|
||||
// staleness budget for derived analytics (#1240 / #1262 / #672 axis 2).
|
||||
const NeighborGraphRecomputerDefaultInterval = 60 * time.Second
|
||||
|
||||
var (
|
||||
neighborRecompStartedMu sync.Mutex
|
||||
neighborRecompStarted bool
|
||||
)
|
||||
|
||||
// StartNeighborGraphRecomputer launches the background goroutine that
|
||||
// re-reads neighbor_edges every `interval` and atomic-swaps the
|
||||
// resulting NeighborGraph into s.graph. Idempotent — subsequent calls
|
||||
// are no-ops and return a no-op stop closure.
|
||||
//
|
||||
// Server NEVER writes to neighbor_edges; the ingestor owns those
|
||||
// writes per #1287. This recomputer is the ONLY thing that updates
|
||||
// s.graph at steady state (the initial startup load in main.go is the
|
||||
// other writer to s.graph, only at boot).
|
||||
func (s *PacketStore) StartNeighborGraphRecomputer(interval time.Duration) func() {
|
||||
if interval <= 0 {
|
||||
interval = NeighborGraphRecomputerDefaultInterval
|
||||
}
|
||||
|
||||
neighborRecompStartedMu.Lock()
|
||||
if neighborRecompStarted {
|
||||
neighborRecompStartedMu.Unlock()
|
||||
return func() {}
|
||||
}
|
||||
neighborRecompStarted = true
|
||||
stop := make(chan struct{})
|
||||
done := make(chan struct{})
|
||||
neighborRecompStartedMu.Unlock()
|
||||
|
||||
var stopOnce sync.Once
|
||||
go func() {
|
||||
defer close(done)
|
||||
t := time.NewTicker(interval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-t.C:
|
||||
s.refreshNeighborGraphFromSnapshot()
|
||||
case <-stop:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return func() {
|
||||
stopOnce.Do(func() { close(stop) })
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// refreshNeighborGraphFromSnapshot re-reads neighbor_edges through
|
||||
// the read-only DB handle and atomic-swaps a freshly built graph.
|
||||
// Panics are swallowed defensively — the previous snapshot remains
|
||||
// valid if a read fails.
|
||||
func (s *PacketStore) refreshNeighborGraphFromSnapshot() {
|
||||
defer func() { _ = recover() }()
|
||||
if s.db == nil || s.db.conn == nil {
|
||||
return
|
||||
}
|
||||
g := loadNeighborEdgesFromDB(s.db.conn)
|
||||
if g != nil {
|
||||
s.graph.Store(g)
|
||||
}
|
||||
}
|
||||
|
||||
// resetNeighborRecomputerForTest is a test helper — production code
|
||||
// MUST NOT call this.
|
||||
func resetNeighborRecomputerForTest() {
|
||||
neighborRecompStartedMu.Lock()
|
||||
neighborRecompStarted = false
|
||||
neighborRecompStartedMu.Unlock()
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/meshcore-analyzer/dbschema"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// TestNeighborGraphRecomputerLoadsSnapshot enforces #1287 Option 4:
|
||||
// the server LOADS its in-memory neighbor graph from the SQLite
|
||||
// snapshot the ingestor writes. After a write to neighbor_edges (here
|
||||
// done synthetically), the recomputer's atomic-swap must reflect it.
|
||||
func TestNeighborGraphRecomputerLoadsSnapshot(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "neighbor_recomp.db")
|
||||
|
||||
// Bootstrap a WAL DB with the neighbor_edges table.
|
||||
rw, err := sql.Open("sqlite", "file:"+dbPath+"?_journal_mode=WAL")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rw.Close()
|
||||
if _, err := rw.Exec(`CREATE TABLE neighbor_edges (
|
||||
node_a TEXT NOT NULL,
|
||||
node_b TEXT NOT NULL,
|
||||
count INTEGER DEFAULT 1,
|
||||
last_seen TEXT,
|
||||
PRIMARY KEY (node_a, node_b)
|
||||
)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Stage one edge.
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
if _, err := rw.Exec(
|
||||
`INSERT INTO neighbor_edges (node_a, node_b, count, last_seen) VALUES (?, ?, ?, ?)`,
|
||||
"aaa", "bbb", 5, now,
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Server opens read-only and refreshes via the recomputer.
|
||||
d, err := OpenDB(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenDB: %v", err)
|
||||
}
|
||||
defer d.conn.Close()
|
||||
store := &PacketStore{db: d}
|
||||
store.graph.Store(NewNeighborGraph())
|
||||
|
||||
store.refreshNeighborGraphFromSnapshot()
|
||||
g := store.graph.Load()
|
||||
if g == nil {
|
||||
t.Fatal("graph nil after refresh")
|
||||
}
|
||||
if got := len(g.AllEdges()); got != 1 {
|
||||
t.Fatalf("expected 1 edge after first refresh, got %d", got)
|
||||
}
|
||||
|
||||
// Add another row, refresh, assert the new total.
|
||||
if _, err := rw.Exec(
|
||||
`INSERT INTO neighbor_edges (node_a, node_b, count, last_seen) VALUES (?, ?, ?, ?)`,
|
||||
"ccc", "ddd", 2, now,
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store.refreshNeighborGraphFromSnapshot()
|
||||
g = store.graph.Load()
|
||||
if got := len(g.AllEdges()); got != 2 {
|
||||
t.Fatalf("expected 2 edges after second refresh, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestServerStartupRequiresMigratedSchema enforces #1287: the server
|
||||
// MUST refuse to start if the ingestor hasn't run schema migrations.
|
||||
// AssertReady on a DB missing the required columns returns an error
|
||||
// listing every missing surface; main.go then calls log.Fatalf.
|
||||
func TestServerStartupRequiresMigratedSchema(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "unmigrated.db")
|
||||
|
||||
// Bootstrap with ONLY transmissions/observations (the things
|
||||
// server tries to read) but WITHOUT the columns dbschema asserts
|
||||
// (resolved_path, inactive, last_packet_at, iata, foreign_advert,
|
||||
// from_pubkey, neighbor_edges).
|
||||
rw, err := sql.Open("sqlite", "file:"+dbPath+"?_journal_mode=WAL")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rw.Close()
|
||||
for _, s := range []string{
|
||||
`CREATE TABLE transmissions (id INTEGER PRIMARY KEY, hash TEXT, payload_type INTEGER)`,
|
||||
`CREATE TABLE observations (id INTEGER PRIMARY KEY, transmission_id INTEGER)`,
|
||||
`CREATE TABLE observers (id TEXT PRIMARY KEY, name TEXT)`,
|
||||
`CREATE TABLE nodes (public_key TEXT PRIMARY KEY)`,
|
||||
`CREATE TABLE inactive_nodes (public_key TEXT PRIMARY KEY)`,
|
||||
} {
|
||||
if _, err := rw.Exec(s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Open the read-only server handle and call AssertReady directly
|
||||
// (production path: main.go does this before any business logic).
|
||||
d, err := OpenDB(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenDB: %v", err)
|
||||
}
|
||||
defer d.conn.Close()
|
||||
|
||||
// The package-level dbschema.AssertReady requires every missing
|
||||
// surface to be reported. We hit it directly through the same
|
||||
// path main.go uses.
|
||||
if err := assertReadyForTest(d); err == nil {
|
||||
t.Fatal("expected AssertReady to fail against an unmigrated DB; server would have started against an incomplete schema")
|
||||
}
|
||||
}
|
||||
|
||||
// assertReadyForTest is the same call main.go makes — declared here so
|
||||
// the test stays decoupled from any future inlining or rename.
|
||||
func assertReadyForTest(d *DB) error {
|
||||
return dbschemaAssertReadyShim(d)
|
||||
}
|
||||
|
||||
// dbschemaAssertReadyShim wraps the package import so tests don't
|
||||
// directly depend on the import being present (production wires it
|
||||
// via main.go).
|
||||
func dbschemaAssertReadyShim(d *DB) error { return dbschema.AssertReady(d.conn) }
|
||||
@@ -0,0 +1,119 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestHandleNodesLimit2000ColdMiss is a regression guard for issue #1262.
|
||||
//
|
||||
// Background: PR #1260 added a 15s-TTL bulk-cache for repeater
|
||||
// enrichment in handleNodes (GetRepeaterRelayInfoMap /
|
||||
// GetRepeaterUsefulnessScoreMap). On warm hits the request is ~40ms.
|
||||
// On the very first request after server startup (or after the 15s TTL
|
||||
// expires) the cache rebuild runs on the request-serving goroutine and
|
||||
// is O(byPathHop + parsed timestamps). On staging (75k tx, 600 nodes)
|
||||
// the cold rebuild took 15.7s.
|
||||
//
|
||||
// /api/nodes?limit=2000 is the SPA's hop-resolver bootstrap call (see
|
||||
// public/live.js) so EVERY cold SPA load eats the cold-rebuild cost.
|
||||
//
|
||||
// Acceptance: /api/nodes?limit=2000 must return in <2s on a
|
||||
// realistic-shape fleet WITHOUT a prior warmup request — i.e. once the
|
||||
// store has been initialized and the steady-state repeater-enrichment
|
||||
// recomputer prewarm has run.
|
||||
func TestHandleNodesLimit2000ColdMiss(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("perf test")
|
||||
}
|
||||
srv, router := setupTestServer(t)
|
||||
conn := srv.db.conn
|
||||
|
||||
// Seed 600 nodes — 50 repeaters/rooms with most-recent last_seen so
|
||||
// they sit at the top of the limit=2000 page, plus 550 stale
|
||||
// companions.
|
||||
tx, err := conn.Begin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stmt, err := tx.Prepare(`INSERT INTO nodes
|
||||
(public_key, name, role, lat, lon, last_seen, first_seen, advert_count, foreign_advert)
|
||||
VALUES (?, ?, ?, 0, 0, ?, '2026-01-01T00:00:00Z', 1, 0)`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
for i := 0; i < 50; i++ {
|
||||
pk := fmt.Sprintf("pkrepeat%056x", i)
|
||||
ts := now.Add(-time.Duration(i) * time.Minute).Format(time.RFC3339Nano)
|
||||
if _, err := stmt.Exec(pk, fmt.Sprintf("rep%d", i), "repeater", ts); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for i := 0; i < 550; i++ {
|
||||
pk := fmt.Sprintf("pkcompan%056x", i)
|
||||
ts := now.Add(-time.Duration(60+i) * time.Minute).Format(time.RFC3339Nano)
|
||||
if _, err := stmt.Exec(pk, fmt.Sprintf("comp%d", i), "companion", ts); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Seed the in-memory packet store: a non-trivial body of non-advert
|
||||
// traffic where each repeater appears as a path hop on many txs.
|
||||
// This is what makes the bulk-cache rebuild expensive.
|
||||
const numTx = 150000
|
||||
const hopsPerTx = 6
|
||||
pt2 := 2
|
||||
store := srv.store
|
||||
for i := 0; i < numTx; i++ {
|
||||
txID := 100000 + i
|
||||
ts := now.Add(-time.Duration(i) * time.Second).Format(time.RFC3339Nano)
|
||||
stx := &StoreTx{
|
||||
ID: txID,
|
||||
Hash: fmt.Sprintf("h%d", txID),
|
||||
FirstSeen: ts,
|
||||
PayloadType: &pt2,
|
||||
}
|
||||
store.byPayloadType[pt2] = append(store.byPayloadType[pt2], stx)
|
||||
// Shared 1-byte prefix bucket to mirror production hop-prefix
|
||||
// collisions.
|
||||
store.byPathHop["pk"] = append(store.byPathHop["pk"], stx)
|
||||
for h := 0; h < hopsPerTx; h++ {
|
||||
repIdx := (i + h) % 50
|
||||
pk := fmt.Sprintf("pkrepeat%056x", repIdx)
|
||||
store.byPathHop[pk] = append(store.byPathHop[pk], stx)
|
||||
}
|
||||
}
|
||||
|
||||
// Steady-state repeater-enrichment recomputer (the fix for #1262)
|
||||
// prewarms the bulk caches at startup so the first handler request
|
||||
// — which is /api/nodes?limit=2000 from live.js on every cold SPA
|
||||
// load — hits the cache instead of rebuilding it on-thread.
|
||||
stop := store.StartRepeaterEnrichmentRecomputer(24, 5*time.Minute)
|
||||
defer stop()
|
||||
|
||||
// NO HTTP warmup — we are explicitly measuring the first
|
||||
// limit=2000 request, the way live.js sees it.
|
||||
|
||||
start := time.Now()
|
||||
req := httptest.NewRequest("GET", "/api/nodes?limit=2000", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
elapsed := time.Since(start)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
const budget = 2 * time.Second
|
||||
t.Logf("/api/nodes?limit=2000 elapsed=%v on %d nodes, %d tx", elapsed, 600, numTx)
|
||||
if elapsed > budget {
|
||||
t.Fatalf("/api/nodes?limit=2000 cold-miss too slow for #1262: %v (budget %v) on %d nodes, %d tx",
|
||||
elapsed, budget, 600, numTx)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestHandleNodesPerfLargeFleet asserts the /api/nodes endpoint (no `limit`
|
||||
// param — relying on the server-side default) returns in well under 2s on a
|
||||
// realistic-shape fleet: 600 nodes, ~50 of them repeaters/rooms with rich
|
||||
// path-hop activity, and a non-trivial byPayloadType + byPathHop index.
|
||||
//
|
||||
// Regression guard for issue #1257:
|
||||
// /api/nodes (no limit) → 32.9s, 30KB on staging (637 nodes)
|
||||
// /api/nodes?limit=2000 → 4.9s, 360KB
|
||||
//
|
||||
// Root cause class: per-repeater enrichment in handleNodes calls
|
||||
// store.GetRepeaterRelayInfo + GetRepeaterUsefulnessScore separately for
|
||||
// each node in the page. Each call takes its own RLock and walks
|
||||
// byPathHop[pk] / byPayloadType, doing expensive timestamp parsing.
|
||||
// For the default-page case (top-50 by last_seen, mostly hot repeaters)
|
||||
// that is hundreds of thousands of timestamp parses per request.
|
||||
//
|
||||
// Budget: 2s. On the broken implementation with this fixture the
|
||||
// endpoint blows the budget; with batched/cached per-page enrichment it
|
||||
// completes in well under 500ms.
|
||||
func TestHandleNodesPerfLargeFleet(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("perf test")
|
||||
}
|
||||
srv, router := setupTestServer(t)
|
||||
conn := srv.db.conn
|
||||
|
||||
// Seed 600 nodes — 50 repeaters/rooms with most-recent last_seen so
|
||||
// they land on the default page, plus 550 stale companions.
|
||||
tx, err := conn.Begin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stmt, err := tx.Prepare(`INSERT INTO nodes
|
||||
(public_key, name, role, lat, lon, last_seen, first_seen, advert_count, foreign_advert)
|
||||
VALUES (?, ?, ?, 0, 0, ?, '2026-01-01T00:00:00Z', 1, 0)`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
for i := 0; i < 50; i++ {
|
||||
pk := fmt.Sprintf("pkrepeat%056x", i)
|
||||
ts := now.Add(-time.Duration(i) * time.Minute).Format(time.RFC3339Nano)
|
||||
if _, err := stmt.Exec(pk, fmt.Sprintf("rep%d", i), "repeater", ts); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for i := 0; i < 550; i++ {
|
||||
pk := fmt.Sprintf("pkcompan%056x", i)
|
||||
ts := now.Add(-time.Duration(60+i) * time.Minute).Format(time.RFC3339Nano)
|
||||
if _, err := stmt.Exec(pk, fmt.Sprintf("comp%d", i), "companion", ts); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Seed the in-memory packet store: a body of non-advert traffic with
|
||||
// each repeater appearing as a path hop on many of them. This is what
|
||||
// makes the per-node GetRepeaterRelayInfo / GetRepeaterUsefulnessScore
|
||||
// calls expensive on the broken impl.
|
||||
const numTx = 150000
|
||||
const hopsPerTx = 6
|
||||
pt2 := 2 // non-advert payload type
|
||||
store := srv.store
|
||||
// Also index every tx under a single shared 1-byte prefix so the
|
||||
// GetRepeaterRelayInfo prefix-collision branch fans every per-node
|
||||
// call through the full non-advert tx set (matches production where
|
||||
// many repeaters share a 1-byte hop prefix).
|
||||
for i := 0; i < numTx; i++ {
|
||||
txID := 100000 + i
|
||||
ts := now.Add(-time.Duration(i) * time.Second).Format(time.RFC3339Nano)
|
||||
stx := &StoreTx{
|
||||
ID: txID,
|
||||
Hash: fmt.Sprintf("h%d", txID),
|
||||
FirstSeen: ts,
|
||||
PayloadType: &pt2,
|
||||
}
|
||||
store.byPayloadType[pt2] = append(store.byPayloadType[pt2], stx)
|
||||
store.byPathHop["pk"] = append(store.byPathHop["pk"], stx)
|
||||
// Index each repeater under byPathHop so per-node enrichment walks
|
||||
// a non-trivial slice.
|
||||
for h := 0; h < hopsPerTx; h++ {
|
||||
repIdx := (i + h) % 50
|
||||
pk := fmt.Sprintf("pkrepeat%056x", repIdx)
|
||||
store.byPathHop[pk] = append(store.byPathHop[pk], stx)
|
||||
}
|
||||
}
|
||||
|
||||
// Warm-up to amortize first-call costs (cache misses, prepare). Note:
|
||||
// the per-node Repeater* enrichment is NOT cached, so this warmup
|
||||
// does not hide the perf bug — it only amortizes one-shot prep.
|
||||
{
|
||||
req := httptest.NewRequest("GET", "/api/nodes?limit=1", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("warmup status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
req := httptest.NewRequest("GET", "/api/nodes", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
elapsed := time.Since(start)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
const budget = 2 * time.Second
|
||||
t.Logf("/api/nodes (no limit) elapsed=%v on %d nodes, %d tx", elapsed, 600, numTx)
|
||||
if elapsed > budget {
|
||||
t.Fatalf("/api/nodes (no limit) too slow for #1257: %v (budget %v) on %d nodes, %d tx",
|
||||
elapsed, budget, 600, numTx)
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,7 @@ func routeDescriptions() map[string]routeMeta {
|
||||
"GET /api/stats": {Summary: "Network statistics", Description: "Returns aggregate stats (node counts, packet counts, observer counts). Cached for 10s.", Tag: "admin"},
|
||||
"GET /api/perf": {Summary: "Performance statistics", Description: "Returns per-endpoint request timing and slow query log.", Tag: "admin"},
|
||||
"POST /api/perf/reset": {Summary: "Reset performance stats", Tag: "admin", Auth: true},
|
||||
"POST /api/admin/prune": {Summary: "Prune old data", Description: "Deletes packets and nodes older than the configured retention period.", Tag: "admin", Auth: true},
|
||||
// "POST /api/admin/prune" removed in #1283 (ingestor owns prune).
|
||||
"GET /api/debug/affinity": {Summary: "Debug neighbor affinity scores", Tag: "admin", Auth: true},
|
||||
"GET /api/backup": {Summary: "Download SQLite backup", Description: "Streams a consistent SQLite snapshot of the analyzer DB (VACUUM INTO). Response is application/octet-stream with attachment filename corescope-backup-<unix>.db.", Tag: "admin", Auth: true},
|
||||
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
// Test (#1188): /api/packets response must include observer_iata per packet
|
||||
// so the frontend can render the IATA inline without per-row observer lookups.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestPacketsEndpointIncludesObserverIATA asserts the ungrouped packets endpoint
|
||||
// surfaces the joined observer's IATA on each packet row.
|
||||
func TestPacketsEndpointIncludesObserverIATA(t *testing.T) {
|
||||
_, router := setupTestServer(t)
|
||||
req := httptest.NewRequest("GET", "/api/packets?limit=10", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("invalid JSON: %v", err)
|
||||
}
|
||||
packets, ok := body["packets"].([]interface{})
|
||||
if !ok || len(packets) == 0 {
|
||||
t.Fatal("expected non-empty packets array")
|
||||
}
|
||||
|
||||
// Seeded observers: obs1 → SJC, obs2 → SFO. At least one packet row
|
||||
// must carry a non-empty observer_iata string.
|
||||
gotIATA := false
|
||||
for _, p := range packets {
|
||||
m, _ := p.(map[string]interface{})
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
if _, present := m["observer_iata"]; !present {
|
||||
t.Fatalf("packet missing observer_iata field; got keys: %v", keysOfMap(m))
|
||||
}
|
||||
if s, _ := m["observer_iata"].(string); s != "" {
|
||||
gotIATA = true
|
||||
}
|
||||
}
|
||||
if !gotIATA {
|
||||
t.Fatalf("expected at least one packet with non-empty observer_iata (seed has SJC/SFO)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPacketsGroupedIncludesObserverIATA asserts the grouped (groupByHash)
|
||||
// view also surfaces observer_iata for the header row.
|
||||
func TestPacketsGroupedIncludesObserverIATA(t *testing.T) {
|
||||
_, router := setupTestServer(t)
|
||||
req := httptest.NewRequest("GET", "/api/packets?groupByHash=true&limit=10", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &body)
|
||||
packets, _ := body["packets"].([]interface{})
|
||||
if len(packets) == 0 {
|
||||
t.Fatal("expected non-empty grouped packets")
|
||||
}
|
||||
gotIATA := false
|
||||
for _, p := range packets {
|
||||
m, _ := p.(map[string]interface{})
|
||||
if _, present := m["observer_iata"]; !present {
|
||||
t.Fatalf("grouped packet missing observer_iata field; got keys: %v", keysOfMap(m))
|
||||
}
|
||||
if s, _ := m["observer_iata"].(string); s != "" {
|
||||
gotIATA = true
|
||||
}
|
||||
}
|
||||
if !gotIATA {
|
||||
t.Fatalf("expected at least one grouped packet with non-empty observer_iata")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPacketDetailObservationsIncludeIATA asserts /api/packets/{id} returns
|
||||
// per-observation observer_iata so the detail pane can render it.
|
||||
func TestPacketDetailObservationsIncludeIATA(t *testing.T) {
|
||||
_, router := setupTestServer(t)
|
||||
// transmission_id 1 has two observations (obs1 SJC, obs2 SFO) from seedTestData
|
||||
req := httptest.NewRequest("GET", "/api/packets/1", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var body map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &body)
|
||||
obs, _ := body["observations"].([]interface{})
|
||||
if len(obs) == 0 {
|
||||
t.Fatalf("expected observations in detail response; body: %s", w.Body.String())
|
||||
}
|
||||
gotIATA := false
|
||||
for _, o := range obs {
|
||||
m, _ := o.(map[string]interface{})
|
||||
if _, present := m["observer_iata"]; !present {
|
||||
t.Fatalf("observation missing observer_iata field; got keys: %v", keysOfMap(m))
|
||||
}
|
||||
if s, _ := m["observer_iata"].(string); s != "" {
|
||||
gotIATA = true
|
||||
}
|
||||
}
|
||||
if !gotIATA {
|
||||
t.Fatalf("expected at least one observation with non-empty observer_iata")
|
||||
}
|
||||
}
|
||||
|
||||
func keysOfMap(m map[string]interface{}) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
+101
-39
@@ -59,6 +59,9 @@ type pathInspectResponse struct {
|
||||
Candidates []pathCandidate `json:"candidates"`
|
||||
Input map[string]interface{} `json:"input"`
|
||||
Stats map[string]interface{} `json:"stats"`
|
||||
// Stale is true when the response was served from a stale neighbor graph
|
||||
// while a background rebuild is in progress (issue #1203).
|
||||
Stale bool `json:"stale,omitempty"`
|
||||
}
|
||||
|
||||
// beamEntry represents a partial path being extended during beam search.
|
||||
@@ -163,28 +166,29 @@ func (s *Server) handlePathInspect(w http.ResponseWriter, r *http.Request) {
|
||||
nodeByPK[strings.ToLower(nodes[i].PublicKey)] = &nodes[i]
|
||||
}
|
||||
|
||||
// Get neighbor graph; handle cold start.
|
||||
graph := s.store.graph
|
||||
if graph == nil || graph.IsStale() {
|
||||
rebuilt := make(chan struct{})
|
||||
go func() {
|
||||
s.store.ensureNeighborGraph()
|
||||
close(rebuilt)
|
||||
}()
|
||||
select {
|
||||
case <-rebuilt:
|
||||
graph = s.store.graph
|
||||
case <-time.After(2 * time.Second):
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"retry": true})
|
||||
return
|
||||
// Get neighbor graph (issue #1203): stale-while-revalidate.
|
||||
// - cold start (nil): return 503 + kick off async rebuild for next request.
|
||||
// - stale non-nil: serve it immediately with stale:true + async rebuild.
|
||||
// - fresh: serve normally.
|
||||
graph := s.store.graph.Load()
|
||||
stale := false
|
||||
if graph == nil {
|
||||
// Cold start — kick off rebuild so the next request lands warm,
|
||||
// then return 503 immediately. Don't spawn a fresh goroutine if a
|
||||
// rebuild is already in-flight: singleflight dedups the BUILD, not
|
||||
// the goroutine launch (PR #1208 carmack #2).
|
||||
if !s.store.rebuildInFlight() {
|
||||
go s.store.ensureNeighborGraph()
|
||||
}
|
||||
if graph == nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"retry": true})
|
||||
return
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"retry": true})
|
||||
return
|
||||
}
|
||||
if graph.IsStale() {
|
||||
stale = true
|
||||
if !s.store.rebuildInFlight() {
|
||||
go s.store.ensureNeighborGraph()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,6 +261,7 @@ func (s *Server) handlePathInspect(w http.ResponseWriter, r *http.Request) {
|
||||
elapsed := time.Since(start).Milliseconds()
|
||||
resp := pathInspectResponse{
|
||||
Candidates: candidates,
|
||||
Stale: stale,
|
||||
Input: map[string]interface{}{
|
||||
"prefixes": req.Prefixes,
|
||||
"hops": len(req.Prefixes),
|
||||
@@ -268,22 +273,27 @@ func (s *Server) handlePathInspect(w http.ResponseWriter, r *http.Request) {
|
||||
},
|
||||
}
|
||||
|
||||
// Cache result (and evict stale entries).
|
||||
s.store.inspectMu.Lock()
|
||||
if s.store.inspectCache == nil {
|
||||
s.store.inspectCache = make(map[string]*inspectCachedResult)
|
||||
}
|
||||
now2 := time.Now()
|
||||
for k, v := range s.store.inspectCache {
|
||||
if now2.After(v.expiresAt) {
|
||||
delete(s.store.inspectCache, k)
|
||||
// Cache result (and evict stale entries). Don't cache when the response
|
||||
// itself is stale — the rebuild kicked off above will land a fresh graph
|
||||
// shortly and we don't want to pin a stale answer for inspectCacheTTL
|
||||
// (issue #1203).
|
||||
if !stale {
|
||||
s.store.inspectMu.Lock()
|
||||
if s.store.inspectCache == nil {
|
||||
s.store.inspectCache = make(map[string]*inspectCachedResult)
|
||||
}
|
||||
now2 := time.Now()
|
||||
for k, v := range s.store.inspectCache {
|
||||
if now2.After(v.expiresAt) {
|
||||
delete(s.store.inspectCache, k)
|
||||
}
|
||||
}
|
||||
s.store.inspectCache[cacheKey] = &inspectCachedResult{
|
||||
data: resp,
|
||||
expiresAt: now2.Add(inspectCacheTTL),
|
||||
}
|
||||
s.store.inspectMu.Unlock()
|
||||
}
|
||||
s.store.inspectCache[cacheKey] = &inspectCachedResult{
|
||||
data: resp,
|
||||
expiresAt: now2.Add(inspectCacheTTL),
|
||||
}
|
||||
s.store.inspectMu.Unlock()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
@@ -417,11 +427,63 @@ func sortBeam(beam []beamEntry) {
|
||||
})
|
||||
}
|
||||
|
||||
// ensureNeighborGraph triggers a graph rebuild if nil or stale.
|
||||
// buildGraphFn is the function used by ensureNeighborGraph to rebuild the
|
||||
// neighbor graph. It's a package-level var so tests can swap it for a counter
|
||||
// wrapper. Default is BuildFromStore.
|
||||
var buildGraphFn = func(s *PacketStore) *NeighborGraph { return BuildFromStore(s) }
|
||||
|
||||
// Singleflight state for ensureNeighborGraph lives on *PacketStore
|
||||
// (see store.go: rebuildMu, rebuildInFlt). It moved off package globals in
|
||||
// PR #1208 round-1 so that parallel tests with independent stores don't
|
||||
// share rebuild state (cross-store deadlock/skip under -race).
|
||||
|
||||
// ensureNeighborGraph triggers a graph rebuild if nil or stale. Concurrent
|
||||
// callers share a single in-flight build (singleflight) so the store doesn't
|
||||
// churn N parallel BuildFromStore goroutines under load.
|
||||
func (s *PacketStore) ensureNeighborGraph() {
|
||||
if s.graph != nil && !s.graph.IsStale() {
|
||||
if g := s.graph.Load(); g != nil && !g.IsStale() {
|
||||
return
|
||||
}
|
||||
g := BuildFromStore(s)
|
||||
s.graph = g
|
||||
s.rebuildMu.Lock()
|
||||
// Re-check under lock to avoid racing two callers past the cheap check.
|
||||
if g := s.graph.Load(); g != nil && !g.IsStale() {
|
||||
s.rebuildMu.Unlock()
|
||||
return
|
||||
}
|
||||
if s.rebuildInFlt != nil {
|
||||
// Another caller is rebuilding — wait for it.
|
||||
ch := s.rebuildInFlt
|
||||
s.rebuildMu.Unlock()
|
||||
<-ch
|
||||
return
|
||||
}
|
||||
// We're the leader. Publish the channel before unlocking so late
|
||||
// arrivals can attach.
|
||||
done := make(chan struct{})
|
||||
s.rebuildInFlt = done
|
||||
s.rebuildMu.Unlock()
|
||||
|
||||
// Defer cleanup so a panic in buildGraphFn doesn't leak the in-flight
|
||||
// channel (which would deadlock every future waiter).
|
||||
var g *NeighborGraph
|
||||
defer func() {
|
||||
if g != nil {
|
||||
s.graph.Store(g)
|
||||
}
|
||||
s.rebuildMu.Lock()
|
||||
s.rebuildInFlt = nil
|
||||
s.rebuildMu.Unlock()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
g = buildGraphFn(s)
|
||||
}
|
||||
|
||||
// rebuildInFlight reports whether a graph rebuild is currently in progress.
|
||||
// Used by callers that want to avoid spawning a goroutine that would just
|
||||
// block on the in-flight singleflight wait (PR #1208 carmack #2).
|
||||
func (s *PacketStore) rebuildInFlight() bool {
|
||||
s.rebuildMu.Lock()
|
||||
defer s.rebuildMu.Unlock()
|
||||
return s.rebuildInFlt != nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestPacketStoreGraph_ConcurrentReadWrite_NoRace (PR #1208 kent #1)
|
||||
// asserts that concurrent readers of s.graph racing with a writer that
|
||||
// replaces the pointer don't trip the Go race detector. The PR #1203
|
||||
// migration from plain `*NeighborGraph` to `atomic.Pointer[NeighborGraph]`
|
||||
// is what makes this safe — without it, the reader/writer race is a real
|
||||
// data race the runtime will flag under `go test -race`.
|
||||
//
|
||||
// Anti-tautology / mutation verification: revert s.graph to a plain
|
||||
// `*NeighborGraph` field (drop atomic.Pointer, use direct assignment
|
||||
// `s.graph = g` in ensureNeighborGraph and direct read `s.graph` at the
|
||||
// callsites) and this test FAILS under `-race` with a "WARNING: DATA
|
||||
// RACE" report on s.graph. Confirmed on a manually-reverted local branch.
|
||||
//
|
||||
// This test must be run under `-race` to actually exercise the assertion;
|
||||
// CI runs the full suite under -race so it gates the migration.
|
||||
func TestPacketStoreGraph_ConcurrentReadWrite_NoRace(t *testing.T) {
|
||||
store := &PacketStore{}
|
||||
|
||||
// Prime with an initial graph so readers don't all bail on nil.
|
||||
initial := NewNeighborGraph()
|
||||
initial.builtAt = time.Now()
|
||||
store.graph.Store(initial)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
stop := make(chan struct{})
|
||||
|
||||
// Spawn N readers that hot-loop Load() — these would race against the
|
||||
// writer's Store() if s.graph were a plain pointer.
|
||||
const readers = 16
|
||||
for i := 0; i < readers; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
g := store.graph.Load()
|
||||
// Touch a field so the optimizer doesn't elide the load.
|
||||
if g != nil {
|
||||
_ = g.builtAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Writer: replace the pointer repeatedly for 200ms. With atomic.Pointer
|
||||
// each Store() is publication-safe; with a plain pointer this is a
|
||||
// classic data race.
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
deadline := time.Now().Add(200 * time.Millisecond)
|
||||
for time.Now().Before(deadline) {
|
||||
g := NewNeighborGraph()
|
||||
g.builtAt = time.Now()
|
||||
store.graph.Store(g)
|
||||
}
|
||||
}()
|
||||
|
||||
// Let it run, then stop readers.
|
||||
time.Sleep(220 * time.Millisecond)
|
||||
close(stop)
|
||||
wg.Wait()
|
||||
|
||||
// Reaching here without -race firing IS the assertion. If -race
|
||||
// detected a write/read collision on s.graph the test process will
|
||||
// have already failed with exit code 66.
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestHandlePathInspect_ColdStartKicksRebuild (issue #1203 Pair C) asserts that
|
||||
// a true cold start (nil graph) returns 503 immediately AND kicks off a
|
||||
// background rebuild, so the next request lands warm.
|
||||
//
|
||||
// Anti-tautology: if the cold-start branch stops calling ensureNeighborGraph
|
||||
// (the regression that motivated this fix — synchronous 2s gate version
|
||||
// blocked on response instead of kicking-and-returning), the follow-up
|
||||
// request would still be 503 and this test would fail.
|
||||
func TestHandlePathInspect_ColdStartKicksRebuild(t *testing.T) {
|
||||
srv := newTestServerForInspect(t)
|
||||
srv.store.graph.Store(nil)
|
||||
|
||||
var built int32
|
||||
origBuild := buildGraphFn
|
||||
defer func() { buildGraphFn = origBuild }()
|
||||
buildGraphFn = func(s *PacketStore) *NeighborGraph {
|
||||
atomic.AddInt32(&built, 1)
|
||||
time.Sleep(100 * time.Millisecond) // small async window
|
||||
g := NewNeighborGraph()
|
||||
g.builtAt = time.Now()
|
||||
return g
|
||||
}
|
||||
|
||||
// Seed nodes so the post-rebuild request can return a candidate.
|
||||
pk := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
srv.store.nodeCache = []nodeInfo{{PublicKey: pk, Name: "N", Role: "repeater"}}
|
||||
srv.store.nodePM = buildPrefixMap(srv.store.nodeCache)
|
||||
srv.store.nodeCacheTime = time.Now()
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/paths/inspect", bytes.NewBufferString(`{"prefixes":["aa"]}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
start := time.Now()
|
||||
srv.handlePathInspect(rr, req)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if rr.Code != 503 {
|
||||
t.Fatalf("cold start: expected 503, got %d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if elapsed > 500*time.Millisecond {
|
||||
t.Fatalf("cold-start 503 should be near-instant, took %v", elapsed)
|
||||
}
|
||||
|
||||
// Wait for rebuild to land.
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if atomic.LoadInt32(&built) >= 1 && srv.store.graph.Load() != nil && !srv.store.graph.Load().IsStale() {
|
||||
break
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
if atomic.LoadInt32(&built) < 1 {
|
||||
t.Fatal("cold-start did not kick off a rebuild")
|
||||
}
|
||||
|
||||
// Follow-up request now lands warm (200, not 503).
|
||||
// Use a different prefix so the inspect cache from pair A's earlier call
|
||||
// (if any) doesn't satisfy it.
|
||||
rr2 := httptest.NewRecorder()
|
||||
req2 := httptest.NewRequest("POST", "/api/paths/inspect", bytes.NewBufferString(`{"prefixes":["aa"]}`))
|
||||
req2.Header.Set("Content-Type", "application/json")
|
||||
srv.handlePathInspect(rr2, req2)
|
||||
if rr2.Code != 200 {
|
||||
t.Fatalf("follow-up after cold-start rebuild expected 200, got %d body=%s", rr2.Code, rr2.Body.String())
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user