mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-15 22:09:56 +00:00
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>
166 lines
5.6 KiB
Go
166 lines
5.6 KiB
Go
package main
|
|
|
|
import (
|
|
"database/sql"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// txRewriteFixture is the ingestor's durable output for one GRP_TXT stored
|
|
// before its channel key was known: still encrypted, scope unmatched. The
|
|
// returned writer plays the ingestor's key backfill.
|
|
func txRewriteFixture(t *testing.T, withFeed bool) (*DB, *sql.DB) {
|
|
t.Helper()
|
|
path := createTestDBWithResolvedPath(t, 1, []string{"fixture-relay"})
|
|
w, err := sql.Open("sqlite3", path+"?_journal_mode=WAL")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { w.Close() })
|
|
stmts := []string{
|
|
`ALTER TABLE transmissions ADD COLUMN scope_name TEXT`,
|
|
`ALTER TABLE transmissions ADD COLUMN channel_hash TEXT`,
|
|
}
|
|
if withFeed {
|
|
stmts = append(stmts, txRewriteFeedDDL)
|
|
}
|
|
for _, stmt := range stmts {
|
|
if _, err := w.Exec(stmt); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
now := time.Now().UTC().Add(-time.Minute).Format(time.RFC3339)
|
|
if _, err := w.Exec(`UPDATE transmissions SET payload_type=5, route_type=0, first_seen=?, scope_name='',
|
|
channel_hash='enc_AB', decoded_json='{"type":"GRP_TXT","channelHashHex":"AB"}'; UPDATE observations SET timestamp=?`, now, now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
db, err := OpenDB(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { db.conn.Close() })
|
|
return db, w
|
|
}
|
|
|
|
// Mirrors internal/dbschema/key_backfill.go; the server never creates it.
|
|
const txRewriteFeedDDL = `CREATE TABLE tx_rewrite_feed (id INTEGER PRIMARY KEY AUTOINCREMENT, tx_id INTEGER NOT NULL, created_at INTEGER NOT NULL DEFAULT 0)`
|
|
|
|
// backfillRewrite does what the ingestor's commit does: rewrite the row and
|
|
// append its feed entry in one transaction.
|
|
func backfillRewrite(t *testing.T, w *sql.DB, decoded, scope string) {
|
|
t.Helper()
|
|
if _, err := w.Exec(`BEGIN; UPDATE transmissions SET decoded_json=?, channel_hash='#saar', scope_name=? WHERE id=1;
|
|
INSERT INTO tx_rewrite_feed(tx_id) VALUES(1); COMMIT`, decoded, scope); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestTxRewriteFeedReachesStoreWithoutRestart(t *testing.T) {
|
|
db, w := txRewriteFixture(t, true)
|
|
s := NewPacketStore(db, &PacketStoreConfig{})
|
|
if err := s.Load(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tx := s.byTxID[1]
|
|
if tx == nil {
|
|
t.Fatal("fixture transmission not loaded")
|
|
}
|
|
// Parse once before the rewrite: the cached map must not survive it.
|
|
if got := tx.ParsedDecoded()["type"]; got != "GRP_TXT" {
|
|
t.Fatalf("fixture decoded type %v", got)
|
|
}
|
|
|
|
backfillRewrite(t, w, `{"type":"CHAN","channel":"#saar","text":"Alice: moin"}`, "#saar")
|
|
s.IngestNewObservations(0, 100) // the server's normal poll path
|
|
|
|
if got := tx.ParsedDecoded()["channel"]; got != "#saar" {
|
|
t.Fatalf("in-memory decoded still stale after poll: channel=%v json=%s", got, tx.DecodedJSON)
|
|
}
|
|
if tx.ScopeName == nil || *tx.ScopeName != "#saar" {
|
|
t.Fatalf("in-memory scope_name not refreshed: %v", tx.ScopeName)
|
|
}
|
|
if s.txRewriteCursor != 1 {
|
|
t.Fatalf("cursor=%d, want 1", s.txRewriteCursor)
|
|
}
|
|
}
|
|
|
|
// Rows committed before Load are already in what Load read; the watermark
|
|
// keeps them from being re-applied (harmless, but wasted work at scale).
|
|
func TestTxRewriteFeedCursorStartsAtLoadWatermark(t *testing.T) {
|
|
db, w := txRewriteFixture(t, true)
|
|
backfillRewrite(t, w, `{"type":"CHAN","channel":"#saar"}`, "#saar")
|
|
s := NewPacketStore(db, &PacketStoreConfig{})
|
|
if s.txRewriteCursor != 1 {
|
|
t.Fatalf("cursor=%d before Load, want the feed's MAX(id)=1", s.txRewriteCursor)
|
|
}
|
|
}
|
|
|
|
// An older ingestor never creates the feed. The server must not error, and
|
|
// must start tailing as soon as a newer ingestor creates it - no restart.
|
|
func TestTxRewriteFeedAbsentThenCreated(t *testing.T) {
|
|
db, w := txRewriteFixture(t, false)
|
|
s := NewPacketStore(db, &PacketStoreConfig{})
|
|
if err := s.Load(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.pollTxRewrites(500); err != nil {
|
|
t.Fatalf("absent feed must be a no-op, got %v", err)
|
|
}
|
|
if _, err := w.Exec(txRewriteFeedDDL); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
backfillRewrite(t, w, `{"type":"CHAN","channel":"#saar"}`, "#saar")
|
|
if err := s.pollTxRewrites(500); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := s.byTxID[1].ParsedDecoded()["channel"]; got != "#saar" {
|
|
t.Fatalf("feed created after startup was not picked up: %v", got)
|
|
}
|
|
}
|
|
|
|
func TestTxRewriteFeedFailedReadKeepsCursorAndRetries(t *testing.T) {
|
|
db, w := txRewriteFixture(t, true)
|
|
s := NewPacketStore(db, &PacketStoreConfig{})
|
|
if err := s.Load(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
backfillRewrite(t, w, `{"type":"CHAN","channel":"#saar"}`, "#saar")
|
|
if _, err := w.Exec(`ALTER TABLE tx_rewrite_feed RENAME TO feed_unavailable`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.pollTxRewrites(500); err == nil {
|
|
t.Fatal("expected failed feed read")
|
|
}
|
|
if s.txRewriteCursor != 0 {
|
|
t.Fatal("failed read advanced the cursor")
|
|
}
|
|
if _, err := w.Exec(`ALTER TABLE feed_unavailable RENAME TO tx_rewrite_feed`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.pollTxRewrites(500); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := s.byTxID[1].ParsedDecoded()["channel"]; got != "#saar" {
|
|
t.Fatalf("retry did not apply the rewrite: %v", got)
|
|
}
|
|
}
|
|
|
|
// A feed row for a transmission the store does not hold advances the cursor
|
|
// and is otherwise ignored; the row is read fresh if it is ever loaded.
|
|
func TestTxRewriteFeedSkipsUnloadedTransmission(t *testing.T) {
|
|
db, w := txRewriteFixture(t, true)
|
|
s := NewPacketStore(db, &PacketStoreConfig{})
|
|
if err := s.Load(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := w.Exec(`INSERT INTO tx_rewrite_feed(tx_id) VALUES(999)`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.pollTxRewrites(500); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if s.byTxID[999] != nil || s.txRewriteCursor != 1 {
|
|
t.Fatalf("unloaded tx: present=%v cursor=%d", s.byTxID[999] != nil, s.txRewriteCursor)
|
|
}
|
|
}
|