diff --git a/cmd/ingestor/db.go b/cmd/ingestor/db.go index 3d2a7897..bd67c8c1 100644 --- a/cmd/ingestor/db.go +++ b/cmd/ingestor/db.go @@ -28,6 +28,9 @@ type DBStats struct { ObserverUpserts atomic.Int64 WriteErrors atomic.Int64 SignatureDrops atomic.Int64 + // RelayTouches counts nodes.last_seen refreshes driven by relay + // participation rather than an ADVERT (#1598). + RelayTouches atomic.Int64 // WALCommits tracks every successful tx.Commit() that may have flushed // WAL pages. WALCommits atomic.Int64 @@ -79,6 +82,7 @@ type Store struct { stmtGetObserverRowid *sql.Stmt stmtUpdateObserverLastSeen *sql.Stmt stmtUpdateNodeTelemetry *sql.Stmt + stmtTouchNodeLastSeen *sql.Stmt stmtUpsertMetrics *sql.Stmt sampleIntervalSec int @@ -93,8 +97,26 @@ type Store struct { // by the context-aware resolver (#1560). Rebuilt on startup and // once per neighbor-edges builder tick (60s). neighborGraph neighborGraphHolder + + // relayTouched is the debounce map for touchRelayNodesLocked: + // pubkey -> rxTime of the last last_seen write (#1598). Guarded by + // writerMu, which InsertTransmission holds for its whole body. + relayTouched map[string]time.Time } +// relayTouchDebounce is the minimum interval between two last_seen writes +// for the same relay node. A backbone repeater appears in thousands of +// paths per hour; without this the ingest path would issue one UPDATE per +// observation for no added freshness. +const relayTouchDebounce = 5 * time.Minute + +// relayTouchedMaxEntries caps the debounce map. One entry per node ever +// seen relaying — ~10^3-10^4 on real deployments — but a long-lived +// process on a large mesh should not grow it without bound. On overflow +// we drop entries older than two debounce windows, which can only cause +// an extra UPDATE, never a missed one. +const relayTouchedMaxEntries = 50000 + // OpenStore opens or creates a SQLite DB at the given path, applying the // v3 schema that is compatible with the Node.js server. func OpenStore(dbPath string) (*Store, error) { @@ -779,6 +801,12 @@ func (s *Store) prepareStatements() error { // backfill completes. Recorded in _migrations under // "tx_last_seen_backfill_v1". // PREFLIGHT: async=true reason="prepared-statement row-level UPDATE BY PRIMARY KEY (transmissions.id) — single-row touch per observation, indexed by PK, constant-time at any scale. Not a migration." + s.stmtTouchNodeLastSeen, err = s.db.Prepare( + "UPDATE nodes SET last_seen = ? WHERE public_key = ? AND (last_seen IS NULL OR last_seen < ?)") + if err != nil { + return fmt.Errorf("preparing touch node last_seen: %w", err) + } + s.stmtBumpTxLastSeen, err = s.db.Prepare("UPDATE transmissions SET last_seen = ? WHERE id = ? AND last_seen < ?") if err != nil { return err @@ -990,6 +1018,10 @@ func (s *Store) InsertTransmission(data *PacketData) (bool, error) { log.Printf("[db] observation insert (non-fatal): %v", err) } else { s.Stats.ObservationsInserted.Add(1) + // #1598: a resolved hop proves the node was forwarding traffic at + // rxTime. Refresh its last_seen so staleness/eviction logic sees + // relay activity, not just ADVERTs. + s.touchRelayNodesLocked(resolvedPubkeys(resolved), rxTime) // #1690: bump transmissions.last_seen so cold-load can filter on // effective recency. Conditional `last_seen < ?` so we never go // backwards on out-of-order ingest. @@ -1508,6 +1540,87 @@ func (s *Store) LogStats() { ) } +// touchRelayNodesLocked refreshes nodes.last_seen for nodes observed as +// relay hops, so a node that forwards traffic stays fresh even when it +// adverts rarely or not at all. +// +// MUST be called with writerMu held. InsertTransmission holds it for its +// entire body, so the debounce map needs no lock of its own; the name +// carries the requirement for future callers. +// +// Ownership (#1283/#1287/#1289): nodes is written by the ingestor only. +// The server opens SQLite mode=ro; its former touchRelayLastSeen has been +// failing on every call since that refactor and is removed in this change. +// cmd/server/readonly_invariant_test.go now guards against reintroduction. +// +// Callers pass the resolved pubkeys already computed for +// observations.resolved_path (#1547/#1560) — only unambiguously resolved +// hops reach this function, so a 1-byte prefix collision cannot keep a +// silent node alive. resolvedPubkeys already dedups, so no second pass here. +// +// Never inserts: the UPDATE matches an existing row or does nothing. +// Never rewinds: the last_seen guard makes out-of-order ingest a no-op. +// UpsertNode's ON CONFLICT clause is monotonic in the same direction +// (MAX(MIN(last_seen, ingestNow), rxTime) reduces to MAX(last_seen, rxTime) +// for any non-future stored value), so an ADVERT cannot undo a touch. +func (s *Store) touchRelayNodesLocked(pubkeys []string, rxTime string) { + if len(pubkeys) == 0 || s.stmtTouchNodeLastSeen == nil { + return + } + // Reject unparsable timestamps rather than writing them into the node + // directory. Callers hand us the observation rxTime, which comes off + // the wire and is not guaranteed well-formed. + ts, err := time.Parse(time.RFC3339, rxTime) + if err != nil { + return + } + // Same layout UpsertNode uses for last_seen, so the SQL comparisons + // above stay lexicographic-equals-chronological. + stamp := ts.UTC().Format(time.RFC3339) + + if s.relayTouched == nil { + s.relayTouched = make(map[string]time.Time) + } + if len(s.relayTouched) >= relayTouchedMaxEntries { + s.compactRelayTouched(ts) + } + for _, pk := range pubkeys { + if pk == "" { + continue + } + if last, ok := s.relayTouched[pk]; ok && ts.Sub(last) < relayTouchDebounce { + continue + } + res, err := s.stmtTouchNodeLastSeen.Exec(stamp, pk, stamp) + if err != nil { + s.Stats.WriteErrors.Add(1) + continue + } + // Debounce on attempt, not on row match: an unknown pubkey would + // otherwise be retried on every observation it appears in. + // + // Side effect: a pubkey touched while absent from nodes, then + // inserted by an ADVERT moments later, is skipped for the rest of + // the window. Harmless — the ADVERT wrote last_seen itself, and it + // is newer than anything this window would have written. + s.relayTouched[pk] = ts + if n, _ := res.RowsAffected(); n > 0 { + s.Stats.RelayTouches.Add(n) + } + } +} + +// compactRelayTouched drops debounce entries older than two windows. +// Caller must hold writerMu. +func (s *Store) compactRelayTouched(now time.Time) { + cutoff := now.Add(-2 * relayTouchDebounce) + for pk, t := range s.relayTouched { + if t.Before(cutoff) { + delete(s.relayTouched, pk) + } + } +} + // MoveStaleNodes moves nodes not seen in nodeDays to the inactive_nodes table. // Returns the number of nodes moved. func (s *Store) MoveStaleNodes(nodeDays int) (int64, error) { diff --git a/cmd/ingestor/relay_touch_test.go b/cmd/ingestor/relay_touch_test.go new file mode 100644 index 00000000..53fa2348 --- /dev/null +++ b/cmd/ingestor/relay_touch_test.go @@ -0,0 +1,146 @@ +package main + +import ( + "testing" + "time" +) + +// Issue #1598 / #1611 — the relay-aware last_seen touch. +// +// History: the server had touchRelayLastSeen (cmd/server/store.go), which +// called TouchNodeLastSeen → UPDATE nodes SET last_seen. Since #1283/#1289 +// the server opens SQLite with mode=ro, so that UPDATE has failed with +// "attempt to write a readonly database" on every call, and the error was +// discarded at the call site. Net effect: nodes.last_seen has tracked +// ADVERT arrivals only, and relay participation has never refreshed it. +// +// The writer lives in the ingestor, which since #1547 already resolves hop +// prefixes to full pubkeys for observations.resolved_path. These tests pin +// the touch to that existing resolution point. + +// helper: seed a node so the prefix index can resolve a hop to it. +func seedRelayNode(t *testing.T, s *Store, pubkey, name, lastSeen string) { + t.Helper() + if err := s.UpsertNode(pubkey, name, "repeater", nil, nil, lastSeen); err != nil { + t.Fatalf("seed node %s: %v", name, err) + } +} + +func nodeLastSeen(t *testing.T, s *Store, pubkey string) string { + t.Helper() + var ls string + if err := s.db.QueryRow(`SELECT COALESCE(last_seen,'') FROM nodes WHERE public_key=?`, pubkey).Scan(&ls); err != nil { + t.Fatalf("read last_seen for %s: %v", pubkey, err) + } + return ls +} + +// TestTouchRelayNodes_AdvancesLastSeen is the core regression: a node that +// appears as a resolved relay hop must have its last_seen advanced, even +// though it sent no ADVERT of its own. +func TestTouchRelayNodes_AdvancesLastSeen(t *testing.T) { + store := newTestStore(t) + + const relay = "aa11223344556677889900aabbccddeeff00112233445566778899aabbccddee" + seedRelayNode(t, store, relay, "RelayOnly", "2026-07-01T00:00:00Z") + + rxTime := "2026-07-10T12:00:00Z" + store.touchRelayNodesLocked([]string{relay}, rxTime) + + got := nodeLastSeen(t, store, relay) + if got != rxTime { + t.Errorf("last_seen = %q, want %q — relay participation did not refresh the node", got, rxTime) + } + if n := store.Stats.RelayTouches.Load(); n != 1 { + t.Errorf("RelayTouches = %d, want 1", n) + } +} + +// TestTouchRelayNodes_NeverGoesBackwards guards the monotonic invariant. +// Out-of-order ingest (a late observation with an older rxTime) must not +// rewind a node's last_seen. +func TestTouchRelayNodes_NeverGoesBackwards(t *testing.T) { + store := newTestStore(t) + + const relay = "bb11223344556677889900aabbccddeeff00112233445566778899aabbccddee" + seedRelayNode(t, store, relay, "Backbone", "2026-07-10T12:00:00Z") + + store.touchRelayNodesLocked([]string{relay}, "2026-07-09T00:00:00Z") + + if got := nodeLastSeen(t, store, relay); got != "2026-07-10T12:00:00Z" { + t.Errorf("last_seen went backwards: got %q, want 2026-07-10T12:00:00Z", got) + } +} + +// TestTouchRelayNodes_Debounces pins the write-amplification guard. The +// ingest path is hot; a backbone repeater appears in thousands of paths per +// hour and must not produce one UPDATE per observation. +func TestTouchRelayNodes_Debounces(t *testing.T) { + store := newTestStore(t) + + const relay = "cc11223344556677889900aabbccddeeff00112233445566778899aabbccddee" + seedRelayNode(t, store, relay, "Chatty", "2026-07-01T00:00:00Z") + + base := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC) + store.touchRelayNodesLocked([]string{relay}, base.Format(time.RFC3339)) + // Second hit two minutes later — inside the debounce window, no write. + store.touchRelayNodesLocked([]string{relay}, base.Add(2*time.Minute).Format(time.RFC3339)) + + if n := store.Stats.RelayTouches.Load(); n != 1 { + t.Errorf("RelayTouches = %d, want 1 (second touch should be debounced)", n) + } + if got := nodeLastSeen(t, store, relay); got != base.Format(time.RFC3339) { + t.Errorf("last_seen = %q, want %q", got, base.Format(time.RFC3339)) + } + + // Past the debounce window the write goes through again. + later := base.Add(6 * time.Minute) + store.touchRelayNodesLocked([]string{relay}, later.Format(time.RFC3339)) + if n := store.Stats.RelayTouches.Load(); n != 2 { + t.Errorf("RelayTouches = %d, want 2 after debounce window elapsed", n) + } + if got := nodeLastSeen(t, store, relay); got != later.Format(time.RFC3339) { + t.Errorf("last_seen = %q, want %q", got, later.Format(time.RFC3339)) + } +} + +// TestTouchRelayNodes_IgnoresEmptyAndUnknown covers the unresolved-hop case: +// resolvePathWithContext yields nil for ambiguous or unknown prefixes, and +// unknown pubkeys must not create rows. +func TestTouchRelayNodes_IgnoresEmptyAndUnknown(t *testing.T) { + store := newTestStore(t) + + store.touchRelayNodesLocked(nil, "2026-07-10T12:00:00Z") + store.touchRelayNodesLocked([]string{}, "2026-07-10T12:00:00Z") + store.touchRelayNodesLocked([]string{""}, "2026-07-10T12:00:00Z") + store.touchRelayNodesLocked([]string{"ff99887766554433221100ffeeddccbbaa99887766554433221100ffeeddccbb"}, "2026-07-10T12:00:00Z") + + var count int + if err := store.db.QueryRow(`SELECT COUNT(*) FROM nodes`).Scan(&count); err != nil { + t.Fatal(err) + } + if count != 0 { + t.Errorf("nodes count = %d, want 0 — touch must never insert rows", count) + } + if n := store.Stats.RelayTouches.Load(); n != 0 { + t.Errorf("RelayTouches = %d, want 0", n) + } +} + +// TestTouchRelayNodes_MalformedTimestamp: rxTime that does not parse must be +// a no-op rather als writing a garbage timestamp into the node directory. +func TestTouchRelayNodes_MalformedTimestamp(t *testing.T) { + store := newTestStore(t) + + const relay = "dd11223344556677889900aabbccddeeff00112233445566778899aabbccddee" + seedRelayNode(t, store, relay, "Fine", "2026-07-01T00:00:00Z") + + store.touchRelayNodesLocked([]string{relay}, "not-a-timestamp") + + if got := nodeLastSeen(t, store, relay); got != "2026-07-01T00:00:00Z" { + t.Errorf("last_seen = %q, want it unchanged on malformed rxTime", got) + } + if n := store.Stats.RelayTouches.Load(); n != 0 { + t.Errorf("RelayTouches = %d, want 0", n) + } +} diff --git a/cmd/ingestor/resolved_path.go b/cmd/ingestor/resolved_path.go index 5a416836..e6bf9e49 100644 --- a/cmd/ingestor/resolved_path.go +++ b/cmd/ingestor/resolved_path.go @@ -111,3 +111,23 @@ func (s *Store) RefreshPrefixIndex() error { s.prefixIdx.store(idx) return nil } + +// resolvedPubkeys flattens a resolved path to the non-nil pubkeys it +// contains, deduplicating repeats within the same path. Used by the +// relay-aware last_seen touch (#1598); nil entries are unresolved or +// ambiguous hops and are deliberately dropped. +func resolvedPubkeys(rp []*string) []string { + if len(rp) == 0 { + return nil + } + out := make([]string, 0, len(rp)) + seen := make(map[string]bool, len(rp)) + for _, p := range rp { + if p == nil || *p == "" || seen[*p] { + continue + } + seen[*p] = true + out = append(out, *p) + } + return out +} diff --git a/cmd/server/channel_analytics_test.go b/cmd/server/channel_analytics_test.go index 67203f5f..cc1f47ed 100644 --- a/cmd/server/channel_analytics_test.go +++ b/cmd/server/channel_analytics_test.go @@ -30,7 +30,6 @@ func newChannelTestStore(packets []*StoreTx) *PacketStore { spIndex: make(map[string]int), spTxIndex: make(map[string][]*StoreTx), advertPubkeys: make(map[string]int), - lastSeenTouched: make(map[string]time.Time), clockSkew: NewClockSkewEngine(), } ps.byPayloadType[5] = packets diff --git a/cmd/server/db.go b/cmd/server/db.go index f2693894..640ea4ab 100644 --- a/cmd/server/db.go +++ b/cmd/server/db.go @@ -2638,16 +2638,6 @@ func (db *DB) GetMetricsSummary(since string) ([]MetricsSummaryRow, error) { // (PruneOldMetrics / RemoveStaleObservers removed in #1283 — see note // above the MetricsSample type. Ingestor owns these writes now.) -// TouchNodeLastSeen updates last_seen for a node identified by full public key. -// Only updates if the new timestamp is newer than the existing value (or NULL). -// Returns nil even if no rows are affected (node doesn't exist). -func (db *DB) TouchNodeLastSeen(pubkey string, timestamp string) error { - _, err := db.conn.Exec( - "UPDATE nodes SET last_seen = ? WHERE public_key = ? AND (last_seen IS NULL OR last_seen < ?)", - timestamp, pubkey, timestamp, - ) - return err -} // GetDroppedPackets returns recently dropped packets, newest first. func (db *DB) GetDroppedPackets(limit int, observerID, nodePubkey string) ([]map[string]interface{}, error) { diff --git a/cmd/server/readonly_invariant_test.go b/cmd/server/readonly_invariant_test.go index 10ccd5e7..ab1fadef 100644 --- a/cmd/server/readonly_invariant_test.go +++ b/cmd/server/readonly_invariant_test.go @@ -29,12 +29,28 @@ func TestServerSourceHasNoCachedRWCalls(t *testing.T) { regexp.MustCompile(`\bcachedRW\s*\(`), regexp.MustCompile(`mode=rw`), regexp.MustCompile(`sql\.Open\([^)]*\?[^)]*_journal_mode=WAL[^)]*\)`), - // #1324 follow-up: PR #903's persistMultibyteCapability moved - // to cmd/ingestor — the server may NEVER UPDATE these columns - // (it opens mode=ro since #1289). Server publishes a snapshot - // file via internal/mbcapqueue; the ingestor applies it. - regexp.MustCompile(`UPDATE\s+nodes\s+SET\s+multibyte_`), - regexp.MustCompile(`UPDATE\s+inactive_nodes\s+SET\s+multibyte_`), + // The node directory is ingestor-owned (#1283/#1287); the + // server opens mode=ro since #1289 and may never write it. + // + // This deliberately matches ANY column rather than naming + // them. The column-specific form is what let #1598 through: + // #1324 added `UPDATE nodes SET multibyte_` after relocating + // PR #903's writer, then touchRelayLastSeen introduced a + // second write shape (`SET last_seen`) that the grep did not + // cover. It failed on every call for months with the error + // discarded at the call site, so nodes.last_seen silently + // degraded into an advert-age proxy. Enumerating shapes does + // not scale — forbid the table instead. + // + // Writers live in cmd/ingestor: Store.TouchRelayNodes (#1598) + // and RunMultibyteCapPersist (#1324, fed by a snapshot the + // server publishes via internal/mbcapqueue). + // Shapes are normalised before matching (see nodeTableWritePattern): + // optional OR-conflict clause, optional quoting, optional alias. + nodeTableWritePattern(`UPDATE(\s+OR\s+\w+)?`, `SET`), + nodeTableWritePattern(`INSERT\s+(OR\s+\w+\s+)?INTO`, ``), + nodeTableWritePattern(`REPLACE\s+INTO`, ``), + nodeTableWritePattern(`DELETE\s+FROM`, ``), regexp.MustCompile(`\bpersistMultibyteCapability\s*\(`), regexp.MustCompile(`\bmaybePersistMultibyteCapability\s*\(`), } @@ -164,3 +180,33 @@ func TestPacketStoreHasNoMultibytePersistMethods(t *testing.T) { } } } + +// nodeTableWritePattern builds a matcher for DML against the +// ingestor-owned node directory. verb is the leading keyword(s); trailer +// is what must follow the table name (e.g. SET for UPDATE), or empty. +// +// Covers the shapes a plain `UPDATE nodes SET` regex misses: +// +// UPDATE OR REPLACE nodes SET ... +// UPDATE "nodes" SET ... / `nodes` / [nodes] +// UPDATE nodes AS n SET ... +// REPLACE INTO nodes ... +// +// Residual gap, stated rather than papered over: SQL assembled at +// runtime (fmt.Sprintf("UPDATE %s SET ...", tbl)) cannot be caught by +// source grepping. That is what TestServerDBConnIsReadOnly and +// TestServerDBHasNoWriteMethods are for — the handle physically cannot +// write and the write helpers do not exist on the type. This test is the +// cheap first line that names the offending file and line; those two are +// the structural backstop. +func nodeTableWritePattern(verb, trailer string) *regexp.Regexp { + const table = "[\"`\\[]?(nodes|inactive_nodes)[\"`\\]]?" + const alias = `(\s+(AS\s+)?[a-z]\w*)?` + expr := `(?i)` + verb + `\s+` + table + alias + if trailer != "" { + expr += `\s+` + trailer + } else { + expr += `\b` + } + return regexp.MustCompile(expr) +} diff --git a/cmd/server/resolved_index_test.go b/cmd/server/resolved_index_test.go index e27e35fa..30f2669d 100644 --- a/cmd/server/resolved_index_test.go +++ b/cmd/server/resolved_index_test.go @@ -221,28 +221,6 @@ func TestAddToByNode_WithoutResolvedPathField(t *testing.T) { } } -// TestTouchRelayLastSeen_WithoutResolvedPathField verifies relay last_seen is -// still updated via explicit pubkey list. -func TestTouchRelayLastSeen_WithoutResolvedPathField(t *testing.T) { - db := setupTestDB(t) - defer db.Close() - - db.conn.Exec("INSERT INTO nodes (public_key, name, role) VALUES (?, ?, ?)", "relay_pk", "R1", "REPEATER") - - s := &PacketStore{ - db: db, - lastSeenTouched: make(map[string]time.Time), - } - - s.touchRelayLastSeen([]string{"relay_pk"}, time.Now()) - - var lastSeen sql.NullString - db.conn.QueryRow("SELECT last_seen FROM nodes WHERE public_key = ?", "relay_pk").Scan(&lastSeen) - if !lastSeen.Valid { - t.Fatal("expected last_seen to be set") - } -} - // TestWebSocketBroadcast_IncludesResolvedPath verifies broadcast maps carry resolved_path // from the decode-window, not from struct fields. func TestWebSocketBroadcast_IncludesResolvedPath(t *testing.T) { @@ -662,30 +640,6 @@ func TestDecodeWindow_LockHoldTimeBounded(t *testing.T) { // --- Integration / regression tests --- -// TestRepeaterLiveness_StillAccurate verifies touchRelayLastSeen still works -// with the new pubkey-list interface. -func TestRepeaterLiveness_StillAccurate(t *testing.T) { - db := setupTestDB(t) - defer db.Close() - - db.conn.Exec("INSERT INTO nodes (public_key, name, role) VALUES (?, ?, ?)", "r1", "Relay1", "REPEATER") - db.conn.Exec("INSERT INTO nodes (public_key, name, role) VALUES (?, ?, ?)", "r2", "Relay2", "REPEATER") - - s := &PacketStore{ - db: db, - lastSeenTouched: make(map[string]time.Time), - } - - s.touchRelayLastSeen([]string{"r1", "r2"}, time.Now()) - - var ls1, ls2 sql.NullString - db.conn.QueryRow("SELECT last_seen FROM nodes WHERE public_key = ?", "r1").Scan(&ls1) - db.conn.QueryRow("SELECT last_seen FROM nodes WHERE public_key = ?", "r2").Scan(&ls2) - if !ls1.Valid || !ls2.Valid { - t.Error("expected both relays to have last_seen updated") - } -} - // --- Benchmarks --- // BenchmarkResolvedPubkeyIndex_Memory measures index memory at different cardinalities. diff --git a/cmd/server/store.go b/cmd/server/store.go index 8c6d4f66..fd33b7d1 100644 --- a/cmd/server/store.go +++ b/cmd/server/store.go @@ -364,9 +364,6 @@ type PacketStore struct { // Updated incrementally during Load/Ingest/Evict — avoids JSON parsing in GetPerfStoreStats. advertPubkeys map[string]int // pubkey → number of advert packets referencing it - // Debounce map for touchRelayLastSeen: pubkey → last time we wrote last_seen to DB. - // Limits DB writes to at most 1 per node per 5 minutes. - lastSeenTouched map[string]time.Time // Resolved path membership index: xxhash → []txID (forward) and txID → []hashes (reverse). // Replaces per-StoreTx/StoreObs ResolvedPath []*string field (#800). @@ -669,7 +666,6 @@ func NewPacketStore(db *DB, cfg *PacketStoreConfig, cacheTTLs ...map[string]inte spIndex: make(map[string]int, 4096), spTxIndex: make(map[string][]*StoreTx, 4096), advertPubkeys: make(map[string]int), - lastSeenTouched: make(map[string]time.Time), clockSkew: NewClockSkewEngine(), useResolvedPathIndex: true, areaNodeCache: make(map[string]map[string]bool), @@ -1745,31 +1741,6 @@ func (s *PacketStore) addToByNode(tx *StoreTx, pubkey string) bool { return isNew } -// touchRelayLastSeen updates last_seen in the DB for relay nodes that appear -// in resolved paths. Debounced to at most 1 write per node per 5 minutes. -// resolvedPubkeys is the pre-extracted list from the decode window. -// Must be called under s.mu write lock (reads/writes lastSeenTouched). -func (s *PacketStore) touchRelayLastSeen(resolvedPubkeys []string, now time.Time) { - if s.db == nil || len(resolvedPubkeys) == 0 { - return - } - const debounceInterval = 5 * time.Minute - - ts := now.UTC().Format(time.RFC3339) - seen := make(map[string]bool, len(resolvedPubkeys)) - for _, pk := range resolvedPubkeys { - if pk == "" || seen[pk] { - continue - } - seen[pk] = true - if last, ok := s.lastSeenTouched[pk]; ok && now.Sub(last) < debounceInterval { - continue - } - if err := s.db.TouchNodeLastSeen(pk, ts); err == nil { - s.lastSeenTouched[pk] = now - } - } -} // trackAdvertPubkey increments the advertPubkeys refcount for ADVERT packets. // Must be called under s.mu write lock. @@ -2731,10 +2702,8 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac // carmack #1) — one Load per ingest call, not one per row. cachedGraph := s.graph.Load() - // Decode-window tracking: resolved pubkeys per-tx for touchRelayLastSeen, - // and resolved paths per-obs for broadcast/persist. - var broadcastRP map[int][]*string // obsID → resolved path (for broadcast/persist) - allResolvedPKs := make(map[int][]string) // txID → all resolved pubkeys (for touchRelayLastSeen) + // Decode-window tracking: resolved paths per-obs for broadcast/persist. + var broadcastRP map[int][]*string // obsID → resolved path (for broadcast/persist) hopsSeen := make(map[string]bool) // reused across observations; cleared per use @@ -2825,11 +2794,6 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac } broadcastRP[*r.obsID] = rpForBroadcast } - // Collect resolved pubkeys per-tx for touchRelayLastSeen - if len(resolvedPubkeys) > 0 { - allResolvedPKs[r.txID] = append(allResolvedPKs[r.txID], resolvedPubkeys...) - } - tx.Observations = append(tx.Observations, obs) tx.obsKeys[dk] = true if obs.ObserverID != "" && !tx.observerSet[obs.ObserverID] { @@ -2857,14 +2821,6 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac pickBestObservation(tx) } - // Phase 2 of #660: update last_seen in DB for relay nodes seen in resolved_path. - now := time.Now() - for txID := range broadcastTxs { - if pks, ok := allResolvedPKs[txID]; ok { - s.touchRelayLastSeen(pks, now) - } - } - // Incrementally update precomputed subpath index with new transmissions for _, tx := range broadcastTxs { if addTxToSubpathIndexFull(s.spIndex, s.spTxIndex, tx) { diff --git a/cmd/server/touch_last_seen_test.go b/cmd/server/touch_last_seen_test.go deleted file mode 100644 index 604ee74c..00000000 --- a/cmd/server/touch_last_seen_test.go +++ /dev/null @@ -1,126 +0,0 @@ -package main - -import ( - "database/sql" - "testing" - "time" - - _ "modernc.org/sqlite" -) - -func TestTouchNodeLastSeen_UpdatesDB(t *testing.T) { - db := setupTestDB(t) - defer db.Close() - - // Insert a node with no last_seen - db.conn.Exec("INSERT INTO nodes (public_key, name, role) VALUES (?, ?, ?)", "abc123", "relay1", "REPEATER") - - err := db.TouchNodeLastSeen("abc123", "2026-04-12T04:00:00Z") - if err != nil { - t.Fatalf("TouchNodeLastSeen returned error: %v", err) - } - - var lastSeen sql.NullString - db.conn.QueryRow("SELECT last_seen FROM nodes WHERE public_key = ?", "abc123").Scan(&lastSeen) - if !lastSeen.Valid || lastSeen.String != "2026-04-12T04:00:00Z" { - t.Fatalf("expected last_seen=2026-04-12T04:00:00Z, got %v", lastSeen) - } -} - -func TestTouchNodeLastSeen_DoesNotGoBackwards(t *testing.T) { - db := setupTestDB(t) - defer db.Close() - - db.conn.Exec("INSERT INTO nodes (public_key, name, role, last_seen) VALUES (?, ?, ?, ?)", - "abc123", "relay1", "REPEATER", "2026-04-12T05:00:00Z") - - // Try to set an older timestamp - err := db.TouchNodeLastSeen("abc123", "2026-04-12T04:00:00Z") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - var lastSeen string - db.conn.QueryRow("SELECT last_seen FROM nodes WHERE public_key = ?", "abc123").Scan(&lastSeen) - if lastSeen != "2026-04-12T05:00:00Z" { - t.Fatalf("last_seen went backwards: got %s", lastSeen) - } -} - -func TestTouchNodeLastSeen_NonExistentNode(t *testing.T) { - db := setupTestDB(t) - defer db.Close() - - // Should not error for non-existent node - err := db.TouchNodeLastSeen("nonexistent", "2026-04-12T04:00:00Z") - if err != nil { - t.Fatalf("unexpected error for non-existent node: %v", err) - } -} - -func TestTouchRelayLastSeen_Debouncing(t *testing.T) { - db := setupTestDB(t) - defer db.Close() - - db.conn.Exec("INSERT INTO nodes (public_key, name, role) VALUES (?, ?, ?)", "relay1", "R1", "REPEATER") - - s := &PacketStore{ - db: db, - lastSeenTouched: make(map[string]time.Time), - } - - // After #800, touchRelayLastSeen takes a []string of pubkeys (from decode-window) - pks := []string{"relay1"} - - now := time.Now() - s.touchRelayLastSeen(pks, now) - - // Verify it was written - var lastSeen sql.NullString - db.conn.QueryRow("SELECT last_seen FROM nodes WHERE public_key = ?", "relay1").Scan(&lastSeen) - if !lastSeen.Valid { - t.Fatal("expected last_seen to be set after first touch") - } - - // Reset last_seen to check debounce prevents second write - db.conn.Exec("UPDATE nodes SET last_seen = NULL WHERE public_key = ?", "relay1") - - // Call again within 5 minutes — should be debounced (no write) - s.touchRelayLastSeen(pks, now.Add(2*time.Minute)) - - db.conn.QueryRow("SELECT last_seen FROM nodes WHERE public_key = ?", "relay1").Scan(&lastSeen) - if lastSeen.Valid { - t.Fatal("expected debounce to prevent second write within 5 minutes") - } - - // Call after 5 minutes — should write again - s.touchRelayLastSeen(pks, now.Add(6*time.Minute)) - db.conn.QueryRow("SELECT last_seen FROM nodes WHERE public_key = ?", "relay1").Scan(&lastSeen) - if !lastSeen.Valid { - t.Fatal("expected write after debounce interval expired") - } -} - -func TestTouchRelayLastSeen_SkipsEmptyPubkeys(t *testing.T) { - db := setupTestDB(t) - defer db.Close() - - s := &PacketStore{ - db: db, - lastSeenTouched: make(map[string]time.Time), - } - - // Empty pubkeys — should not panic or error - s.touchRelayLastSeen([]string{}, time.Now()) - s.touchRelayLastSeen(nil, time.Now()) -} - -func TestTouchRelayLastSeen_NilDB(t *testing.T) { - s := &PacketStore{ - db: nil, - lastSeenTouched: make(map[string]time.Time), - } - - // Should not panic with nil db - s.touchRelayLastSeen([]string{"abc"}, time.Now()) -}