diff --git a/cmd/server/node_reach.go b/cmd/server/node_reach.go index 0cfa9789..3ec2567f 100644 --- a/cmd/server/node_reach.go +++ b/cmd/server/node_reach.go @@ -735,6 +735,26 @@ func (s *Server) getDegreeSnapshot(ctx context.Context) *degreeSnapshot { return snap } +// reachScanSQL is the reach scan for nTokens path tokens. Arguments: the +// window start (epoch seconds), one LIKE pattern per token, the row limit. +// +// CROSS JOIN fixes the join order in SQLite: observations is the outer loop, +// read through idx_observations_timestamp for the window. With a plain JOIN +// the planner scanned every transmission and probed observations per row +// (3.2-3.3 s against 1.8-2.0 s on an 18M-observation database, #2146). +func reachScanSQL(nTokens int) string { + likes := make([]string, nTokens) + for i := range likes { + likes[i] = "o.path_json LIKE ?" + } + return `SELECT LOWER(COALESCE(obs.id,'')), LOWER(COALESCE(t.from_pubkey,'')), COALESCE(t.payload_type,0), o.path_json, o.snr + FROM observations o + CROSS JOIN transmissions t ON t.id = o.transmission_id + LEFT JOIN observers obs ON obs.rowid = o.observer_idx + WHERE o.timestamp >= ? AND (` + strings.Join(likes, " OR ") + `) + LIMIT ?` +} + // scanReachRows reads windowed observations whose path contains any reliable // token, with the originator + observer + snr needed for attribution. Observer // id and originator pubkey are lowercased in SQL (not per row), the path slice @@ -749,7 +769,6 @@ func (s *Server) scanReachRows(ctx context.Context, tokens map[string]bool, sinc if len(tokens) == 0 { return nil, nil // defensive: an empty LIKE chain would render `AND ()` (SQL error) } - likes := make([]string, 0, len(tokens)) args := []interface{}{sinceEpoch} // Sort tokens so the generated SQL text is byte-stable across requests // with the same token set — preserves the driver's prepared-statement @@ -760,17 +779,10 @@ func (s *Server) scanReachRows(ctx context.Context, tokens map[string]bool, sinc } sort.Strings(toks) for _, tok := range toks { - likes = append(likes, "o.path_json LIKE ?") args = append(args, "%\""+tok+"\"%") } - q := `SELECT LOWER(COALESCE(obs.id,'')), LOWER(COALESCE(t.from_pubkey,'')), COALESCE(t.payload_type,0), o.path_json, o.snr - FROM observations o - JOIN transmissions t ON t.id = o.transmission_id - LEFT JOIN observers obs ON obs.rowid = o.observer_idx - WHERE o.timestamp >= ? AND (` + strings.Join(likes, " OR ") + `) - LIMIT ?` args = append(args, reachScanRowLimit) - rows, err := s.db.conn.QueryContext(ctx, q, args...) + rows, err := s.db.conn.QueryContext(ctx, reachScanSQL(len(toks)), args...) if err != nil { log.Printf("[reach] scan query failed: %v", err) return nil, err diff --git a/cmd/server/node_reach_test.go b/cmd/server/node_reach_test.go index 55938919..fef55896 100644 --- a/cmd/server/node_reach_test.go +++ b/cmd/server/node_reach_test.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "strconv" + "strings" "testing" _ "github.com/mattn/go-sqlite3" @@ -289,3 +290,52 @@ func TestScanReachRows_DecodesRows(t *testing.T) { t.Fatalf("snr not decoded: valid=%v val=%v", got.snrValid, got.snr) } } + +// #2146: the reach scan must be driven from the observations time window. +// With the default join order SQLite picked SCAN transmissions (every row) +// and probed observations per transmission: 3.2-3.3 s on an 18M-observation +// database against 1.8-2.0 s when it starts from idx_observations_timestamp. +func TestReachScanStartsFromObservationsTimeIndex(t *testing.T) { + db := newReachScanTestDB(t) + // The planner's choice depends on sqlite_stat1. These are the statistics + // ANALYZE produced on that database (18.3M observations, 1.5M + // transmissions), loaded so the test plans like production does. + for _, s := range []string{ + `CREATE INDEX idx_observations_timestamp ON observations(timestamp)`, + `CREATE INDEX idx_observations_tx_ts ON observations(transmission_id, timestamp)`, + `ANALYZE`, + `DELETE FROM sqlite_stat1`, + `INSERT INTO sqlite_stat1 (tbl, idx, stat) VALUES + ('observations', 'idx_observations_timestamp', '18270402 4'), + ('observations', 'idx_observations_tx_ts', '18270402 11 3'), + ('observations', NULL, '18270402'), + ('transmissions', NULL, '1517599'), + ('observers', NULL, '88')`, + `ANALYZE sqlite_master`, + } { + if _, err := db.conn.Exec(s); err != nil { + t.Fatal(err) + } + } + args := []interface{}{int64(0), `%"01FA"%`, `%"01FA00"%`, 10} + rows, err := db.conn.Query("EXPLAIN QUERY PLAN "+reachScanSQL(2), args...) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + var steps []string + for rows.Next() { + var id, parent, notused int + var detail string + if err := rows.Scan(&id, &parent, ¬used, &detail); err != nil { + t.Fatal(err) + } + steps = append(steps, detail) + } + if len(steps) == 0 { + t.Fatal("no query plan") + } + if !strings.Contains(steps[0], "o USING INDEX idx_observations_timestamp") { + t.Fatalf("reach scan must start from observations by timestamp; plan: %q", steps) + } +}