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:
+59
-2
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user