Repository navigation
Same advert seen on flood and zero-hop routes keeps the route of the first inserted observation #89
Description
Summary
CoreScope deduplicates transmissions by content hash. MeshCore's content hash leaves out the route bits, transport codes and path, so the same ADVERT payload can arrive once as a flood packet (route 0/1) and again as a zero-hop/direct packet (route 2/3), and both land on one transmissions row.
That row's route_type and raw_hex are set once, when the first observation is inserted, and are never updated afterwards. The per-observation raw_hex is also not a reliable record of both variants: the observation upsert overwrites it when the same observer sees the same path again.
As a result, anything that classifies by route_type depends on ingest order for these transmissions. That includes the Relay Airtime Share split added in #86, which was intentionally built on the stored route.
This issue documents the current contract and the options for defining a better one. It is not a report that #86 is wrong: #86 classifies exactly by the stored route_type and says so in its docs and tests.
Verified on master e51272d96aa80c2d19ef5b6b90f0c4216869d76b (the #86 merge) and MeshCore firmware 0679dbeffc504d562d2f09eb072fdc223f8ffc2a. The relevant firmware files are unchanged at e94125987ed87497e706a0b54d1e80c709343980.
How the same advert reaches both route classes
- Firmware stores and re-shares the advert.
BaseChatMesh::onAdvertRecvstores each contact's raw advert with the route bits forced toROUTE_TYPE_FLOOD(src/helpers/BaseChatMesh.cpp:142-146,putBlobByKeyat:188).BaseChatMesh::shareContactZeroHop(:539-551, the companion "share contact" command) reads that stored advert and sends it unchanged throughMesh::sendZeroHop(packet, {0,0}). That call setsROUTE_TYPE_TRANSPORT_DIRECTandpath_len = 0(src/Mesh.cpp:728-739).- The payload (public key, timestamp, signature, app data) is byte-identical to the original flood advert.
- The hash ignores the route.
ComputeContentHash(cmd/ingestor/decoder.go) hashes the payload type and payload only. Route bits, transport codes and path bytes are not included, so both variants get the same hash. - Not a source: an originator's own periodic flood advert and local zero-hop advert are separate
createSelfAdvert()calls on separate timers. They have different timestamps and signatures, and therefore different hashes.
Current ingest and load behaviour
cmd/ingestor/db.go, InsertTransmission:
| Statement | Effect |
|---|---|
SELECT id, first_seen FROM transmissions WHERE hash = ? (:852) |
dedup by content hash |
INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, …) (:857) |
the only writer of route_type and raw_hex, executed for the first inserted observation |
UPDATE transmissions SET first_seen = ? (:865) |
moves first_seen back when a later-inserted observation has an earlier receive time |
UPDATE transmissions SET last_seen = ? … AND last_seen < ? (:912) |
moves last_seen forward |
observation INSERT … ON CONFLICT(transmission_id, observer_idx, COALESCE(path_json,'')) DO UPDATE SET … raw_hex = COALESCE(excluded.raw_hex, raw_hex) … (:928-937) |
a repeat from the same observer with the same path_json overwrites raw_hex, snr, rssi, score and resolved_path, but keeps the original timestamp and direction |
scope_name is also written only at insert, so transport-code scope follows the same first-inserted rule. A shareContactZeroHop re-share uses codes {0,0}, so it is never scoped.
On the server side, every path that builds a StoreTx takes RouteType from transmissions.route_type:
- cold load:
store.go:896,store.go:1224,chunked_load.go:548; - live poll:
store.go:2797.
The observation raw_hex is scanned but deliberately not kept in memory (store.go:3193, chunked_load.go:591). The comments justify this with "same content hash ⇒ same frame", which does not hold for this case (compare upstream #1999). So the server has no other copy of the route.
Reproduction
The committed test cmd/ingestor/advert_route_first_ingested_test.go drives the real DecodePacket, BuildPacketData and InsertTransmission with a flood advert (0x11, two relay hops) and its shareContactZeroHop form (0x13, codes 0000 0000, path_len 0):
cd cmd/ingestor && go test -run 'AdvertRouteVariantsShareContentHash|AdvertRouteIsFirstIngested' -v .A local scratch extension (not committed) did two more things. It added a same-observer case, and it cold-loaded each resulting database into the server store to read Relay Airtime Share. The receive times were T0 = 10:00:00Z and T5 = 10:05:00Z.
| # | Insert order | Stored route_type / raw_hex header |
first_seen |
last_seen |
Observations (raw_hex header) |
Relay Airtime Share after cold load |
|---|---|---|---|---|---|---|
| 1 | flood (obs A, T0), then zero-hop (obs B, T5) | 1 / 11 |
T0 | T5 | 2 (11, 13), both kept |
ADVERT (flood), flood |
| 2 | zero-hop (obs B, T0), then flood (obs A, T5) | 3 / 13 |
T0 | T5 | 2 (13, 11), both kept |
ADVERT (zero-hop), zero_hop |
| 3 | zero-hop inserted first with rx T5, then flood with rx T0 | 3 / 13 |
T0 (moved back) | T5 | 2 (13, 11), both kept |
ADVERT (zero-hop), zero_hop |
| 4 | same observer, both path []: zero-hop (T0), then transport-flood heard at 0 hops (0x10, T5) |
3 / 13 |
T0 | T5 | 1 (10); the zero-hop bytes were overwritten, the timestamp stayed T0 |
ADVERT (zero-hop), zero_hop |
- In every case the server's
StoreTx.RouteTypeequals the storedroute_type, andobs.RawHexis empty in memory. - When the zero-hop variant is inserted first (cases 2 and 3), relays resolved from the flood observations are scored on the
zero_hoprow. A genuine zero-hop advert is never relayed (src/Mesh.cpp:78,:346). This is pinned byTestRelayAirtimeShare_MixedRouteHashFollowsStoredRoute, and was reproduced in feat(analytics): split advert relay airtime by route #86's browser validation (1.55 s of relay airtime onADVERT (zero-hop)from a single such hash). - Case 4 matches a real row on staging (below).
Observed vs. expected
- Observed:
- The route class of a mixed-route hash is decided by which observation the ingestor happens to insert first. That can be the later-received one.
- Once an observation's
raw_hexis overwritten, it can no longer show which variant it came from.
- Expected: no single "right" answer exists yet; that is what this issue is for. At minimum:
- an explicit, documented contract for mixed-route hashes;
- behaviour that does not depend on ingest order;
- no silent loss of the fact that both route classes were seen.
Impact
Kept separate on purpose:
- Not data corruption. Each
transmissionsrow is internally consistent (route_typematches itsraw_hexheader), and deduplication by content hash is intentional. - Deliberate deduplication. One logical packet per content hash is the existing model. The open question is only which route (or routes) that row should represent.
- Lossy observation merge. In case 4, one
observationsrow combines fields from two receptions: the first reception'stimestamp/direction, and the second'sraw_hex/snr/rssi/score. The route of the first reception is gone from the database. - Possible misclassification in Relay Airtime Share. A mixed hash is counted once, on the row of its stored class, and its relays follow it. Its Time-on-Air is computed from the stored frame length, and the two variants differ by 4 transport-code bytes plus the path bytes. The cause is not the feat(analytics): split advert relay airtime by route #86 aggregation itself.
- Other readers of
route_type(all use the stored, first-inserted value; not individually quantified here):- packet list/detail
route_typeand theroute=filter (store.go:3491); - flood-advert detection (
txHasObservedFloodPath,node_activity.go:277); - repeater-liveness relay entries (
repeater_liveness.go:136); - hop-depth transport classification (
classifyHopAnalyticsTransport,store.go:9766); - scope stats SQL (
routeTypeTransportSQL,db.go:70).
- packet list/detail
- Reach and paths analytics use resolved paths from observations, not
route_type, so they are not directly affected. A zero-hop re-share contributes no path. - Frequency (read-only on staging, 2026-09-24):
- Signature: at least one observation header whose route class differs from the stored class.
- Last 24 h, all payload types: 0 of 15,420 transmissions.
- Last 7 days, ADVERT: 1 of 11,914.
- That one is tx
1000443(2026-09-17). It is stored as route 2 with header12, and its only remaining observation has header10,path []and the same second. This is case 4. It is outside the live in-memory window and contributed a score of 0. - The overwrite in case 4 can hide variants, so this is a lower bound. It does not suggest the situation is common.
Options
None of these is recommended until its contract can be tested.
A. Keep the route per observation
Derive each observation's route from its own frame, and stop the upsert from destroying the only copy of a different variant. Options:
- include the route class (or header byte) in the observation conflict key;
- add an
observations.route_typecolumn filled at insert; - keep one observation row per route variant.
Tradeoffs:
- Needs a migration of the unique index
idx_observations_dedup(it is on the hot insert path). - Grows the database: extra rows only for the rare mixed case, but an extra column on every row.
- Old rows keep whatever
raw_hexsurvived, so historical cases stay partially unrecoverable. - The server would still need that information in memory without undoing the memory saving from
#881/#1773: for example one byte per observation, or a per-transmission summary (see B).
B. Aggregate the route classes seen per transmission
Add a bitmask on transmissions, e.g. bit 0 = flood seen, bit 1 = direct seen, set on insert and on every duplicate observation.
Open questions and costs:
- One extra conditional
UPDATEon the duplicate path, but only when the bit changes. - Backfill is possible only from surviving
observations.raw_hex, and is lossy for old overwritten rows. - The API has to define what a mixed hash shows: a
"mixed"route_class, or both classes. - How airtime is split for a mixed hash, which is a product decision:
- relay score to flood, count to both?
- attribute by observation?
- The server needs the field in all four load paths.
C. Deterministic priority rule
Pick one class by rule, for example "flood wins over zero-hop if any flood observation exists". This makes ingest order irrelevant, and it is cheap once B's data exists.
However, it can be semantically wrong: the original transmission may have been one class and the re-share the other, and the rule erases that. Detecting a flood from relay evidence alone is also not safe, because firmware direct forwarding (Mesh.cpp:78) applies to every payload type, so a relayed "direct" packet is not proof of a flood.
D. Compute at query time from observations
Read each observation's raw_hex header when classifying. This needs no ingest change, but has problems:
- The history is not reliably preserved (case 4).
- The server does not keep
obs.RawHexin memory, so it would need per-request DB reads, or would lose the memory saving.dborup/CoreScope#77(a port of upstream#2055) adds bounded per-request reads for packet detail only, which may be reusable. - Cache behaviour and the cost of whole-window analytics such as Relay Airtime Share are unclear.
Acceptance criteria for a future PR
- An explicit, documented data contract for content hashes seen on more than one route class, covering storage, API fields and Relay Airtime Share.
- Classification no longer depends on ingest order.
- Neither observed route class is lost, including in the same-observer/same-path upsert case.
- Deterministic Relay Airtime Share output for mixed hashes, with a documented rule for count, score and Time-on-Air.
- A migration and compatibility plan for existing databases, including how rows with already-overwritten
raw_hexare treated. - Tests for all three insert orders (flood first, zero-hop first, zero-hop first with earlier-received flood), plus the same-observer overwrite case.
- Tests for the observation upsert/conflict behaviour.
- Tests for server cold load, chunked load and live poll/ingest.
-
-racefor the touched ingest and store paths, and a performance measurement on realistic data (insert path, cold load, memory). - No change in behaviour or output for ordinary transmissions seen on a single route class.
Related
- feat(analytics): split advert relay airtime by route #86 — Relay Airtime Share flood/zero-hop split (background; it documents and tests the first-inserted contract).
dborup/CoreScope#77— per-observation wire bytes in the packet detail API (port of upstream#2055, which covers upstream issue#1999). Related, but only about showing stored frames, not route classification or the upsert overwrite.
Activity
dborup commentedon Sep 25, 2026
Fixed by #93, merged as f8ee2be: every observed route is now recorded per transmission (route_mask), and mixed flood/zero-hop adverts are shown as their own class in Relay Airtime Share. Master CI 36131751453 is green.
fix(ingestor): recompute merged route_mask inline when a side is unco…
docs(ingestor): note the change row a NULL-mask survivor logs on merge (