tying up some loose ends (#11292)

This commit is contained in:
Tom
2026-07-30 13:33:31 +00:00
committed by GitHub
co-authored by GitHub
parent a5940b4c6c
commit 06577e6b36
3 changed files with 112 additions and 43 deletions
-2
View File
@@ -1,2 +0,0 @@
tasks:
- init: pip install platformio && pip install --upgrade pip
+85 -41
View File
@@ -18,6 +18,22 @@ its 4-bit cached role acts as a final fallback when all three identity tiers mis
Sources of truth: `src/mesh/NodeDB.{h,cpp}`, `src/mesh/WarmNodeStore.h`, Sources of truth: `src/mesh/NodeDB.{h,cpp}`, `src/mesh/WarmNodeStore.h`,
`src/modules/TrafficManagementModule.{h,cpp}`, sizing in `src/mesh/mesh-pb-constants.h`. `src/modules/TrafficManagementModule.{h,cpp}`, sizing in `src/mesh/mesh-pb-constants.h`.
**Memory classes.** The warm tier (§2) and unified cache (§3) size themselves from
`MESHTASTIC_MEM_CLASS` (`src/memory/MemClass.h`), which ranks a build by _usable app heap after
platform overheads_ (SoftDevice, WiFi+BLE stacks) rather than by raw RAM or chip family. The hot
store (§1) is flash-shaped and the NodeInfo cache (§4) is present-or-absent, so neither is classed:
| Class | Heap | Parts |
| ------ | --------------------- | -------------------------------------------- |
| LARGE | PSRAM or host | ESP32-S3 with PSRAM, portduino/native |
| MEDIUM | ~250-500 KB, no PSRAM | ESP32-S3/C6/P4 without PSRAM |
| SMALL | ~100-250 KB | classic ESP32/S2/C3, nRF52840, RP2040/RP2350 |
| TINY | <32 KB | STM32WL |
An unclassified chip lands in SMALL on purpose: small caches are a recoverable default, an
exhausted heap is not. Where a capacity table names a specific part beside these classes, that
part is deliberately class-deviant and the reason is given under the table.
--- ---
## 1. NodeDB hot store (authoritative) ## 1. NodeDB hot store (authoritative)
@@ -26,8 +42,6 @@ Sources of truth: `src/mesh/NodeDB.{h,cpp}`, `src/mesh/WarmNodeStore.h`,
flattened fields (names, role, public key, bitfield flags such as `HAS_XEDDSA_SIGNED`; flattened fields (names, role, public key, bitfield flags such as `HAS_XEDDSA_SIGNED`;
position/telemetry live in satellite stores reached via copy-out accessors, not nested position/telemetry live in satellite stores reached via copy-out accessors, not nested
members). Everything else in this document is a cache or a fallback for it. members). Everything else in this document is a cache or a fallback for it.
- **Capacity:** `MAX_NUM_NODES`, per platform - 250 on native, 120 on nRF52840/generic
ESP32, 10 on STM32WL (see `mesh-pb-constants.h`).
- **Eviction:** oldest non-protected node when full (`getOrCreateMeshNode`). On eviction - **Eviction:** oldest non-protected node when full (`getOrCreateMeshNode`). On eviction
the node's essentials are **absorbed into the warm tier** (see §2); on re-admission the the node's essentials are **absorbed into the warm tier** (see §2); on re-admission the
warm record is rehydrated back (`take()`), including the signer bit. warm record is rehydrated back (`take()`), including the signer bit.
@@ -45,6 +59,17 @@ Sources of truth: `src/mesh/NodeDB.{h,cpp}`, `src/mesh/WarmNodeStore.h`,
warm-evicted signer be impersonated with unsigned frames. warm-evicted signer be impersonated with unsigned frames.
- `getNodeRole(n)` - hot store, then the role cached in the warm tier, else `CLIENT`. - `getNodeRole(n)` - hot store, then the role cached in the warm tier, else `CLIENT`.
**Capacity** - `MAX_NUM_NODES` (`mesh-pb-constants.h`):
| ESP32-S3 | Native | nRF52840, generic ESP32 | STM32WL |
| --------------- | ------ | ----------------------- | ------- |
| 250 / 200 / 100 | 250 | 120 | 10 |
This one is flash-shaped rather than heap-shaped, so it is unclassed: `nodes.proto` has to fit the
filesystem. ESP32-S3 is the only runtime tier, picked at boot from the flash chip (>=15 MB />=7 MB
/ smaller); the 120 covers nRF52840 plus generic ESP32 including C3, and is what keeps `nodes.proto`
inside the stock 28 KB LittleFS.
## 2. Warm tier - `WarmNodeStore` (NodeDB-owned) ## 2. Warm tier - `WarmNodeStore` (NodeDB-owned)
- **What:** the "long-tail" second tier. When a node ages out of the hot store, a minimal - **What:** the "long-tail" second tier. When a node ages out of the hot store, a minimal
@@ -53,7 +78,6 @@ Sources of truth: `src/mesh/NodeDB.{h,cpp}`, `src/mesh/WarmNodeStore.h`,
- **Entry:** exactly 40 bytes - `num(4) | last_heard(4) | public_key(32)`. The low 7 bits - **Entry:** exactly 40 bytes - `num(4) | last_heard(4) | public_key(32)`. The low 7 bits
of `last_heard` are omitted, and replaced with metadata (role: 4 bits, protected of `last_heard` are omitted, and replaced with metadata (role: 4 bits, protected
category: 2, signer bit: 1), leaving ~128 s recency resolution - plenty for LRU ranking. category: 2, signer bit: 1), leaving ~128 s recency resolution - plenty for LRU ranking.
- **Capacity:** `WARM_NODE_COUNT` (100 on constrained parts; platform-tiered).
- **Eviction:** LRU by `last_heard`, with keyed entries outranking keyless; keyless - **Eviction:** LRU by `last_heard`, with keyed entries outranking keyless; keyless
candidates never displace keyed entries. candidates never displace keyed entries.
- **Persistence:** nRF52840 uses a 12 KB raw-flash record-ring below LittleFS - **Persistence:** nRF52840 uses a 12 KB raw-flash record-ring below LittleFS
@@ -61,6 +85,18 @@ Sources of truth: `src/mesh/NodeDB.{h,cpp}`, `src/mesh/WarmNodeStore.h`,
- **Membership invariant:** a node lives in the hot **XOR** warm tier. `take()` removes - **Membership invariant:** a node lives in the hot **XOR** warm tier. `take()` removes
the warm record when the node is re-admitted hot, restoring role/protected/signer bits. the warm record when the node is re-admitted hot, restoring role/protected/signer bits.
**Capacity** - `WARM_NODE_COUNT` (`mesh-pb-constants.h`):
| LARGE | MEDIUM | RP2040 / RP2350 | nRF52840 | SMALL | TINY |
| ----- | ------ | --------------- | -------- | ----- | ---- |
| 2000 | 150 | 150 | 100 | 100 | 0 |
TINY's 0 disables the tier outright. At 40 B/entry, LARGE costs ~80 KB and lives in PSRAM, MEDIUM
~6 KB of heap. Both named parts are class-deviant on purpose: RP2040/RP2350 is bounded so the
`warm.dat` write fits the 8 s watchdog (#10746) rather than by RAM, and nRF52840 dropped from 200 to
100 because its RAM cache is calloc'd from the ~115 KB heap arena shared with SoftDevice, which
2.8.0 field reports showed at 99% use.
## 3. TMM unified cache (base, traffic state) ## 3. TMM unified cache (base, traffic state)
- **What:** TMM's own flat array of packed 10-byte `UnifiedCacheEntry` records - the - **What:** TMM's own flat array of packed 10-byte `UnifiedCacheEntry` records - the
@@ -76,15 +112,22 @@ Sources of truth: `src/mesh/NodeDB.{h,cpp}`, `src/mesh/WarmNodeStore.h`,
`node(4) | pos_fingerprint(1) | rate_count(1) | unknown_count(1) | pos_time(1) | rate_unknown_time(1) | next_hop(1)` `node(4) | pos_fingerprint(1) | rate_count(1) | unknown_count(1) | pos_time(1) | rate_unknown_time(1) | next_hop(1)`
= 10 bytes, all platforms. Timestamps are free-running modular ticks (uint8 / nibbles) = 10 bytes, all platforms. Timestamps are free-running modular ticks (uint8 / nibbles)
with presence carried by non-zero sentinels - no epochs, no absolute time. with presence carried by non-zero sentinels - no epochs, no absolute time.
- **Capacity:** `TRAFFIC_MANAGEMENT_CACHE_SIZE`, per memory class: 2048 (PSRAM S3 /
native), 500 (medium), 400 (small), 250 (nRF52840 - deliberately class-deviant for heap
headroom), 0 when `HAS_TRAFFIC_MANAGEMENT=0`. Variant-overridable.
- **Eviction:** linear scan; insertion on a full cache evicts the stalest entry, - **Eviction:** linear scan; insertion on a full cache evicts the stalest entry,
preferring to keep entries with a `next_hop` hint **or** a cached special (non-`CLIENT`) preferring to keep entries with a `next_hop` hint **or** a cached special (non-`CLIENT`)
role - the long-tail state this cache exists to retain (`findOrCreateEntry`'s `preferred` role - the long-tail state this cache exists to retain (`findOrCreateEntry`'s `preferred`
test covers both, not just `next_hop`). test covers both, not just `next_hop`).
- **Persistence:** none - RAM/PSRAM only, rebuilt from traffic. - **Persistence:** none - RAM/PSRAM only, rebuilt from traffic.
**Capacity** - `TRAFFIC_MANAGEMENT_CACHE_SIZE` (`mesh-pb-constants.h`), variant-overridable:
| LARGE | MEDIUM | SMALL | nRF52840 | `HAS_TRAFFIC_MANAGEMENT=0` |
| ----- | ------ | ----- | -------- | -------------------------- |
| 2048 | 500 | 400 | 250 | 0 |
At 10 B/entry that is ~5 KB on MEDIUM and ~2.5 KB on nRF52840, which is class-deviant for the same
heap reason as the warm tier (its class would give 400); 250 entries still tracks over 2x the
120-node hot store, and LRU victim recycling absorbs busier meshes.
## 4. TMM NodeInfo payload cache (extended, the ephemeral third tier) ## 4. TMM NodeInfo payload cache (extended, the ephemeral third tier)
- **What:** a flat array of `NodeInfoPayloadEntry` (PSRAM-backed on hardware; see - **What:** a flat array of `NodeInfoPayloadEntry` (PSRAM-backed on hardware; see
@@ -101,11 +144,20 @@ Sources of truth: `src/mesh/NodeDB.{h,cpp}`, `src/mesh/WarmNodeStore.h`,
`keySignerProven`, `hasObserved`, `hasFullUser`, `isMember`. (The direct-response throttle `keySignerProven`, `hasObserved`, `hasFullUser`, `isMember`. (The direct-response throttle
no longer keeps per-entry state here - it is a pair of separate RAM tables; see the module no longer keeps per-entry state here - it is a pair of separate RAM tables; see the module
doc.) doc.)
- **Capacity:** `kNodeInfoCacheEntries = 2000`, linear scan (NodeInfo traffic is
low-rate).
- **Persistence:** none - this tier is deliberately ephemeral; it reconstructs from NodeDB - **Persistence:** none - this tier is deliberately ephemeral; it reconstructs from NodeDB
seeding plus observed traffic after every boot. seeding plus observed traffic after every boot.
**Capacity** - `kNodeInfoCacheEntries` (`TrafficManagementModule.h`), gated by
`TMM_HAS_NODEINFO_CACHE`:
| ESP32 + PSRAM | Native unit-test builds | Everything else |
| ------------- | ----------------------- | --------------- |
| 2000 | 2000 | not compiled |
Not class-tiered: the array is either compiled or it isn't. ESP32+PSRAM is the production home (in
PSRAM); native test builds put the same 2000 entries on the plain heap so the trust and retention
paths run in CI. Linear scan in every build - NodeInfo traffic is low-rate.
### Trust & provenance model ### Trust & provenance model
- **Key pin, three layers deep:** an incoming NodeInfo key is checked against - **Key pin, three layers deep:** an incoming NodeInfo key is checked against
@@ -172,22 +224,14 @@ rehydration.
### Tick clocks and wrap safety ### Tick clocks and wrap safety
All TMM timestamps are free-running modular ticks (uint8 or nibble) from `clockMs()`; modular This cache's `obsTick` recency stamp, like the unified cache's pos/rate/unknown stamps, is a
subtraction is correct only while the true age stays below the counter period, so every clock free-running modular tick rather than an absolute time, and depends on the maintenance sweep to
needs something to clear expired state before it aliases. clear expired state before it aliases. The per-clock periods, windows, and what keeps each honest
are documented with the module in
| Clock | Tick / period | Window | Kept honest by | [traffic_management_module.md](traffic_management_module.md#tick-clocks-and-wrap-safety). The sharp
| ------------------ | -------------- | --------------- | -------------------------------------------------- | case for this tier is `obsTick`: the sweep clearing `hasObserved` is the _sole_ guarantee the 6 h
| pos | 6 min / 25.6 h | <=255 ticks | 60 s sweep (margin as low as 1 tick at the clamp) | serve gate never reads an aliased stamp, which is why it is a compile-time invariant guarded by
| rate | 5 min / 80 min | <=15 ticks | sweep + read-time window reset (`isRateLimited()`) | `TMM_HAS_NODEINFO_CACHE` alone.
| unknown | 1 min / 16 min | 12 ticks | sweep + read-time window reset |
| NodeInfo `obsTick` | 3 min / 12.8 h | 120 ticks (6 h) | sweep only |
`obsTick` is the sharp case: `maintainNodeInfoCacheLocked()` clearing `hasObserved` is the
_sole_ guarantee the 6 h serve gate never reads an aliased stamp. That makes the sweep a
compile-time invariant - guarded by `TMM_HAS_NODEINFO_CACHE` **alone** (never
`TRAFFIC_MANAGEMENT_CACHE_SIZE`, which a variant may zero independently), mirroring `purgeAll()`:
a build that has the cache always has its sweep.
The warm tier is different by design: `WarmNodeStore.last_heard` is an **absolute** unix-seconds The warm tier is different by design: `WarmNodeStore.last_heard` is an **absolute** unix-seconds
timestamp (128 s quantised), so it cannot wrap until 2106 and needs no sweep - the TMM caches timestamp (128 s quantised), so it cannot wrap until 2106 and needs no sweep - the TMM caches
@@ -207,23 +251,23 @@ behaviour - is documented with the module in
Side-by-side view of what each store actually holds ("-" = not held). Details and Side-by-side view of what each store actually holds ("-" = not held). Details and
rationale live in the per-store sections above. rationale live in the per-store sections above.
| Property | 1. Hot store (`NodeInfoLite`) | 2. Warm tier (`WarmNodeEntry`) | 3. NodeInfo cache (`NodeInfoPayloadEntry`) | 4. Unified cache (`UnifiedCacheEntry`) | | Property | 1. Hot store (`NodeInfoLite`) | 2. Warm tier (`WarmNodeEntry`) | 3. NodeInfo cache (`NodeInfoPayloadEntry`) | 4. Unified cache (`UnifiedCacheEntry`) |
| ------------------------- | -------------------------------- | ---------------------------------------------- | ------------------------------------------------------------- | ---------------------------------------------------- | | ------------------------- | -------------------------------- | ---------------------------------------------- | ------------------------------------------------------------- | -------------------------------------------------- |
| Node number | yes | yes | yes (0 = free slot) | yes (0 = free slot) | | Node number | yes | yes | yes (0 = free slot) | yes (0 = free slot) |
| Names + user id | yes (flattened fields) | - | yes (full `User`, when `hasFullUser`) | - | | Names + user id | yes (flattened fields) | - | yes (full `User`, when `hasFullUser`) | - |
| Public key (32 B) | yes (authoritative) | yes (keyed entries) | yes (TOFU or proven; pinned against tiers 1-2) | - | | Public key (32 B) | yes (authoritative) | yes (keyed entries) | yes (TOFU or proven; pinned against tiers 1-2) | - |
| Signer provenance | `HAS_XEDDSA_SIGNED` bitfield bit | 1 signer bit (shared with `last_heard`) | `keySignerProven` (monotonic per key) | - | | Signer provenance | `HAS_XEDDSA_SIGNED` bitfield bit | 1 signer bit (shared with `last_heard`) | `keySignerProven` (monotonic per key) | - |
| Device role | `role` field | 4-bit role (metadata steal) | inside the cached `User` | 4-bit role in count-byte top bits (final fallback) | | Device role | `role` field | 4-bit role (metadata steal) | inside the cached `User` | 4-bit role in count-byte top bits (final fallback) |
| Recency | `last_heard` (unix secs) | `last_heard` (unix secs, 128 s quantised) | `obsTick` (3 min modular tick) + `hasObserved` | pos/rate/unknown modular ticks | | Recency | `last_heard` (unix secs) | `last_heard` (unix secs, 128 s quantised) | `obsTick` (3 min modular tick) + `hasObserved` | pos/rate/unknown modular ticks |
| Position / telemetry | via satellite copy-out accessors | - | - | 8-bit position _fingerprint_ only (dedup) | | Position / telemetry | via satellite copy-out accessors | - | - | 8-bit position _fingerprint_ only (dedup) |
| Protected / favorite | bitfield flags | 2-bit protected category | - (`isMember` keep-alive instead) | - | | Protected / favorite | bitfield flags | 2-bit protected category | - (`isMember` keep-alive instead) | - |
| Routing hint (`next_hop`) | yes (persisted field) | - | - | ACK-confirmed relay byte (preloaded from tier 1) | | Routing hint (`next_hop`) | yes (persisted field) | - | - | ACK-confirmed relay byte (preloaded from tier 1) |
| Direct-reply metadata | - | - | `sourceChannel`, `decodedBitfield` (+ `hasDecodedBitfield`) | - | | Direct-reply metadata | - | - | `sourceChannel`, `decodedBitfield` (+ `hasDecodedBitfield`) | - |
| Traffic-shaping counters | - | - | - | rate + unknown counts, pos fingerprint | | Traffic-shaping counters | - | - | - | rate + unknown counts, pos fingerprint |
| Entry size | largest (full struct) | 40 B exact | ~`sizeof(User)`+8, platform-padded (no size assert by design) | 10 B exact | | Entry size | largest (full struct) | 40 B exact | ~`sizeof(User)`+8, platform-padded (no size assert by design) | 10 B exact |
| Capacity | `MAX_NUM_NODES` (250/120/10) | `WARM_NODE_COUNT` (~100) | `kNodeInfoCacheEntries` (2000) | `TRAFFIC_MANAGEMENT_CACHE_SIZE` (2048/500/400/250/0) | | Capacity | `MAX_NUM_NODES` (10-250) | `WARM_NODE_COUNT` (0-2000) | `kNodeInfoCacheEntries` (2000) | `TRAFFIC_MANAGEMENT_CACHE_SIZE` (0-2048) |
| Persistence | node DB file | raw-flash ring (nRF52840) or `/prefs/warm.dat` | none (rebuilt from seed + traffic) | none | | Persistence | node DB file | raw-flash ring (nRF52840) or `/prefs/warm.dat` | none (rebuilt from seed + traffic) | none |
| Storage | RAM | RAM + flash | PSRAM on hardware; plain heap in native tests | PSRAM when available, else heap | | Storage | RAM | RAM + flash | PSRAM on hardware; plain heap in native tests | PSRAM when available, else heap |
## How a lookup falls through the tiers ## How a lookup falls through the tiers
+27
View File
@@ -177,6 +177,33 @@ carries throttle state.
--- ---
## Tick clocks and wrap safety
Every per-node timestamp in TMM's caches is a free-running modular tick (uint8 or nibble) taken
from `clockMs()` - never an absolute time. That is what keeps `UnifiedCacheEntry` at 10 bytes
across up to 2048 entries. The cost is that modular subtraction is only correct while the true age
stays below the counter's period, so every clock needs something to clear expired state before it
aliases. (The direct-serve throttle above is the deliberate exception: full `uint32` milliseconds
compared by wrap-safe subtraction, hence no tick and no sweep.)
| Clock | Tick / period | Window | Kept honest by |
| ------------------ | -------------- | --------------- | -------------------------------------------------- |
| pos | 6 min / 25.6 h | <=255 ticks | 60 s sweep (margin as low as 1 tick at the clamp) |
| rate | 5 min / 80 min | <=15 ticks | sweep + read-time window reset (`isRateLimited()`) |
| unknown | 1 min / 16 min | 12 ticks | sweep + read-time window reset |
| NodeInfo `obsTick` | 3 min / 12.8 h | 120 ticks (6 h) | sweep only |
`obsTick` is the sharp case: `maintainNodeInfoCacheLocked()` clearing `hasObserved` is the
_sole_ guarantee the 6 h serve gate never reads an aliased stamp. That makes the sweep a
compile-time invariant - guarded by `TMM_HAS_NODEINFO_CACHE` **alone** (never
`TRAFFIC_MANAGEMENT_CACHE_SIZE`, which a variant may zero independently), mirroring `purgeAll()`:
a build that has the cache always has its sweep.
The stores these clocks stamp, and the warm tier's contrasting absolute timestamps, are described
in [node_info_stores.md](node_info_stores.md).
---
## Configuration ## Configuration
All tunables live under `moduleConfig.traffic_management`; the whole module is gated by the All tunables live under `moduleConfig.traffic_management`; the whole module is gated by the