mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-13 09:05:50 +00:00
Fixes #1854. Refs #1598, #1611, #1845.
## The bug
`cmd/server/db.go:54` opens SQLite `mode=ro` (#1283/#1289).
`touchRelayLastSeen` → `TouchNodeLastSeen` issues `UPDATE nodes SET
last_seen` on that handle. It has failed on every call since, with the
error discarded at the call site:
```go
if err := s.db.TouchNodeLastSeen(pk, ts); err == nil {
s.lastSeenTouched[pk] = now
}
```
`nodes.last_seen` has therefore tracked ADVERT arrivals only. Verified
on live.saarmesh.de (1388 nodes): 1362 have `last_seen` within one
minute of their own most recent ADVERT. Reproduced directly with the
server's DSN in #1854.
Secondary effect: `lastSeenTouched` is populated only in the success
branch, so the debounce never engaged — the server retried the failing
UPDATE for every resolved pubkey in every decode window.
## The fix
The writer moves to `cmd/ingestor`, which owns `nodes` per #1283/#1287
and since #1547 already resolves hop prefixes to full pubkeys for
`observations.resolved_path`. The touch hooks into that existing
resolution point, so there is no new IPC surface and no second resolver.
Only unambiguously resolved hops qualify — a 1-byte prefix collision
cannot keep a silent node alive.
I considered the `internal/mbcapqueue` snapshot handoff used for
#903/#1324 and did not need it: that pattern exists because the
capability computation lives in the server's analytics cycle. Path
resolution already happens in the ingestor, so a file handoff would add
a hop for nothing.
`Store.TouchRelayNodes`:
- monotonic guard in SQL (`last_seen IS NULL OR last_seen < ?`) —
out-of-order ingest never rewinds
- 5-minute debounce keyed on `rxTime`, matching the interval the server
intended
- UPDATE only — unknown pubkeys never create rows
- unparsable `rxTime` is a no-op rather than writing garbage into the
node directory
- `Stats.RelayTouches` for `/api/perf` visibility
- debounce records the *attempt*, not the row match, so an unknown
pubkey is not retried per observation
## Server-side removal
`touchRelayLastSeen`, `DB.TouchNodeLastSeen`, the `lastSeenTouched` map
and the now-unused `allResolvedPKs` decode-window map are deleted.
`readonly_invariant_test.go` gains `UPDATE\s+nodes\s+SET\s+last_seen`.
`cmd/server/touch_last_seen_test.go` and two tests in
`resolved_index_test.go` go with it. Worth stating why they were green
for months: they build their `PacketStore` on `setupTestDB`, which opens
read-write. The production constraint is the one thing they did not
reproduce, which is why the added invariant regex — not a replacement
unit test — is the right guard here.
## Tests
Five tests in `cmd/ingestor/relay_touch_test.go`, committed red first
(573bbde3) with a stubbed `TouchRelayNodes` so the suite compiles and
reds on assertions:
```
--- FAIL: TestTouchRelayNodes_AdvancesLastSeen
last_seen = "2026-07-01T00:00:00Z", want "2026-07-10T12:00:00Z"
RelayTouches = 0, want 1
--- FAIL: TestTouchRelayNodes_Debounces
RelayTouches = 0, want 1 (second touch should be debounced)
```
Coverage: `AdvancesLastSeen` (core regression), `NeverGoesBackwards`
(monotonic), `Debounces` (write amplification on the hot path),
`IgnoresEmptyAndUnknown` (unresolved hops must not create rows),
`MalformedTimestamp`.
`cmd/ingestor`: full suite green, 100.7s.
`cmd/server`: green for the invariant and the affected packages, but the
suite is order-dependent on master today. Unmodified `upstream/master`
produced 8 failures on this machine (`TestHandleNodePaths_*`,
`TestHandleAnalytics*`, `TestComputeAnalyticsDistanceLockHoldDuration`);
this branch produced 5, and the set shifts between runs. All pass in
isolation. Untouched by this change — flagging rather than papering
over, and happy to open a separate issue if that is not already known.
## Impact on the open threads
This is the backend half of #1598. The frontend work there keys on relay
recency; that signal was never being written, so the two changes are
complementary rather than alternatives. It also removes the eviction
problem I raised in #1845 without touching `MoveStaleNodes`: once
`last_seen` reflects relay activity, the existing `last_seen < cutoff`
predicate stops evicting nodes that are carrying traffic.
Not addressed here: the duplicate-row behaviour between `nodes` and
`inactive_nodes` (609 keys in both on my deployment), which is an
independent defect and wants its own change.
## Verification offer
I run a 1100-repeater MeshCore deployment and can run this against
production traffic and report `RelayTouches` plus the resulting
`last_seen` distribution before/after, if that is useful for review.
---------
Co-authored-by: SaarMesh-Bot <300107934+SaarMesh-Bot@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
134 lines
3.8 KiB
Go
134 lines
3.8 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
"sync/atomic"
|
|
)
|
|
|
|
// Issue #1547 — resolved_path writer (ingestor-owned).
|
|
//
|
|
// Per the #1283 refactor (server is read-only; ingestor owns the
|
|
// neighbor graph + node directory), the writer that populated
|
|
// `observations.resolved_path` must live here in the ingestor. PR #1289
|
|
// removed the server-side writer without porting it — this restores it.
|
|
//
|
|
// Approach:
|
|
// - `resolvePath` is a pure function: hop prefixes → full pubkeys
|
|
// using the in-memory prefix index built from `nodes.public_key`.
|
|
// - Unique-prefix hops resolve to the full pubkey; ambiguous or
|
|
// unknown hops resolve to `nil`. The output shape is `[]*string`
|
|
// (with nulls for unresolved positions) — the JSON serialization
|
|
// matches what the server's `unmarshalResolvedPath` /
|
|
// frontend `getResolvedPath` already consume.
|
|
// - The prefix index is rebuilt on startup and once per neighbor-
|
|
// builder tick (60s) so new nodes start resolving within a minute
|
|
// without blocking the MQTT ingest path.
|
|
|
|
// resolvePath maps each hop prefix to a full pubkey when the index
|
|
// has exactly one candidate; returns nil at that position otherwise.
|
|
// Returns nil for empty/no hops.
|
|
func resolvePath(hops []string, idx prefixIndex) []*string {
|
|
if len(hops) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]*string, len(hops))
|
|
if idx == nil {
|
|
return out
|
|
}
|
|
for i, hop := range hops {
|
|
h := strings.ToLower(hop)
|
|
candidates := idx[h]
|
|
if len(candidates) == 1 {
|
|
pk := candidates[0]
|
|
out[i] = &pk
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// marshalResolvedPath JSON-encodes a resolved path. Returns "" when
|
|
// the input is empty OR when every element is nil (writer treats "" as
|
|
// SQL NULL).
|
|
//
|
|
// The all-nil case matters because of the UPSERT in InsertTransmission:
|
|
//
|
|
// resolved_path = COALESCE(excluded.resolved_path, resolved_path)
|
|
//
|
|
// If we emitted "[null,null]" here, nilIfEmpty() would let it through
|
|
// as a non-NULL string and the COALESCE would OVERWRITE a previously
|
|
// stored good resolved_path on re-ingest. Returning "" lets nilIfEmpty
|
|
// produce SQL NULL so the COALESCE falls through to the existing value.
|
|
// See issue #1547 / PR #1548 reviewer findings.
|
|
func marshalResolvedPath(rp []*string) string {
|
|
if len(rp) == 0 {
|
|
return ""
|
|
}
|
|
allNil := true
|
|
for _, p := range rp {
|
|
if p != nil {
|
|
allNil = false
|
|
break
|
|
}
|
|
}
|
|
if allNil {
|
|
return ""
|
|
}
|
|
b, err := json.Marshal(rp)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
// prefixIdxHolder caches the prefix index for the InsertTransmission
|
|
// hot path. atomic.Value lets the 60s rebuild happen without a lock on
|
|
// the read side.
|
|
type prefixIdxHolder struct {
|
|
v atomic.Value // holds prefixIndex
|
|
}
|
|
|
|
func (h *prefixIdxHolder) load() prefixIndex {
|
|
if v := h.v.Load(); v != nil {
|
|
return v.(prefixIndex)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (h *prefixIdxHolder) store(idx prefixIndex) {
|
|
h.v.Store(idx)
|
|
}
|
|
|
|
// RefreshPrefixIndex rebuilds the in-memory prefix index from the
|
|
// nodes table and publishes it atomically. Called on startup and from
|
|
// the neighbor-edges builder tick (60s) so new nodes become resolvable
|
|
// without per-insert DB scans.
|
|
func (s *Store) RefreshPrefixIndex() error {
|
|
idx, err := buildPrefixIndex(s.db)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s.prefixIdx.store(idx)
|
|
return nil
|
|
}
|
|
|
|
// resolvedPubkeys flattens a resolved path to the non-nil pubkeys it
|
|
// contains, deduplicating repeats within the same path. Used by the
|
|
// relay-aware last_seen touch (#1598); nil entries are unresolved or
|
|
// ambiguous hops and are deliberately dropped.
|
|
func resolvedPubkeys(rp []*string) []string {
|
|
if len(rp) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]string, 0, len(rp))
|
|
seen := make(map[string]bool, len(rp))
|
|
for _, p := range rp {
|
|
if p == nil || *p == "" || seen[*p] {
|
|
continue
|
|
}
|
|
seen[*p] = true
|
|
out = append(out, *p)
|
|
}
|
|
return out
|
|
}
|