a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
4010 lines
120 KiB
Go
4010 lines
120 KiB
Go
package graph
|
||
|
||
import (
|
||
"iter"
|
||
"math/bits"
|
||
"os"
|
||
"runtime"
|
||
"slices"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"sync/atomic"
|
||
)
|
||
|
||
// GraphStats holds summary counts of the graph contents.
|
||
type GraphStats struct {
|
||
TotalNodes int `json:"total_nodes"`
|
||
TotalEdges int `json:"total_edges"`
|
||
ByKind map[string]int `json:"by_kind"`
|
||
ByLanguage map[string]int `json:"by_language"`
|
||
}
|
||
|
||
// The graph's write fan-out is sharded: each shard owns a disjoint
|
||
// slice of node IDs (by FNV hash) and its own RWMutex, so parallel
|
||
// indexers writing distinct nodes don't contend for a single lock. The
|
||
// shard count is chosen once per Graph at construction (see
|
||
// defaultShardCount) rather than fixed at compile time, so many-core
|
||
// machines get more fan-out while small machines keep the historical
|
||
// count. It is always a power of two: shardIdx masks an ID's hash with
|
||
// count-1, which equals the modulo only for powers of two.
|
||
//
|
||
// Trade-off: more shards cut write contention but make cross-shard
|
||
// operations (AddEdge across shards, plus exhaustive reads that walk
|
||
// every shard) take more locks. To avoid deadlock we always acquire
|
||
// shards in ascending index order.
|
||
const (
|
||
// shardCountEnv pins the shard fan-out for a new graph, overriding
|
||
// the CPU-derived default. Useful for benchmarks and tests. The
|
||
// value is coerced to a power of two within [minShards, maxShards].
|
||
shardCountEnv = "GORTEX_GRAPH_SHARDS"
|
||
|
||
// minShards / maxShards bound every shard count — derived or
|
||
// explicitly overridden — to a sane, power-of-two range. minShards
|
||
// must stay >= 1 so the shardMask (count-1) is a valid mask.
|
||
minShards = 1
|
||
maxShards = 512
|
||
|
||
// derivedShardFloor / derivedShardCeiling bound the CPU-derived
|
||
// count. The floor preserves the historical fan-out of 16 on typical
|
||
// (<= 16 logical CPU) machines, so sharded results stay
|
||
// byte-identical there; the ceiling caps per-graph shard overhead on
|
||
// many-core boxes.
|
||
derivedShardFloor = 16
|
||
derivedShardCeiling = 256
|
||
)
|
||
|
||
// edgeKey is the logical identity of an edge — two edges with the same
|
||
// key are considered the same edge and dedup to one entry in the
|
||
// adjacency lists. Line is part of the key because a caller can hold
|
||
// the same target reference at two different call sites and both are
|
||
// real call-graph edges (see `foo(); foo();` in the same function).
|
||
//
|
||
// Both From and To are in the key even though the adjacency lists are
|
||
// keyed by one endpoint each — the other endpoint varies within an
|
||
// inEdges[to] slice (many From → same To) and within an outEdges[from]
|
||
// slice the inverse varies. Without both, property-test graphs with
|
||
// several callers to the same callee and no FilePath/Line distinction
|
||
// would dedup down to one entry and lose cross-caller traversal.
|
||
type edgeKey struct {
|
||
From string
|
||
To string
|
||
Kind EdgeKind
|
||
FilePath string
|
||
Line int
|
||
}
|
||
|
||
func keyOf(e *Edge) edgeKey {
|
||
return edgeKey{From: e.From, To: e.To, Kind: e.Kind, FilePath: e.FilePath, Line: e.Line}
|
||
}
|
||
|
||
// edgeHash is the stored form of an edge's logical identity. A full
|
||
// edgeKey carries four strings plus an int — 72 bytes as a map key or
|
||
// sidecar element. Collapsing it to a 128-bit hash shrinks the
|
||
// outEdgeIdx / inEdgeIdx maps and the outEdgeKeys / inEdgeKeys sidecars
|
||
// by ~4.5×, the single largest line item in cold-warmup resident
|
||
// memory. 128 bits keeps a collision between any two distinct edges in
|
||
// a graph of realistic size out of reach (~1e-25 at 10M edges), so the
|
||
// indexes stay unique-key maps — the dedup and swap-with-last logic is
|
||
// byte-for-byte the same as the edgeKey version, only the key type
|
||
// changes.
|
||
type edgeHash struct{ lo, hi uint64 }
|
||
|
||
const fnvOffset64 = 1469598103934665603
|
||
const fnvPrime64 = 1099511628211
|
||
|
||
// hashEdgeKey computes the 128-bit identity hash of an edge key. Each
|
||
// 64-bit half is an independent FNV-1a pass: distinct seeds plus a
|
||
// reversed field order decorrelate the halves so the pair behaves as a
|
||
// 128-bit value rather than two views of the same 64-bit hash.
|
||
func hashEdgeKey(k edgeKey) edgeHash {
|
||
return edgeHash{
|
||
lo: fnv1aEdge(fnvOffset64, k, false),
|
||
hi: fnv1aEdge(0x9e3779b97f4a7c15, k, true),
|
||
}
|
||
}
|
||
|
||
// fnv1aEdge folds every edgeKey field into one FNV-1a 64-bit hash. A
|
||
// 0xff separator after each string keeps "ab"+"c" distinct from
|
||
// "a"+"bc"; reversed==true walks the string fields back-to-front.
|
||
func fnv1aEdge(seed uint64, k edgeKey, reversed bool) uint64 {
|
||
h := seed
|
||
mix := func(s string) {
|
||
for i := 0; i < len(s); i++ {
|
||
h ^= uint64(s[i])
|
||
h *= fnvPrime64
|
||
}
|
||
h ^= 0xff
|
||
h *= fnvPrime64
|
||
}
|
||
if reversed {
|
||
mix(k.FilePath)
|
||
mix(string(k.Kind))
|
||
mix(k.To)
|
||
mix(k.From)
|
||
} else {
|
||
mix(k.From)
|
||
mix(k.To)
|
||
mix(string(k.Kind))
|
||
mix(k.FilePath)
|
||
}
|
||
h ^= uint64(k.Line)
|
||
h *= fnvPrime64
|
||
return h
|
||
}
|
||
|
||
// hashEdgeIdentity computes the provenance-bearing identity hash of an
|
||
// edge: the logical key folded together with Origin. It reuses the
|
||
// edgeKey FNV machinery — each 64-bit half is hashEdgeKey's
|
||
// corresponding half with the Origin string mixed in as a sixth field
|
||
// (same 0xff-separated FNV-1a fold, same reversed-order decorrelation).
|
||
//
|
||
// This is deliberately distinct from hashEdgeKey: the dedup index
|
||
// (outEdgeIdx / inEdgeIdx) stays keyed on the Origin-free logical key
|
||
// so an Origin upgrade replaces the slot in place rather than creating
|
||
// a parallel edge. The identity hash is the separate value Edge.
|
||
// IdentityHash exposes so a provenance change is observable as a
|
||
// distinct identity.
|
||
func hashEdgeIdentity(k edgeKey, origin string) edgeHash {
|
||
return edgeHash{
|
||
lo: mixOriginInto(fnv1aEdge(fnvOffset64, k, false), origin),
|
||
hi: mixOriginInto(fnv1aEdge(0x9e3779b97f4a7c15, k, true), origin),
|
||
}
|
||
}
|
||
|
||
// mixOriginInto folds the Origin string into an already-computed
|
||
// fnv1aEdge hash with the same 0xff-separated FNV-1a step the key
|
||
// fields use, so Origin is a first-class sixth field of the identity
|
||
// rather than a bolt-on.
|
||
func mixOriginInto(h uint64, origin string) uint64 {
|
||
for i := 0; i < len(origin); i++ {
|
||
h ^= uint64(origin[i])
|
||
h *= fnvPrime64
|
||
}
|
||
h ^= 0xff
|
||
h *= fnvPrime64
|
||
return h
|
||
}
|
||
|
||
// shard is a fragment of the graph's data. Each shard holds the node
|
||
// metadata for the subset of IDs that hash to it, plus the outgoing
|
||
// edges whose source ID is in this shard and the incoming edges whose
|
||
// target ID is in this shard. Secondary indexes (byName, byFile, etc.)
|
||
// inside a shard only contain nodes owned by that shard; aggregate
|
||
// queries iterate every shard and concatenate.
|
||
//
|
||
// Each slice-valued secondary index has a paired "Idx" sidecar that
|
||
// maps an entry's identity to its position in the slice. Two invariants
|
||
// the sidecars enforce:
|
||
//
|
||
// 1. Idempotent writes. AddNode/AddEdge consult the sidecar; duplicate
|
||
// inserts replace the pointer in place instead of appending.
|
||
// Without this, restart paths that load a snapshot and then re-run
|
||
// IndexCtx (which doesn't evict first) silently double every edge
|
||
// and every secondary-index entry. Fixes bug B1 at the write layer,
|
||
// regardless of which call site forgets to evict first.
|
||
// 2. O(1) removal via swap-with-last. Removing a node from a
|
||
// byRepo slice of 50k entries with filterEdge-style scanning was
|
||
// O(N); now it's O(1) — flip the last element into the removed
|
||
// slot and shrink the slice by one. Iteration order changes after a
|
||
// removal, but callers that require stable ordering (snapshot
|
||
// encoding) sort before emitting anyway.
|
||
type shard struct {
|
||
mu sync.RWMutex
|
||
nodes map[string]*Node
|
||
outEdges map[string][]*Edge
|
||
inEdges map[string][]*Edge
|
||
byFile map[string][]*Node
|
||
byName map[string][]*Node
|
||
byQual map[string]*Node
|
||
byRepo map[string][]*Node // repoPrefix → nodes owned by this shard
|
||
|
||
// Sidecar position indexes — see comment on shard. Reads are
|
||
// unchanged (callers still iterate the slices); only writes
|
||
// consult these maps.
|
||
byFileIdx map[string]map[string]int // filePath → id → position
|
||
byNameIdx map[string]map[string]int // name → id → position
|
||
byRepoIdx map[string]map[string]int // repoPrefix → id → position
|
||
outEdgeIdx map[string]map[edgeHash]int // fromID → hash → position
|
||
inEdgeIdx map[string]map[edgeHash]int // toID → hash → position
|
||
|
||
// Reverse-index slices that remember each entry's insertion-time
|
||
// edgeHash, parallel to outEdges / inEdges. Required because keyOf
|
||
// is computed from live Edge fields (To, From, ...) — and the
|
||
// resolver mutates Edge.To when retargeting an unresolved edge.
|
||
// During swap-with-last in removeEdgeFromBucket, computing
|
||
// keyOf(swapped) on a swapped element whose To was just mutated
|
||
// produced a different key than the one it was originally indexed
|
||
// under, leaving a stale sidecar position pointing past the slice
|
||
// (panic: "index out of range [42] with length 41" in
|
||
// addEdgeToBucket on the next insert that collided with the stale
|
||
// key). Storing the original hash here makes the swap update
|
||
// independent of live Edge state.
|
||
outEdgeKeys map[string][]edgeHash // fromID → position → hash
|
||
inEdgeKeys map[string][]edgeHash // toID → position → hash
|
||
|
||
// Running per-repo memory totals maintained under the shard's
|
||
// existing write lock. Reading them out of RepoMemoryEstimate is
|
||
// O(shard count) instead of the O(repo nodes + total edges) walk
|
||
// the function used to do — and AllRepoMemoryEstimates collapses
|
||
// R repo-by-repo queries into one O(R · S) sum. Edges are charged
|
||
// to the source node's RepoPrefix; the shard owning the source
|
||
// edge bucket owns the counter, so accounting matches what the
|
||
// old AllEdges walk attributed.
|
||
repoNodeBytes map[string]uint64
|
||
repoNodeCount map[string]int
|
||
repoEdgeBytes map[string]uint64
|
||
repoEdgeCount map[string]int
|
||
}
|
||
|
||
func newShard() *shard {
|
||
return &shard{
|
||
nodes: make(map[string]*Node),
|
||
outEdges: make(map[string][]*Edge),
|
||
inEdges: make(map[string][]*Edge),
|
||
byFile: make(map[string][]*Node),
|
||
byName: make(map[string][]*Node),
|
||
byQual: make(map[string]*Node),
|
||
byRepo: make(map[string][]*Node),
|
||
byFileIdx: make(map[string]map[string]int),
|
||
byNameIdx: make(map[string]map[string]int),
|
||
byRepoIdx: make(map[string]map[string]int),
|
||
outEdgeIdx: make(map[string]map[edgeHash]int),
|
||
inEdgeIdx: make(map[string]map[edgeHash]int),
|
||
outEdgeKeys: make(map[string][]edgeHash),
|
||
inEdgeKeys: make(map[string][]edgeHash),
|
||
repoNodeBytes: make(map[string]uint64),
|
||
repoNodeCount: make(map[string]int),
|
||
repoEdgeBytes: make(map[string]uint64),
|
||
repoEdgeCount: make(map[string]int),
|
||
}
|
||
}
|
||
|
||
// repoNodeAdd registers a node under its RepoPrefix bucket. Caller
|
||
// must hold s.mu.Lock. No-op for nodes without a prefix (single-repo
|
||
// mode and synthetic nodes that intentionally skip byRepo accounting).
|
||
func (s *shard) repoNodeAdd(n *Node) {
|
||
if n == nil || n.RepoPrefix == "" {
|
||
return
|
||
}
|
||
s.repoNodeBytes[n.RepoPrefix] += nodeBytes(n)
|
||
s.repoNodeCount[n.RepoPrefix]++
|
||
}
|
||
|
||
// repoNodeRemove undoes repoNodeAdd. Clamps to zero on underflow so a
|
||
// stale counter cannot wrap a uint64.
|
||
func (s *shard) repoNodeRemove(n *Node) {
|
||
if n == nil || n.RepoPrefix == "" {
|
||
return
|
||
}
|
||
b := nodeBytes(n)
|
||
if cur := s.repoNodeBytes[n.RepoPrefix]; cur >= b {
|
||
s.repoNodeBytes[n.RepoPrefix] = cur - b
|
||
} else {
|
||
s.repoNodeBytes[n.RepoPrefix] = 0
|
||
}
|
||
if s.repoNodeCount[n.RepoPrefix] > 0 {
|
||
s.repoNodeCount[n.RepoPrefix]--
|
||
}
|
||
if s.repoNodeBytes[n.RepoPrefix] == 0 && s.repoNodeCount[n.RepoPrefix] == 0 {
|
||
delete(s.repoNodeBytes, n.RepoPrefix)
|
||
delete(s.repoNodeCount, n.RepoPrefix)
|
||
}
|
||
}
|
||
|
||
// repoEdgeAdd attributes an edge to its source repo. repoPrefix is the
|
||
// source node's RepoPrefix as resolved at the time the edge is
|
||
// inserted; storing it here means a later swap of Edge.To (ReindexEdge)
|
||
// doesn't have to walk the source-node lookup again.
|
||
func (s *shard) repoEdgeAdd(repoPrefix string, e *Edge) {
|
||
if repoPrefix == "" || e == nil {
|
||
return
|
||
}
|
||
s.repoEdgeBytes[repoPrefix] += edgeBytes(e)
|
||
s.repoEdgeCount[repoPrefix]++
|
||
}
|
||
|
||
// repoEdgeRemove undoes repoEdgeAdd. Same clamp-to-zero discipline as
|
||
// repoNodeRemove.
|
||
func (s *shard) repoEdgeRemove(repoPrefix string, e *Edge) {
|
||
if repoPrefix == "" || e == nil {
|
||
return
|
||
}
|
||
b := edgeBytes(e)
|
||
if cur := s.repoEdgeBytes[repoPrefix]; cur >= b {
|
||
s.repoEdgeBytes[repoPrefix] = cur - b
|
||
} else {
|
||
s.repoEdgeBytes[repoPrefix] = 0
|
||
}
|
||
if s.repoEdgeCount[repoPrefix] > 0 {
|
||
s.repoEdgeCount[repoPrefix]--
|
||
}
|
||
if s.repoEdgeBytes[repoPrefix] == 0 && s.repoEdgeCount[repoPrefix] == 0 {
|
||
delete(s.repoEdgeBytes, repoPrefix)
|
||
delete(s.repoEdgeCount, repoPrefix)
|
||
}
|
||
}
|
||
|
||
// addNodeToBucket appends n to bucket[key] unless an entry with that id
|
||
// is already present, in which case the existing slot is overwritten
|
||
// with the new pointer. Returns the position of the entry.
|
||
func addNodeToBucket(bucket map[string][]*Node, idx map[string]map[string]int, key, id string, n *Node) {
|
||
if inner, ok := idx[key]; ok {
|
||
if pos, exists := inner[id]; exists {
|
||
bucket[key][pos] = n
|
||
return
|
||
}
|
||
}
|
||
pos := len(bucket[key])
|
||
bucket[key] = append(bucket[key], n)
|
||
inner, ok := idx[key]
|
||
if !ok {
|
||
inner = make(map[string]int)
|
||
idx[key] = inner
|
||
}
|
||
inner[id] = pos
|
||
}
|
||
|
||
// removeNodeFromBucket swap-removes the entry with id from bucket[key],
|
||
// updating the sidecar position of the swapped-in element. No-op when
|
||
// the entry is absent. Cleans up the bucket + sidecar when the last
|
||
// entry for key leaves.
|
||
func removeNodeFromBucket(bucket map[string][]*Node, idx map[string]map[string]int, key, id string) {
|
||
inner, ok := idx[key]
|
||
if !ok {
|
||
return
|
||
}
|
||
pos, exists := inner[id]
|
||
if !exists {
|
||
return
|
||
}
|
||
slice := bucket[key]
|
||
last := len(slice) - 1
|
||
if pos != last {
|
||
swapped := slice[last]
|
||
slice[pos] = swapped
|
||
inner[swapped.ID] = pos
|
||
}
|
||
slice = slice[:last]
|
||
delete(inner, id)
|
||
if len(inner) == 0 {
|
||
delete(idx, key)
|
||
delete(bucket, key)
|
||
} else {
|
||
bucket[key] = slice
|
||
}
|
||
}
|
||
|
||
// addEdgeToBucket appends e to bucket[key] unless an entry with the
|
||
// same logical identity (edgeKey) is already there, in which case the
|
||
// existing slot is overwritten. Reports whether this was a new insert
|
||
// and, on an in-place replace, whether the replacement carried a
|
||
// different Origin than the edge it displaced.
|
||
//
|
||
// keys is the parallel slice that remembers each slot's insertion-time
|
||
// edgeKey so removeEdgeFromBucket can update sidecars without
|
||
// recomputing keyOf on a possibly-mutated swapped Edge.
|
||
//
|
||
// originChanged surfaces the resolver path where an edge is re-added
|
||
// (via AddEdge) with upgraded provenance rather than mutated in place:
|
||
// the logical key still matches, so the slot is replaced, but the
|
||
// edge's identity has changed. AddEdge funnels this into the
|
||
// graph-level identity-revision counter so that re-add path is as
|
||
// observable as an explicit SetEdgeProvenance call.
|
||
func addEdgeToBucket(bucket map[string][]*Edge, keys map[string][]edgeHash, idx map[string]map[edgeHash]int, key string, e *Edge) (inserted, originChanged bool) {
|
||
h := hashEdgeKey(keyOf(e))
|
||
if inner, ok := idx[key]; ok {
|
||
if pos, exists := inner[h]; exists {
|
||
old := bucket[key][pos]
|
||
originChanged = old != nil && old != e && old.Origin != e.Origin
|
||
bucket[key][pos] = e
|
||
// keys[key][pos] already equals h — same logical identity.
|
||
return false, originChanged
|
||
}
|
||
}
|
||
pos := len(bucket[key])
|
||
bucket[key] = append(bucket[key], e)
|
||
keys[key] = append(keys[key], h)
|
||
inner, ok := idx[key]
|
||
if !ok {
|
||
inner = make(map[edgeHash]int)
|
||
idx[key] = inner
|
||
}
|
||
inner[h] = pos
|
||
return true, false
|
||
}
|
||
|
||
// removeEdgeFromBucket removes the entry with key k from bucket[key]
|
||
// using swap-with-last, maintaining the sidecar. No-op when absent.
|
||
func removeEdgeFromBucket(bucket map[string][]*Edge, keys map[string][]edgeHash, idx map[string]map[edgeHash]int, key string, k edgeHash) bool {
|
||
inner, ok := idx[key]
|
||
if !ok {
|
||
return false
|
||
}
|
||
pos, exists := inner[k]
|
||
if !exists {
|
||
return false
|
||
}
|
||
slice := bucket[key]
|
||
keySlice := keys[key]
|
||
last := len(slice) - 1
|
||
if pos != last {
|
||
slice[pos] = slice[last]
|
||
// Use the swapped slot's STORED insertion-time hash, not
|
||
// hashEdgeKey(keyOf(swapped)). The Edge's To may have been
|
||
// mutated by the resolver between insertion and now, in which
|
||
// case keyOf would yield a different hash than the sidecar
|
||
// entry that actually points at this slot — leaking a stale
|
||
// "last" position that later panics in addEdgeToBucket.
|
||
swappedKey := keySlice[last]
|
||
keySlice[pos] = swappedKey
|
||
inner[swappedKey] = pos
|
||
}
|
||
slice = slice[:last]
|
||
keySlice = keySlice[:last]
|
||
delete(inner, k)
|
||
if len(inner) == 0 {
|
||
delete(idx, key)
|
||
delete(bucket, key)
|
||
delete(keys, key)
|
||
} else {
|
||
bucket[key] = slice
|
||
keys[key] = keySlice
|
||
}
|
||
return true
|
||
}
|
||
|
||
// Graph is a thread-safe in-memory knowledge graph. Internally sharded
|
||
// by node-ID hash so writers touching disjoint IDs run in parallel.
|
||
//
|
||
// resolveMu is a graph-wide lock shared by every resolver.Resolver
|
||
// constructed against this Graph. Per-shard locks protect individual
|
||
// node/edge writes, but resolution phases (ResolveAll, ResolveFile)
|
||
// iterate every shard and mutate edge targets in place — two
|
||
// resolvers racing on the same edge produced the data race that
|
||
// MultiIndexer.indexMultiRepo triggered when its per-repo goroutines
|
||
// each created their own Resolver. Routing every resolver through
|
||
// this single mutex serialises those phases without blocking
|
||
// ordinary shard-scoped reads and writes.
|
||
type Graph struct {
|
||
// shards holds the lock-sharded node/edge maps. Its length is
|
||
// shardCount (a power of two); shardMask is shardCount-1, precomputed
|
||
// so the hot-path shardIdx masks with a single AND instead of a
|
||
// modulo.
|
||
shards []*shard
|
||
shardCount int
|
||
shardMask uint32
|
||
resolveMu sync.Mutex
|
||
// edgeIdentityRevisions counts how many times an in-graph edge's
|
||
// provenance-bearing identity changed — i.e. its Origin was
|
||
// upgraded or reverted while its logical (From,To,Kind,FilePath,
|
||
// Line) key stayed fixed. Each such change is conceptually a
|
||
// retirement of the old identity and creation of a new one. Both
|
||
// sanctioned mutation paths feed this counter: SetEdgeProvenance
|
||
// (an explicit in-place change) and addEdgeToBucket's in-place
|
||
// replace branch (a re-add of the same logical edge carrying an
|
||
// upgraded Origin). The count is the tamper-evidence surface:
|
||
// provenance cannot churn without it moving.
|
||
edgeIdentityRevisions atomic.Int64
|
||
// edgeMutGen bumps whenever the AllEdges output would change —
|
||
// new edge inserted, existing edge removed, or an edge's
|
||
// canonical key changed via ReindexEdge. Origin-only updates
|
||
// (counted by edgeIdentityRevisions) do not bump this because the
|
||
// slice content is unaffected. AllEdges uses the counter as a
|
||
// cache-validity tag so repeated post-resolve analysis walks
|
||
// share one materialised slice instead of each rebuilding the
|
||
// 4 M-edge snapshot.
|
||
edgeMutGen atomic.Uint64
|
||
|
||
allEdgesCacheMu sync.Mutex
|
||
allEdgesCache []*Edge
|
||
allEdgesCacheGen uint64
|
||
|
||
// cloneShingles is the in-memory implementation of the
|
||
// CloneShingle* capability: per-symbol MinHash shingle sets keyed by
|
||
// node id, alongside the repo prefix that owns each row so per-repo
|
||
// reseeds isolate correctly. Guarded by cloneShinglesMu. Slices are
|
||
// deep-copied on set and on read so callers can't mutate the stored
|
||
// state. The on-disk backend persists the same shape; the in-memory
|
||
// store keeps it live so the conformance suite exercises both.
|
||
cloneShinglesMu sync.Mutex
|
||
cloneShingles map[string]cloneShingleEntry
|
||
|
||
// churnEnrich is the in-memory churn-enrichment sidecar (change A).
|
||
churnEnrichMu sync.Mutex
|
||
churnEnrich map[string]ChurnEnrichment
|
||
|
||
// coverageEnrich is the in-memory coverage-enrichment sidecar.
|
||
coverageEnrichMu sync.Mutex
|
||
coverageEnrich map[string]CoverageEnrichment
|
||
|
||
// releaseEnrich is the in-memory release-enrichment sidecar.
|
||
releaseEnrichMu sync.Mutex
|
||
releaseEnrich map[string]ReleaseEnrichment
|
||
|
||
// blameEnrich is the in-memory blame-enrichment sidecar.
|
||
blameEnrichMu sync.Mutex
|
||
blameEnrich map[string]BlameEnrichment
|
||
|
||
// constValues is the in-memory implementation of the ConstantValue*
|
||
// capability: a KindConstant node's literal value keyed by node id,
|
||
// alongside its owning file (for file-scoped eviction) and repo
|
||
// prefix. Guarded by constValuesMu.
|
||
constValuesMu sync.Mutex
|
||
constValues map[string]constValueEntry
|
||
|
||
// fileMetas holds per-file metadata rows keyed by repoPrefix -> filePath
|
||
// (the files sidecar feeding index_health). Guarded by fileMetasMu.
|
||
fileMetasMu sync.Mutex
|
||
fileMetas map[string]map[string]FileMetaRow
|
||
}
|
||
|
||
// cloneShingleEntry is one in-memory clone_shingles row: the owning
|
||
// repo prefix plus the (already deep-copied) shingle set.
|
||
type cloneShingleEntry struct {
|
||
repoPrefix string
|
||
shingles []uint64
|
||
}
|
||
|
||
// constValueEntry is one in-memory constant_values row: the owning repo
|
||
// prefix and file (for file-scoped eviction) plus the literal value.
|
||
type constValueEntry struct {
|
||
repoPrefix string
|
||
filePath string
|
||
value string
|
||
}
|
||
|
||
// Compile-time assertions that the in-memory *Graph satisfies the
|
||
// optional per-symbol clone-shingle persistence capabilities, so the
|
||
// conformance suite exercises the same code path against both backends.
|
||
var (
|
||
_ CloneShingleWriter = (*Graph)(nil)
|
||
_ CloneShingleReader = (*Graph)(nil)
|
||
_ ChurnEnrichmentWriter = (*Graph)(nil)
|
||
_ ChurnEnrichmentReader = (*Graph)(nil)
|
||
_ CoverageEnrichmentWriter = (*Graph)(nil)
|
||
_ CoverageEnrichmentReader = (*Graph)(nil)
|
||
_ ReleaseEnrichmentWriter = (*Graph)(nil)
|
||
_ ReleaseEnrichmentReader = (*Graph)(nil)
|
||
_ BlameEnrichmentWriter = (*Graph)(nil)
|
||
_ BlameEnrichmentReader = (*Graph)(nil)
|
||
_ ReleaseEnrichmentWriter = (*Graph)(nil)
|
||
_ ReleaseEnrichmentReader = (*Graph)(nil)
|
||
_ ConstantValueWriter = (*Graph)(nil)
|
||
_ ConstantValueReader = (*Graph)(nil)
|
||
)
|
||
|
||
// New creates an empty graph with a shard fan-out derived from the host
|
||
// (see defaultShardCount).
|
||
func New() *Graph {
|
||
return newWithShardCount(defaultShardCount())
|
||
}
|
||
|
||
// newWithShardCount creates an empty graph with an explicit shard
|
||
// fan-out. The count is coerced to a power of two within
|
||
// [minShards, maxShards] so the shardMask (count-1) modulo trick stays
|
||
// exact regardless of what the caller asks for. Used by New and by
|
||
// benchmarks/tests that pin a specific fan-out.
|
||
func newWithShardCount(count int) *Graph {
|
||
count = coerceShardCount(count)
|
||
g := &Graph{
|
||
shards: make([]*shard, count),
|
||
shardCount: count,
|
||
shardMask: uint32(count - 1),
|
||
}
|
||
for i := range g.shards {
|
||
g.shards[i] = newShard()
|
||
}
|
||
return g
|
||
}
|
||
|
||
// defaultShardCount picks the shard fan-out for a new graph. An explicit
|
||
// GORTEX_GRAPH_SHARDS override wins (coerced to a power of two within
|
||
// [minShards, maxShards]); otherwise the count derives from
|
||
// runtime.NumCPU(), rounded up to the next power of two and clamped to
|
||
// [derivedShardFloor, derivedShardCeiling].
|
||
func defaultShardCount() int {
|
||
if v := strings.TrimSpace(os.Getenv(shardCountEnv)); v != "" {
|
||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||
return coerceShardCount(n)
|
||
}
|
||
}
|
||
n := nextPow2(runtime.NumCPU())
|
||
if n < derivedShardFloor {
|
||
n = derivedShardFloor
|
||
}
|
||
if n > derivedShardCeiling {
|
||
n = derivedShardCeiling
|
||
}
|
||
return n
|
||
}
|
||
|
||
// coerceShardCount rounds n up to the nearest power of two and clamps it
|
||
// to [minShards, maxShards]. Both bounds are powers of two, so the
|
||
// result is always a valid power-of-two shard count — the invariant the
|
||
// shardMask modulo trick depends on.
|
||
func coerceShardCount(n int) int {
|
||
n = nextPow2(n)
|
||
if n < minShards {
|
||
n = minShards
|
||
}
|
||
if n > maxShards {
|
||
n = maxShards
|
||
}
|
||
return n
|
||
}
|
||
|
||
// nextPow2 returns the smallest power of two >= n, and at least 1.
|
||
func nextPow2(n int) int {
|
||
if n <= 1 {
|
||
return 1
|
||
}
|
||
return 1 << bits.Len(uint(n-1))
|
||
}
|
||
|
||
// ResolveMutex returns the graph-wide mutex resolvers use to
|
||
// serialise resolution phases against this graph. Exposed for the
|
||
// resolver package; every Resolver built from the same Graph shares
|
||
// the same lock.
|
||
func (g *Graph) ResolveMutex() *sync.Mutex {
|
||
return &g.resolveMu
|
||
}
|
||
|
||
// ReindexEdges is the batched sibling of ReindexEdge. The in-memory
|
||
// store has no per-call commit overhead so the implementation is a
|
||
// straight loop; the value of the batch API lives in the disk
|
||
// backends, where it collapses N transaction commits into one.
|
||
func (g *Graph) ReindexEdges(batch []EdgeReindex) {
|
||
for _, r := range batch {
|
||
if r.Edge == nil {
|
||
continue
|
||
}
|
||
g.ReindexEdge(r.Edge, r.OldTo)
|
||
}
|
||
}
|
||
|
||
// BulkSetCloneShingles is the in-memory implementation of
|
||
// CloneShingleWriter. It records every (nodeID -> shingles) entry for
|
||
// one repo prefix, replacing any prior value in place. Slices are
|
||
// deep-copied on the way in so a later mutation of the caller's slice
|
||
// can't corrupt the stored state. Empty input is a no-op.
|
||
func (g *Graph) BulkSetCloneShingles(repoPrefix string, rows map[string][]uint64) error {
|
||
if len(rows) == 0 {
|
||
return nil
|
||
}
|
||
g.cloneShinglesMu.Lock()
|
||
defer g.cloneShinglesMu.Unlock()
|
||
if g.cloneShingles == nil {
|
||
g.cloneShingles = make(map[string]cloneShingleEntry, len(rows))
|
||
}
|
||
for id, sh := range rows {
|
||
cp := make([]uint64, len(sh))
|
||
copy(cp, sh)
|
||
g.cloneShingles[id] = cloneShingleEntry{repoPrefix: repoPrefix, shingles: cp}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// DeleteCloneShingles is the in-memory implementation of the
|
||
// CloneShingleWriter delete side. It drops the rows for the supplied
|
||
// node ids. Empty input is a no-op; missing ids are ignored.
|
||
func (g *Graph) DeleteCloneShingles(nodeIDs []string) error {
|
||
if len(nodeIDs) == 0 {
|
||
return nil
|
||
}
|
||
g.cloneShinglesMu.Lock()
|
||
defer g.cloneShinglesMu.Unlock()
|
||
for _, id := range nodeIDs {
|
||
if id == "" {
|
||
continue
|
||
}
|
||
delete(g.cloneShingles, id)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// LoadCloneShingles is the in-memory implementation of
|
||
// CloneShingleReader. It returns a fresh map of the shingle sets owned
|
||
// by one repo prefix, deep-copying each slice so callers can't mutate
|
||
// the stored state. Always returns a non-nil (possibly empty) map and
|
||
// never an error.
|
||
func (g *Graph) LoadCloneShingles(repoPrefix string) (map[string][]uint64, error) {
|
||
g.cloneShinglesMu.Lock()
|
||
defer g.cloneShinglesMu.Unlock()
|
||
out := make(map[string][]uint64)
|
||
for id, entry := range g.cloneShingles {
|
||
if entry.repoPrefix != repoPrefix {
|
||
continue
|
||
}
|
||
cp := make([]uint64, len(entry.shingles))
|
||
copy(cp, entry.shingles)
|
||
out[id] = cp
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// BulkSetConstantValues is the in-memory ConstantValueWriter. It records
|
||
// every (nodeID -> value) row for one repo prefix, replacing any prior
|
||
// value in place. Empty input is a no-op.
|
||
func (g *Graph) BulkSetConstantValues(repoPrefix string, rows []ConstantValueRow) error {
|
||
if len(rows) == 0 {
|
||
return nil
|
||
}
|
||
g.constValuesMu.Lock()
|
||
defer g.constValuesMu.Unlock()
|
||
if g.constValues == nil {
|
||
g.constValues = make(map[string]constValueEntry, len(rows))
|
||
}
|
||
for _, r := range rows {
|
||
if r.NodeID == "" {
|
||
continue
|
||
}
|
||
g.constValues[r.NodeID] = constValueEntry{repoPrefix: repoPrefix, filePath: r.FilePath, value: r.Value}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// DeleteConstantValuesByFiles is the in-memory ConstantValueWriter delete
|
||
// side: it drops every row whose file is in the supplied set for the given
|
||
// repo prefix, so a reindex of those files replaces their values cleanly.
|
||
func (g *Graph) DeleteConstantValuesByFiles(repoPrefix string, files []string) error {
|
||
if len(files) == 0 {
|
||
return nil
|
||
}
|
||
fileSet := make(map[string]struct{}, len(files))
|
||
for _, f := range files {
|
||
fileSet[f] = struct{}{}
|
||
}
|
||
g.constValuesMu.Lock()
|
||
defer g.constValuesMu.Unlock()
|
||
for id, entry := range g.constValues {
|
||
if entry.repoPrefix != repoPrefix {
|
||
continue
|
||
}
|
||
if _, ok := fileSet[entry.filePath]; ok {
|
||
delete(g.constValues, id)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// ConstantValuesByNodeIDs is the in-memory ConstantValueReader. It returns
|
||
// the recorded values for the supplied node ids (omitting ids with no
|
||
// recorded value). Always returns a non-nil map and never an error.
|
||
func (g *Graph) ConstantValuesByNodeIDs(nodeIDs []string) (map[string]string, error) {
|
||
out := make(map[string]string, len(nodeIDs))
|
||
if len(nodeIDs) == 0 {
|
||
return out, nil
|
||
}
|
||
g.constValuesMu.Lock()
|
||
defer g.constValuesMu.Unlock()
|
||
for _, id := range nodeIDs {
|
||
if entry, ok := g.constValues[id]; ok {
|
||
out[id] = entry.value
|
||
}
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// SetFileMetas is the in-memory FileMetaWriter. It records each per-file row
|
||
// for one repo prefix, replacing any prior row in place. Empty input is a
|
||
// no-op.
|
||
func (g *Graph) SetFileMetas(repoPrefix string, rows []FileMetaRow) error {
|
||
if len(rows) == 0 {
|
||
return nil
|
||
}
|
||
g.fileMetasMu.Lock()
|
||
defer g.fileMetasMu.Unlock()
|
||
if g.fileMetas == nil {
|
||
g.fileMetas = make(map[string]map[string]FileMetaRow)
|
||
}
|
||
byFile := g.fileMetas[repoPrefix]
|
||
if byFile == nil {
|
||
byFile = make(map[string]FileMetaRow, len(rows))
|
||
g.fileMetas[repoPrefix] = byFile
|
||
}
|
||
for _, r := range rows {
|
||
if r.FilePath == "" {
|
||
continue
|
||
}
|
||
byFile[r.FilePath] = r
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// DeleteFileMetasByFiles drops the rows for the supplied files in one repo
|
||
// prefix so a reindex replaces them cleanly.
|
||
func (g *Graph) DeleteFileMetasByFiles(repoPrefix string, files []string) error {
|
||
if len(files) == 0 {
|
||
return nil
|
||
}
|
||
g.fileMetasMu.Lock()
|
||
defer g.fileMetasMu.Unlock()
|
||
byFile := g.fileMetas[repoPrefix]
|
||
if byFile == nil {
|
||
return nil
|
||
}
|
||
for _, f := range files {
|
||
delete(byFile, f)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// FileMetasForRepo is the in-memory FileMetaReader: every recorded file row
|
||
// for the repo prefix. Always non-nil.
|
||
func (g *Graph) FileMetasForRepo(repoPrefix string) ([]FileMetaRow, error) {
|
||
g.fileMetasMu.Lock()
|
||
defer g.fileMetasMu.Unlock()
|
||
byFile := g.fileMetas[repoPrefix]
|
||
out := make([]FileMetaRow, 0, len(byFile))
|
||
for _, r := range byFile {
|
||
out = append(out, r)
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// BulkSetChurn is the in-memory ChurnEnrichmentWriter. ChurnEnrichment
|
||
// is a flat value type, so a map store needs no deep copy.
|
||
func (g *Graph) BulkSetChurn(repoPrefix string, rows []ChurnEnrichment) error {
|
||
if len(rows) == 0 {
|
||
return nil
|
||
}
|
||
g.churnEnrichMu.Lock()
|
||
defer g.churnEnrichMu.Unlock()
|
||
if g.churnEnrich == nil {
|
||
g.churnEnrich = make(map[string]ChurnEnrichment, len(rows))
|
||
}
|
||
for _, r := range rows {
|
||
r.RepoPrefix = repoPrefix
|
||
g.churnEnrich[r.NodeID] = r
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// DeleteChurn is the in-memory ChurnEnrichmentWriter delete side.
|
||
func (g *Graph) DeleteChurn(nodeIDs []string) error {
|
||
if len(nodeIDs) == 0 {
|
||
return nil
|
||
}
|
||
g.churnEnrichMu.Lock()
|
||
defer g.churnEnrichMu.Unlock()
|
||
for _, id := range nodeIDs {
|
||
if id != "" {
|
||
delete(g.churnEnrich, id)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// ChurnRows is the in-memory ChurnEnrichmentReader. An empty repoPrefix
|
||
// returns all rows across repos.
|
||
func (g *Graph) ChurnRows(repoPrefix string) []ChurnEnrichment {
|
||
g.churnEnrichMu.Lock()
|
||
defer g.churnEnrichMu.Unlock()
|
||
out := make([]ChurnEnrichment, 0, len(g.churnEnrich))
|
||
for _, r := range g.churnEnrich {
|
||
if repoPrefix != "" && r.RepoPrefix != repoPrefix {
|
||
continue
|
||
}
|
||
out = append(out, r)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// BulkSetCoverage is the in-memory CoverageEnrichmentWriter.
|
||
func (g *Graph) BulkSetCoverage(repoPrefix string, rows []CoverageEnrichment) error {
|
||
if len(rows) == 0 {
|
||
return nil
|
||
}
|
||
g.coverageEnrichMu.Lock()
|
||
defer g.coverageEnrichMu.Unlock()
|
||
if g.coverageEnrich == nil {
|
||
g.coverageEnrich = make(map[string]CoverageEnrichment, len(rows))
|
||
}
|
||
for _, r := range rows {
|
||
r.RepoPrefix = repoPrefix
|
||
g.coverageEnrich[r.NodeID] = r
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// DeleteCoverage is the in-memory CoverageEnrichmentWriter delete side.
|
||
func (g *Graph) DeleteCoverage(nodeIDs []string) error {
|
||
if len(nodeIDs) == 0 {
|
||
return nil
|
||
}
|
||
g.coverageEnrichMu.Lock()
|
||
defer g.coverageEnrichMu.Unlock()
|
||
for _, id := range nodeIDs {
|
||
if id != "" {
|
||
delete(g.coverageEnrich, id)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// ChurnRows-style reader for coverage; empty repoPrefix returns all.
|
||
func (g *Graph) CoverageRows(repoPrefix string) []CoverageEnrichment {
|
||
g.coverageEnrichMu.Lock()
|
||
defer g.coverageEnrichMu.Unlock()
|
||
out := make([]CoverageEnrichment, 0, len(g.coverageEnrich))
|
||
for _, r := range g.coverageEnrich {
|
||
if repoPrefix != "" && r.RepoPrefix != repoPrefix {
|
||
continue
|
||
}
|
||
out = append(out, r)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// BulkSetReleases is the in-memory ReleaseEnrichmentWriter.
|
||
func (g *Graph) BulkSetReleases(repoPrefix string, rows []ReleaseEnrichment) error {
|
||
if len(rows) == 0 {
|
||
return nil
|
||
}
|
||
g.releaseEnrichMu.Lock()
|
||
defer g.releaseEnrichMu.Unlock()
|
||
if g.releaseEnrich == nil {
|
||
g.releaseEnrich = make(map[string]ReleaseEnrichment, len(rows))
|
||
}
|
||
for _, r := range rows {
|
||
r.RepoPrefix = repoPrefix
|
||
g.releaseEnrich[r.NodeID] = r
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// DeleteReleases is the in-memory ReleaseEnrichmentWriter delete side.
|
||
func (g *Graph) DeleteReleases(nodeIDs []string) error {
|
||
if len(nodeIDs) == 0 {
|
||
return nil
|
||
}
|
||
g.releaseEnrichMu.Lock()
|
||
defer g.releaseEnrichMu.Unlock()
|
||
for _, id := range nodeIDs {
|
||
if id != "" {
|
||
delete(g.releaseEnrich, id)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// ReleaseRows reads release rows; empty repoPrefix returns all.
|
||
func (g *Graph) ReleaseRows(repoPrefix string) []ReleaseEnrichment {
|
||
g.releaseEnrichMu.Lock()
|
||
defer g.releaseEnrichMu.Unlock()
|
||
out := make([]ReleaseEnrichment, 0, len(g.releaseEnrich))
|
||
for _, r := range g.releaseEnrich {
|
||
if repoPrefix != "" && r.RepoPrefix != repoPrefix {
|
||
continue
|
||
}
|
||
out = append(out, r)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// BulkSetBlame is the in-memory BlameEnrichmentWriter.
|
||
func (g *Graph) BulkSetBlame(repoPrefix string, rows []BlameEnrichment) error {
|
||
if len(rows) == 0 {
|
||
return nil
|
||
}
|
||
g.blameEnrichMu.Lock()
|
||
defer g.blameEnrichMu.Unlock()
|
||
if g.blameEnrich == nil {
|
||
g.blameEnrich = make(map[string]BlameEnrichment, len(rows))
|
||
}
|
||
for _, r := range rows {
|
||
r.RepoPrefix = repoPrefix
|
||
g.blameEnrich[r.NodeID] = r
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// DeleteBlame is the in-memory BlameEnrichmentWriter delete side.
|
||
func (g *Graph) DeleteBlame(nodeIDs []string) error {
|
||
if len(nodeIDs) == 0 {
|
||
return nil
|
||
}
|
||
g.blameEnrichMu.Lock()
|
||
defer g.blameEnrichMu.Unlock()
|
||
for _, id := range nodeIDs {
|
||
if id != "" {
|
||
delete(g.blameEnrich, id)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// BlameRows reads blame rows; empty repoPrefix returns all.
|
||
func (g *Graph) BlameRows(repoPrefix string) []BlameEnrichment {
|
||
g.blameEnrichMu.Lock()
|
||
defer g.blameEnrichMu.Unlock()
|
||
out := make([]BlameEnrichment, 0, len(g.blameEnrich))
|
||
for _, r := range g.blameEnrich {
|
||
if repoPrefix != "" && r.RepoPrefix != repoPrefix {
|
||
continue
|
||
}
|
||
out = append(out, r)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// EdgesByKind yields every edge whose Kind matches. In-memory
|
||
// implementation iterates the materialised AllEdges() slice and
|
||
// filters; the algorithmic cost is identical to a hand-written
|
||
// "for _, e := range g.AllEdges() { if e.Kind == kind }" loop, which
|
||
// is what most call sites used before the predicate API existed.
|
||
// Disk backends override this with an index-backed scan.
|
||
func (g *Graph) EdgesByKind(kind EdgeKind) iter.Seq[*Edge] {
|
||
return func(yield func(*Edge) bool) {
|
||
for _, e := range g.AllEdges() {
|
||
if e == nil || e.Kind != kind {
|
||
continue
|
||
}
|
||
if !yield(e) {
|
||
return
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// EdgesByKinds is the in-memory reference implementation of
|
||
// EdgesByKindsScanner. Single pass over AllEdges with a small
|
||
// pre-built kind set — same algorithmic cost as the legacy `for _, e
|
||
// := range g.AllEdges() { if e.Kind == X || e.Kind == Y }` loop the
|
||
// edge-driven analyzers used before this capability existed. Disk
|
||
// backends override with a single `WHERE kind IN $kinds` query so the
|
||
// edge-driven analyzers stop firing one EdgesByKind per kind (or
|
||
// worse, scanning AllEdges and filtering Go-side).
|
||
//
|
||
// Empty kinds yields nothing — matches the disk contract.
|
||
func (g *Graph) EdgesByKinds(kinds []EdgeKind) iter.Seq[*Edge] {
|
||
if len(kinds) == 0 {
|
||
return func(yield func(*Edge) bool) {}
|
||
}
|
||
set := make(map[EdgeKind]struct{}, len(kinds))
|
||
for _, k := range kinds {
|
||
if k == "" {
|
||
continue
|
||
}
|
||
set[k] = struct{}{}
|
||
}
|
||
if len(set) == 0 {
|
||
return func(yield func(*Edge) bool) {}
|
||
}
|
||
return func(yield func(*Edge) bool) {
|
||
for _, e := range g.AllEdges() {
|
||
if e == nil {
|
||
continue
|
||
}
|
||
if _, ok := set[e.Kind]; !ok {
|
||
continue
|
||
}
|
||
if !yield(e) {
|
||
return
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// NodesByKind yields every node whose Kind matches. Same semantics
|
||
// and same in-memory cost story as EdgesByKind.
|
||
func (g *Graph) NodesByKind(kind NodeKind) iter.Seq[*Node] {
|
||
return func(yield func(*Node) bool) {
|
||
for _, n := range g.AllNodes() {
|
||
if n == nil || n.Kind != kind {
|
||
continue
|
||
}
|
||
if !yield(n) {
|
||
return
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// GetNodesByIDs returns a map id→*Node for every input ID that
|
||
// exists in the store. The in-memory implementation loops the
|
||
// existing GetNode — algorithmic cost identical to a hand-written
|
||
// loop in the caller, no concurrency win here. The value of the
|
||
// batched API lives in the disk backends, where it collapses N
|
||
// per-id SQL/bolt queries into one.
|
||
func (g *Graph) GetNodesByIDs(ids []string) map[string]*Node {
|
||
if len(ids) == 0 {
|
||
return nil
|
||
}
|
||
out := make(map[string]*Node, len(ids))
|
||
for _, id := range ids {
|
||
if id == "" {
|
||
continue
|
||
}
|
||
if _, ok := out[id]; ok {
|
||
continue
|
||
}
|
||
if n := g.GetNode(id); n != nil {
|
||
out[id] = n
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// FindNodesByNames is the batched sibling of FindNodesByName.
|
||
func (g *Graph) FindNodesByNames(names []string) map[string][]*Node {
|
||
if len(names) == 0 {
|
||
return nil
|
||
}
|
||
out := make(map[string][]*Node, len(names))
|
||
for _, name := range names {
|
||
if name == "" {
|
||
continue
|
||
}
|
||
if _, ok := out[name]; ok {
|
||
continue
|
||
}
|
||
matches := g.FindNodesByName(name)
|
||
if len(matches) > 0 {
|
||
out[name] = matches
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// EdgesWithUnresolvedTarget yields every edge whose To has the
|
||
// "unresolved::" prefix — the resolver's main pending-edge filter.
|
||
// In-memory iterates all edges and prefix-checks; disk backends back
|
||
// it with a range scan on a to-keyed index. Gate-owned fn-value
|
||
// placeholders (`unresolved::fnvalue::<name>`) are excluded: the master
|
||
// resolver can never bind them, so surfacing them here only bloats the
|
||
// pending set it reconciles every pass — the fn-value gate scans them
|
||
// itself via EdgesByKind(references).
|
||
func (g *Graph) EdgesWithUnresolvedTarget() iter.Seq[*Edge] {
|
||
return func(yield func(*Edge) bool) {
|
||
for _, e := range g.AllEdges() {
|
||
if e == nil {
|
||
continue
|
||
}
|
||
// IsUnresolvedTarget matches both the bare `unresolved::<name>`
|
||
// form and the multi-repo `<repoPrefix>::unresolved::<name>`
|
||
// form that the disk backend's bulk-load rewrite produces. A bare
|
||
// HasPrefix check silently skipped every prefixed stub, so the
|
||
// Go resolver never got a second pass at multi-repo edges.
|
||
// IsFnValuePlaceholder drops the gate-owned fnvalue sub-namespace.
|
||
if !IsUnresolvedTarget(e.To) || IsFnValuePlaceholder(e.To) {
|
||
continue
|
||
}
|
||
if !yield(e) {
|
||
return
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// FnValuePlaceholderEdges implements graph.FnValuePlaceholderScanner: it yields
|
||
// every edge whose To is a fn-value gate placeholder (both the bare and the
|
||
// multi-repo COPY-rewrite forms) — the exact inverse of the exclusion
|
||
// EdgesWithUnresolvedTarget applies. Full edges with Meta intact, since the gate
|
||
// reads Meta["via"] and the captured fn_value_name off each one.
|
||
func (g *Graph) FnValuePlaceholderEdges() iter.Seq[*Edge] {
|
||
return func(yield func(*Edge) bool) {
|
||
for _, e := range g.AllEdges() {
|
||
if e == nil || !IsFnValuePlaceholder(e.To) {
|
||
continue
|
||
}
|
||
if !yield(e) {
|
||
return
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// AllEdgesLight implements graph.LightEdgeScanner for the in-memory backend.
|
||
// There is no separate meta blob to skip here — the edges are live structs — so
|
||
// it returns the matching edges as-is, Meta present. Honouring the "Meta
|
||
// absent" half of the contract would mean copying every edge to strip Meta,
|
||
// doubling heap for zero benefit; the contract only promises Meta MAY be absent
|
||
// and correct callers read only the promoted fields either way. An empty kinds
|
||
// list returns every edge.
|
||
func (g *Graph) AllEdgesLight(kinds ...EdgeKind) []*Edge {
|
||
all := g.AllEdges()
|
||
if len(kinds) == 0 {
|
||
return all
|
||
}
|
||
want := make(map[EdgeKind]struct{}, len(kinds))
|
||
for _, k := range kinds {
|
||
if k != "" {
|
||
want[k] = struct{}{}
|
||
}
|
||
}
|
||
if len(want) == 0 {
|
||
return nil
|
||
}
|
||
out := make([]*Edge, 0, len(all))
|
||
for _, e := range all {
|
||
if e != nil {
|
||
if _, ok := want[e.Kind]; ok {
|
||
out = append(out, e)
|
||
}
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// EdgesForKindsLight returns the edges of the given kinds (an empty kinds list
|
||
// means every kind) for a whole-graph scan, preferring the meta-less
|
||
// LightEdgeScanner capability when the store implements it (skips the per-edge
|
||
// Meta decode on disk backends) and otherwise falling back to AllEdges() with a
|
||
// Go-side kind filter. See LightEdgeScanner for the Meta-presence contract:
|
||
// callers must read only the promoted edge fields, never arbitrary Meta.
|
||
func EdgesForKindsLight(g Store, kinds ...EdgeKind) []*Edge {
|
||
if g == nil {
|
||
return nil
|
||
}
|
||
if sc, ok := g.(LightEdgeScanner); ok {
|
||
return sc.AllEdgesLight(kinds...)
|
||
}
|
||
all := g.AllEdges()
|
||
if len(kinds) == 0 {
|
||
return all
|
||
}
|
||
want := make(map[EdgeKind]struct{}, len(kinds))
|
||
for _, k := range kinds {
|
||
if k != "" {
|
||
want[k] = struct{}{}
|
||
}
|
||
}
|
||
if len(want) == 0 {
|
||
return nil
|
||
}
|
||
out := make([]*Edge, 0, len(all))
|
||
for _, e := range all {
|
||
if e != nil {
|
||
if _, ok := want[e.Kind]; ok {
|
||
out = append(out, e)
|
||
}
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// DeadCodeCandidates is the in-memory reference implementation of
|
||
// DeadCodeCandidator. Iterates the requested node kinds and filters
|
||
// out anything whose incoming-edge bucket contains an allowlist match
|
||
// — same algorithm the analysis.FindDeadCode loop runs, just exposed
|
||
// as a single capability the disk backend can short-circuit with
|
||
// one query per kind. Pure map / slice walks here; the win lives
|
||
// in the disk backend where the equivalent path materialises the full
|
||
// in-edge map.
|
||
func (g *Graph) DeadCodeCandidates(allowedNodeKinds []NodeKind, allowedInEdgeKinds map[NodeKind][]EdgeKind) []*Node {
|
||
if len(allowedNodeKinds) == 0 {
|
||
return nil
|
||
}
|
||
// Build a per-kind set so the inner loop can match against a map
|
||
// instead of re-scanning the allowlist slice for every edge.
|
||
allowedSet := make(map[NodeKind]map[EdgeKind]struct{}, len(allowedNodeKinds))
|
||
for _, k := range allowedNodeKinds {
|
||
set := make(map[EdgeKind]struct{}, len(allowedInEdgeKinds[k]))
|
||
for _, ek := range allowedInEdgeKinds[k] {
|
||
set[ek] = struct{}{}
|
||
}
|
||
allowedSet[k] = set
|
||
}
|
||
|
||
var out []*Node
|
||
for _, k := range allowedNodeKinds {
|
||
allowed, hasAllow := allowedSet[k]
|
||
anyKindCounts := !hasAllow || len(allowed) == 0
|
||
for n := range g.NodesByKind(k) {
|
||
if n == nil {
|
||
continue
|
||
}
|
||
incoming := g.GetInEdges(n.ID)
|
||
dead := true
|
||
for _, e := range incoming {
|
||
if e == nil {
|
||
continue
|
||
}
|
||
if anyKindCounts {
|
||
dead = false
|
||
break
|
||
}
|
||
if _, ok := allowed[e.Kind]; ok {
|
||
dead = false
|
||
break
|
||
}
|
||
}
|
||
if dead {
|
||
out = append(out, n)
|
||
}
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// IfaceImplementsRows is the in-memory reference implementation of
|
||
// IfaceImplementsScanner. Joins KindInterface nodes carrying
|
||
// Meta["methods"] with their EdgeImplements predecessors and returns
|
||
// one row per (typeID, ifaceID, ifaceMeta) tuple.
|
||
func (g *Graph) IfaceImplementsRows() []IfaceImplementsRow {
|
||
// Index interfaces with methods by ID so the edge walk is O(edges)
|
||
// rather than O(edges × interfaces).
|
||
ifaceMeta := make(map[string]map[string]any)
|
||
for n := range g.NodesByKind(KindInterface) {
|
||
if n == nil || n.Meta == nil {
|
||
continue
|
||
}
|
||
if _, ok := n.Meta["methods"]; !ok {
|
||
continue
|
||
}
|
||
ifaceMeta[n.ID] = n.Meta
|
||
}
|
||
if len(ifaceMeta) == 0 {
|
||
return nil
|
||
}
|
||
var out []IfaceImplementsRow
|
||
for e := range g.EdgesByKind(EdgeImplements) {
|
||
if e == nil {
|
||
continue
|
||
}
|
||
meta, ok := ifaceMeta[e.To]
|
||
if !ok {
|
||
continue
|
||
}
|
||
out = append(out, IfaceImplementsRow{
|
||
TypeID: e.From,
|
||
IfaceID: e.To,
|
||
IfaceMeta: meta,
|
||
})
|
||
}
|
||
return out
|
||
}
|
||
|
||
// NodeDegreeCounts is the in-memory reference implementation of
|
||
// NodeDegreeAggregator. Walks the per-node in/out edge buckets the
|
||
// in-memory backend already maintains — same cost as the per-node
|
||
// loop GraphConnectivity ran before this capability landed, just
|
||
// folded into one method call so the analyzer can pick the disk
|
||
// backend's bulk implementation transparently. Missing ids are
|
||
// elided from the result (matching the disk contract).
|
||
func (g *Graph) NodeDegreeCounts(ids []string, usageKinds []EdgeKind) []NodeDegreeRow {
|
||
if len(ids) == 0 {
|
||
return nil
|
||
}
|
||
usage := make(map[EdgeKind]struct{}, len(usageKinds))
|
||
for _, k := range usageKinds {
|
||
usage[k] = struct{}{}
|
||
}
|
||
seen := make(map[string]struct{}, len(ids))
|
||
out := make([]NodeDegreeRow, 0, len(ids))
|
||
for _, id := range ids {
|
||
if id == "" {
|
||
continue
|
||
}
|
||
if _, dup := seen[id]; dup {
|
||
continue
|
||
}
|
||
seen[id] = struct{}{}
|
||
// Skip unknown ids — the disk backend's WHERE n.id IN $ids
|
||
// clause naturally drops them; mirror that here so both
|
||
// backends return the same row count.
|
||
if g.GetNode(id) == nil {
|
||
continue
|
||
}
|
||
in := g.GetInEdges(id)
|
||
row := NodeDegreeRow{
|
||
NodeID: id,
|
||
InCount: len(in),
|
||
OutCount: len(g.GetOutEdges(id)),
|
||
}
|
||
if len(usage) > 0 {
|
||
for _, e := range in {
|
||
if e == nil {
|
||
continue
|
||
}
|
||
if _, ok := usage[e.Kind]; ok {
|
||
row.UsageInCount++
|
||
}
|
||
}
|
||
}
|
||
out = append(out, row)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// FileImporters is the in-memory reference implementation of the
|
||
// FileImporters capability. Iterates EdgeImports via the byKind
|
||
// bucket — same cost as the legacy AllEdges()+filter loop in
|
||
// handleCheckReferences, but exposes the predicate as a single call
|
||
// the disk backend can short-circuit with one query.
|
||
//
|
||
// Matches edges whose To node satisfies filePath == n.FilePath OR
|
||
// filePath == n.ID. The dual match keeps parity with the indexer's
|
||
// two import shapes: file-targeted imports point at the file node
|
||
// (n.ID == filePath), while symbol-targeted imports land on a symbol
|
||
// whose FilePath equals filePath.
|
||
func (g *Graph) FileImporters(filePath string) []FileImporterRow {
|
||
if filePath == "" {
|
||
return nil
|
||
}
|
||
var out []FileImporterRow
|
||
for e := range g.EdgesByKind(EdgeImports) {
|
||
if e == nil {
|
||
continue
|
||
}
|
||
to := g.GetNode(e.To)
|
||
if to == nil {
|
||
continue
|
||
}
|
||
if to.FilePath != filePath && to.ID != filePath {
|
||
continue
|
||
}
|
||
from := g.GetNode(e.From)
|
||
if from == nil {
|
||
continue
|
||
}
|
||
out = append(out, FileImporterRow{
|
||
FromFile: from.FilePath,
|
||
FromID: from.ID,
|
||
FromName: from.Name,
|
||
FromKind: from.Kind,
|
||
})
|
||
}
|
||
return out
|
||
}
|
||
|
||
// NodeFanCounts is the in-memory reference implementation of
|
||
// NodeFanAggregator. Two passes over the per-node in/out edge buckets
|
||
// the in-memory backend already maintains, filtered by the caller's
|
||
// kind sets. The disk backend overrides with one query per direction
|
||
// to drop the AllEdges() materialisation FindHotspots / health_score
|
||
// were running every call.
|
||
func (g *Graph) NodeFanCounts(ids []string, fanInKinds []EdgeKind, fanOutKinds []EdgeKind) []NodeFanRow {
|
||
if len(ids) == 0 {
|
||
return nil
|
||
}
|
||
inSet := make(map[EdgeKind]struct{}, len(fanInKinds))
|
||
for _, k := range fanInKinds {
|
||
inSet[k] = struct{}{}
|
||
}
|
||
outSet := make(map[EdgeKind]struct{}, len(fanOutKinds))
|
||
for _, k := range fanOutKinds {
|
||
outSet[k] = struct{}{}
|
||
}
|
||
seen := make(map[string]struct{}, len(ids))
|
||
out := make([]NodeFanRow, 0, len(ids))
|
||
for _, id := range ids {
|
||
if id == "" {
|
||
continue
|
||
}
|
||
if _, dup := seen[id]; dup {
|
||
continue
|
||
}
|
||
seen[id] = struct{}{}
|
||
if g.GetNode(id) == nil {
|
||
continue
|
||
}
|
||
row := NodeFanRow{NodeID: id}
|
||
if len(inSet) > 0 {
|
||
for _, e := range g.GetInEdges(id) {
|
||
if e == nil {
|
||
continue
|
||
}
|
||
if _, ok := inSet[e.Kind]; ok {
|
||
row.FanIn++
|
||
}
|
||
}
|
||
}
|
||
if len(outSet) > 0 {
|
||
for _, e := range g.GetOutEdges(id) {
|
||
if e == nil {
|
||
continue
|
||
}
|
||
if _, ok := outSet[e.Kind]; ok {
|
||
row.FanOut++
|
||
}
|
||
}
|
||
}
|
||
out = append(out, row)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// InEdgeCountsByKind is the in-memory reference implementation of
|
||
// the InEdgeCounter capability. Walks each requested EdgeKind via
|
||
// the byKind bucket and increments a per-To counter. Same algorithm
|
||
// the AllEdges-bucketing fallback in handleGetUntestedSymbols runs;
|
||
// the win lives in the disk backend where AllEdges() materialises every
|
||
// edge just to bucket by target.
|
||
//
|
||
// Dedupes the kind set up front so a sloppy caller passing the same
|
||
// kind twice doesn't double-count — matches the disk backend's
|
||
// IN-list dedup.
|
||
func (g *Graph) InEdgeCountsByKind(kinds []EdgeKind) map[string]int {
|
||
if len(kinds) == 0 {
|
||
return nil
|
||
}
|
||
seen := make(map[EdgeKind]struct{}, len(kinds))
|
||
out := make(map[string]int)
|
||
for _, k := range kinds {
|
||
if _, ok := seen[k]; ok {
|
||
continue
|
||
}
|
||
seen[k] = struct{}{}
|
||
for e := range g.EdgesByKind(k) {
|
||
if e == nil {
|
||
continue
|
||
}
|
||
out[e.To]++
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// NodesInFilesByKind is the in-memory reference implementation of
|
||
// the NodesInFilesByKindFinder capability. Filters NodesByKind for
|
||
// each requested kind down to the file set. Same algorithm as the
|
||
// Go-side loop in find_declaration's buildDeclFileIndex; the win
|
||
// lives in disk backends where AllNodes() over cgo dwarfs the few
|
||
// hundred surviving rows.
|
||
func (g *Graph) NodesInFilesByKind(files []string, kinds []NodeKind) []*Node {
|
||
if len(files) == 0 || len(kinds) == 0 {
|
||
return nil
|
||
}
|
||
wanted := make(map[string]struct{}, len(files))
|
||
for _, f := range files {
|
||
if f == "" {
|
||
continue
|
||
}
|
||
wanted[f] = struct{}{}
|
||
}
|
||
if len(wanted) == 0 {
|
||
return nil
|
||
}
|
||
// Dedup the kinds so a sloppy caller doesn't double-scan.
|
||
seenKind := make(map[NodeKind]struct{}, len(kinds))
|
||
var out []*Node
|
||
for _, k := range kinds {
|
||
if _, ok := seenKind[k]; ok {
|
||
continue
|
||
}
|
||
seenKind[k] = struct{}{}
|
||
for n := range g.NodesByKind(k) {
|
||
if n == nil {
|
||
continue
|
||
}
|
||
if _, ok := wanted[n.FilePath]; !ok {
|
||
continue
|
||
}
|
||
out = append(out, n)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// NodesByKinds is the in-memory reference implementation of the
|
||
// NodesByKindsScanner capability. Loops the existing NodesByKind
|
||
// iterator per requested kind — algorithmic cost identical to the
|
||
// hand-written `for _, n := range AllNodes() if n.Kind == K` pattern
|
||
// the metadata analyzers used before. The win lives in the disk
|
||
// backend, where one IN-list query replaces the AllNodes() pull.
|
||
//
|
||
// Dedupes the kind set up front so a sloppy caller passing the same
|
||
// kind twice doesn't double-yield — matches the disk backend's
|
||
// IN-list dedup. Empty kinds returns nil without touching the store.
|
||
func (g *Graph) NodesByKinds(kinds []NodeKind) []*Node {
|
||
if len(kinds) == 0 {
|
||
return nil
|
||
}
|
||
seen := make(map[NodeKind]struct{}, len(kinds))
|
||
var out []*Node
|
||
for _, k := range kinds {
|
||
if _, ok := seen[k]; ok {
|
||
continue
|
||
}
|
||
seen[k] = struct{}{}
|
||
for n := range g.NodesByKind(k) {
|
||
if n == nil {
|
||
continue
|
||
}
|
||
out = append(out, n)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// EdgeAdjacencyForKinds is the in-memory reference implementation of
|
||
// the EdgeAdjacencyForKinds capability. One AllEdges scan that yields
|
||
// (from, to) pairs whose Kind is in the supplied edge-kind set AND
|
||
// whose endpoints both have a Kind in the node-kind set — identical
|
||
// shape to the join the disk backend folds into a single
|
||
// query.
|
||
//
|
||
// Empty edgeKinds or empty nodeKinds yields nothing — matches the
|
||
// disk contract.
|
||
func (g *Graph) EdgeAdjacencyForKinds(edgeKinds []EdgeKind, nodeKinds []NodeKind) iter.Seq[[2]string] {
|
||
if len(edgeKinds) == 0 || len(nodeKinds) == 0 {
|
||
return func(yield func([2]string) bool) {}
|
||
}
|
||
eset := make(map[EdgeKind]struct{}, len(edgeKinds))
|
||
for _, k := range edgeKinds {
|
||
if k == "" {
|
||
continue
|
||
}
|
||
eset[k] = struct{}{}
|
||
}
|
||
nset := make(map[NodeKind]struct{}, len(nodeKinds))
|
||
for _, k := range nodeKinds {
|
||
if k == "" {
|
||
continue
|
||
}
|
||
nset[k] = struct{}{}
|
||
}
|
||
if len(eset) == 0 || len(nset) == 0 {
|
||
return func(yield func([2]string) bool) {}
|
||
}
|
||
return func(yield func([2]string) bool) {
|
||
for _, e := range g.AllEdges() {
|
||
if e == nil {
|
||
continue
|
||
}
|
||
if _, ok := eset[e.Kind]; !ok {
|
||
continue
|
||
}
|
||
from := g.GetNode(e.From)
|
||
to := g.GetNode(e.To)
|
||
if from == nil || to == nil {
|
||
continue
|
||
}
|
||
if _, ok := nset[from.Kind]; !ok {
|
||
continue
|
||
}
|
||
if _, ok := nset[to.Kind]; !ok {
|
||
continue
|
||
}
|
||
if !yield([2]string{e.From, e.To}) {
|
||
return
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// CommunityCrossingsByKind is the in-memory reference implementation
|
||
// of the CommunityCrossingsByKind capability. AllEdges scan with the
|
||
// kind-set filter, then a Go-side community comparison per edge —
|
||
// the exact loop FindHotspots.countCrossings ran before this
|
||
// capability existed.
|
||
//
|
||
// Empty kinds or empty nodeToComm returns nil. Zero-count sources
|
||
// never surface (matches the disk contract — callers probe by
|
||
// existence).
|
||
func (g *Graph) CommunityCrossingsByKind(kinds []EdgeKind, nodeToComm map[string]string) map[string]int {
|
||
if len(kinds) == 0 || len(nodeToComm) == 0 {
|
||
return nil
|
||
}
|
||
set := make(map[EdgeKind]struct{}, len(kinds))
|
||
for _, k := range kinds {
|
||
if k == "" {
|
||
continue
|
||
}
|
||
set[k] = struct{}{}
|
||
}
|
||
if len(set) == 0 {
|
||
return nil
|
||
}
|
||
out := make(map[string]int)
|
||
for _, e := range g.AllEdges() {
|
||
if e == nil {
|
||
continue
|
||
}
|
||
if _, ok := set[e.Kind]; !ok {
|
||
continue
|
||
}
|
||
from := nodeToComm[e.From]
|
||
to := nodeToComm[e.To]
|
||
if from == "" || to == "" || from == to {
|
||
continue
|
||
}
|
||
out[e.From]++
|
||
}
|
||
if len(out) == 0 {
|
||
return nil
|
||
}
|
||
return out
|
||
}
|
||
|
||
// NodeIDsByKinds is the in-memory reference implementation of the
|
||
// NodeIDsByKinds capability. Single AllNodes pass with a kind-set
|
||
// filter, deduped on input — same algorithm as NodesByKinds but
|
||
// returns only the ID column. The disk-backend win is the projection
|
||
// drop, not the algorithmic shape.
|
||
func (g *Graph) NodeIDsByKinds(kinds []NodeKind) []string {
|
||
if len(kinds) == 0 {
|
||
return nil
|
||
}
|
||
seen := make(map[NodeKind]struct{}, len(kinds))
|
||
for _, k := range kinds {
|
||
if k == "" {
|
||
continue
|
||
}
|
||
seen[k] = struct{}{}
|
||
}
|
||
if len(seen) == 0 {
|
||
return nil
|
||
}
|
||
var out []string
|
||
for _, n := range g.AllNodes() {
|
||
if n == nil {
|
||
continue
|
||
}
|
||
if _, ok := seen[n.Kind]; !ok {
|
||
continue
|
||
}
|
||
out = append(out, n.ID)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// EdgeKindCounts is the in-memory reference implementation of the
|
||
// EdgeKindCounter capability. One AllEdges scan with a per-kind
|
||
// tally — the exact loop the get_surprising_connections Go fallback
|
||
// already runs today, just exposed as a single method call so the
|
||
// the disk backend can short-circuit with a server-side GROUP BY.
|
||
//
|
||
// Empty graph returns nil so callers can short-circuit a downstream
|
||
// "kindCounts != nil" gate.
|
||
func (g *Graph) EdgeKindCounts() map[EdgeKind]int {
|
||
out := map[EdgeKind]int{}
|
||
for _, e := range g.AllEdges() {
|
||
if e == nil {
|
||
continue
|
||
}
|
||
out[e.Kind]++
|
||
}
|
||
if len(out) == 0 {
|
||
return nil
|
||
}
|
||
return out
|
||
}
|
||
|
||
// CrossRepoEdgeCounts is the in-memory reference implementation of
|
||
// CrossRepoEdgeAggregator. Iterates the four cross_repo_* byKind
|
||
// buckets and groups by (kind, fromRepoPrefix, toRepoPrefix). Same
|
||
// algorithm as the architecture handler's AllEdges loop but exposes
|
||
// it as a single capability so the disk backend can fold the join into
|
||
// one query.
|
||
//
|
||
// Returns nil when the graph carries no cross-repo edges (single-
|
||
// repo mode) so the caller's empty-list rendering kicks in without
|
||
// allocating.
|
||
func (g *Graph) CrossRepoEdgeCounts() []CrossRepoEdgeRow {
|
||
type key struct {
|
||
kind EdgeKind
|
||
fromRepo string
|
||
toRepo string
|
||
}
|
||
counts := map[key]int{}
|
||
for _, k := range []EdgeKind{
|
||
EdgeCrossRepoCalls,
|
||
EdgeCrossRepoImplements,
|
||
EdgeCrossRepoExtends,
|
||
} {
|
||
for e := range g.EdgesByKind(k) {
|
||
if e == nil {
|
||
continue
|
||
}
|
||
from := g.GetNode(e.From)
|
||
to := g.GetNode(e.To)
|
||
if from == nil || to == nil {
|
||
continue
|
||
}
|
||
counts[key{kind: e.Kind, fromRepo: from.RepoPrefix, toRepo: to.RepoPrefix}]++
|
||
}
|
||
}
|
||
if len(counts) == 0 {
|
||
return nil
|
||
}
|
||
out := make([]CrossRepoEdgeRow, 0, len(counts))
|
||
for k, c := range counts {
|
||
out = append(out, CrossRepoEdgeRow{
|
||
Kind: k.kind, FromRepo: k.fromRepo, ToRepo: k.toRepo, Count: c,
|
||
})
|
||
}
|
||
return out
|
||
}
|
||
|
||
// FileImportCounts is the in-memory reference implementation of
|
||
// FileImportAggregator. Iterates the EdgeImports byKind bucket and
|
||
// groups by the target file path — coalescing to To-node FilePath
|
||
// or, when the indexer pointed the import edge at the file node
|
||
// directly, the target ID. Same algorithm as the AllEdges loop in
|
||
// mostImportedFiles; the win lives in disk backends where AllEdges
|
||
// + per-edge GetNode round-trips over cgo dwarf the few hundred
|
||
// surviving rows.
|
||
//
|
||
// scope, when non-nil, bounds the result to edges whose target ID
|
||
// lies in the slice (session-workspace clamp). A nil scope counts
|
||
// every imports edge. An empty (non-nil) scope returns nil — never
|
||
// a whole-graph scan.
|
||
func (g *Graph) FileImportCounts(scope []string) []FileImportCountRow {
|
||
if scope != nil && len(scope) == 0 {
|
||
return nil
|
||
}
|
||
var allowed map[string]struct{}
|
||
if scope != nil {
|
||
allowed = make(map[string]struct{}, len(scope))
|
||
for _, id := range scope {
|
||
if id == "" {
|
||
continue
|
||
}
|
||
allowed[id] = struct{}{}
|
||
}
|
||
if len(allowed) == 0 {
|
||
return nil
|
||
}
|
||
}
|
||
counts := map[string]int{}
|
||
for e := range g.EdgesByKind(EdgeImports) {
|
||
if e == nil {
|
||
continue
|
||
}
|
||
target := g.GetNode(e.To)
|
||
if target == nil {
|
||
continue
|
||
}
|
||
if allowed != nil {
|
||
if _, ok := allowed[target.ID]; !ok {
|
||
continue
|
||
}
|
||
}
|
||
path := target.FilePath
|
||
if path == "" {
|
||
path = target.ID
|
||
}
|
||
if path == "" {
|
||
continue
|
||
}
|
||
counts[path]++
|
||
}
|
||
if len(counts) == 0 {
|
||
return nil
|
||
}
|
||
out := make([]FileImportCountRow, 0, len(counts))
|
||
for p, c := range counts {
|
||
out = append(out, FileImportCountRow{FilePath: p, Count: c})
|
||
}
|
||
return out
|
||
}
|
||
|
||
// SetEdgeProvenanceBatch is the batched sibling of SetEdgeProvenance.
|
||
// Same story as ReindexEdges: per-call in memory, one transaction in
|
||
// the disk backends. Returns the number of edges whose Origin
|
||
// actually changed (matches the sum of per-edge SetEdgeProvenance
|
||
// boolean returns).
|
||
func (g *Graph) SetEdgeProvenanceBatch(batch []EdgeProvenanceUpdate) int {
|
||
changed := 0
|
||
for _, u := range batch {
|
||
if u.Edge == nil {
|
||
continue
|
||
}
|
||
if g.SetEdgeProvenance(u.Edge, u.NewOrigin) {
|
||
changed++
|
||
}
|
||
}
|
||
return changed
|
||
}
|
||
|
||
// shardIdx picks the shard index for an ID using FNV-1a. Inlined to
|
||
// avoid the per-call hash-object allocation that the stdlib's
|
||
// fnv.New32a() incurs — shardIdx is on the hottest path in the graph
|
||
// (every AddNode / AddEdge / GetNode call), and the heap profile shows
|
||
// 690 MB/30 s of fnv state allocations during cold-start indexing.
|
||
// shardMask is shardCount-1 and shardCount is a power of two, so
|
||
// h & shardMask is an exact, branch-free modulo.
|
||
func (g *Graph) shardIdx(id string) int {
|
||
var h uint32 = 2166136261
|
||
for i := 0; i < len(id); i++ {
|
||
h ^= uint32(id[i])
|
||
h *= 16777619
|
||
}
|
||
return int(h & g.shardMask)
|
||
}
|
||
|
||
// shardFor returns the shard that owns the given ID.
|
||
func (g *Graph) shardFor(id string) *shard {
|
||
return g.shards[g.shardIdx(id)]
|
||
}
|
||
|
||
// lockTwoWrite locks two shards for write in ascending index order to
|
||
// prevent deadlock. If both IDs land in the same shard, the mutex is
|
||
// locked exactly once. Returns a closure the caller defers to unlock.
|
||
func (g *Graph) lockTwoWrite(idA, idB string) func() {
|
||
a := g.shardIdx(idA)
|
||
b := g.shardIdx(idB)
|
||
if a == b {
|
||
s := g.shards[a]
|
||
s.mu.Lock()
|
||
return s.mu.Unlock
|
||
}
|
||
lo, hi := a, b
|
||
if lo > hi {
|
||
lo, hi = hi, lo
|
||
}
|
||
sLo := g.shards[lo]
|
||
sHi := g.shards[hi]
|
||
sLo.mu.Lock()
|
||
sHi.mu.Lock()
|
||
return func() {
|
||
sHi.mu.Unlock()
|
||
sLo.mu.Unlock()
|
||
}
|
||
}
|
||
|
||
// lockThreeWrite locks up to three shards for write in ascending index
|
||
// order, deduplicating any that collide. Used by ReindexEdge, which
|
||
// mutates the From shard's outEdgeIdx plus both in-edge buckets when a
|
||
// resolver step retargets an edge; missing any of the three races with
|
||
// concurrent AddEdge on that shard (bug: "concurrent map read and map
|
||
// write" in addEdgeToBucket).
|
||
func (g *Graph) lockThreeWrite(idA, idB, idC string) func() {
|
||
a, b, c := g.shardIdx(idA), g.shardIdx(idB), g.shardIdx(idC)
|
||
// Sort (a, b, c) ascending without allocating a slice.
|
||
if a > b {
|
||
a, b = b, a
|
||
}
|
||
if b > c {
|
||
b, c = c, b
|
||
}
|
||
if a > b {
|
||
a, b = b, a
|
||
}
|
||
// Dedupe: lock each distinct index once.
|
||
idxs := [3]int{a, -1, -1}
|
||
n := 1
|
||
if b != a {
|
||
idxs[n] = b
|
||
n++
|
||
}
|
||
if c != b && c != a {
|
||
idxs[n] = c
|
||
n++
|
||
}
|
||
for i := 0; i < n; i++ {
|
||
g.shards[idxs[i]].mu.Lock()
|
||
}
|
||
return func() {
|
||
for i := n - 1; i >= 0; i-- {
|
||
g.shards[idxs[i]].mu.Unlock()
|
||
}
|
||
}
|
||
}
|
||
|
||
// lockAllWrite / lockAllRead take every shard's lock in order. Used by
|
||
// operations that have to touch the whole graph (AllNodes, Stats,
|
||
// EvictRepo). Callers must match with unlockAllWrite / unlockAllRead.
|
||
func (g *Graph) lockAllWrite() {
|
||
for _, s := range g.shards {
|
||
s.mu.Lock()
|
||
}
|
||
}
|
||
|
||
func (g *Graph) unlockAllWrite() {
|
||
for i := len(g.shards) - 1; i >= 0; i-- {
|
||
g.shards[i].mu.Unlock()
|
||
}
|
||
}
|
||
|
||
func (g *Graph) lockAllRead() {
|
||
for _, s := range g.shards {
|
||
s.mu.RLock()
|
||
}
|
||
}
|
||
|
||
func (g *Graph) unlockAllRead() {
|
||
for i := len(g.shards) - 1; i >= 0; i-- {
|
||
g.shards[i].mu.RUnlock()
|
||
}
|
||
}
|
||
|
||
// AddNode inserts or updates a node in the graph and all secondary
|
||
// indexes. Idempotent — a second call with the same ID replaces the
|
||
// existing Node pointer in place instead of appending duplicates to the
|
||
// byFile / byName / byRepo slices. If the new Node's FilePath, Name, or
|
||
// RepoPrefix differs from the stored one, the secondary-index entries
|
||
// are migrated from the old bucket to the new one atomically under the
|
||
// shard lock.
|
||
func (g *Graph) AddNode(n *Node) {
|
||
s := g.shardFor(n.ID)
|
||
s.mu.Lock()
|
||
g.addNodeLocked(s, n)
|
||
s.mu.Unlock()
|
||
}
|
||
|
||
// addNodeLocked is AddNode's body, expecting the caller to already hold
|
||
// the shard's write lock. Used by AddBatch to amortise lock acquisition
|
||
// across many node inserts targeting the same shard.
|
||
func (g *Graph) addNodeLocked(s *shard, n *Node) {
|
||
prev, hadPrev := s.nodes[n.ID]
|
||
// Subtract the previous size/count before overwriting; the new
|
||
// node's contribution is re-added after the RepoPrefix-preservation
|
||
// logic below has settled on the final prefix.
|
||
if hadPrev {
|
||
s.repoNodeRemove(prev)
|
||
}
|
||
s.nodes[n.ID] = n
|
||
|
||
if hadPrev {
|
||
if prev.FilePath != n.FilePath {
|
||
removeNodeFromBucket(s.byFile, s.byFileIdx, prev.FilePath, n.ID)
|
||
}
|
||
if prev.Name != n.Name {
|
||
removeNodeFromBucket(s.byName, s.byNameIdx, prev.Name, n.ID)
|
||
}
|
||
if prev.QualName != n.QualName && prev.QualName != "" {
|
||
// byQual is a 1:1 index, not a slice — only delete when
|
||
// the stored entry still points at this node ID (a
|
||
// different node may have since taken the slot).
|
||
if cur, ok := s.byQual[prev.QualName]; ok && cur.ID == n.ID {
|
||
delete(s.byQual, prev.QualName)
|
||
}
|
||
}
|
||
if prev.RepoPrefix != n.RepoPrefix && prev.RepoPrefix != "" {
|
||
// Preserve a previously-set RepoPrefix rather than letting
|
||
// an empty-prefix re-add silently strip the node out of its
|
||
// byRepo bucket. The downgrade-to-empty case has no
|
||
// legitimate caller (contract nodes use distinct IDs that
|
||
// never collide with symbol IDs; the parse and IncrementalReindex
|
||
// paths route through applyRepoPrefix which always stamps
|
||
// the active idx.repoPrefix on the new node) and previously
|
||
// caused per-repo `byRepo[prefix]` to drain mid-warmup,
|
||
// breaking RepoStats / RepoMemoryEstimate / GetRepoNodes.
|
||
// Restore the old prefix on the new node so the bucket
|
||
// stays populated. The legitimate RepoPrefix-change case
|
||
// (snapshot prefix → new prefix because config moved) still
|
||
// works because n.RepoPrefix is non-empty there.
|
||
if n.RepoPrefix == "" {
|
||
n.RepoPrefix = prev.RepoPrefix
|
||
} else {
|
||
removeNodeFromBucket(s.byRepo, s.byRepoIdx, prev.RepoPrefix, n.ID)
|
||
}
|
||
}
|
||
}
|
||
|
||
addNodeToBucket(s.byFile, s.byFileIdx, n.FilePath, n.ID, n)
|
||
addNodeToBucket(s.byName, s.byNameIdx, n.Name, n.ID, n)
|
||
if n.QualName != "" {
|
||
s.byQual[n.QualName] = n
|
||
}
|
||
if n.RepoPrefix != "" {
|
||
addNodeToBucket(s.byRepo, s.byRepoIdx, n.RepoPrefix, n.ID, n)
|
||
}
|
||
s.repoNodeAdd(n)
|
||
}
|
||
|
||
// AddBatch inserts a set of nodes and edges in shard-grouped passes,
|
||
// acquiring each involved shard's write lock at most once across the
|
||
// whole batch. Replaces the O(N + 2E) per-item lock acquisitions of
|
||
// AddNode / AddEdge with O(distinct_shards) — typically ~16 instead of
|
||
// ~450 per per-file worker batch. The contention profile measured 69
|
||
// of 102 goroutines blocked on lockTwoWrite during cold-start parsing;
|
||
// batching is the throughput fix.
|
||
//
|
||
// Observable semantics differ slightly from a sequence of AddNode /
|
||
// AddEdge calls: for cross-shard edges, the From shard's outEdges
|
||
// receives the edge before the To shard's inEdges does. Readers that
|
||
// query one side only see the change atomically per shard; readers
|
||
// that join both sides may briefly see an outgoing edge whose
|
||
// reciprocal in-edge hasn't landed yet. The parser worker path that
|
||
// drives this is followed by ResolveAll / global derivation passes
|
||
// that take the resolver mutex graph-wide, so no concurrent reader is
|
||
// expected to depend on cross-side atomicity during warmup.
|
||
func (g *Graph) AddBatch(nodes []*Node, edges []*Edge) {
|
||
if len(nodes) == 0 && len(edges) == 0 {
|
||
return
|
||
}
|
||
nodesByShard := make([][]*Node, g.shardCount)
|
||
outEdgesByShard := make([][]*Edge, g.shardCount)
|
||
inEdgesByShard := make([][]*Edge, g.shardCount)
|
||
for _, n := range nodes {
|
||
if n == nil || n.ID == "" {
|
||
continue
|
||
}
|
||
i := g.shardIdx(n.ID)
|
||
nodesByShard[i] = append(nodesByShard[i], n)
|
||
}
|
||
for _, e := range edges {
|
||
if e == nil {
|
||
continue
|
||
}
|
||
outEdgesByShard[g.shardIdx(e.From)] = append(outEdgesByShard[g.shardIdx(e.From)], e)
|
||
inEdgesByShard[g.shardIdx(e.To)] = append(inEdgesByShard[g.shardIdx(e.To)], e)
|
||
}
|
||
|
||
for i := range g.shards {
|
||
if len(nodesByShard[i]) == 0 && len(outEdgesByShard[i]) == 0 && len(inEdgesByShard[i]) == 0 {
|
||
continue
|
||
}
|
||
s := g.shards[i]
|
||
s.mu.Lock()
|
||
for _, n := range nodesByShard[i] {
|
||
g.addNodeLocked(s, n)
|
||
}
|
||
// Out-side writes own the "was this a new insert?" signal that
|
||
// drives the per-repo edge counter and the "did Origin change?"
|
||
// signal that drives the identity-revision counter — the in-side
|
||
// write is bookkeeping only and charges neither (mirrors
|
||
// AddEdge's behaviour).
|
||
for _, e := range outEdgesByShard[i] {
|
||
inserted, originChanged := addEdgeToBucket(s.outEdges, s.outEdgeKeys, s.outEdgeIdx, e.From, e)
|
||
if originChanged {
|
||
g.edgeIdentityRevisions.Add(1)
|
||
}
|
||
if inserted {
|
||
g.edgeMutGen.Add(1)
|
||
var srcRepo string
|
||
if src, ok := s.nodes[e.From]; ok && src != nil {
|
||
srcRepo = src.RepoPrefix
|
||
}
|
||
s.repoEdgeAdd(srcRepo, e)
|
||
}
|
||
}
|
||
for _, e := range inEdgesByShard[i] {
|
||
addEdgeToBucket(s.inEdges, s.inEdgeKeys, s.inEdgeIdx, e.To, e)
|
||
}
|
||
s.mu.Unlock()
|
||
}
|
||
}
|
||
|
||
// AddEdge inserts or updates a directed edge in the graph. Locks both
|
||
// the From and To shards (same shard locked once if they collide) so
|
||
// outEdges and inEdges stay consistent. Idempotent: a second call with
|
||
// the same (From, To, Kind, FilePath, Line) replaces the stored *Edge
|
||
// pointer in place — newer metadata (Confidence, Origin, etc.) wins,
|
||
// adjacency-list length is unchanged. Drops the double-edge problem
|
||
// that used to surface after daemon restarts (bug B1).
|
||
func (g *Graph) AddEdge(e *Edge) {
|
||
unlock := g.lockTwoWrite(e.From, e.To)
|
||
defer unlock()
|
||
sFrom := g.shardFor(e.From)
|
||
sTo := g.shardFor(e.To)
|
||
// Only charge the source-repo counter on a brand-new insert.
|
||
// Idempotent re-adds (same edgeKey) replace the slot in place and
|
||
// would otherwise double-count. addEdgeToBucket reports inserted ==
|
||
// true only when it actually appended. The out-side write owns both
|
||
// signals: the new-insert flag (per-repo counter) and the origin-
|
||
// changed flag (identity-revision counter). The in-side write is
|
||
// bookkeeping only — charging either counter there would double it.
|
||
inserted, originChanged := addEdgeToBucket(sFrom.outEdges, sFrom.outEdgeKeys, sFrom.outEdgeIdx, e.From, e)
|
||
addEdgeToBucket(sTo.inEdges, sTo.inEdgeKeys, sTo.inEdgeIdx, e.To, e)
|
||
if originChanged {
|
||
// A re-add with the same logical key but an upgraded Origin:
|
||
// the old identity is retired, a new one created.
|
||
g.edgeIdentityRevisions.Add(1)
|
||
}
|
||
if inserted {
|
||
g.edgeMutGen.Add(1)
|
||
var srcRepo string
|
||
if src, ok := sFrom.nodes[e.From]; ok && src != nil {
|
||
srcRepo = src.RepoPrefix
|
||
}
|
||
sFrom.repoEdgeAdd(srcRepo, e)
|
||
}
|
||
}
|
||
|
||
// SetEdgeProvenance changes the Origin of an edge already in the graph
|
||
// and is the only sanctioned way to do so. Conceptually it is a
|
||
// delete-then-insert of the edge's identity: because Origin is part of
|
||
// IdentityHash, the old provenance-bearing identity is retired and a
|
||
// new one created — even though the logical (From,To,Kind,FilePath,
|
||
// Line) key, and therefore the adjacency-list slot, is unchanged.
|
||
//
|
||
// It computes the edge's old and new IdentityHash. When they are equal
|
||
// (newOrigin matches the current Origin) nothing changes and it returns
|
||
// false. When they differ it applies e.Origin = newOrigin, re-derives
|
||
// the Origin-derived Tier label when one was set (Confidence and
|
||
// ConfidenceLabel are score-derived, not Origin-derived, so they are
|
||
// left intact), increments the graph-level identity-revision counter,
|
||
// and returns true.
|
||
//
|
||
// Mutating Edge.Origin directly on an in-graph edge bypasses the
|
||
// counter and is a provenance-tampering bug — route every such change
|
||
// here so the churn stays observable via EdgeIdentityRevisions.
|
||
func (g *Graph) SetEdgeProvenance(e *Edge, newOrigin string) bool {
|
||
if e == nil {
|
||
return false
|
||
}
|
||
unlock := g.lockTwoWrite(e.From, e.To)
|
||
defer unlock()
|
||
oldIdentity := e.IdentityHash()
|
||
newIdentity := hashEdgeIdentity(keyOf(e), newOrigin)
|
||
if oldIdentity == newIdentity {
|
||
return false
|
||
}
|
||
e.Origin = newOrigin
|
||
// Tier is a pure projection of Origin (graph.ResolvedBy). Re-derive
|
||
// it only when it was already populated — an empty Tier is the
|
||
// in-memory default and re-deriving would silently start stamping
|
||
// it. The same *Edge pointer lives in both the out- and in-edge
|
||
// buckets, so this write is visible from every adjacency view.
|
||
if e.Tier != "" {
|
||
e.Tier = ResolvedBy(newOrigin)
|
||
}
|
||
g.edgeIdentityRevisions.Add(1)
|
||
return true
|
||
}
|
||
|
||
// EdgeIdentityRevisions returns how many times an in-graph edge's
|
||
// provenance-bearing identity has changed over this graph's lifetime —
|
||
// the running total fed by SetEdgeProvenance and by AddEdge's in-place
|
||
// re-add path. It is monotonic and never decremented; a reindex that
|
||
// retires and recreates edges does not roll it back. Surfaced through
|
||
// graph_stats as the tamper-evidence signal for provenance churn.
|
||
func (g *Graph) EdgeIdentityRevisions() int {
|
||
return int(g.edgeIdentityRevisions.Load())
|
||
}
|
||
|
||
// VerifyEdgeIdentities walks every edge and confirms its provenance-
|
||
// bearing identity is internally consistent: the edge stored in a
|
||
// source node's outEdges bucket and the edge stored in the target
|
||
// node's inEdges bucket are the same *Edge pointer and therefore agree
|
||
// on IdentityHash. addEdgeToBucket stores one shared pointer in both
|
||
// buckets, so a consistent graph always passes; a divergence means
|
||
// some code mutated Origin on a copied edge (e.g. a resolver clone)
|
||
// and wrote it into only one adjacency view, leaving the two sides
|
||
// disagreeing about provenance. Returns nil when every edge is
|
||
// consistent, or an error naming the first divergent edge.
|
||
//
|
||
// This is the assertion a test uses to prove provenance cannot be
|
||
// silently changed outside SetEdgeProvenance.
|
||
func (g *Graph) VerifyEdgeIdentities() error {
|
||
g.lockAllRead()
|
||
defer g.unlockAllRead()
|
||
for _, s := range g.shards {
|
||
for _, edges := range s.outEdges {
|
||
for _, e := range edges {
|
||
if e == nil {
|
||
continue
|
||
}
|
||
want := e.IdentityHash()
|
||
sTo := g.shardFor(e.To)
|
||
found := false
|
||
for _, in := range sTo.inEdges[e.To] {
|
||
if in == e {
|
||
if in.IdentityHash() != want {
|
||
return &edgeIdentityError{edge: e, reason: "inEdges pointer disagrees on identity hash"}
|
||
}
|
||
found = true
|
||
break
|
||
}
|
||
}
|
||
if !found {
|
||
return &edgeIdentityError{edge: e, reason: "outEdges edge missing from target inEdges bucket"}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// edgeIdentityError reports the first edge VerifyEdgeIdentities found
|
||
// to be inconsistent across the out- and in-edge adjacency views.
|
||
type edgeIdentityError struct {
|
||
edge *Edge
|
||
reason string
|
||
}
|
||
|
||
func (e *edgeIdentityError) Error() string {
|
||
return "edge identity inconsistent (" + e.reason + "): " +
|
||
e.edge.From + " -" + string(e.edge.Kind) + "-> " + e.edge.To
|
||
}
|
||
|
||
// ReindexEdge updates the inEdges index after an edge's To field has
|
||
// been mutated (e.g., by the resolver changing "unresolved::X" to a
|
||
// real target). oldTo is the previous value of e.To before mutation.
|
||
//
|
||
// Three sidecar entries change: the outEdgeIdx key for From (since the
|
||
// edgeKey depends on To), the inEdgeIdx entry on the old target bucket
|
||
// (removed), and the inEdgeIdx entry on the new target bucket (added).
|
||
func (g *Graph) ReindexEdge(e *Edge, oldTo string) {
|
||
if oldTo == e.To {
|
||
return
|
||
}
|
||
// Must lock the From shard too — we mutate sFrom.outEdgeIdx below,
|
||
// and without its lock a concurrent AddEdge on From panics the
|
||
// runtime with "concurrent map read and map write".
|
||
unlock := g.lockThreeWrite(e.From, oldTo, e.To)
|
||
defer unlock()
|
||
|
||
// Old identity uses oldTo; the current edge struct already has the
|
||
// new To set, so we reconstruct the key before mutation.
|
||
oldKey := hashEdgeKey(edgeKey{From: e.From, To: oldTo, Kind: e.Kind, FilePath: e.FilePath, Line: e.Line})
|
||
newKey := hashEdgeKey(keyOf(e))
|
||
|
||
sFrom := g.shardFor(e.From)
|
||
// outEdges slot position doesn't move — only the key under which
|
||
// the sidecar records it changes. Avoid a churn of slice growth by
|
||
// swapping the sidecar entry in place.
|
||
//
|
||
// The parallel outEdgeKeys slice MUST be updated alongside
|
||
// outEdgeIdx. removeEdgeFromBucket reads outEdgeKeys[pos] to
|
||
// learn the swapped slot's insertion-time key during swap-with-
|
||
// last; leaving outEdgeKeys stale here would re-insert the old
|
||
// key into outEdgeIdx pointing at a swapped position, and the
|
||
// next swap on that key would compute a pos past the (now
|
||
// shorter) slice — the exact index-out-of-range panic that
|
||
// surfaces during evictEdgesLocked when warmup retargets a lot
|
||
// of edges via ReindexEdge.
|
||
if fromIdx, ok := sFrom.outEdgeIdx[e.From]; ok {
|
||
if pos, exists := fromIdx[oldKey]; exists {
|
||
delete(fromIdx, oldKey)
|
||
fromIdx[newKey] = pos
|
||
if keys, ok := sFrom.outEdgeKeys[e.From]; ok && pos < len(keys) {
|
||
keys[pos] = newKey
|
||
}
|
||
}
|
||
}
|
||
|
||
// Move from the old target's inEdges bucket to the new one.
|
||
sOld := g.shardFor(oldTo)
|
||
removeEdgeFromBucket(sOld.inEdges, sOld.inEdgeKeys, sOld.inEdgeIdx, oldTo, oldKey)
|
||
sNew := g.shardFor(e.To)
|
||
addEdgeToBucket(sNew.inEdges, sNew.inEdgeKeys, sNew.inEdgeIdx, e.To, e)
|
||
// No edgeMutGen bump here: outEdges retains the same *Edge slot,
|
||
// and the cached AllEdges slice holds pointers — readers see the
|
||
// already-mutated e.To via that pointer. The cache stays valid.
|
||
}
|
||
|
||
// GetNode returns a node by ID, or nil if not found.
|
||
func (g *Graph) GetNode(id string) *Node {
|
||
s := g.shardFor(id)
|
||
s.mu.RLock()
|
||
defer s.mu.RUnlock()
|
||
return s.nodes[id]
|
||
}
|
||
|
||
// GetNodeByQualName returns a node by fully-qualified name, or nil.
|
||
// The qual name index is partitioned across shards (each shard owns the
|
||
// qual names of the nodes it stores), so we ask every shard.
|
||
func (g *Graph) GetNodeByQualName(qualName string) *Node {
|
||
for _, s := range g.shards {
|
||
s.mu.RLock()
|
||
if n, ok := s.byQual[qualName]; ok {
|
||
s.mu.RUnlock()
|
||
return n
|
||
}
|
||
s.mu.RUnlock()
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// GetNodesByQualNames is the batch form of GetNodeByQualName — returns
|
||
// only the qual_names that have a node (an absent key means "no node").
|
||
// The in-memory byQual index makes each lookup O(1); the method exists
|
||
// for Store-interface parity with the disk backend, where it collapses
|
||
// N per-edge qual_name scans into a single IN-scan.
|
||
func (g *Graph) GetNodesByQualNames(qualNames []string) map[string]*Node {
|
||
out := make(map[string]*Node, len(qualNames))
|
||
for _, q := range qualNames {
|
||
if q == "" {
|
||
continue
|
||
}
|
||
if _, done := out[q]; done {
|
||
continue
|
||
}
|
||
for _, s := range g.shards {
|
||
s.mu.RLock()
|
||
n, ok := s.byQual[q]
|
||
s.mu.RUnlock()
|
||
if ok {
|
||
out[q] = n
|
||
break
|
||
}
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// FindNodesByName returns all nodes matching the short name.
|
||
//
|
||
// Implementation walks every shard's byName bucket. The two-pass shape
|
||
// (sum then allocate) trades one extra read-lock round trip per shard
|
||
// for a single right-sized allocation — the prior single-pass append
|
||
// re-grew `out` on every hot shard (1 + log2(N) reallocations), which
|
||
// the cold-index heap profile attributed 5.22 GB / 14% of total alloc
|
||
// to. Names with a long candidate list (`Visit`, `init`, `create`)
|
||
// see the biggest win.
|
||
func (g *Graph) FindNodesByName(name string) []*Node {
|
||
total := 0
|
||
for _, s := range g.shards {
|
||
s.mu.RLock()
|
||
total += len(s.byName[name])
|
||
s.mu.RUnlock()
|
||
}
|
||
if total == 0 {
|
||
return nil
|
||
}
|
||
out := make([]*Node, 0, total)
|
||
for _, s := range g.shards {
|
||
s.mu.RLock()
|
||
if src := s.byName[name]; len(src) > 0 {
|
||
out = append(out, src...)
|
||
}
|
||
s.mu.RUnlock()
|
||
}
|
||
return out
|
||
}
|
||
|
||
// FindNodesByNameInRepo returns nodes matching the short name that are
|
||
// either in the given repoPrefix or carry an empty RepoPrefix (synthetic
|
||
// / stdlib nodes — kept same-repo by convention). Equivalent to
|
||
// filterSameRepo(repoPrefix, FindNodesByName(name)) but skips the
|
||
// intermediate cross-repo candidate slice.
|
||
//
|
||
// In single-repo graphs (repoPrefix == ""), behaves identically to
|
||
// FindNodesByName.
|
||
func (g *Graph) FindNodesByNameInRepo(name, repoPrefix string) []*Node {
|
||
if repoPrefix == "" {
|
||
return g.FindNodesByName(name)
|
||
}
|
||
// First pass: count matches that pass the repo filter. Counting in
|
||
// a separate pass keeps `out` right-sized even when ~95% of the
|
||
// byName bucket lives in unrelated repos.
|
||
total := 0
|
||
for _, s := range g.shards {
|
||
s.mu.RLock()
|
||
for _, n := range s.byName[name] {
|
||
if n.RepoPrefix == "" || n.RepoPrefix == repoPrefix {
|
||
total++
|
||
}
|
||
}
|
||
s.mu.RUnlock()
|
||
}
|
||
if total == 0 {
|
||
return nil
|
||
}
|
||
out := make([]*Node, 0, total)
|
||
for _, s := range g.shards {
|
||
s.mu.RLock()
|
||
for _, n := range s.byName[name] {
|
||
if n.RepoPrefix == "" || n.RepoPrefix == repoPrefix {
|
||
out = append(out, n)
|
||
}
|
||
}
|
||
s.mu.RUnlock()
|
||
}
|
||
return out
|
||
}
|
||
|
||
// FindNodesByNameContaining returns nodes whose Name (case-insensitive)
|
||
// contains substr. The in-memory backend has no name-substring index,
|
||
// so this is a single pass over the byName buckets (which already group
|
||
// nodes by exact name — the same allocation we'd pay for one FindNodesByName
|
||
// call per distinct name). limit caps the slice; 0 means "no limit".
|
||
//
|
||
// Stable order is the caller's responsibility — bucket iteration is
|
||
// deterministic per shard but cross-shard order isn't fixed.
|
||
func (g *Graph) FindNodesByNameContaining(substr string, limit int) []*Node {
|
||
if substr == "" {
|
||
return nil
|
||
}
|
||
needle := strings.ToLower(substr)
|
||
var out []*Node
|
||
for _, s := range g.shards {
|
||
s.mu.RLock()
|
||
for name, bucket := range s.byName {
|
||
if !strings.Contains(strings.ToLower(name), needle) {
|
||
continue
|
||
}
|
||
out = append(out, bucket...)
|
||
if limit > 0 && len(out) >= limit {
|
||
s.mu.RUnlock()
|
||
return out[:limit]
|
||
}
|
||
}
|
||
s.mu.RUnlock()
|
||
}
|
||
if limit > 0 && len(out) > limit {
|
||
out = out[:limit]
|
||
}
|
||
return out
|
||
}
|
||
|
||
// GetFileNodes returns all nodes defined in the given file.
|
||
func (g *Graph) GetFileNodes(filePath string) []*Node {
|
||
var out []*Node
|
||
for _, s := range g.shards {
|
||
s.mu.RLock()
|
||
if src := s.byFile[filePath]; len(src) > 0 {
|
||
out = append(out, src...)
|
||
}
|
||
s.mu.RUnlock()
|
||
}
|
||
return out
|
||
}
|
||
|
||
// GetOutEdges returns outgoing edges for a node.
|
||
func (g *Graph) GetOutEdges(nodeID string) []*Edge {
|
||
s := g.shardFor(nodeID)
|
||
s.mu.RLock()
|
||
defer s.mu.RUnlock()
|
||
src := s.outEdges[nodeID]
|
||
out := make([]*Edge, len(src))
|
||
copy(out, src)
|
||
return out
|
||
}
|
||
|
||
// GetInEdges returns incoming edges for a node.
|
||
func (g *Graph) GetInEdges(nodeID string) []*Edge {
|
||
s := g.shardFor(nodeID)
|
||
s.mu.RLock()
|
||
defer s.mu.RUnlock()
|
||
src := s.inEdges[nodeID]
|
||
out := make([]*Edge, len(src))
|
||
copy(out, src)
|
||
return out
|
||
}
|
||
|
||
// GetOutEdgesByNodeIDs returns a map id→outgoing edges for every input
|
||
// id. The in-memory backend loops the existing GetOutEdges — cost
|
||
// matches a hand-written loop in the caller. The value of the batched
|
||
// API lives in the disk backend, where it collapses N point lookups into
|
||
// one bulk query. Empty input returns nil; duplicate ids are
|
||
// deduped naturally. Missing ids are absent from the returned map.
|
||
func (g *Graph) GetOutEdgesByNodeIDs(ids []string) map[string][]*Edge {
|
||
if len(ids) == 0 {
|
||
return nil
|
||
}
|
||
out := make(map[string][]*Edge, len(ids))
|
||
for _, id := range ids {
|
||
if id == "" {
|
||
continue
|
||
}
|
||
if _, ok := out[id]; ok {
|
||
continue
|
||
}
|
||
out[id] = g.GetOutEdges(id)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// GetInEdgesByNodeIDs is the inbound sibling of GetOutEdgesByNodeIDs.
|
||
// See that doc-comment for the contract.
|
||
func (g *Graph) GetInEdgesByNodeIDs(ids []string) map[string][]*Edge {
|
||
if len(ids) == 0 {
|
||
return nil
|
||
}
|
||
out := make(map[string][]*Edge, len(ids))
|
||
for _, id := range ids {
|
||
if id == "" {
|
||
continue
|
||
}
|
||
if _, ok := out[id]; ok {
|
||
continue
|
||
}
|
||
out[id] = g.GetInEdges(id)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// EvictFile removes all nodes and edges belonging to the given file
|
||
// path. Nodes for one file can span many shards (different IDs hash
|
||
// differently), so we lock all shards for this multi-shard operation.
|
||
func (g *Graph) EvictFile(filePath string) (nodesRemoved, edgesRemoved int) {
|
||
g.lockAllWrite()
|
||
defer g.unlockAllWrite()
|
||
|
||
// Gather nodes across shards.
|
||
var nodes []*Node
|
||
for _, s := range g.shards {
|
||
nodes = append(nodes, s.byFile[filePath]...)
|
||
}
|
||
if len(nodes) == 0 {
|
||
return 0, 0
|
||
}
|
||
// id → source-repo captured BEFORE we delete the node from
|
||
// s.nodes; evictEdgesLocked needs the repo to debit per-repo
|
||
// edge counters and the live node would already be gone.
|
||
evictedIDs := make(map[string]string, len(nodes))
|
||
for _, n := range nodes {
|
||
evictedIDs[n.ID] = n.RepoPrefix
|
||
}
|
||
|
||
for _, n := range nodes {
|
||
s := g.shardFor(n.ID)
|
||
s.repoNodeRemove(n)
|
||
delete(s.nodes, n.ID)
|
||
if n.QualName != "" {
|
||
if cur, ok := s.byQual[n.QualName]; ok && cur.ID == n.ID {
|
||
delete(s.byQual, n.QualName)
|
||
}
|
||
}
|
||
removeNodeFromBucket(s.byName, s.byNameIdx, n.Name, n.ID)
|
||
removeNodeFromBucket(s.byFile, s.byFileIdx, filePath, n.ID)
|
||
if n.RepoPrefix != "" {
|
||
removeNodeFromBucket(s.byRepo, s.byRepoIdx, n.RepoPrefix, n.ID)
|
||
}
|
||
}
|
||
nodesRemoved = len(nodes)
|
||
|
||
edgesRemoved = g.evictEdgesLocked(evictedIDs)
|
||
return nodesRemoved, edgesRemoved
|
||
}
|
||
|
||
// evictEdgesLocked is the shared edge-removal core used by EvictFile
|
||
// and EvictRepo. Callers must hold every shard's write lock.
|
||
//
|
||
// For each evicted node we remove its outEdges and inEdges entries. To
|
||
// clean the reverse index on non-evicted endpoints we do a swap-with-
|
||
// last removal via sidecar, which is O(1) per edge instead of the
|
||
// O(slice-size) filterEdge scan the older implementation used.
|
||
func (g *Graph) evictEdgesLocked(evictedIDs map[string]string) int {
|
||
removed := 0
|
||
defer func() {
|
||
if removed > 0 {
|
||
g.edgeMutGen.Add(1)
|
||
}
|
||
}()
|
||
|
||
// Phase 1: remove outgoing edges from every evicted node. Use the
|
||
// parallel outEdgeKeys slice to look up each entry's insertion-time
|
||
// edgeKey rather than recomputing keyOf — the latter races with
|
||
// resolver-driven Edge.To mutations elsewhere in the graph and
|
||
// can yield a key that doesn't match the inEdges sidecar.
|
||
for id, srcRepo := range evictedIDs {
|
||
s := g.shardFor(id)
|
||
edges := s.outEdges[id]
|
||
keys := s.outEdgeKeys[id]
|
||
removed += len(edges)
|
||
for i, e := range edges {
|
||
s.repoEdgeRemove(srcRepo, e)
|
||
if _, evicted := evictedIDs[e.To]; !evicted {
|
||
sTo := g.shardFor(e.To)
|
||
removeEdgeFromBucket(sTo.inEdges, sTo.inEdgeKeys, sTo.inEdgeIdx, e.To, keys[i])
|
||
}
|
||
}
|
||
delete(s.outEdges, id)
|
||
delete(s.outEdgeKeys, id)
|
||
delete(s.outEdgeIdx, id)
|
||
}
|
||
|
||
// Phase 2: remove incoming edges to every evicted node (from
|
||
// non-evicted sources — same-direction edges were already handled
|
||
// in phase 1 and counted). Each surviving source's repo is read
|
||
// from its live node — sFrom.nodes[e.From] is still present in
|
||
// this phase because that source wasn't evicted.
|
||
for id := range evictedIDs {
|
||
s := g.shardFor(id)
|
||
edges := s.inEdges[id]
|
||
keys := s.inEdgeKeys[id]
|
||
for i, e := range edges {
|
||
if _, evicted := evictedIDs[e.From]; !evicted {
|
||
removed++
|
||
sFrom := g.shardFor(e.From)
|
||
var srcRepo string
|
||
if src, ok := sFrom.nodes[e.From]; ok && src != nil {
|
||
srcRepo = src.RepoPrefix
|
||
}
|
||
removeEdgeFromBucket(sFrom.outEdges, sFrom.outEdgeKeys, sFrom.outEdgeIdx, e.From, keys[i])
|
||
sFrom.repoEdgeRemove(srcRepo, e)
|
||
}
|
||
}
|
||
delete(s.inEdges, id)
|
||
delete(s.inEdgeKeys, id)
|
||
delete(s.inEdgeIdx, id)
|
||
}
|
||
|
||
return removed
|
||
}
|
||
|
||
// RemoveEdge removes a specific edge by from, to, and kind. Returns
|
||
// true if the edge was found and removed. When multiple edges match
|
||
// (same from/to/kind but different file/line — rare but possible),
|
||
// removes the first one encountered.
|
||
func (g *Graph) RemoveEdge(from, to string, kind EdgeKind) bool {
|
||
unlock := g.lockTwoWrite(from, to)
|
||
defer unlock()
|
||
|
||
sFrom := g.shardFor(from)
|
||
outList := sFrom.outEdges[from]
|
||
outKeys := sFrom.outEdgeKeys[from]
|
||
targetIdx := -1
|
||
for i, e := range outList {
|
||
if e.To == to && e.Kind == kind {
|
||
targetIdx = i
|
||
break
|
||
}
|
||
}
|
||
if targetIdx < 0 {
|
||
return false
|
||
}
|
||
|
||
// Snapshot the edge plus its source-repo before mutating the
|
||
// buckets — once removeEdgeFromBucket swaps the tail in, outList[i]
|
||
// is no longer the edge we're removing.
|
||
removed := outList[targetIdx]
|
||
var srcRepo string
|
||
if src, ok := sFrom.nodes[from]; ok && src != nil {
|
||
srcRepo = src.RepoPrefix
|
||
}
|
||
|
||
// Use the stored insertion-time key rather than keyOf(target) so
|
||
// removal is robust against in-flight Edge.To mutations.
|
||
k := outKeys[targetIdx]
|
||
removeEdgeFromBucket(sFrom.outEdges, sFrom.outEdgeKeys, sFrom.outEdgeIdx, from, k)
|
||
sTo := g.shardFor(to)
|
||
removeEdgeFromBucket(sTo.inEdges, sTo.inEdgeKeys, sTo.inEdgeIdx, to, k)
|
||
sFrom.repoEdgeRemove(srcRepo, removed)
|
||
g.edgeMutGen.Add(1)
|
||
return true
|
||
}
|
||
|
||
// NodeCount returns the total number of nodes.
|
||
func (g *Graph) NodeCount() int {
|
||
total := 0
|
||
for _, s := range g.shards {
|
||
s.mu.RLock()
|
||
total += len(s.nodes)
|
||
s.mu.RUnlock()
|
||
}
|
||
return total
|
||
}
|
||
|
||
// EdgeCount returns the total number of edges.
|
||
func (g *Graph) EdgeCount() int {
|
||
total := 0
|
||
for _, s := range g.shards {
|
||
s.mu.RLock()
|
||
for _, edges := range s.outEdges {
|
||
total += len(edges)
|
||
}
|
||
s.mu.RUnlock()
|
||
}
|
||
return total
|
||
}
|
||
|
||
// AllNodes returns a snapshot of all nodes. Locks every shard for read
|
||
// to produce a coherent view — callers use this for snapshots,
|
||
// contracts extraction, etc. where a consistent crop matters.
|
||
func (g *Graph) AllNodes() []*Node {
|
||
g.lockAllRead()
|
||
defer g.unlockAllRead()
|
||
total := 0
|
||
for _, s := range g.shards {
|
||
total += len(s.nodes)
|
||
}
|
||
out := make([]*Node, 0, total)
|
||
for _, s := range g.shards {
|
||
for _, n := range s.nodes {
|
||
out = append(out, n)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// AllEdges returns a snapshot of all outgoing edges across every shard.
|
||
//
|
||
// Cached by the edgeMutGen counter: once built, subsequent calls
|
||
// return the same slice pointer as long as no edge has been
|
||
// inserted, removed, or had its slot pointer replaced. Mutations
|
||
// bump edgeMutGen, the next AllEdges sees the mismatch and rebuilds.
|
||
//
|
||
// On a 4 M-edge graph (k8s) one snapshot is ~32 MB of pointer slice;
|
||
// the post-resolve analysis fan-out (cycles, communities, deadcode,
|
||
// hierarchy, pagerank, hits, betweenness, several MCP analyzers) used
|
||
// to call this dozens of times per cold-index, all allocating fresh
|
||
// — 2.72 GB / 8 % of total in the heap profile. Caching collapses
|
||
// that to a single allocation per generation.
|
||
//
|
||
// Callers MUST treat the returned slice as read-only. Mutating its
|
||
// pointers is a data race against any concurrent reader holding the
|
||
// same cached reference. The underlying *Edge structs are themselves
|
||
// shared with the graph and may be mutated by ReindexEdge /
|
||
// SetEdgeProvenance — that's intentional, and readers see those
|
||
// mutations through the pointer.
|
||
func (g *Graph) AllEdges() []*Edge {
|
||
curGen := g.edgeMutGen.Load()
|
||
g.allEdgesCacheMu.Lock()
|
||
defer g.allEdgesCacheMu.Unlock()
|
||
if g.allEdgesCache != nil && g.allEdgesCacheGen == curGen {
|
||
return g.allEdgesCache
|
||
}
|
||
|
||
g.lockAllRead()
|
||
// Pre-size from the per-shard outEdges entry counts. EdgeCount
|
||
// would re-lock; just sum inline while holding the locks.
|
||
total := 0
|
||
for _, s := range g.shards {
|
||
for _, edges := range s.outEdges {
|
||
total += len(edges)
|
||
}
|
||
}
|
||
out := make([]*Edge, 0, total)
|
||
for _, s := range g.shards {
|
||
for _, edges := range s.outEdges {
|
||
out = append(out, edges...)
|
||
}
|
||
}
|
||
g.unlockAllRead()
|
||
|
||
g.allEdgesCache = out
|
||
g.allEdgesCacheGen = curGen
|
||
return out
|
||
}
|
||
|
||
// DrainNodes yields every node and FREES the graph's internal node
|
||
// storage shard-by-shard as it goes. After Drain finishes the graph
|
||
// holds zero nodes. Intended for the one-shot persist path where the
|
||
// shadow is about to be discarded: AllNodes would pin the full 11 GB
|
||
// graph for the entire persist phase; Drain releases each shard's
|
||
// node map (and the per-name / per-file / per-repo indexes) as soon
|
||
// as that shard's iteration completes, so GC can reclaim ~700 MB at
|
||
// a time on a Linux-scale graph instead of waiting for the indexer's
|
||
// defer to return.
|
||
//
|
||
// The graph remains structurally consistent during Drain — edges and
|
||
// other indexes are untouched, only the node maps are emptied. If
|
||
// you also need DrainEdges, call them in either order; both are
|
||
// destructive and idempotent (a second call yields nothing).
|
||
func (g *Graph) DrainNodes() iter.Seq[*Node] {
|
||
return func(yield func(*Node) bool) {
|
||
for _, s := range g.shards {
|
||
s.mu.Lock()
|
||
nodes := s.nodes
|
||
// Replace with an empty map so the shard's read methods
|
||
// keep working (return zero) instead of nil-panicking.
|
||
s.nodes = map[string]*Node{}
|
||
s.byFile = map[string][]*Node{}
|
||
s.byName = map[string][]*Node{}
|
||
s.byQual = map[string]*Node{}
|
||
s.byRepo = map[string][]*Node{}
|
||
s.byFileIdx = map[string]map[string]int{}
|
||
s.byNameIdx = map[string]map[string]int{}
|
||
s.byRepoIdx = map[string]map[string]int{}
|
||
s.mu.Unlock()
|
||
for _, n := range nodes {
|
||
if !yield(n) {
|
||
return
|
||
}
|
||
}
|
||
// nodes goes out of scope here — the shard's old map plus
|
||
// every *Node it referenced is now GC-eligible (assuming
|
||
// the caller has dropped any remaining reference).
|
||
}
|
||
}
|
||
}
|
||
|
||
// DrainEdges yields every edge and FREES the graph's internal edge
|
||
// storage shard-by-shard. Same semantics as DrainNodes — meant for
|
||
// the persist hand-off, not for general queries.
|
||
func (g *Graph) DrainEdges() iter.Seq[*Edge] {
|
||
// Invalidate the AllEdges cache so any subsequent caller doesn't
|
||
// see drained-shard zombies. The cache holds direct *Edge slice
|
||
// references that DrainEdges is about to start freeing.
|
||
g.allEdgesCacheMu.Lock()
|
||
g.allEdgesCache = nil
|
||
g.allEdgesCacheGen = 0
|
||
g.allEdgesCacheMu.Unlock()
|
||
return func(yield func(*Edge) bool) {
|
||
for _, s := range g.shards {
|
||
s.mu.Lock()
|
||
outEdges := s.outEdges
|
||
s.outEdges = map[string][]*Edge{}
|
||
s.inEdges = map[string][]*Edge{}
|
||
s.outEdgeIdx = map[string]map[edgeHash]int{}
|
||
s.inEdgeIdx = map[string]map[edgeHash]int{}
|
||
s.outEdgeKeys = map[string][]edgeHash{}
|
||
s.inEdgeKeys = map[string][]edgeHash{}
|
||
s.mu.Unlock()
|
||
for _, edges := range outEdges {
|
||
for _, e := range edges {
|
||
if !yield(e) {
|
||
return
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Stats returns summary counts by kind and language.
|
||
func (g *Graph) Stats() GraphStats {
|
||
g.lockAllRead()
|
||
defer g.unlockAllRead()
|
||
|
||
byKind := make(map[string]int)
|
||
byLang := make(map[string]int)
|
||
totalNodes := 0
|
||
for _, s := range g.shards {
|
||
for _, n := range s.nodes {
|
||
// Cross-daemon proxy-edge nodes stand in for symbols a
|
||
// remote daemon owns; they are never counted in local
|
||
// stats. Inert until edge-minting is enabled.
|
||
if IsProxyNode(n) {
|
||
continue
|
||
}
|
||
totalNodes++
|
||
byKind[string(n.Kind)]++
|
||
if n.Language != "" {
|
||
byLang[n.Language]++
|
||
}
|
||
}
|
||
}
|
||
|
||
edgeCount := 0
|
||
for _, s := range g.shards {
|
||
for _, edges := range s.outEdges {
|
||
edgeCount += len(edges)
|
||
}
|
||
}
|
||
|
||
return GraphStats{
|
||
TotalNodes: totalNodes,
|
||
TotalEdges: edgeCount,
|
||
ByKind: byKind,
|
||
ByLanguage: byLang,
|
||
}
|
||
}
|
||
|
||
// GetRepoNodes returns all nodes belonging to the given repository
|
||
// prefix. Each shard holds a byRepo slice for nodes it owns; we
|
||
// aggregate across shards.
|
||
func (g *Graph) GetRepoNodes(repoPrefix string) []*Node {
|
||
var out []*Node
|
||
for _, s := range g.shards {
|
||
s.mu.RLock()
|
||
if src := s.byRepo[repoPrefix]; len(src) > 0 {
|
||
out = append(out, src...)
|
||
}
|
||
s.mu.RUnlock()
|
||
}
|
||
return out
|
||
}
|
||
|
||
// GetRepoEdges returns every edge whose source node has the given
|
||
// RepoPrefix — the in-memory reference implementation of the
|
||
// Store-interface method. Walks each shard's byRepo bucket and
|
||
// concatenates that node's outEdges in place (no per-node
|
||
// GetOutEdges call, so no per-call slice copy). Equivalent in
|
||
// observable behaviour to the GetRepoNodes(r) × GetOutEdges loop
|
||
// callers used before this method existed; meant to give disk
|
||
// backends a single-query hook without changing in-memory cost.
|
||
// Empty repoPrefix returns nil (callers use AllEdges() instead).
|
||
func (g *Graph) GetRepoEdges(repoPrefix string) []*Edge {
|
||
if repoPrefix == "" {
|
||
return nil
|
||
}
|
||
var out []*Edge
|
||
for _, s := range g.shards {
|
||
s.mu.RLock()
|
||
for _, n := range s.byRepo[repoPrefix] {
|
||
if src := s.outEdges[n.ID]; len(src) > 0 {
|
||
out = append(out, src...)
|
||
}
|
||
}
|
||
s.mu.RUnlock()
|
||
}
|
||
return out
|
||
}
|
||
|
||
// EvictRepo removes all nodes with matching RepoPrefix and all edges
|
||
// referencing those nodes. Returns counts of removed nodes and edges.
|
||
func (g *Graph) EvictRepo(repoPrefix string) (nodesRemoved, edgesRemoved int) {
|
||
g.lockAllWrite()
|
||
defer g.unlockAllWrite()
|
||
|
||
var nodes []*Node
|
||
for _, s := range g.shards {
|
||
nodes = append(nodes, s.byRepo[repoPrefix]...)
|
||
}
|
||
if len(nodes) == 0 {
|
||
return 0, 0
|
||
}
|
||
evictedIDs := make(map[string]string, len(nodes))
|
||
for _, n := range nodes {
|
||
evictedIDs[n.ID] = repoPrefix
|
||
}
|
||
|
||
for _, n := range nodes {
|
||
s := g.shardFor(n.ID)
|
||
s.repoNodeRemove(n)
|
||
delete(s.nodes, n.ID)
|
||
if n.QualName != "" {
|
||
if cur, ok := s.byQual[n.QualName]; ok && cur.ID == n.ID {
|
||
delete(s.byQual, n.QualName)
|
||
}
|
||
}
|
||
removeNodeFromBucket(s.byName, s.byNameIdx, n.Name, n.ID)
|
||
removeNodeFromBucket(s.byFile, s.byFileIdx, n.FilePath, n.ID)
|
||
removeNodeFromBucket(s.byRepo, s.byRepoIdx, repoPrefix, n.ID)
|
||
}
|
||
nodesRemoved = len(nodes)
|
||
|
||
edgesRemoved = g.evictEdgesLocked(evictedIDs)
|
||
return nodesRemoved, edgesRemoved
|
||
}
|
||
|
||
// RepoStats returns per-repository node and edge counts.
|
||
func (g *Graph) RepoStats() map[string]GraphStats {
|
||
g.lockAllRead()
|
||
defer g.unlockAllRead()
|
||
|
||
// Aggregate byRepo across shards first.
|
||
repoNodes := make(map[string][]*Node)
|
||
for _, s := range g.shards {
|
||
for prefix, nodes := range s.byRepo {
|
||
repoNodes[prefix] = append(repoNodes[prefix], nodes...)
|
||
}
|
||
}
|
||
|
||
stats := make(map[string]GraphStats, len(repoNodes))
|
||
repoByKind := make(map[string]map[string]int)
|
||
repoByLang := make(map[string]map[string]int)
|
||
repoNodeCount := make(map[string]int)
|
||
for prefix, nodes := range repoNodes {
|
||
repoNodeCount[prefix] = len(nodes)
|
||
byKind := make(map[string]int)
|
||
byLang := make(map[string]int)
|
||
for _, n := range nodes {
|
||
byKind[string(n.Kind)]++
|
||
if n.Language != "" {
|
||
byLang[n.Language]++
|
||
}
|
||
}
|
||
repoByKind[prefix] = byKind
|
||
repoByLang[prefix] = byLang
|
||
}
|
||
|
||
// Count edges per repo by the From node's repo. Need to look up the
|
||
// From node in whichever shard owns it.
|
||
repoEdgeCount := make(map[string]int)
|
||
for _, s := range g.shards {
|
||
for _, edges := range s.outEdges {
|
||
for _, e := range edges {
|
||
fromShard := g.shardFor(e.From)
|
||
if fromNode, ok := fromShard.nodes[e.From]; ok && fromNode.RepoPrefix != "" {
|
||
repoEdgeCount[fromNode.RepoPrefix]++
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
for prefix := range repoNodes {
|
||
stats[prefix] = GraphStats{
|
||
TotalNodes: repoNodeCount[prefix],
|
||
TotalEdges: repoEdgeCount[prefix],
|
||
ByKind: repoByKind[prefix],
|
||
ByLanguage: repoByLang[prefix],
|
||
}
|
||
}
|
||
|
||
return stats
|
||
}
|
||
|
||
// RepoMemoryEstimate is an approximate breakdown of how many bytes a
|
||
// single repository's graph contribution occupies. It covers the
|
||
// sharded node/edge maps only — search and vector indexes are
|
||
// orthogonal and computed elsewhere.
|
||
type RepoMemoryEstimate struct {
|
||
NodeBytes uint64 `json:"node_bytes"`
|
||
EdgeBytes uint64 `json:"edge_bytes"`
|
||
NodeCount int `json:"node_count"`
|
||
EdgeCount int `json:"edge_count"`
|
||
}
|
||
|
||
// Total returns the sum of NodeBytes and EdgeBytes.
|
||
func (e RepoMemoryEstimate) Total() uint64 { return e.NodeBytes + e.EdgeBytes }
|
||
|
||
// per-node fixed overhead: the struct header plus the amortised cost
|
||
// of the pointers held by byRepo/byFile/byName/byQual secondary
|
||
// indexes inside each shard (4 maps × ~24 bytes for map bucket + slice
|
||
// element ≈ 100 bytes). Tuned against runtime.ReadMemStats deltas on a
|
||
// 50k-node repo; within ~10% of actual.
|
||
const nodeStructOverhead = 240
|
||
|
||
// per-edge fixed overhead: two string pointers, kind, filepath, line,
|
||
// plus slice-header and adjacency-map amortisation for outEdges AND
|
||
// inEdges (every edge is stored once as a struct but is referenced from
|
||
// both the source's out-adjacency list and the target's in-adjacency
|
||
// list, so the amortised overhead is ~2× slice-element + map-bucket).
|
||
const edgeStructOverhead = 144
|
||
|
||
// RepoMemoryEstimate sums the running per-shard counters maintained
|
||
// by AddNode / AddEdge / RemoveEdge / EvictFile / EvictRepo. O(shard
|
||
// count) instead of the O(repo nodes + total edges) walk this used to
|
||
// do — relevant because daemon-status queries call this once per
|
||
// tracked repo, and on a 488-repo / 1.9M-edge graph the old
|
||
// implementation was the single biggest source of writer contention
|
||
// during warmup.
|
||
func (g *Graph) RepoMemoryEstimate(repoPrefix string) RepoMemoryEstimate {
|
||
g.lockAllRead()
|
||
defer g.unlockAllRead()
|
||
|
||
var est RepoMemoryEstimate
|
||
for _, s := range g.shards {
|
||
est.NodeBytes += s.repoNodeBytes[repoPrefix]
|
||
est.NodeCount += s.repoNodeCount[repoPrefix]
|
||
est.EdgeBytes += s.repoEdgeBytes[repoPrefix]
|
||
est.EdgeCount += s.repoEdgeCount[repoPrefix]
|
||
}
|
||
return est
|
||
}
|
||
|
||
// AllRepoMemoryEstimates returns the per-repo estimate for every
|
||
// repo with a tracked counter — one pass across shards, one read lock
|
||
// acquisition. Callers driving a per-repo loop (daemon status) should
|
||
// prefer this over the single-repo variant.
|
||
func (g *Graph) AllRepoMemoryEstimates() map[string]RepoMemoryEstimate {
|
||
g.lockAllRead()
|
||
defer g.unlockAllRead()
|
||
|
||
out := make(map[string]RepoMemoryEstimate)
|
||
for _, s := range g.shards {
|
||
for prefix, bytes := range s.repoNodeBytes {
|
||
est := out[prefix]
|
||
est.NodeBytes += bytes
|
||
est.NodeCount += s.repoNodeCount[prefix]
|
||
out[prefix] = est
|
||
}
|
||
for prefix, bytes := range s.repoEdgeBytes {
|
||
est := out[prefix]
|
||
est.EdgeBytes += bytes
|
||
est.EdgeCount += s.repoEdgeCount[prefix]
|
||
out[prefix] = est
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// nodeBytes estimates the memory footprint of a single graph.Node.
|
||
func nodeBytes(n *Node) uint64 {
|
||
if n == nil {
|
||
return 0
|
||
}
|
||
b := uint64(nodeStructOverhead)
|
||
b += uint64(len(n.ID) + len(n.Name) + len(n.QualName) + len(n.FilePath) + len(n.Language) + len(n.RepoPrefix))
|
||
b += metaBytes(n.Meta)
|
||
return b
|
||
}
|
||
|
||
// edgeBytes estimates the memory footprint of a single graph.Edge.
|
||
func edgeBytes(e *Edge) uint64 {
|
||
if e == nil {
|
||
return 0
|
||
}
|
||
b := uint64(edgeStructOverhead)
|
||
b += uint64(len(e.From) + len(e.To) + len(e.Kind) + len(e.FilePath))
|
||
return b
|
||
}
|
||
|
||
// metaBytes approximates the size of a Node.Meta map. Only handles the
|
||
// kinds of values we actually produce (string, bool, numeric, nested
|
||
// map, []string) — more exotic types fall back to a conservative
|
||
// constant rather than reflecting recursively.
|
||
func metaBytes(m map[string]any) uint64 {
|
||
if m == nil {
|
||
return 0
|
||
}
|
||
// map header + bucket amortisation for small maps.
|
||
b := uint64(48 + 8*len(m))
|
||
for k, v := range m {
|
||
b += uint64(len(k)) + 16 // key entry overhead
|
||
switch val := v.(type) {
|
||
case string:
|
||
b += uint64(len(val)) + 16
|
||
case bool:
|
||
b += 1 + 16
|
||
case int, int32, int64, uint, uint32, uint64, float32, float64:
|
||
b += 8 + 16
|
||
case []string:
|
||
b += 24 // slice header
|
||
for _, s := range val {
|
||
b += uint64(len(s)) + 16
|
||
}
|
||
case map[string]any:
|
||
b += metaBytes(val)
|
||
default:
|
||
b += 32 // unknown — leave a sensible estimate
|
||
}
|
||
}
|
||
return b
|
||
}
|
||
|
||
// RepoPrefixes returns a list of unique repository prefixes in the
|
||
// graph.
|
||
func (g *Graph) RepoPrefixes() []string {
|
||
seen := make(map[string]struct{})
|
||
for _, s := range g.shards {
|
||
s.mu.RLock()
|
||
for prefix := range s.byRepo {
|
||
seen[prefix] = struct{}{}
|
||
}
|
||
s.mu.RUnlock()
|
||
}
|
||
prefixes := make([]string, 0, len(seen))
|
||
for prefix := range seen {
|
||
prefixes = append(prefixes, prefix)
|
||
}
|
||
return prefixes
|
||
}
|
||
|
||
// InDegreeForNodes is the in-memory reference implementation of the
|
||
// InDegreeForNodes capability. Walks the per-target in-edge buckets
|
||
// directly — the same arithmetic the disk backend pushes into a single
|
||
// server-side COUNT.
|
||
func (g *Graph) InDegreeForNodes(ids []string) map[string]int {
|
||
if len(ids) == 0 {
|
||
return nil
|
||
}
|
||
out := make(map[string]int, len(ids))
|
||
for _, id := range ids {
|
||
if id == "" {
|
||
continue
|
||
}
|
||
c := len(g.GetInEdges(id))
|
||
if c == 0 {
|
||
continue
|
||
}
|
||
out[id] = c
|
||
}
|
||
return out
|
||
}
|
||
|
||
// ReachableForwardByKinds is the in-memory reference implementation
|
||
// of the ReachableForwardByKinds capability. Layer-by-layer BFS from
|
||
// the seed frontier, following only edges whose Kind is in the
|
||
// supplied set. Pure map / slice walks here — the win is the disk
|
||
// backend folds the BFS into one variable-length match.
|
||
func (g *Graph) ReachableForwardByKinds(seeds []string, kinds []EdgeKind) map[string]bool {
|
||
if len(seeds) == 0 {
|
||
return nil
|
||
}
|
||
covered := make(map[string]bool, len(seeds))
|
||
frontier := make([]string, 0, len(seeds))
|
||
for _, id := range seeds {
|
||
if id == "" || covered[id] {
|
||
continue
|
||
}
|
||
covered[id] = true
|
||
frontier = append(frontier, id)
|
||
}
|
||
if len(kinds) == 0 {
|
||
return covered
|
||
}
|
||
allowed := make(map[EdgeKind]struct{}, len(kinds))
|
||
for _, k := range kinds {
|
||
allowed[k] = struct{}{}
|
||
}
|
||
for len(frontier) > 0 {
|
||
next := frontier[:0:0]
|
||
for _, id := range frontier {
|
||
for _, e := range g.GetOutEdges(id) {
|
||
if e == nil {
|
||
continue
|
||
}
|
||
if _, ok := allowed[e.Kind]; !ok {
|
||
continue
|
||
}
|
||
if !covered[e.To] {
|
||
covered[e.To] = true
|
||
next = append(next, e.To)
|
||
}
|
||
}
|
||
}
|
||
frontier = next
|
||
}
|
||
return covered
|
||
}
|
||
|
||
// ThrowerErrorSurface is the in-memory reference implementation of
|
||
// the ThrowerErrorSurfacer capability. Walks EdgeThrows once for the
|
||
// per-thrower target dedup, then walks each thrower's out-edges for
|
||
// the EdgeEmits → KindString(context=error_msg) attachment. The disk
|
||
// backend collapses both passes into two server-side GROUP BYs.
|
||
func (g *Graph) ThrowerErrorSurface(pathPrefix string) []ThrowerErrorRow {
|
||
byThrower := map[string]*ThrowerErrorRow{}
|
||
addUnique := func(set []string, v string) []string {
|
||
if slices.Contains(set, v) {
|
||
return set
|
||
}
|
||
return append(set, v)
|
||
}
|
||
for e := range g.EdgesByKind(EdgeThrows) {
|
||
if e == nil {
|
||
continue
|
||
}
|
||
if pathPrefix != "" && !strings.HasPrefix(e.FilePath, pathPrefix) {
|
||
continue
|
||
}
|
||
row, ok := byThrower[e.From]
|
||
if !ok {
|
||
file := e.FilePath
|
||
line := e.Line
|
||
n := g.GetNode(e.From)
|
||
if n != nil {
|
||
if file == "" {
|
||
file = n.FilePath
|
||
}
|
||
if line == 0 {
|
||
line = n.StartLine
|
||
}
|
||
}
|
||
row = &ThrowerErrorRow{ThrowerID: e.From, FilePath: file, Line: line}
|
||
byThrower[e.From] = row
|
||
}
|
||
row.Throws++
|
||
row.ErrorTargets = addUnique(row.ErrorTargets, e.To)
|
||
}
|
||
for thrower, row := range byThrower {
|
||
for _, e := range g.GetOutEdges(thrower) {
|
||
if e == nil || e.Kind != EdgeEmits {
|
||
continue
|
||
}
|
||
n := g.GetNode(e.To)
|
||
if n == nil || n.Kind != KindString {
|
||
continue
|
||
}
|
||
ctxLabel, _ := n.Meta["context"].(string)
|
||
if ctxLabel != "error_msg" {
|
||
continue
|
||
}
|
||
row.ErrorMsgs = addUnique(row.ErrorMsgs, n.Name)
|
||
}
|
||
}
|
||
out := make([]ThrowerErrorRow, 0, len(byThrower))
|
||
for _, r := range byThrower {
|
||
out = append(out, *r)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// MemberMethodsByType is the in-memory reference implementation of the
|
||
// MemberMethodsByType capability. One EdgesByKind(EdgeMemberOf) walk
|
||
// joined with the in-memory node table to filter Kind == KindMethod
|
||
// and project the four columns the resolver consumes — the exact
|
||
// loop the resolver runs today, just exposed as a single method call
|
||
// so the disk backend can fold the join into one query.
|
||
//
|
||
// Empty graph returns nil. Per-type method lists are deduplicated by
|
||
// MethodID so a method that appears twice in the EdgeMemberOf bucket
|
||
// (defensive against double-insertion) yields a single row.
|
||
func (g *Graph) MemberMethodsByType() map[string][]MemberMethodInfo {
|
||
out := map[string][]MemberMethodInfo{}
|
||
seen := map[string]map[string]struct{}{}
|
||
for e := range g.EdgesByKind(EdgeMemberOf) {
|
||
if e == nil {
|
||
continue
|
||
}
|
||
m := g.GetNode(e.From)
|
||
if m == nil || m.Kind != KindMethod {
|
||
continue
|
||
}
|
||
typeID := e.To
|
||
dedup := seen[typeID]
|
||
if dedup == nil {
|
||
dedup = make(map[string]struct{})
|
||
seen[typeID] = dedup
|
||
}
|
||
if _, ok := dedup[m.ID]; ok {
|
||
continue
|
||
}
|
||
dedup[m.ID] = struct{}{}
|
||
out[typeID] = append(out[typeID], MemberMethodInfo{
|
||
MethodID: m.ID,
|
||
Name: m.Name,
|
||
FilePath: m.FilePath,
|
||
StartLine: m.StartLine,
|
||
RepoPrefix: m.RepoPrefix,
|
||
})
|
||
}
|
||
if len(out) == 0 {
|
||
return nil
|
||
}
|
||
return out
|
||
}
|
||
|
||
// StructuralParentEdges is the in-memory reference implementation of
|
||
// the StructuralParentEdges capability. Single AllEdges scan with the
|
||
// (Extends | Implements | Composes) kind gate and the
|
||
// (Type | Interface) endpoint-kind gate applied per edge.
|
||
//
|
||
// Empty graph or no matching edges returns nil.
|
||
func (g *Graph) StructuralParentEdges() []StructuralParentEdgeRow {
|
||
var out []StructuralParentEdgeRow
|
||
for _, e := range g.AllEdges() {
|
||
if e == nil {
|
||
continue
|
||
}
|
||
switch e.Kind {
|
||
case EdgeExtends, EdgeImplements, EdgeComposes:
|
||
default:
|
||
continue
|
||
}
|
||
from := g.GetNode(e.From)
|
||
to := g.GetNode(e.To)
|
||
if from == nil || to == nil {
|
||
continue
|
||
}
|
||
if from.Kind != KindType && from.Kind != KindInterface {
|
||
continue
|
||
}
|
||
if to.Kind != KindType && to.Kind != KindInterface {
|
||
continue
|
||
}
|
||
out = append(out, StructuralParentEdgeRow{
|
||
FromID: from.ID,
|
||
ToID: to.ID,
|
||
FromKind: from.Kind,
|
||
ToKind: to.Kind,
|
||
Origin: e.Origin,
|
||
})
|
||
}
|
||
return out
|
||
}
|
||
|
||
// CrossRepoCandidates is the in-memory reference implementation of the
|
||
// CrossRepoCandidates capability. Single AllEdges scan with the
|
||
// edge-kind gate + the (non-empty, distinct) repo-prefix gate. Returns
|
||
// one row per surviving edge carrying the underlying Edge pointer plus
|
||
// the two RepoPrefix values projected from the endpoints.
|
||
//
|
||
// Empty baseKinds returns nil — matches the disk-backend contract.
|
||
// Single-repo graphs (or graphs whose nodes carry no RepoPrefix)
|
||
// return no rows because the prefix gate filters them out.
|
||
func (g *Graph) CrossRepoCandidates(baseKinds []EdgeKind) []CrossRepoCandidateRow {
|
||
if len(baseKinds) == 0 {
|
||
return nil
|
||
}
|
||
kset := make(map[EdgeKind]struct{}, len(baseKinds))
|
||
for _, k := range baseKinds {
|
||
if k == "" {
|
||
continue
|
||
}
|
||
kset[k] = struct{}{}
|
||
}
|
||
if len(kset) == 0 {
|
||
return nil
|
||
}
|
||
var out []CrossRepoCandidateRow
|
||
for _, e := range g.AllEdges() {
|
||
if e == nil {
|
||
continue
|
||
}
|
||
if _, ok := kset[e.Kind]; !ok {
|
||
continue
|
||
}
|
||
from := g.GetNode(e.From)
|
||
to := g.GetNode(e.To)
|
||
if from == nil || to == nil {
|
||
continue
|
||
}
|
||
if from.RepoPrefix == "" || to.RepoPrefix == "" {
|
||
continue
|
||
}
|
||
if from.RepoPrefix == to.RepoPrefix {
|
||
continue
|
||
}
|
||
out = append(out, CrossRepoCandidateRow{
|
||
Edge: e,
|
||
FromRepo: from.RepoPrefix,
|
||
ToRepo: to.RepoPrefix,
|
||
})
|
||
}
|
||
return out
|
||
}
|
||
|
||
// bfsHopLess orders two discovery candidates for the SAME node at the
|
||
// SAME depth: smaller ParentID first, then smaller EdgeKind. This is the
|
||
// tie-break the SQLite implementation applies via
|
||
// ROW_NUMBER() OVER (PARTITION BY node_id ORDER BY depth, parent_id,
|
||
// edge_kind), so both backends settle on the identical discovery edge.
|
||
func bfsHopLess(a, b BFSHop) bool {
|
||
if a.ParentID != b.ParentID {
|
||
return a.ParentID < b.ParentID
|
||
}
|
||
return a.EdgeKind < b.EdgeKind
|
||
}
|
||
|
||
// BFS is the in-memory reference implementation of the BFSCapable
|
||
// capability — the oracle the SQLite recursive-CTE walk is shadow-tested
|
||
// against. Layer-by-layer BFS over GetOutEdges / GetInEdges; see the
|
||
// BFSCapable doc for the exact reachability / determinism / bound
|
||
// semantics. Always returns a nil error (the error in the signature is
|
||
// for the disk implementation's query failures).
|
||
func (g *Graph) BFS(seeds []string, dir Direction, kinds []EdgeKind, maxDepth, limit int) ([]BFSHop, error) {
|
||
if len(seeds) == 0 {
|
||
return nil, nil
|
||
}
|
||
kset := make(map[EdgeKind]struct{}, len(kinds))
|
||
for _, k := range kinds {
|
||
if k != "" {
|
||
kset[k] = struct{}{}
|
||
}
|
||
}
|
||
forward := dir != DirectionBackward
|
||
|
||
// chosen[node] = the winning hop for that node (min depth, then the
|
||
// (parent, kind)-smallest discovery edge). Once a node is in chosen it
|
||
// is settled at its minimum depth and never revisited — that, plus the
|
||
// maxDepth bound, is what terminates a cyclic graph.
|
||
chosen := make(map[string]BFSHop, len(seeds))
|
||
frontier := make([]string, 0, len(seeds))
|
||
for _, s := range seeds {
|
||
if s == "" {
|
||
continue
|
||
}
|
||
if _, ok := chosen[s]; ok {
|
||
continue
|
||
}
|
||
chosen[s] = BFSHop{NodeID: s, Depth: 0}
|
||
frontier = append(frontier, s)
|
||
}
|
||
|
||
if len(kset) > 0 && maxDepth > 0 {
|
||
for depth := 0; depth < maxDepth && len(frontier) > 0; depth++ {
|
||
// Collect every depth+1 candidate across the WHOLE frontier
|
||
// before settling, so a node reached by several same-layer
|
||
// parents converges on the deterministic (parent, kind)-smallest
|
||
// — matching the CTE's ROW_NUMBER pick.
|
||
cand := make(map[string]BFSHop)
|
||
for _, cur := range frontier {
|
||
var edges []*Edge
|
||
if forward {
|
||
edges = g.GetOutEdges(cur)
|
||
} else {
|
||
edges = g.GetInEdges(cur)
|
||
}
|
||
for _, e := range edges {
|
||
if e == nil {
|
||
continue
|
||
}
|
||
if _, ok := kset[e.Kind]; !ok {
|
||
continue
|
||
}
|
||
nb := e.To
|
||
if !forward {
|
||
nb = e.From
|
||
}
|
||
if nb == "" {
|
||
continue
|
||
}
|
||
if _, ok := chosen[nb]; ok {
|
||
continue // already settled at a shallower-or-equal depth
|
||
}
|
||
// Node-backed targets only: an edge to an unresolved /
|
||
// external stub (no node row) is not a reachable hop.
|
||
if g.GetNode(nb) == nil {
|
||
continue
|
||
}
|
||
h := BFSHop{NodeID: nb, Depth: depth + 1, ParentID: cur, EdgeKind: e.Kind}
|
||
if existing, ok := cand[nb]; !ok || bfsHopLess(h, existing) {
|
||
cand[nb] = h
|
||
}
|
||
}
|
||
}
|
||
next := make([]string, 0, len(cand))
|
||
for nb, h := range cand {
|
||
chosen[nb] = h
|
||
next = append(next, nb)
|
||
}
|
||
frontier = next
|
||
}
|
||
}
|
||
|
||
out := make([]BFSHop, 0, len(chosen))
|
||
for _, h := range chosen {
|
||
out = append(out, h)
|
||
}
|
||
slices.SortFunc(out, func(a, b BFSHop) int {
|
||
if a.Depth != b.Depth {
|
||
return a.Depth - b.Depth
|
||
}
|
||
if a.NodeID < b.NodeID {
|
||
return -1
|
||
}
|
||
if a.NodeID > b.NodeID {
|
||
return 1
|
||
}
|
||
return 0
|
||
})
|
||
if limit > 0 && len(out) > limit {
|
||
out = out[:limit]
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// ExtractCandidates is the in-memory reference implementation of
|
||
// ExtractCandidatesScanner. Walks NodesByKind for function + method,
|
||
// applies the threshold gates locally, and counts distinct in-edge
|
||
// From / out-edge To values restricted to the requested edge kinds.
|
||
func (g *Graph) ExtractCandidates(
|
||
kinds []EdgeKind,
|
||
minLines, minCallers, minFanOut int,
|
||
pathPrefix string,
|
||
) []ExtractCandidateRow {
|
||
if len(kinds) == 0 {
|
||
return nil
|
||
}
|
||
kset := make(map[EdgeKind]struct{}, len(kinds))
|
||
for _, k := range kinds {
|
||
if k == "" {
|
||
continue
|
||
}
|
||
kset[k] = struct{}{}
|
||
}
|
||
if len(kset) == 0 {
|
||
return nil
|
||
}
|
||
var out []ExtractCandidateRow
|
||
for _, n := range g.NodesByKinds([]NodeKind{KindFunction, KindMethod}) {
|
||
if n == nil {
|
||
continue
|
||
}
|
||
if pathPrefix != "" && !strings.HasPrefix(n.FilePath, pathPrefix) {
|
||
continue
|
||
}
|
||
if n.StartLine == 0 || n.EndLine == 0 {
|
||
continue
|
||
}
|
||
lineCount := n.EndLine - n.StartLine + 1
|
||
if lineCount < minLines {
|
||
continue
|
||
}
|
||
callerSet := make(map[string]struct{})
|
||
for _, e := range g.GetInEdges(n.ID) {
|
||
if e == nil {
|
||
continue
|
||
}
|
||
if _, ok := kset[e.Kind]; !ok {
|
||
continue
|
||
}
|
||
callerSet[e.From] = struct{}{}
|
||
}
|
||
if len(callerSet) < minCallers {
|
||
continue
|
||
}
|
||
calleeSet := make(map[string]struct{})
|
||
for _, e := range g.GetOutEdges(n.ID) {
|
||
if e == nil {
|
||
continue
|
||
}
|
||
if _, ok := kset[e.Kind]; !ok {
|
||
continue
|
||
}
|
||
calleeSet[e.To] = struct{}{}
|
||
}
|
||
if len(calleeSet) < minFanOut {
|
||
continue
|
||
}
|
||
out = append(out, ExtractCandidateRow{
|
||
NodeID: n.ID,
|
||
Name: n.Name,
|
||
FilePath: n.FilePath,
|
||
StartLine: n.StartLine,
|
||
EndLine: n.EndLine,
|
||
LineCount: lineCount,
|
||
CallerCount: len(callerSet),
|
||
FanOut: len(calleeSet),
|
||
})
|
||
}
|
||
return out
|
||
}
|
||
|
||
// FileSymbolNamesByPaths is the in-memory reference implementation of
|
||
// the FileSymbolNamesByPaths capability. Walks GetFileNodes for every
|
||
// input path, keeps the requested kinds, and emits one row per
|
||
// (path, name) pair. Duplicates within a file collapse to a single
|
||
// row (a method declared once per file emits once regardless of how
|
||
// many times the indexer touched it).
|
||
func (g *Graph) FileSymbolNamesByPaths(paths []string, kinds []NodeKind) []FileSymbolNameRow {
|
||
if len(paths) == 0 {
|
||
return nil
|
||
}
|
||
kset := make(map[NodeKind]struct{}, len(kinds))
|
||
for _, k := range kinds {
|
||
if k == "" {
|
||
continue
|
||
}
|
||
kset[k] = struct{}{}
|
||
}
|
||
seen := make(map[string]struct{})
|
||
dedupKey := func(p, name string) string { return p + "\x00" + name }
|
||
var out []FileSymbolNameRow
|
||
for _, p := range paths {
|
||
if p == "" {
|
||
continue
|
||
}
|
||
for _, n := range g.GetFileNodes(p) {
|
||
if n == nil || n.Name == "" {
|
||
continue
|
||
}
|
||
if len(kset) > 0 {
|
||
if _, ok := kset[n.Kind]; !ok {
|
||
continue
|
||
}
|
||
}
|
||
k := dedupKey(p, n.Name)
|
||
if _, ok := seen[k]; ok {
|
||
continue
|
||
}
|
||
seen[k] = struct{}{}
|
||
out = append(out, FileSymbolNameRow{FilePath: p, Name: n.Name})
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// ClassHierarchyTraverse is the in-memory reference implementation of
|
||
// ClassHierarchyTraverser. Performs the same BFS as
|
||
// query.ClassHierarchy, but stops at the kind/depth gates and returns
|
||
// the full Path + EdgeKinds for each terminal node reached so the
|
||
// disk backend's variable-length match can be a drop-in
|
||
// replacement. Direction "up" follows out-edges; "down" follows
|
||
// in-edges.
|
||
func (g *Graph) ClassHierarchyTraverse(
|
||
seedID string,
|
||
direction string,
|
||
kinds []EdgeKind,
|
||
depth int,
|
||
) []ClassHierarchyRow {
|
||
if seedID == "" || depth <= 0 || len(kinds) == 0 {
|
||
return nil
|
||
}
|
||
kset := make(map[EdgeKind]struct{}, len(kinds))
|
||
for _, k := range kinds {
|
||
if k == "" {
|
||
continue
|
||
}
|
||
kset[k] = struct{}{}
|
||
}
|
||
if len(kset) == 0 {
|
||
return nil
|
||
}
|
||
if g.GetNode(seedID) == nil {
|
||
return nil
|
||
}
|
||
walkUp := direction == "up"
|
||
walkDown := direction == "down"
|
||
if !walkUp && !walkDown {
|
||
return nil
|
||
}
|
||
type queued struct {
|
||
id string
|
||
path []string
|
||
edgeKinds []EdgeKind
|
||
hops int
|
||
}
|
||
visited := map[string]struct{}{seedID: {}}
|
||
queue := []queued{{id: seedID, path: nil, edgeKinds: nil, hops: 0}}
|
||
var out []ClassHierarchyRow
|
||
for len(queue) > 0 {
|
||
cur := queue[0]
|
||
queue = queue[1:]
|
||
if cur.hops >= depth {
|
||
continue
|
||
}
|
||
var edges []*Edge
|
||
if walkUp {
|
||
edges = g.GetOutEdges(cur.id)
|
||
} else {
|
||
edges = g.GetInEdges(cur.id)
|
||
}
|
||
for _, e := range edges {
|
||
if e == nil {
|
||
continue
|
||
}
|
||
if _, ok := kset[e.Kind]; !ok {
|
||
continue
|
||
}
|
||
var nb string
|
||
if walkUp {
|
||
nb = e.To
|
||
} else {
|
||
nb = e.From
|
||
}
|
||
if nb == "" {
|
||
continue
|
||
}
|
||
if _, ok := visited[nb]; ok {
|
||
continue
|
||
}
|
||
visited[nb] = struct{}{}
|
||
newPath := append([]string(nil), cur.path...)
|
||
newPath = append(newPath, nb)
|
||
newKinds := append([]EdgeKind(nil), cur.edgeKinds...)
|
||
newKinds = append(newKinds, e.Kind)
|
||
out = append(out, ClassHierarchyRow{
|
||
Path: newPath,
|
||
EdgeKinds: newKinds,
|
||
})
|
||
queue = append(queue, queued{id: nb, path: newPath, edgeKinds: newKinds, hops: cur.hops + 1})
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// FileEditingContext is the in-memory reference implementation of the
|
||
// FileEditingContext capability. Performs the equivalent of
|
||
// GetFileSymbols + per-function GetCallers/GetCallChain but bounded
|
||
// to the call/method node set, so the disk backend's batched query
|
||
// returns the same projection. The kinds parameter is the set of
|
||
// kinds treated as call targets (function + method).
|
||
func (g *Graph) FileEditingContext(filePath string, kinds []NodeKind) *FileEditingContextResult {
|
||
if filePath == "" {
|
||
return nil
|
||
}
|
||
nodes := g.GetFileNodes(filePath)
|
||
if len(nodes) == 0 {
|
||
return nil
|
||
}
|
||
kset := make(map[NodeKind]struct{}, len(kinds))
|
||
for _, k := range kinds {
|
||
if k == "" {
|
||
continue
|
||
}
|
||
kset[k] = struct{}{}
|
||
}
|
||
res := &FileEditingContextResult{}
|
||
var fileNodeID string
|
||
var defNodeIDs []string
|
||
for _, n := range nodes {
|
||
if n == nil {
|
||
continue
|
||
}
|
||
if n.Kind == KindFile {
|
||
res.FileNode = n
|
||
fileNodeID = n.ID
|
||
continue
|
||
}
|
||
res.Defines = append(res.Defines, n)
|
||
if _, ok := kset[n.Kind]; ok {
|
||
defNodeIDs = append(defNodeIDs, n.ID)
|
||
}
|
||
}
|
||
if fileNodeID != "" {
|
||
for _, e := range g.GetOutEdges(fileNodeID) {
|
||
if e == nil {
|
||
continue
|
||
}
|
||
if e.Kind == EdgeImports {
|
||
res.Imports = append(res.Imports, e)
|
||
}
|
||
}
|
||
}
|
||
if len(defNodeIDs) == 0 {
|
||
return res
|
||
}
|
||
inEdges := g.GetInEdgesByNodeIDs(defNodeIDs)
|
||
outEdges := g.GetOutEdgesByNodeIDs(defNodeIDs)
|
||
callerIDSet := make(map[string]struct{})
|
||
calleeIDSet := make(map[string]struct{})
|
||
for _, id := range defNodeIDs {
|
||
for _, e := range inEdges[id] {
|
||
if e == nil || e.Kind != EdgeCalls {
|
||
continue
|
||
}
|
||
if e.From == "" {
|
||
continue
|
||
}
|
||
callerIDSet[e.From] = struct{}{}
|
||
}
|
||
for _, e := range outEdges[id] {
|
||
if e == nil || e.Kind != EdgeCalls {
|
||
continue
|
||
}
|
||
if e.To == "" {
|
||
continue
|
||
}
|
||
calleeIDSet[e.To] = struct{}{}
|
||
}
|
||
}
|
||
callerIDs := make([]string, 0, len(callerIDSet))
|
||
for id := range callerIDSet {
|
||
callerIDs = append(callerIDs, id)
|
||
}
|
||
calleeIDs := make([]string, 0, len(calleeIDSet))
|
||
for id := range calleeIDSet {
|
||
calleeIDs = append(calleeIDs, id)
|
||
}
|
||
callerNodes := g.GetNodesByIDs(callerIDs)
|
||
calleeNodes := g.GetNodesByIDs(calleeIDs)
|
||
for _, id := range callerIDs {
|
||
n := callerNodes[id]
|
||
if n == nil || n.FilePath == filePath {
|
||
continue
|
||
}
|
||
res.CalledBy = append(res.CalledBy, n)
|
||
}
|
||
for _, id := range calleeIDs {
|
||
n := calleeNodes[id]
|
||
if n == nil || n.FilePath == filePath {
|
||
continue
|
||
}
|
||
res.Calls = append(res.Calls, n)
|
||
}
|
||
return res
|
||
}
|
||
|
||
// GetFileSubGraph is the in-memory reference implementation of the
|
||
// FileSubGraphReader capability. Iterates the existing per-file
|
||
// byFile bucket and the per-node outEdges / inEdges shards — the
|
||
// same lookups Engine.GetFileSymbols' fallback path already runs,
|
||
// just collapsed behind one method so the disk backend can push the
|
||
// whole walk into a single query.
|
||
func (g *Graph) GetFileSubGraph(filePath string) ([]*Node, []*Edge) {
|
||
if filePath == "" {
|
||
return nil, nil
|
||
}
|
||
nodes := g.GetFileNodes(filePath)
|
||
if len(nodes) == 0 {
|
||
return nil, nil
|
||
}
|
||
ids := make([]string, 0, len(nodes))
|
||
for _, n := range nodes {
|
||
if n != nil && n.ID != "" {
|
||
ids = append(ids, n.ID)
|
||
}
|
||
}
|
||
outByID := g.GetOutEdgesByNodeIDs(ids)
|
||
inByID := g.GetInEdgesByNodeIDs(ids)
|
||
type edgeKey struct {
|
||
from string
|
||
to string
|
||
kind EdgeKind
|
||
}
|
||
seen := make(map[edgeKey]struct{}, 2*len(ids))
|
||
edges := make([]*Edge, 0, 2*len(ids))
|
||
add := func(e *Edge) {
|
||
if e == nil {
|
||
return
|
||
}
|
||
k := edgeKey{from: e.From, to: e.To, kind: e.Kind}
|
||
if _, ok := seen[k]; ok {
|
||
return
|
||
}
|
||
seen[k] = struct{}{}
|
||
edges = append(edges, e)
|
||
}
|
||
for _, id := range ids {
|
||
for _, e := range outByID[id] {
|
||
add(e)
|
||
}
|
||
for _, e := range inByID[id] {
|
||
add(e)
|
||
}
|
||
}
|
||
return nodes, edges
|
||
}
|
||
|
||
// GetFileSubGraphCounts is the in-memory reference implementation of
|
||
// FileSubGraphCountReader. The per-node bucket reads are already
|
||
// O(1) so it just walks GetFileSubGraph and reports len(edges); the
|
||
// row-materialisation win belongs to disk backends.
|
||
func (g *Graph) GetFileSubGraphCounts(filePath string) ([]*Node, int) {
|
||
nodes, edges := g.GetFileSubGraph(filePath)
|
||
return nodes, len(edges)
|
||
}
|
||
|
||
// NodeDegreeByKinds is the in-memory reference implementation of the
|
||
// NodeDegreeByKinds capability. Walks NodesByKinds and reads each
|
||
// node's in/out edge buckets — the disk backend overrides with one
|
||
// kind-filtered aggregation per direction so the IN-list of node IDs
|
||
// the legacy NodeDegreeCounts path needed is avoided altogether.
|
||
func (g *Graph) NodeDegreeByKinds(kinds []NodeKind, pathPrefix string) []NodeDegreeRow {
|
||
if len(kinds) == 0 {
|
||
return nil
|
||
}
|
||
pool := g.NodesByKinds(kinds)
|
||
out := make([]NodeDegreeRow, 0, len(pool))
|
||
for _, n := range pool {
|
||
if n == nil {
|
||
continue
|
||
}
|
||
if pathPrefix != "" && !strings.HasPrefix(n.FilePath, pathPrefix) {
|
||
continue
|
||
}
|
||
out = append(out, NodeDegreeRow{
|
||
NodeID: n.ID,
|
||
InCount: len(g.GetInEdges(n.ID)),
|
||
OutCount: len(g.GetOutEdges(n.ID)),
|
||
})
|
||
}
|
||
return out
|
||
}
|