From e6c6f52dfe59788c6bd257afd6e493bafb4d153e Mon Sep 17 00:00:00 2001 From: isink17 <39876158+isink17@users.noreply.github.com> Date: Thu, 23 Apr 2026 11:47:07 +0200 Subject: [PATCH 1/2] store: narrow ResolveEdgesForNames candidates with indexed exact-match prefilter --- .../012_edges_unresolved_dst_name_idx.sql | 6 ++ internal/store/store.go | 89 +++++++++++++++---- 2 files changed, 80 insertions(+), 15 deletions(-) create mode 100644 internal/store/schema/012_edges_unresolved_dst_name_idx.sql diff --git a/internal/store/schema/012_edges_unresolved_dst_name_idx.sql b/internal/store/schema/012_edges_unresolved_dst_name_idx.sql new file mode 100644 index 0000000..e765b48 --- /dev/null +++ b/internal/store/schema/012_edges_unresolved_dst_name_idx.sql @@ -0,0 +1,6 @@ +-- Helps targeted cross-file edge resolution (ResolveEdgesForNames) quickly find +-- exact dst_name matches without scanning all unresolved edges. +CREATE INDEX IF NOT EXISTS idx_edges_repo_unresolved_dst_name +ON edges(repo_id, dst_name) +WHERE dst_symbol_id IS NULL AND dst_name != ''; + diff --git a/internal/store/store.go b/internal/store/store.go index 6ce8718..251de9e 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -1851,44 +1851,103 @@ func (s *Store) ResolveEdgesForNames(ctx context.Context, repoID int64, names [] return 0, nil } - // Query shape: do a single unresolved-edge scan, then filter in Go. - // This avoids repeated SQL queries with many OR/L LIKE clauses (which are - // difficult for SQLite to optimize with indexes). + nameSet := make(map[string]struct{}, len(unique)) + for _, name := range unique { + nameSet[name] = struct{}{} + } + + // Candidate selection: + // + // 1) Use indexed exact matches for dst_name = (covers the common case + // where unresolved edges reference the simple symbol name directly). + // 2) Only if needed, scan unresolved edges that contain a '.' and filter in Go + // for suffix matches (dst_name ends with "."). + // + // This avoids scanning the full unresolved-edge set on large repos where many + // unresolved edges have simple (non-qualified) dst_name values. + targetByID := make(map[int64]edgeTarget, 64) + + const maxVars = 999 // SQLite default + const chunkSize = 900 + for start := 0; start < len(unique); start += chunkSize { + end := min(start+chunkSize, len(unique)) + chunk := unique[start:end] + + // args: repoID + N names + if len(chunk)+1 > maxVars { + // Defensive: chunkSize should keep us under maxVars. + chunk = chunk[:maxVars-1] + } + + placeholders := strings.TrimRight(strings.Repeat("?,", len(chunk)), ",") + query := `SELECT id, dst_name FROM edges WHERE repo_id = ? AND dst_symbol_id IS NULL AND dst_name IN (` + placeholders + `)` + args := make([]any, 1+len(chunk)) + args[0] = repoID + for i, name := range chunk { + args[i+1] = name + } + + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return 0, err + } + for rows.Next() { + var id int64 + var dstName string + if err := rows.Scan(&id, &dstName); err != nil { + _ = rows.Close() + return 0, err + } + targetByID[id] = edgeTarget{edgeID: id, dstName: dstName} + } + if err := rows.Err(); err != nil { + _ = rows.Close() + return 0, err + } + if err := rows.Close(); err != nil { + return 0, err + } + } + + // Suffix matching requires looking at qualified dst_name values. Keep this + // as a single pass over the qualified unresolved set (no repeated LIKE + // queries), but avoid scanning simple dst_name values entirely. rows, err := s.db.QueryContext(ctx, ` SELECT id, dst_name FROM edges - WHERE repo_id = ? AND dst_symbol_id IS NULL AND dst_name != '' + WHERE repo_id = ? AND dst_symbol_id IS NULL AND dst_name != '' AND instr(dst_name, '.') > 0 `, repoID) if err != nil { return 0, err } - defer rows.Close() - - nameSet := make(map[string]struct{}, len(unique)) - for _, name := range unique { - nameSet[name] = struct{}{} - } - - targets := make([]edgeTarget, 0, 64) for rows.Next() { var id int64 var dstName string if err := rows.Scan(&id, &dstName); err != nil { + _ = rows.Close() return 0, err } - if _, ok := nameSet[dstName]; ok { - targets = append(targets, edgeTarget{edgeID: id, dstName: dstName}) + if _, ok := targetByID[id]; ok { continue } if dot := strings.LastIndexByte(dstName, '.'); dot >= 0 && dot+1 < len(dstName) { if _, ok := nameSet[dstName[dot+1:]]; ok { - targets = append(targets, edgeTarget{edgeID: id, dstName: dstName}) + targetByID[id] = edgeTarget{edgeID: id, dstName: dstName} } } } if err := rows.Err(); err != nil { + _ = rows.Close() return 0, err } + if err := rows.Close(); err != nil { + return 0, err + } + + targets := make([]edgeTarget, 0, len(targetByID)) + for _, target := range targetByID { + targets = append(targets, target) + } if err := s.resolveEdgeTargets(ctx, repoID, targets); err != nil { return 0, err } From 8f20f8b52101a65f00a4834c03538ef99f6b64b4 Mon Sep 17 00:00:00 2001 From: isink17 <39876158+isink17@users.noreply.github.com> Date: Thu, 23 Apr 2026 11:59:31 +0200 Subject: [PATCH 2/2] store: drop redundant unresolved dst_name index; reuse sqlite batch constants --- .../schema/012_edges_unresolved_dst_name_idx.sql | 6 ------ internal/store/store.go | 13 +++---------- 2 files changed, 3 insertions(+), 16 deletions(-) delete mode 100644 internal/store/schema/012_edges_unresolved_dst_name_idx.sql diff --git a/internal/store/schema/012_edges_unresolved_dst_name_idx.sql b/internal/store/schema/012_edges_unresolved_dst_name_idx.sql deleted file mode 100644 index e765b48..0000000 --- a/internal/store/schema/012_edges_unresolved_dst_name_idx.sql +++ /dev/null @@ -1,6 +0,0 @@ --- Helps targeted cross-file edge resolution (ResolveEdgesForNames) quickly find --- exact dst_name matches without scanning all unresolved edges. -CREATE INDEX IF NOT EXISTS idx_edges_repo_unresolved_dst_name -ON edges(repo_id, dst_name) -WHERE dst_symbol_id IS NULL AND dst_name != ''; - diff --git a/internal/store/store.go b/internal/store/store.go index 251de9e..7cd6458 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -1867,18 +1867,11 @@ func (s *Store) ResolveEdgesForNames(ctx context.Context, repoID int64, names [] // unresolved edges have simple (non-qualified) dst_name values. targetByID := make(map[int64]edgeTarget, 64) - const maxVars = 999 // SQLite default - const chunkSize = 900 - for start := 0; start < len(unique); start += chunkSize { - end := min(start+chunkSize, len(unique)) + // Keep under sqliteDefaultMaxVariables (repoID + N names). + for start := 0; start < len(unique); start += sqliteInClauseBatchSize { + end := min(start+sqliteInClauseBatchSize, len(unique)) chunk := unique[start:end] - // args: repoID + N names - if len(chunk)+1 > maxVars { - // Defensive: chunkSize should keep us under maxVars. - chunk = chunk[:maxVars-1] - } - placeholders := strings.TrimRight(strings.Repeat("?,", len(chunk)), ",") query := `SELECT id, dst_name FROM edges WHERE repo_id = ? AND dst_symbol_id IS NULL AND dst_name IN (` + placeholders + `)` args := make([]any, 1+len(chunk))