Files
meshcore-analyzer/internal/dbschema/key_backfill.go
T
b43fd4a470 feat(#2107): backfill unresolved channel/scope rows when the key set grows (#2133)
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>
2026-10-13 08:35:20 +02:00

53 lines
2.4 KiB
Go

package dbschema
import "database/sql"
// ensureKeyBackfill creates the tables behind the key-set backfill (#2107).
//
// tx_rewrite_feed is the change feed: the ingestor appends one row per
// transmission it rewrote in place (a GRP_TXT it can now decrypt, a transport
// scope it can now name), in the same transaction as the rewrite itself. The
// read-only server tails it with its own cursor, exactly like
// advert_route_evidence, so a rewritten row reaches the in-memory store without
// a restart. AUTOINCREMENT keeps feed IDs monotonic after pruning; the
// created_at index serves the age-based prune, and the tx_id index serves the
// ON DELETE CASCADE, so retention deleting a transmission does not scan the
// whole feed.
//
// key_backfill_state holds at most one in-flight run per kind. run_fp is the
// fingerprint of the key set the run is working with: a run whose fingerprint
// no longer matches the live key set is restarted, which is what makes a key
// added mid-run safe. cursor advances in the same transaction as the rewrites
// it covers, so a cancelled run resumes exactly where it committed.
//
// key_backfill_done records which keys have had a complete pass over history,
// so a restart with an unchanged key set does no work at all.
//
// PREFLIGHT: async=true reason="creates three empty tables and two indexes on the empty feed; the backfill itself runs batched in the background after live ingest is ready"
func ensureKeyBackfill(rw *sql.DB) error {
_, err := rw.Exec(`
CREATE TABLE IF NOT EXISTS tx_rewrite_feed (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tx_id INTEGER NOT NULL REFERENCES transmissions(id) ON DELETE CASCADE,
created_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s','now') AS INTEGER))
);
CREATE INDEX IF NOT EXISTS idx_tx_rewrite_feed_created ON tx_rewrite_feed(created_at);
CREATE INDEX IF NOT EXISTS idx_tx_rewrite_feed_tx ON tx_rewrite_feed(tx_id);
CREATE TABLE IF NOT EXISTS key_backfill_state (
kind TEXT PRIMARY KEY CHECK(kind IN ('channel','region')),
run_fp TEXT NOT NULL,
cursor INTEGER NOT NULL DEFAULT 0,
upper INTEGER NOT NULL DEFAULT 0,
rewritten INTEGER NOT NULL DEFAULT 0,
started_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s','now') AS INTEGER))
);
CREATE TABLE IF NOT EXISTS key_backfill_done (
kind TEXT NOT NULL,
key_fp TEXT NOT NULL,
done_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s','now') AS INTEGER)),
PRIMARY KEY(kind, key_fp)
) WITHOUT ROWID;
`)
return err
}