perf(graph,churn): move git-churn enrichment out of nodes.meta into a typed sidecar

Change A (storage unification), churn domain. Enrichment used to ride in
the gob-encoded nodes.meta BLOB, so every node row paid encode/decode for
rarely-read data, and get_churn_rate scanned AllNodes + gob-decoded every
meta blob to peek at one key (~27ms / 220x slower than memory on sqlite).

Churn now persists in a dedicated churn_enrichment table (node_id PK +
typed columns + repo_prefix), mirroring the clone_shingles sidecar: a new
optional Store capability (ChurnEnrichmentWriter/Reader) implemented by
both the in-memory and sqlite backends, with a conformance case forcing
parity.

- The enricher (internal/churn) writes the sidecar via BulkSetChurn when
  the backend implements the capability, and no longer stamps Node.Meta.
- get_churn_rate reads the typed rows via an index over the (small)
  enriched set + one batched node lookup, instead of the AllNodes scan.
  It falls back to the legacy Meta scan when the sidecar is empty
  (un-migrated DB) or the backend lacks the capability, so an existing
  store.sqlite still answers until the next `gortex enrich churn`
  (recompute-on-next-enrich migration; no destructive backfill).

Tests: storetest conformance on both backends; the enricher writes +
round-trips the sidecar (and no longer leaves churn in Meta);
get_churn_rate surfaces sidecar rows (sort_by / min_commits) and the
legacy Meta-scan fallback still works.

First of the per-domain enrichment moves; coverage/releases/blame follow
the same pattern, and the EvictFile DeleteEnrichment cascade lands with
them (orphan rows are currently tolerated, as vectors are).
This commit is contained in:
Andrey Kumanyaev
2026-06-02 01:36:58 +02:00
parent c660af247f
commit 5d19188656
9 changed files with 556 additions and 43 deletions
+59 -2
View File
@@ -123,6 +123,8 @@ func EnrichGraph(ctx context.Context, g graph.Store, repoRoot string, opts Optio
}
res := Result{Branch: opts.Branch, HeadSHA: headSHA}
churnWriter, useChurnSidecar := g.(graph.ChurnEnrichmentWriter)
var churnRows []graph.ChurnEnrichment
for filePath, b := range byPath {
if err := ctx.Err(); err != nil {
return res, err
@@ -143,7 +145,13 @@ func EnrichGraph(ctx context.Context, g graph.Store, repoRoot string, opts Optio
// File summary: aggregate across all commits.
if b.file != nil {
stampFileChurn(b.file, commits, headSHA, opts.Branch, now)
g.AddNode(b.file)
if useChurnSidecar {
churnRows = append(churnRows, churnEnrichmentFromNode(b.file))
delete(b.file.Meta, "churn")
delete(b.file.Meta, "churn_meta")
} else {
g.AddNode(b.file)
}
res.Files++
}
@@ -156,11 +164,33 @@ func EnrichGraph(ctx context.Context, g graph.Store, repoRoot string, opts Optio
// (shallow clones, signed-off cherry-picks).
for _, s := range b.symbols {
if stampSymbolChurn(s, blameLines, commits, now) {
g.AddNode(s)
if useChurnSidecar {
churnRows = append(churnRows, churnEnrichmentFromNode(s))
delete(s.Meta, "churn")
} else {
g.AddNode(s)
}
res.Symbols++
}
}
}
// Sidecar persist (change A): when the backend implements
// ChurnEnrichmentWriter, churn rides in the typed churn_enrichment
// table instead of nodes.meta, so the node hot path stops gob-
// encoding it and get_churn_rate reads via an index. Grouped by
// repo prefix since BulkSetChurn stamps one prefix per call.
if useChurnSidecar && len(churnRows) > 0 {
byPrefix := map[string][]graph.ChurnEnrichment{}
for _, r := range churnRows {
byPrefix[r.RepoPrefix] = append(byPrefix[r.RepoPrefix], r)
}
for prefix, rr := range byPrefix {
if err := churnWriter.BulkSetChurn(prefix, rr); err != nil {
return res, fmt.Errorf("churn: persist sidecar: %w", err)
}
}
}
return res, nil
}
@@ -206,6 +236,33 @@ func fileCommits(repoRoot, branch, relPath string) ([]commitRecord, error) {
return records, scanner.Err()
}
// churnEnrichmentFromNode projects the freshly-stamped Meta["churn"] /
// Meta["churn_meta"] payload into a typed ChurnEnrichment row for the
// sidecar. The stamp functions write int/float64 directly (no JSON
// widening at this point), so the type assertions are exact.
func churnEnrichmentFromNode(n *graph.Node) graph.ChurnEnrichment {
e := graph.ChurnEnrichment{NodeID: n.ID, RepoPrefix: n.RepoPrefix}
if m, ok := n.Meta["churn"].(map[string]any); ok {
if v, ok := m["commit_count"].(int); ok {
e.CommitCount = v
}
if v, ok := m["age_days"].(int); ok {
e.AgeDays = v
}
if v, ok := m["churn_rate"].(float64); ok {
e.ChurnRate = v
}
e.LastAuthor, _ = m["last_author"].(string)
e.LastCommitAt, _ = m["last_commit_at"].(string)
}
if m, ok := n.Meta["churn_meta"].(map[string]any); ok {
e.HeadSHA, _ = m["head_sha"].(string)
e.Branch, _ = m["branch"].(string)
e.ComputedAt, _ = m["computed_at"].(string)
}
return e
}
// stampFileChurn writes the file-level summary onto n.Meta["churn"]
// and pins enrichment provenance under n.Meta["churn_meta"].
func stampFileChurn(n *graph.Node, commits []commitRecord, headSHA, branch string, now time.Time) {
+27 -22
View File
@@ -49,33 +49,38 @@ func TestEnrichGraph_StampsSymbolAndFile(t *testing.T) {
t.Error("HeadSHA should be set")
}
// File summary present.
fileNode := g.GetNode("main.go")
fileChurn, ok := fileNode.Meta["churn"].(map[string]any)
if !ok {
t.Fatalf("file Meta[churn] missing: %+v", fileNode.Meta)
}
if cc, _ := fileChurn["commit_count"].(int); cc != 3 {
t.Errorf("file commit_count = %v, want 3", fileChurn["commit_count"])
}
if _, ok := fileChurn["churn_rate"].(float64); !ok {
t.Errorf("file churn_rate missing or not float: %T %v", fileChurn["churn_rate"], fileChurn["churn_rate"])
}
// Provenance present.
if _, ok := fileNode.Meta["churn_meta"].(map[string]any); !ok {
t.Errorf("file churn_meta missing: %+v", fileNode.Meta)
// Churn now persists in the typed sidecar (change A), not Node.Meta.
byID := map[string]graph.ChurnEnrichment{}
for _, e := range g.ChurnRows("") {
byID[e.NodeID] = e
}
// Per-symbol churn.
sym := g.GetNode("main.go::Hello")
symChurn, ok := sym.Meta["churn"].(map[string]any)
fileChurn, ok := byID["main.go"]
if !ok {
t.Fatalf("symbol Meta[churn] missing: %+v", sym.Meta)
t.Fatalf("file churn row missing from sidecar; rows=%+v", byID)
}
if cc, _ := symChurn["commit_count"].(int); cc < 1 {
t.Errorf("symbol commit_count = %v, want >= 1", symChurn["commit_count"])
if fileChurn.CommitCount != 3 {
t.Errorf("file commit_count = %d, want 3", fileChurn.CommitCount)
}
if _, ok := symChurn["last_author"].(string); !ok {
if fileChurn.ChurnRate == 0 {
t.Errorf("file churn_rate missing")
}
if fileChurn.HeadSHA == "" || fileChurn.Branch == "" {
t.Errorf("file churn provenance (head_sha/branch) missing: %+v", fileChurn)
}
// Meta must NOT carry churn anymore — it moved to the sidecar.
if _, present := g.GetNode("main.go").Meta["churn"]; present {
t.Errorf("churn must not remain in Node.Meta after sidecar migration")
}
symChurn, ok := byID["main.go::Hello"]
if !ok {
t.Fatalf("symbol churn row missing from sidecar")
}
if symChurn.CommitCount < 1 {
t.Errorf("symbol commit_count = %d, want >= 1", symChurn.CommitCount)
}
if symChurn.LastAuthor == "" {
t.Errorf("symbol last_author missing: %+v", symChurn)
}
}
+56 -2
View File
@@ -478,6 +478,10 @@ type Graph struct {
// 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
}
// cloneShingleEntry is one in-memory clone_shingles row: the owning
@@ -491,8 +495,10 @@ type cloneShingleEntry struct {
// 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)
_ CloneShingleWriter = (*Graph)(nil)
_ CloneShingleReader = (*Graph)(nil)
_ ChurnEnrichmentWriter = (*Graph)(nil)
_ ChurnEnrichmentReader = (*Graph)(nil)
)
// New creates an empty graph.
@@ -585,6 +591,54 @@ func (g *Graph) LoadCloneShingles(repoPrefix string) (map[string][]uint64, error
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
}
// 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
+33
View File
@@ -959,6 +959,39 @@ type CloneShingleReader interface {
LoadCloneShingles(repoPrefix string) (map[string][]uint64, error)
}
// ChurnEnrichment is one node's git-churn enrichment, moved out of
// nodes.meta into a typed sidecar (change A). Maps 1:1 to the payload
// internal/churn.EnrichGraph used to stamp on Meta["churn"]/["churn_meta"].
// HeadSHA/Branch/ComputedAt are file-level only (empty for symbols).
type ChurnEnrichment struct {
NodeID string
RepoPrefix string
CommitCount int
AgeDays int
ChurnRate float64
LastAuthor string
LastCommitAt string // RFC3339
HeadSHA string
Branch string
ComputedAt string // RFC3339
}
// ChurnEnrichmentWriter is an optional capability backends MAY implement
// to persist git-churn enrichment in a typed sidecar instead of the
// node meta blob. When absent the enricher falls back to stamping
// Node.Meta (legacy path).
type ChurnEnrichmentWriter interface {
BulkSetChurn(repoPrefix string, rows []ChurnEnrichment) error
DeleteChurn(nodeIDs []string) error
}
// ChurnEnrichmentReader is the read side. ChurnRows returns every churn
// row for repoPrefix; an EMPTY repoPrefix returns ALL rows across repos
// (the cross-repo read get_churn_rate uses, then scope-filters per node).
type ChurnEnrichmentReader interface {
ChurnRows(repoPrefix string) []ChurnEnrichment
}
// EdgesByKindsScanner is an optional capability backends MAY
// implement to stream every edge whose Kind is in the supplied set,
// in a single backend round-trip. The fallback iterates AllEdges()
+21
View File
@@ -101,6 +101,27 @@ CREATE TABLE IF NOT EXISTS vectors (
vec BLOB NOT NULL
) WITHOUT ROWID;
-- churn_enrichment is the per-node git-churn sidecar (change A: move
-- enrichment OUT of nodes.meta so the node hot path stops gob-encoding
-- rarely-read data and get_churn_rate does an indexed read instead of an
-- AllNodes+gob scan). One typed row per enriched file/function/method
-- node, keyed by node_id (join key back to nodes.id); repo_prefix scopes
-- per-repo reseeds/wipes. head_sha/branch/computed_at are file-level only
-- (empty for symbols). WITHOUT ROWID: the PK index IS the table.
CREATE TABLE IF NOT EXISTS churn_enrichment (
node_id TEXT PRIMARY KEY,
repo_prefix TEXT NOT NULL DEFAULT '',
commit_count INTEGER NOT NULL DEFAULT 0,
age_days INTEGER NOT NULL DEFAULT 0,
churn_rate REAL NOT NULL DEFAULT 0,
last_author TEXT NOT NULL DEFAULT '',
last_commit_at TEXT NOT NULL DEFAULT '',
head_sha TEXT NOT NULL DEFAULT '',
branch TEXT NOT NULL DEFAULT '',
computed_at TEXT NOT NULL DEFAULT ''
) WITHOUT ROWID;
CREATE INDEX IF NOT EXISTS churn_by_repo ON churn_enrichment(repo_prefix) WHERE repo_prefix <> '';
-- symbol_fts is the FTS5 full-text index over pre-tokenised symbol
-- names. It replaces the multi-GB in-heap Bleve/BM25 index with an
-- on-disk inverted index the SymbolSearcher / SymbolBundleSearcher
@@ -0,0 +1,155 @@
package store_sqlite
import (
"database/sql"
"github.com/zzet/gortex/internal/graph"
)
// Compile-time assertions that the SQLite Store satisfies the optional
// git-churn enrichment sidecar capabilities (change A: enrichment moved
// out of nodes.meta into a typed table so the node hot path stops
// gob-encoding rarely-read data and get_churn_rate reads via an index
// instead of an AllNodes scan).
var (
_ graph.ChurnEnrichmentWriter = (*Store)(nil)
_ graph.ChurnEnrichmentReader = (*Store)(nil)
)
// churnChunk bounds rows per multi-row INSERT. churn_enrichment has 10
// columns, so at 10 params/row the 999 host-param limit caps a statement
// at 99 rows; 90 leaves headroom. Mirrors shingleChunk / mtimeChunk.
const churnChunk = 90
const churnCols = `node_id, repo_prefix, commit_count, age_days, churn_rate, last_author, last_commit_at, head_sha, branch, computed_at`
// BulkSetChurn persists every churn row for one repo prefix in a single
// transaction, chunked under the host-parameter limit. Idempotent on
// node_id (INSERT OR REPLACE). Empty input is a no-op.
func (s *Store) BulkSetChurn(repoPrefix string, rows []graph.ChurnEnrichment) error {
if len(rows) == 0 {
return nil
}
s.writeMu.Lock()
defer s.writeMu.Unlock()
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback() //nolint:errcheck // rollback after Commit is a no-op
for start := 0; start < len(rows); start += churnChunk {
end := start + churnChunk
if end > len(rows) {
end = len(rows)
}
batch := rows[start:end]
args := make([]any, 0, len(batch)*10)
stmt := make([]byte, 0, 128+len(batch)*24)
stmt = append(stmt, "INSERT OR REPLACE INTO churn_enrichment ("...)
stmt = append(stmt, churnCols...)
stmt = append(stmt, ") VALUES "...)
for i, e := range batch {
if i > 0 {
stmt = append(stmt, ',')
}
stmt = append(stmt, "(?,?,?,?,?,?,?,?,?,?)"...)
args = append(args, e.NodeID, repoPrefix, e.CommitCount, e.AgeDays,
e.ChurnRate, e.LastAuthor, e.LastCommitAt, e.HeadSHA, e.Branch, e.ComputedAt)
}
if _, err := tx.Exec(string(stmt), args...); err != nil {
return err
}
}
return tx.Commit()
}
// DeleteChurn drops churn rows for the supplied node ids, chunked into
// `node_id IN (?, …)` DELETEs. Empty input is a no-op.
func (s *Store) DeleteChurn(nodeIDs []string) error {
if len(nodeIDs) == 0 {
return nil
}
seen := make(map[string]struct{}, len(nodeIDs))
uniq := make([]string, 0, len(nodeIDs))
for _, id := range nodeIDs {
if id == "" {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
uniq = append(uniq, id)
}
if len(uniq) == 0 {
return nil
}
s.writeMu.Lock()
defer s.writeMu.Unlock()
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback() //nolint:errcheck
for start := 0; start < len(uniq); start += churnChunk {
end := start + churnChunk
if end > len(uniq) {
end = len(uniq)
}
chunk := uniq[start:end]
args := make([]any, len(chunk))
stmt := make([]byte, 0, 48+len(chunk)*2)
stmt = append(stmt, "DELETE FROM churn_enrichment WHERE node_id IN ("...)
for i, id := range chunk {
if i > 0 {
stmt = append(stmt, ',')
}
stmt = append(stmt, '?')
args[i] = id
}
stmt = append(stmt, ')')
if _, err := tx.Exec(string(stmt), args...); err != nil {
return err
}
}
return tx.Commit()
}
// ChurnRows returns every churn row for repoPrefix; an EMPTY repoPrefix
// returns ALL rows across repos. This is an index-only read over the
// (small) enriched set — the whole point of the sidecar, replacing the
// AllNodes()+gob-decode scan get_churn_rate used to do.
func (s *Store) ChurnRows(repoPrefix string) []graph.ChurnEnrichment {
var (
rows *sql.Rows
err error
)
if repoPrefix == "" {
rows, err = s.db.Query(`SELECT ` + churnCols + ` FROM churn_enrichment`)
} else {
rows, err = s.db.Query(`SELECT `+churnCols+` FROM churn_enrichment WHERE repo_prefix = ?`, repoPrefix)
}
if err != nil {
return nil
}
defer func() { _ = rows.Close() }()
var out []graph.ChurnEnrichment
for rows.Next() {
var e graph.ChurnEnrichment
if err := rows.Scan(&e.NodeID, &e.RepoPrefix, &e.CommitCount, &e.AgeDays,
&e.ChurnRate, &e.LastAuthor, &e.LastCommitAt, &e.HeadSHA, &e.Branch, &e.ComputedAt); err != nil {
return out
}
out = append(out, e)
}
if err := rows.Err(); err != nil {
return out
}
return out
}
+87
View File
@@ -98,6 +98,7 @@ func RunConformance(t *testing.T, factory Factory) {
t.Run("FileEditingContext", func(t *testing.T) { testFileEditingContext(t, factory) })
t.Run("NodeDegreeByKinds", func(t *testing.T) { testNodeDegreeByKinds(t, factory) })
t.Run("CloneShingleSidecar", func(t *testing.T) { testCloneShingleSidecar(t, factory) })
t.Run("ChurnEnrichmentSidecar", func(t *testing.T) { testChurnEnrichmentSidecar(t, factory) })
}
// -- fixture helpers ---------------------------------------------------
@@ -3401,3 +3402,89 @@ func testCloneShingleSidecar(t *testing.T, factory Factory) {
t.Fatalf("LoadCloneShingles(repoB) = %v, want {c.go::Qux:[5 6]}", bRows)
}
}
// testChurnEnrichmentSidecar mirrors the clone-shingle sidecar
// conformance for the churn enrichment capability (change A): write,
// read-all vs read-by-prefix, idempotent overwrite, per-repo isolation,
// and delete.
func testChurnEnrichmentSidecar(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
w, ok := s.(graph.ChurnEnrichmentWriter)
if !ok {
t.Skip("backend does not implement graph.ChurnEnrichmentWriter")
}
r, ok := s.(graph.ChurnEnrichmentReader)
if !ok {
t.Skip("backend implements ChurnEnrichmentWriter but not ChurnEnrichmentReader")
}
// Empty store + empty input are no-ops.
if got := r.ChurnRows("repoA"); len(got) != 0 {
t.Fatalf("ChurnRows(empty store) = %v, want empty", got)
}
if err := w.BulkSetChurn("repoA", nil); err != nil {
t.Fatalf("BulkSetChurn(nil): %v", err)
}
rowsA := []graph.ChurnEnrichment{
{NodeID: "a.go", CommitCount: 5, AgeDays: 30, ChurnRate: 1.5, LastAuthor: "x@y", LastCommitAt: "2026-01-01T00:00:00Z", HeadSHA: "abc", Branch: "main", ComputedAt: "2026-06-01T00:00:00Z"},
{NodeID: "a.go::Foo", CommitCount: 2, AgeDays: 10, ChurnRate: 0.2, LastAuthor: "z@y", LastCommitAt: "2026-02-01T00:00:00Z"},
}
rowsB := []graph.ChurnEnrichment{
{NodeID: "b.go::Bar", CommitCount: 9, AgeDays: 90, ChurnRate: 0.1, LastAuthor: "q@y"},
}
if err := w.BulkSetChurn("repoA", rowsA); err != nil {
t.Fatalf("BulkSetChurn(repoA): %v", err)
}
if err := w.BulkSetChurn("repoB", rowsB); err != nil {
t.Fatalf("BulkSetChurn(repoB): %v", err)
}
// Per-repo read isolation.
if got := r.ChurnRows("repoA"); len(got) != 2 {
t.Fatalf("ChurnRows(repoA) len = %d, want 2", len(got))
}
if got := r.ChurnRows("repoB"); len(got) != 1 {
t.Fatalf("ChurnRows(repoB) len = %d, want 1", len(got))
}
// Empty prefix returns ALL rows across repos.
all := r.ChurnRows("")
if len(all) != 3 {
t.Fatalf("ChurnRows(\"\") len = %d, want 3 (all repos)", len(all))
}
// Field round-trip + repo_prefix stamping.
byID := map[string]graph.ChurnEnrichment{}
for _, e := range all {
byID[e.NodeID] = e
}
foo := byID["a.go"]
if foo.RepoPrefix != "repoA" || foo.CommitCount != 5 || foo.ChurnRate != 1.5 ||
foo.LastAuthor != "x@y" || foo.LastCommitAt != "2026-01-01T00:00:00Z" ||
foo.HeadSHA != "abc" || foo.Branch != "main" {
t.Fatalf("round-trip mismatch for a.go: %+v", foo)
}
// Idempotent overwrite (INSERT OR REPLACE on node_id).
rowsA[0].CommitCount = 99
if err := w.BulkSetChurn("repoA", rowsA[:1]); err != nil {
t.Fatalf("BulkSetChurn(overwrite): %v", err)
}
for _, e := range r.ChurnRows("repoA") {
if e.NodeID == "a.go" && e.CommitCount != 99 {
t.Fatalf("overwrite failed: a.go commit_count = %d, want 99", e.CommitCount)
}
}
// Delete.
if err := w.DeleteChurn([]string{"a.go", "a.go::Foo"}); err != nil {
t.Fatalf("DeleteChurn: %v", err)
}
if got := r.ChurnRows("repoA"); len(got) != 0 {
t.Fatalf("ChurnRows(repoA) after delete = %d, want 0", len(got))
}
if got := r.ChurnRows("repoB"); len(got) != 1 {
t.Fatalf("DeleteChurn must not touch repoB: len = %d, want 1", len(got))
}
}
+84 -17
View File
@@ -8,6 +8,7 @@ import (
"github.com/mark3labs/mcp-go/mcp"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/query"
)
// registerChurnRateTool wires get_churn_rate — a pure graph scan over
@@ -63,29 +64,77 @@ func (s *Server) handleGetChurnRate(ctx context.Context, req mcp.CallToolRequest
allowed = nil
}
scoped := s.scopedNodes(ctx)
rows := make([]churnRow, 0, 64)
seenFiles := map[string]struct{}{}
sawMeta := false
for _, n := range scoped {
if allowed != nil {
if _, ok := allowed[n.Kind]; !ok {
continue
usedSidecar := false
if reader, ok := s.graph.(graph.ChurnEnrichmentReader); ok {
// Sidecar fast-path (change A): read the typed churn rows via an
// index over the (small) enriched set, then resolve their nodes
// in one batch — instead of scanning AllNodes and gob-decoding
// every meta blob to peek at Meta["churn"].
if enrich := reader.ChurnRows(""); len(enrich) > 0 {
usedSidecar = true
sawMeta = true
ids := make([]string, 0, len(enrich))
for _, e := range enrich {
ids = append(ids, e.NodeID)
}
nodes := s.graph.GetNodesByIDs(ids)
sessWS, _, bound := s.sessionScope(ctx)
var opts query.QueryOptions
if bound {
opts = query.QueryOptions{WorkspaceID: sessWS}
}
for _, e := range enrich {
n := nodes[e.NodeID]
if n == nil {
continue
}
if bound && !opts.ScopeAllows(n) {
continue
}
if allowed != nil {
if _, ok := allowed[n.Kind]; !ok {
continue
}
}
if pathPrefix != "" && !strings.HasPrefix(n.FilePath, pathPrefix) {
continue
}
if e.CommitCount < minCommits {
continue
}
rows = append(rows, churnRowFromEnrichment(n, e))
seenFiles[n.FilePath] = struct{}{}
}
}
if pathPrefix != "" && !strings.HasPrefix(n.FilePath, pathPrefix) {
continue
}
if !usedSidecar {
// Fallback: no sidecar rows yet (un-migrated DB, recompute-on-
// next-enrich) or a backend without the capability — read
// Meta["churn"] off a full AllNodes scan.
for _, n := range s.scopedNodes(ctx) {
if allowed != nil {
if _, ok := allowed[n.Kind]; !ok {
continue
}
}
if pathPrefix != "" && !strings.HasPrefix(n.FilePath, pathPrefix) {
continue
}
row, ok := churnRowFromMeta(n)
if !ok {
continue
}
sawMeta = true
if row.CommitCount < minCommits {
continue
}
rows = append(rows, row)
seenFiles[n.FilePath] = struct{}{}
}
row, ok := churnRowFromMeta(n)
if !ok {
continue
}
sawMeta = true
if row.CommitCount < minCommits {
continue
}
rows = append(rows, row)
seenFiles[n.FilePath] = struct{}{}
}
if !sawMeta {
@@ -135,6 +184,24 @@ func (s *Server) handleGetChurnRate(ctx context.Context, req mcp.CallToolRequest
})
}
// churnRowFromEnrichment builds a response row from a node + its typed
// sidecar churn enrichment (change A read path).
func churnRowFromEnrichment(n *graph.Node, e graph.ChurnEnrichment) churnRow {
endLine := n.EndLine
if endLine == 0 {
endLine = n.StartLine
}
return churnRow{
ID: n.ID, Name: n.Name, File: n.FilePath,
StartLine: n.StartLine, EndLine: endLine,
CommitCount: e.CommitCount,
AgeDays: e.AgeDays,
ChurnRate: e.ChurnRate,
LastAuthor: e.LastAuthor,
LastCommitAt: e.LastCommitAt,
}
}
// churnRowFromMeta projects a node's meta.churn payload into the
// response row. Returns (zero, false) when the node has no churn
// metadata — the caller distinguishes "missing data" from
+34
View File
@@ -210,3 +210,37 @@ func TestChurnRate_TolerantMetaTypes(t *testing.T) {
assert.EqualValues(t, 3, row["age_days"])
assert.InDelta(t, 2.33, row["churn_rate"].(float64), 0.001)
}
// TestChurnRate_SidecarReadPath proves the change-A primary path:
// churn populated in the typed sidecar (BulkSetChurn) — with NO
// Meta["churn"] on the nodes — is surfaced by get_churn_rate via the
// ChurnEnrichmentReader index read, not the AllNodes Meta scan.
func TestChurnRate_SidecarReadPath(t *testing.T) {
g := graph.New()
g.AddNode(&graph.Node{ID: "foo.go::a", Kind: graph.KindFunction, Name: "a", FilePath: "foo.go", StartLine: 1, EndLine: 2})
g.AddNode(&graph.Node{ID: "foo.go::b", Kind: graph.KindFunction, Name: "b", FilePath: "foo.go", StartLine: 3, EndLine: 4})
require.NoError(t, g.BulkSetChurn("", []graph.ChurnEnrichment{
{NodeID: "foo.go::a", CommitCount: 7, ChurnRate: 3.0, LastAuthor: "a@x"},
{NodeID: "foo.go::b", CommitCount: 2, ChurnRate: 0.5, LastAuthor: "b@x"},
}))
s := &Server{
graph: g,
session: newSessionState(),
tokenStats: &tokenStats{},
symHistory: &symbolHistory{entries: make(map[string][]SymbolModification)},
sessions: newSessionMap(),
toolScopes: newScopeRegistry(),
}
out := callChurnHandler(t, s, map[string]any{"sort_by": "commit_count"})
symbols, _ := out["symbols"].([]any)
require.Len(t, symbols, 2, "both sidecar rows must surface")
first, _ := symbols[0].(map[string]any)
assert.Equal(t, "foo.go::a", first["symbol_id"], "sort_by commit_count: a (7) before b (2)")
assert.EqualValues(t, 7, first["commit_count"])
assert.Equal(t, "a@x", first["last_author"])
out2 := callChurnHandler(t, s, map[string]any{"min_commits": 5})
syms2, _ := out2["symbols"].([]any)
require.Len(t, syms2, 1, "min_commits=5 keeps only a")
}