Part 1 of #2107, scoped as discussed there: the backfill on its own, no catalogue promotion. ## Problem Ingest decides a GRP_TXT's channel and a transport packet's region scope once, at insert time. A row stored before its key was known keeps `channel_hash = 'enc_XX'` / `scope_name = ''` forever, even after the key is added — via `hashChannels` plus a restart, or at runtime when the `autoRegionKeys` tier picks up a newly declared region. On live.saarmesh.de we have been closing that gap with an out-of-tree script that rewrites the DB and restarts the container; this moves it into the ingestor and removes the restart. ## What this does **Ingestor** — `cmd/ingestor/key_backfill.go` - Runs after the write path is ready, and again whenever the key set grows (kicked after every `autoRegionKeys` refresh; a no-op when nothing is new). - Touches **only unresolved rows**: `enc_*` GRP_TXT and transport rows with `scope_name = ''`. Every `UPDATE` carries the unresolved value in its `WHERE`, so a row resolved meanwhile by live ingest keeps its value. A decrypted channel or named scope is never rewritten. - **Same decision as ingest**: `DecodePacket` with the full channel key map → `PayloadJSON`, and `regionKeySnapshot.match()` with the full snapshot — including abstaining on an ambiguous collision (#1609). The scope-match counters are not touched; they describe live traffic. - **Bounded**: each batch covers a fixed window of 5000 transmission ids. Unresolved rows are sparse (hundreds in a million), and the ingestor has a single SQLite connection, so a "next N matching rows" query would scan most of the table in one statement while ingest waits. A fixed id window keeps every read a short primary-key range scan. The writer is yielded between windows. - **Resumable and transactional**: one transaction per window holds the rewrites, their change-feed rows and the advanced cursor (`key_backfill_state`). A cancelled or crashed run resumes at its last commit; nothing is applied twice. - **Key-set growth**: a run is tied to the fingerprint of the key set it started with. If the set changes mid-run, the run restarts from the beginning, so a key added halfway still sees all of history. Each key gets one complete pass (`key_backfill_done`); a restart with an unchanged key set does no work. A key that leaves the set forgets its pass and is replayed if it returns. - **Derived field**: a named ADVERT fills `nodes.default_scope` / `inactive_nodes.default_scope` only where it is still empty. A known `default_scope` is newer evidence than a backfilled advert and is never overwritten. **Change feed** — `internal/dbschema/key_backfill.go`, `cmd/server/tx_rewrite_feed.go` - Each rewrite appends a row to `tx_rewrite_feed` in the same transaction as the rewrite. - The server tails it exactly like `advert_route_evidence`: cursor captured before `Load`, absent table re-probed (not required by `AssertReady`, so an older ingestor simply means no feed), polled from `IngestNewObservations`. It refreshes `DecodedJSON` / `ScopeName` of loaded transmissions, resets the `ParsedDecoded` cache, recharges tracked bytes, and invalidates the channel caches. **No restart needed.** - The feed carries only the transmission id; current values are read from `transmissions`, so replaying a row is harmless. Rows older than 24h are pruned. ## Tests Ingestor (`key_backfill_test.go`) — fixtures are real wire frames: GRP_TXT encrypted AES-128-ECB with the firmware MAC, transport frames with the real region HMAC. - decrypts only unresolved rows; already-decrypted and unknown-key rows untouched - backfilled `decoded_json` / `channel_hash` are byte-identical to what `BuildPacketData` stores at ingest - unique match named; a constructed two-name collision on the same payload stays unmatched; named and non-transport rows untouched - ingest scope-match counters unchanged - ADVERT fills only an empty `default_scope` - idempotent: second pass writes nothing, done keys recorded, state cleared - cancel between commits → resume from the committed cursor, every row exactly once, in order - stale run fingerprint (key added mid-run) → restart reaches rows below the old cursor - a later key only adds its own rows; a removed key is replayed over the gap when it returns - a row resolved between decide and commit keeps its value and gets no feed entry - channel hash byte derivation matches the builtin Public key (0x11) - old feed rows pruned Server (`tx_rewrite_feed_test.go`) - rewrite reaches the loaded store through the normal `IngestNewObservations` poll, including a previously parsed `ParsedDecoded` (verified to fail without the `sync.Once` reset) - cursor starts at the load watermark - absent feed is a no-op and is picked up once created, without restart - failed read keeps the cursor and retries - feed row for an unloaded transmission advances the cursor and is ignored `gofmt` clean, `go vet` clean, no `go.mod` / `go.sum` changes. ## Validation against production data Ran the backfill on a snapshot of live.saarmesh.de taken **before** last night's run of our out-of-tree script (13,100 unresolved rows, 164 channel keys, 24 region keys incl. 2 derived), with the current config. It rewrote **the same 31 transmissions with the same channel names** as the script did on production (compared by id), named 0 scopes (script: 0), and a second pass wrote nothing. Browser validation (AGENTS rule 2): this changes no UI. The visible effect — a decrypted message or named scope appearing without a restart — is covered by the server feed tests; I have not yet run it against a deployed instance. ## Notes for review - Window size (5000 ids), inter-batch yield (10 ms) and feed retention (24h) are constants for now; per rule 8 they can move to config/customizer later if anyone needs to tune them. - `client_rx_observations.scope_name` is not backfilled here; happy to add it in a follow-up if wanted. - The orphaned "scope-repair" mention in `region_keys.go`'s `scopeMatch.Candidates` comment predates this PR; I left it alone. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01BKaogNXgy8dEQruqzR5foP --------- Co-authored-by: SaarMesh-Bot <300107934+SaarMesh-Bot@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
MeshCore MQTT Ingestor (Go)
Standalone MQTT ingestion service for CoreScope. Connects to MQTT brokers, decodes raw MeshCore packets, and writes to the same SQLite database used by the Node.js web server.
This is the first step of a larger Go rewrite — separating MQTT ingestion from the web server.
Architecture
MQTT Broker(s) → Go Ingestor → SQLite DB ← Node.js Web Server
(this binary) (shared)
- Single static binary — no runtime dependencies, no CGO
- SQLite via
github.com/mattn/go-sqlite3(cgo; cross-compiled withzig cc, see the rootMakefile) - MQTT via
github.com/eclipse/paho.mqtt.golang - Runs alongside the Node.js server — they share the DB file
- Does NOT serve HTTP/WebSocket — that stays in Node.js
Build
Requires Go 1.22+.
cd cmd/ingestor
go build -o corescope-ingestor .
Cross-compile for Linux (e.g., for the production VM):
GOOS=linux GOARCH=amd64 go build -o corescope-ingestor .
Run
./corescope-ingestor -config /path/to/config.json
The config file uses the same format as the Node.js config.json. The ingestor reads the mqttSources array (or legacy mqtt object) and dbPath fields.
Environment Variables
| Variable | Description | Default |
|---|---|---|
DB_PATH |
SQLite database path | data/meshcore.db |
MQTT_BROKER |
Single MQTT broker URL (overrides config) | — |
MQTT_TOPIC |
MQTT topic (used with MQTT_BROKER) |
meshcore/# |
CORESCOPE_INGESTOR_STATS |
Path to the per-second stats JSON file consumed by the server's /api/perf/io and /api/perf/write-sources endpoints (#1120) |
/tmp/corescope-ingestor-stats.json |
Stats file (CORESCOPE_INGESTOR_STATS)
Every second the ingestor publishes a JSON snapshot of its counters
(tx_inserted, obs_inserted, walCommits, backfillUpdates.*, etc.) plus
a procIO block sampled from /proc/self/io (read/write/cancelled bytes per
second + syscall counts). The server reads this file and surfaces the data on
the Perf page so operators can self-diagnose write-volume anomalies.
The writer uses O_NOFOLLOW | O_CREAT | O_TRUNC mode 0o600, so a
pre-planted symlink at the path cannot be used to clobber an arbitrary file.
Security note: the default lives in /tmp, which is world-writable on
most hosts (sticky bit only protects deletion, not creation). On
shared/multi-tenant hosts, override CORESCOPE_INGESTOR_STATS to point at a
private directory (e.g. /var/lib/corescope/ingestor-stats.json) that only
the corescope user can write to.
Minimal Config
{
"dbPath": "data/meshcore.db",
"mqttSources": [
{
"name": "local",
"broker": "mqtt://localhost:1883",
"topics": ["meshcore/#"]
}
]
}
Full Config (same as Node.js)
The ingestor reads these fields from the existing config.json:
mqttSources[]— array of MQTT broker connectionsname— display name for loggingbroker— MQTT URL (mqtt://,mqtts://)username/password— auth credentialstopics— array of topic patterns to subscribeiataFilter— optional regional filterclientId: optional MQTT ClientID. Defaultcorescope-<name>-<random>, new suffix per ingestor start. Two running ingestors must not share a value on one broker
mqtt— legacy single-broker config (auto-converted tomqttSources)dbPath— SQLite DB path (default:data/meshcore.db)
Test
cd cmd/ingestor
go test -v ./...
What It Does
- Connects to configured MQTT brokers with auto-reconnect
- Subscribes to mesh packet topics (e.g.,
meshcore/+/+/packets) - Receives raw hex packets via JSON messages (
{ "raw": "...", "SNR": ..., "RSSI": ... }) - Decodes MeshCore packet headers, paths, and payloads (ported from
decoder.js) - Computes content hashes (path-independent, SHA-256-based)
- Writes to SQLite:
transmissions+observationstables - Upserts
nodesfrom decoded ADVERT packets (with validation) - Upserts
observersfrom MQTT topic metadata
Schema Compatibility
The Go ingestor creates the same v3 schema as the Node.js server:
transmissions— deduplicated by content hashobservations— per-observer sightings withobserver_idx(rowid reference)nodes— mesh nodes discovered from advertsobservers— MQTT feed sources
Both processes can write to the same DB concurrently (SQLite WAL mode).
What's Not Ported (Yet)
- Companion bridge format (Format 2 —
meshcore/advertisement, channel messages, etc.) - Channel key decryption (GRP_TXT encrypted payload decryption)
- WebSocket broadcast to browsers
- In-memory packet store
- Cache invalidation
These stay in the Node.js server for now.
Files
cmd/ingestor/
main.go — entry point, MQTT connect, message handler
decoder.go — MeshCore packet decoder (ported from decoder.js)
decoder_test.go — decoder tests (25 tests, golden fixtures)
db.go — SQLite writer (schema-compatible with db.js)
db_test.go — DB tests (schema validation, insert/upsert, E2E)
config.go — config struct + loader
util.go — shared utilities
go.mod / go.sum — Go module definition