Repository navigation
fix(liveness): count only evidence that can be the node alone (#2260) - #2261
Conversation
d82bf85 to
1b40d8a
Compare
Review notes and a measurement on live dataReviewed commit 1. Nodes with no certain relay fall back to adverts onlyAfter this change a repeater is heard only through its own packets or through hops whose prefix is unique. The firmware default Measured on a live instance (1678 repeaters and rooms, flood observations of the last 72 h, TRACE excluded, "unique" taken as unique among repeater and room rows in
Multibyte traffic covers most nodes. The 184 are an upper bound for false offline: the data cannot separate dead nodes from nodes that only relay 1-byte traffic. The 241 nodes in the two middle rows depend on adverts alone, so a missed advert takes them offline. This is one 72 h snapshot, network wide. 2. Uniqueness is decided against the
|
1b40d8a to
f7a809d
Compare
|
Review feedback addressed (commit
Not changed, with reasons:
|
Follow-up on the review notes aboveRead the delta The three changes in Still open from the review notes:
QuestionFor points 1 to 3, which of these do you prefer: address them in this PR, land this PR as is and track them in #2260, or land this PR only together with #2262? |
…awbot#2260) A repeater silent since 18 Oct showed "Active, last relayed 4m ago" with Last Heard 36m, and its offline mail never fired. Every hit after that date was the 1-byte hop 67, shared by nine nodes: the live resolver's affinity tiebreak put it in byNode, and last_relayed folds in the 1-byte prefix bucket on purpose (Kpa-clawbot#662). The store now keeps certainHeard per node: the newest transmission that can only be that node. Its own packets (pubKey / srcPubKey) count, and a flood relay hop counts when its prefix at the packet's path hash size matches exactly one path-capable node. Direct-route paths (hops still to come, Mesh::removeSelfFromPath) and TRACE (SNR bytes) never count. The same rule feeds every load path: Load, loadChunk, scanAndMergeChunk, live ingest and late observations. Liveness reads only that: - /api/nodes and the node detail: last_heard is the newer of last_seen and the certain last heard, no longer a copy of last_seen; - health and bulk-health stats.lastHeard; - LastHeardMap, so the notify offline check; the separate relay input (lastRelayed / relayTimes) is gone; - roles.js getEffectiveHeardMs: the newest known timestamp, without last_relayed; the side pane no longer says "Last relayed". Attribution for analytics (byNode, packet counts, last_relayed, relay counters) is unchanged. Cost: noteCertainRelays 116 ns per observation for 8 hops, 0 allocs; LastHeardMap 0.77 ms for 5,000 nodes (was 3.7 ms), one map lookup each. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
f7a809d to
b5556bd
Compare
|
Review notes addressed (commit Answers to the question: everything is addressed in this PR; it lands first, #2262 right after.
|
Review of
|
| Hop width | Before b5556bdc |
With b5556bdc |
Retired rule without the node itself |
|---|---|---|---|
| 2 bytes | 97.1 % | 59.3 % | 84.9 % |
| 3 bytes | 94.9 % | 57.5 % | 94.9 % |
- Repeaters and rooms with an advert in the last 7 days (1678):
| Before | With b5556bdc |
Without the node itself | |
|---|---|---|---|
| certain relay < 72 h | 1256 | 629 | 1206 |
| advert only < 72 h | 240 | 865 | 290 |
| nothing certain < 72 h | 182 | 184 | 182 |
So 627 nodes lose their certain relays, and depend on one advert per 47 h again. TestRetiredNamesakeKeepsHopUncertain has no case where the node itself is in inactive_nodes.
Excluding the node itself (skip retired rows whose public_key is also in nodes, or test the retired set against pk like relayCandidates is) brings it back to 1206. The remaining 50 come from the other 7039 retired rows, many of which may have been silent for months.
Question
Which bound should the retired set get: none beyond excluding the node itself, only rows whose last_seen is within a window (for example 30 days), or only rows still referenced as a hop in the store's packets?
…lawbot#2260) Retention copies a node into inactive_nodes and nothing removes the row when the node is heard again. markRetired loaded those rows too, so such a node's own prefix counted as a retired namesake and it got no certain relay at any width. markRetired now skips rows whose public_key is also in nodes. On a live instance 838 of the 7877 repeater and room rows in inactive_nodes are also in nodes; repeaters and rooms with a certain relay in the last 72 h went from 1256 to 629 with the rule, 1206 with this fix. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Review feedback addressed (commit
Answer to the question: no further bound for now. The other retired rows keep counting, which costs 50 nodes on live (1256 to 1206) and keeps the protection against a retired repeater that still relays. Cost: the query runs per node-cache rebuild (every 30 s). On a live database with 12382
|
…2260) (#2262) Part 2 of #2260. **Builds on #2261** (certain liveness): review/merge that first; until then this diff also shows its commit. ## Adaptive offline threshold `userManagement.notifications.adaptiveOffline` — **off by default**: ```json "notifications": { "enabled": true, "adaptiveOffline": { "enabled": true, "floorMinutes": 30, "minObservers": 3 } } ``` The fixed `infraSilentHours` (72 h) treats every repeater the same. With this on, a watched node's `node.offline` window is learned from its own **certain** traffic (the #2261 rule): 1. Certain packets per hour over the week **before its last certain packet**. Ending there, not at now, keeps an outage from dragging the rate down until the fixed window returns and the node reads "back online". 2. Empty runs are classified: ≤ 6 h and recurring on another day at the same hours = normal silence (kept; the window is never shorter than the longest + 1 h); any other run = outage, left out of the learning so it neither stretches the window nor blocks learning. 3. λ = 10th percentile of the remaining hourly counts, so quiet nights don't alert. 4. Window = 14/λ hours: silence at rate λ has a < 10⁻⁶ chance (Poisson, ln 10⁶ ≈ 13.8). 5. Clamped to [`floorMinutes`, the role's silent window]. Kept on the fixed window: λ < 1/hour, under 24 h of history in memory, or a 10th-percentile hour heard by fewer than `minObservers` observers (one observer dropping out must not read as the node going offline). Silence is measured up to the newest packet in the store, so a stalling feed stops every node's clock. A node stored offline comes back only on evidence from the moment it went offline on. The notifier caches each learned window for 10 minutes and drops unwatched nodes. For the #2260 repeater (~840 packets/day) the mail would have gone out about 30 minutes into the outage, instead of never (or 72 h with #2261 alone). ## Certain node analytics - Each `activityTimeline` bucket carries `certain`: the part that can only be this node (`certainFor`, the packet-level form of the `certainHeard` rule). `TimeBucket.Certain` is `omitempty`, so other endpoints are unchanged. - Availability and longest silence use certain buckets only; the longest silence now includes one **still running** (an outage in progress is the silence worth seeing). - The chart stacks certain (`--accent`) and uncertain (`--text-muted`) — existing theme variables, so no new customizer entry. Descriptions updated. ## Performance `CertainActivity` walks a week of packets per watched node, at most once per node per 10 minutes, with the read lock taken **per node**: ``` BenchmarkCertainActivity-16 12.1 ms/op (50 watched repeaters × 6,000 packets; ~0.25 ms per lock hold) BenchmarkNoteCertainRelays-16 121 ns/op 0 allocs ``` Node analytics adds one `certainFor` per packet of the requested node (a prefix-map lookup, or the cached decoded JSON for its own packets). ## Tests - `adaptiveOfflineAfter`: busy repeater hits the floor; 5/hour; quiet nights set the rate; > 10 % empty hours, short history, too few observers → fixed. - `CertainActivity` ends at the last certain packet and counts only certain packets — verified red by mutating the window to end at now. - Notifier end to end: learned window passed → bad; inside → good; too few observers / setting off → fixed 72 h; cache refresh and prune. - `certainFor`, `longestSilence` (incl. running silence), config defaults, `/api/nodes/{pk}/analytics` splits a shared-prefix bucket. - Frontend: `NodeAnalyticsTimeline.series` (new `tests/unit/test-node-analytics-certain-timeline.js`). - `cmd/server` `go test ./...` and `test-all.sh` pass locally, also on a trial merge into a downstream fork. ## Validation Server on the freshened e2e fixture: analytics buckets carry `certain` (e.g. 89 of 90 packets certain for one repeater). **No browser screenshot**: Playwright isn't installed on this machine. ## Later (rule 8) `floorMinutes` / `minObservers` are server config, not theme settings; an admin UI for notification settings would be the place, not the customizer. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Release notes for v3.15.0: `docs/release-notes/v3.15.0.md` and the `CHANGELOG.md` entry. The release workflow uses the notes file as the GitHub release body. ## What is in it - 77 commits since v3.14.0 (76 merged pull requests plus this one): 27 fix, 23 perf, 19 feat, 3 test, 2 ci, 2 docs, 1 chore. Nineteen feats, so minor. - "Read this before upgrading" first. It covers: - the liveness change (#2261, #2262); - the `users.db` v6 migration and what it means for a rollback (#2174); - the backfill at every ingestor start (#2133) and the 24 h planner-statistics refresh (#2158); - the caches that trade freshness for latency (#2165, #2222, #2147, #2156); - changes in hop names and region placement (#2188, #2190, #2234, #2245, #2220); - the role thresholds on favorites (#2215); - the geo-filter save on a single-file mount (#2137); - the `/api/stats` polling in hidden tabs (#2162). - Features, fixes, performance, then tests/CI/docs. - 29 issues closed, taken from each pull request's `closingIssuesReferences`. - Contributors: pull request authors and issue reporters with counts. The commit co-author trailers only name the pull request's own author or tooling. ## How it was checked - I read every pull request body. Every number in the notes comes from a pull request body. - I checked the config keys and defaults against `config.example.json` on `2bb000f2`. - I checked the UNASKED and opt-in claims for #2125, #2133, #2235 and #2239 against the source. - For the other pull requests I relied on their bodies and did not open the diffs. ## Not covered - The uncertain count is a single count on one staging instance on the day of the change (1,682 repeaters and rooms). It is not a before/after comparison. - #2133 gives no measured cost for its backfill on a large database, and the notes say so. ## After merge Tag `v3.15.0` on the merge commit once its `:edge` image exists. Then check in the registry that `v3.15.0`, `v3.15`, `v3` and `latest` share one digest, and that the release has the notes as its body and both `corescope-decrypt` binaries. --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Fixes #2260 (part 1 of 2: liveness; the adaptive offline threshold follows in a separate PR).
Problem
A repeater silent since 2026-10-18 showed Active — Last relayed 4m ago and Last Heard 36m, and its offline mail never fired. Every hit after that date was the 1-byte hop
67, shared by nine nodes:resolvePathForObs) put those transmissions inbyNode, which fedstats.lastHeardandLastHeardMap;last_relayedfolds in the 1-byte prefix bucket on purpose (feat: repeater liveness — detect if repeater is actively relaying traffic #662), and drove the status inroles.jsand the notify offline check.Fix
The store keeps
certainHeardper node: the newest transmission that can only be that node.pubKey,srcPubKey)Mesh::removeSelfFromPath)destPubKey)A multibyte repeater legitimately appears as a 1-byte hop (the originator picks the hash size,
Mesh::routeRecvPacketappends at that size), so only uniqueness decides. The rule is applied in every load path throughindexResolvedPathHopsand the newindexColdLoadRelayHops: Load, loadChunk, scanAndMergeChunk, live ingest, late observations.Liveness reads only that:
/api/nodesand the node detail:last_heardis the newer oflast_seenand the certain last heard. Before, it was a plain copy oflast_seen.stats.lastHeard.LastHeardMap→ notify offline check. The separate relay input (lastRelayed,relayTimes,notifyInput.Relayed) is removed.roles.js getEffectiveHeardMs: newest known timestamp, withoutlast_relayed. The side pane no longer says "Last relayed"; the Last Relayed row stays (analytics) with an updated tooltip.uncertain(repeaters and rooms): nothing certain within the silent window, but the shared prefix (last_relayed) still in paths. Grey badge with explanation, an Uncertain filter on the nodes list and the map, dimmed like stale. The offline mail for such a node says only its shared prefix was still seen.inactive_nodesis not unique; adverts withsignatureValid: falseare no evidence;/api/nodes/searchreports the samelast_heard.Attribution for analytics (
byNode, packet counts,last_relayed, relay counters, usefulness) is unchanged.Performance
Hot path:
noteCertainRelaysruns per observation on ingest under the write lock: two hex bytes of header parsing plus one prefix-map lookup per resolved hop.Memory: one string per node ever certainly heard, dropped together with the node's
byNodelist on eviction.Tests
certain_heard_loadpaths_test.go: every load path (Load, loadChunk, LoadChunked — each with and without resolved_path — live ingest, late observations) feedscertainHeard; each of the 8 call sites verified red with a nil prefix map.certain_heard_test.go: hop width per route/payload type; retired namesake prefix; failed signature; uncertain offline mail; shared vs unique 1-byte vs 2-byte hops; direct/TRACE/nil prefix map; cold-load path; sender vs destination; eviction;lastHeardValue;/api/nodes, detail and health agree.LastHeardMapstays at X andnode.offlinegoes bad after 72 h. Verified red with the uniqueness check mutated out.TestEvaluateCertainHeardreplaces the relay-input test.last_relayedalone no longer keeps a repeater or room active; newest timestamp wins (an old_liveSeencan't hide a fresh certain relay).cmd/servergo test ./...andtest-all.shpass locally, also on a trial merge into a downstream fork with 457 extra commits.Validation
Ran the server on the freshened e2e fixture: list, detail and health report the same last heard; e.g. a repeater whose
last_relayedis 3 min newer than anything certain keeps its certain time. No browser screenshot: Playwright isn't installed on this machine; CI's E2E covers the pages.Not changed
nodes.last_seen(bug(server): touchRelayLastSeen has been a no-op since the mode=ro refactor — nodes.last_seen never reflects relay activity #1854) resolves a shared hop only when exactly one candidate is adjacent in the neighbor graph; it did not fire in this case. Left as is.lastHeard=list filter still filterslast_seenin SQL.🤖 Generated with Claude Code