Skip to content

Commit 50af510

Browse files
committed
perf: optimize large graph builds
1 parent 8c9bb5f commit 50af510

9 files changed

Lines changed: 892 additions & 74 deletions

File tree

guide/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,5 +28,6 @@ explore graph edges.
2828
| [Runtime Layout](runtime-layout.md) | `ccg`, `ccg-server`, Wiki serving, and shared `ccg-core` ownership boundaries |
2929
| [Architecture](architecture.md) | System architecture, data flow, DB schema |
3030
| [Development](development.md) | Build, test, integration test (Gitea + PostgreSQL) |
31+
| [Large Build Performance](build-performance.md) | Measured full-build bottlenecks, bounded concurrency, and import-file indexing |
3132
| [Namespace Migration](namespace-migration.md) | Default namespace change and migration guide |
3233
| [CLAUDE.md Guide](claude-md-guide.md) | CLAUDE.md template for projects using CCG |

guide/build-performance.md

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
# Large Build Performance
2+
3+
This guide records the measured changes that make a full CCG build practical on
4+
large source trees. It is scoped to parsing, graph persistence, edge resolution,
5+
and search-document rebuilding; it is not a general database tuning guide.
6+
7+
## Benchmark scope
8+
9+
The representative workload was a Kotlin corpus with 1,592 files (about 4.86
10+
MB). Every comparison used a fresh SQLite database, the same binary and build
11+
options, and the same source snapshot. The figures are single fresh-build
12+
measurements: they identify the bottleneck, not a statistically rigorous
13+
throughput claim.
14+
15+
The final graph contained 9,419 nodes, 6,942 persisted edges, and 9,402 search
16+
documents.
17+
18+
| Stage | Before import-file index | After import-file index |
19+
| --- | ---: | ---: |
20+
| Parse and spool | 8,682 ms | 8,728 ms |
21+
| Node persistence | 594 ms | 560 ms |
22+
| Edge resolution | 16,712 ms | 888 ms |
23+
| Search rebuild | 315 ms | 307 ms |
24+
| Total | 26,442 ms | 10,603 ms |
25+
26+
The import-file index reduced total build time by about 59.9% and edge
27+
resolution by about 94.7% for this workload. The parser worker pool had already
28+
reduced an earlier parse stage from 11,123 ms to 8,716 ms (about 21.6%).
29+
30+
## What changed
31+
32+
### Stage timing
33+
34+
`BuildStats.Timing` and the build-complete log report parse, node persistence,
35+
edge resolution, search rebuild, and total duration. Profile a representative
36+
fresh build before changing concurrency, batches, or database configuration.
37+
38+
### Bounded parsing concurrency
39+
40+
The parse-and-spool stage uses four workers. A coordinator preserves input and
41+
spool-record order; database writes, edge resolution, and search rebuilding
42+
remain sequential inside their existing transaction. This keeps failure and
43+
cancellation behavior unchanged while using available CPU for parsing.
44+
45+
### Edge batches
46+
47+
Edges are resolved and upserted in batches capped at 4,000 edges. A one-off
48+
8,000-edge measurement was slightly slower (29,147 ms versus 29,107 ms total),
49+
so 4,000 remains the production limit. Treat a larger batch as a hypothesis to
50+
measure, not an automatic improvement.
51+
52+
### Build-scoped import-file index
53+
54+
The decisive bottleneck was import suffix resolution. Before the index,
55+
`GetFileNodesByPathSuffix` ran 1,983 times and spent 14,742 ms reading all file
56+
nodes in the namespace and comparing paths in Go.
57+
58+
During full-build edge resolution, CCG now reads the namespace's real file
59+
nodes once, then builds an in-memory index for the transaction lifetime:
60+
61+
- an exact directory map;
62+
- a directory-suffix map; and
63+
- the existing priority rule: exact directories win, otherwise every match at
64+
the longest suffix is returned.
65+
66+
The index is created after file nodes are persisted and discarded when the full
67+
build's edge-resolution phase ends. Incremental builds and ordinary suffix
68+
queries retain their existing paths. This avoids a long-lived store cache and
69+
its invalidation requirements.
70+
71+
## Correctness checks
72+
73+
Performance changes were accepted only after comparing independently built,
74+
fresh databases. In both directions, natural-key comparisons found no
75+
differences in the final 9,419 nodes, 6,942 persisted edges, or 9,402 search
76+
documents.
77+
78+
Focused tests also cover:
79+
80+
- exact-directory priority;
81+
- ambiguous longest-suffix matches;
82+
- namespace and `kind=file` filtering; and
83+
- a single import-file read per build resolver.
84+
85+
## Reproducing a measurement
86+
87+
Use a disposable database and a stable source snapshot. For example:
88+
89+
```bash
90+
ccg --db-driver sqlite --db-dsn /tmp/ccg-benchmark.db --log-json build /path/to/repository
91+
```
92+
93+
Read the build-complete JSON log fields for the stage durations. Repeat enough
94+
times to account for local CPU, filesystem cache, and database differences.
95+
Compare graph contents as well as elapsed time; a faster build that changes
96+
nodes, edges, or search documents is a regression.
97+
98+
## PostgreSQL note
99+
100+
PostgreSQL was not benchmarked for this change because no local PostgreSQL
101+
instance was available during the measurement. It was not the immediate next
102+
optimization: the former path transferred and scanned all file nodes 1,983
103+
times, so changing database drivers alone would retain that work and may make
104+
it more expensive over a network.
105+
106+
If PostgreSQL is the deployment target, repeat the same fresh-build and
107+
content-equivalence procedure against its real topology before tuning indexes,
108+
connection settings, or write concurrency.

internal/adapters/outbound/graphgorm/store.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,19 @@ func (s *Store) ListFileNodes(ctx context.Context) ([]graph.Node, error) {
195195
return nodes, nil
196196
}
197197

198+
// ListImportFileNodes returns actual file nodes for build-scoped import-path resolution.
199+
// @intent let full builds create an in-memory import suffix index without reloading all file nodes per import.
200+
func (s *Store) ListImportFileNodes(ctx context.Context) ([]graph.Node, error) {
201+
ns := requestctx.FromContext(ctx)
202+
var nodes []graph.Node
203+
if err := s.db.WithContext(ctx).
204+
Where("namespace = ? AND kind = ?", ns, graph.NodeKindFile).
205+
Find(&nodes).Error; err != nil {
206+
return nil, trace.Wrap(err, "list import file nodes")
207+
}
208+
return nodes, nil
209+
}
210+
198211
// GetFileNodesByPathSuffix finds file nodes whose directory matches an import-path suffix.
199212
// @intent let import edge resolution bind repo-local import paths back to stored file nodes.
200213
func (s *Store) GetFileNodesByPathSuffix(ctx context.Context, suffix string) ([]graph.Node, error) {

internal/adapters/outbound/graphgorm/store_test.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -561,6 +561,32 @@ func TestListFileNodes_ReturnsMinimalNonPackageStateForNamespace(t *testing.T) {
561561
}
562562
}
563563

564+
func TestListImportFileNodes_ReturnsOnlyFileNodesForNamespace(t *testing.T) {
565+
s := setupTestDB(t)
566+
ctxA := requestctx.WithNamespace(context.Background(), "ns-a")
567+
ctxB := requestctx.WithNamespace(context.Background(), "ns-b")
568+
if err := s.UpsertNodes(ctxA, []graph.Node{
569+
{QualifiedName: "a.go", Kind: graph.NodeKindFile, Name: "a.go", FilePath: "a.go", Hash: "hash-a"},
570+
{QualifiedName: "pkg.A", Kind: graph.NodeKindFunction, Name: "A", FilePath: "a.go", Hash: "hash-a"},
571+
}); err != nil {
572+
t.Fatalf("UpsertNodes(ns-a): %v", err)
573+
}
574+
if err := s.UpsertNodes(ctxB, []graph.Node{{QualifiedName: "b.go", Kind: graph.NodeKindFile, Name: "b.go", FilePath: "b.go"}}); err != nil {
575+
t.Fatalf("UpsertNodes(ns-b): %v", err)
576+
}
577+
578+
nodes, err := s.ListImportFileNodes(ctxA)
579+
if err != nil {
580+
t.Fatalf("ListImportFileNodes: %v", err)
581+
}
582+
if len(nodes) != 1 {
583+
t.Fatalf("ListImportFileNodes returned %d nodes, want 1: %#v", len(nodes), nodes)
584+
}
585+
if nodes[0].ID == 0 || nodes[0].Kind != graph.NodeKindFile || nodes[0].FilePath != "a.go" {
586+
t.Fatalf("ListImportFileNodes returned unexpected node: %#v", nodes[0])
587+
}
588+
}
589+
564590
func TestDeletePackageSemanticEdges_FiltersByNamespaceAnchorKindAndLine(t *testing.T) {
565591
s := setupTestDB(t)
566592
ctxA := requestctx.WithNamespace(context.Background(), "ns-a")

internal/app/ingest/ports.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ type PackageContext struct {
6363

6464
// Parser is the minimum source parser consumed by build and update workflows.
6565
// @intent parse source into domain graph values without exposing Tree-sitter or another parser implementation.
66+
// @domainRule full builds may invoke one Parser instance concurrently, so ParseWithContext implementations must isolate mutable parser state.
6667
type Parser interface {
6768
Parse(filePath string, content []byte) ([]graph.Node, []graph.Edge, error)
6869
ParseWithContext(ctx context.Context, filePath string, content []byte) ([]graph.Node, []graph.Edge, error)
@@ -103,6 +104,7 @@ type GraphStore interface {
103104
GetNodesByFiles(ctx context.Context, filePaths []string) (map[string][]graph.Node, error)
104105
GetNodesByQualifiedNames(ctx context.Context, names []string) (map[string][]graph.Node, error)
105106
ListFileNodes(ctx context.Context) ([]graph.Node, error)
107+
ListImportFileNodes(ctx context.Context) ([]graph.Node, error)
106108
GetFileNodesByPathSuffix(ctx context.Context, suffix string) ([]graph.Node, error)
107109
GetEdgesFromNodes(ctx context.Context, nodeIDs []uint) ([]graph.Edge, error)
108110
GetEdgesToNodes(ctx context.Context, nodeIDs []uint) ([]graph.Edge, error)

internal/app/ingest/ports_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ func (graphStoreContractStub) GetNodesByQualifiedNames(context.Context, []string
2121
return nil, nil
2222
}
2323
func (graphStoreContractStub) ListFileNodes(context.Context) ([]graph.Node, error) { return nil, nil }
24+
func (graphStoreContractStub) ListImportFileNodes(context.Context) ([]graph.Node, error) {
25+
return nil, nil
26+
}
2427
func (graphStoreContractStub) GetFileNodesByPathSuffix(context.Context, string) ([]graph.Node, error) {
2528
return nil, nil
2629
}

0 commit comments

Comments
 (0)