Skip to content

Commit ec7252f

Browse files
tae2089claude
andcommitted
perf: resolve each distinct cross-ref target once per sync pass
SyncNamespace issued one ResolveCCGRef round-trip per @see tag and per inbound row, although many rows share the same (namespace, path, symbol) target — comment binding alone duplicates every ref across the function and file node. Memoize matcher outcomes per distinct target for the duration of one sync pass; the memo stays valid across the outbound and inbound phases because a sync pass rewrites only cross_refs rows, never the node tables the matcher reads. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent b6dba0f commit ec7252f

2 files changed

Lines changed: 100 additions & 6 deletions

File tree

internal/app/crossref/service.go

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -52,17 +52,49 @@ func (s *Service) SyncNamespace(ctx context.Context) error {
5252
return trace.New("cross-ref store is not configured")
5353
}
5454
ns := requestctx.FromContext(ctx)
55-
if err := s.rebuildOutbound(ctx, ns); err != nil {
55+
memo := map[targetKey]resolution{}
56+
if err := s.rebuildOutbound(ctx, ns, memo); err != nil {
5657
return trace.Wrap(err, "rebuild outbound cross refs")
5758
}
58-
if err := s.reresolveInbound(ctx, ns); err != nil {
59+
if err := s.reresolveInbound(ctx, ns, memo); err != nil {
5960
return trace.Wrap(err, "re-resolve inbound cross refs")
6061
}
6162
return nil
6263
}
6364

65+
// targetKey identifies one distinct symbolic resolution target within a sync pass.
66+
// @intent resolve every distinct (namespace, path, symbol) target once per sync instead of once per referencing row.
67+
type targetKey struct {
68+
namespace string
69+
path string
70+
symbol string
71+
}
72+
73+
// resolution caches one matcher outcome for reuse across rows that share a target.
74+
type resolution struct {
75+
id *uint
76+
status graph.CrossRefStatus
77+
}
78+
79+
// resolveOnce memoizes resolve results per distinct target for the duration of one sync pass.
80+
// @intent collapse the per-row resolve round-trips (N+1) down to one query per distinct target.
81+
// @domainRule the memo stays valid across the outbound and inbound phases because a sync pass
82+
// only rewrites cross_refs rows, never the node tables the matcher reads.
83+
func (s *Service) resolveOnce(ctx context.Context, ref reference.Ref, memo map[targetKey]resolution) (*uint, graph.CrossRefStatus, error) {
84+
key := targetKey{namespace: ref.Namespace, path: ref.Path, symbol: ref.Symbol}
85+
if cached, ok := memo[key]; ok {
86+
return cached.id, cached.status, nil
87+
}
88+
id, status, err := s.resolve(ctx, ref)
89+
if err != nil {
90+
return nil, status, err
91+
}
92+
memo[key] = resolution{id: id, status: status}
93+
return id, status, nil
94+
}
95+
6496
// @intent replace the namespace's outbound rows with rows derived from its current annotations.
65-
func (s *Service) rebuildOutbound(ctx context.Context, ns string) error {
97+
func (s *Service) rebuildOutbound(ctx context.Context, ns string, memo map[targetKey]resolution) error {
6698
tags, err := s.Store.ListAnnotationCCGRefs(ctx, ns)
6799
if err != nil {
68100
return err
@@ -79,7 +111,7 @@ func (s *Service) rebuildOutbound(ctx context.Context, ns string) error {
79111
s.logger().Debug("skipping malformed ccg ref", "node_id", tag.NodeID, "value", tag.Value, "error", err)
80112
continue
81113
}
82-
resolvedID, status, err := s.resolve(ctx, *ref)
114+
resolvedID, status, err := s.resolveOnce(ctx, *ref, memo)
83115
if err != nil {
84116
return err
85117
}
@@ -99,14 +131,14 @@ func (s *Service) rebuildOutbound(ctx context.Context, ns string) error {
99131
}
100132

101133
// @intent update inbound rows whose resolution changed after this namespace's nodes were rebuilt.
102-
func (s *Service) reresolveInbound(ctx context.Context, ns string) error {
134+
func (s *Service) reresolveInbound(ctx context.Context, ns string, memo map[targetKey]resolution) error {
103135
inbound, err := s.Store.ListInboundCrossRefs(ctx, ns)
104136
if err != nil {
105137
return err
106138
}
107139
for _, row := range inbound {
108140
ref := reference.Ref{Raw: row.Raw, Namespace: row.ToNamespace, Path: row.ToPath, Symbol: row.ToSymbol}
109-
resolvedID, status, err := s.resolve(ctx, ref)
141+
resolvedID, status, err := s.resolveOnce(ctx, ref, memo)
110142
if err != nil {
111143
return err
112144
}

internal/app/crossref/service_test.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"github.com/tae2089/code-context-graph/internal/app/crossref"
1313
requestctx "github.com/tae2089/code-context-graph/internal/ctx"
1414
"github.com/tae2089/code-context-graph/internal/domain/graph"
15+
"github.com/tae2089/code-context-graph/internal/domain/reference"
1516
)
1617

1718
func setupStore(t *testing.T) *graphgorm.Store {
@@ -60,6 +61,67 @@ func syncNamespace(t *testing.T, svc *crossref.Service, namespace string) {
6061
}
6162
}
6263

64+
// countingStore counts resolve round-trips so tests can assert per-distinct-target batching.
65+
type countingStore struct {
66+
*graphgorm.Store
67+
resolveCalls int
68+
}
69+
70+
func (c *countingStore) ResolveCCGRef(ctx context.Context, ref reference.Ref) (uint, bool, error) {
71+
c.resolveCalls++
72+
return c.Store.ResolveCCGRef(ctx, ref)
73+
}
74+
75+
func TestSyncNamespace_ResolvesEachDistinctTargetOnce(t *testing.T) {
76+
s := setupStore(t)
77+
counting := &countingStore{Store: s}
78+
svc := crossref.New(counting)
79+
80+
seedNode(t, s, "auth-svc", graph.Node{
81+
QualifiedName: "auth.ValidateToken", Kind: graph.NodeKindFunction, Name: "ValidateToken",
82+
FilePath: "internal/auth/token.go", StartLine: 10, EndLine: 20,
83+
})
84+
loginID := seedNode(t, s, "web", graph.Node{
85+
QualifiedName: "web.Login", Kind: graph.NodeKindFunction, Name: "Login",
86+
FilePath: "internal/web/login.go", StartLine: 5, EndLine: 25,
87+
})
88+
logoutID := seedNode(t, s, "web", graph.Node{
89+
QualifiedName: "web.Logout", Kind: graph.NodeKindFunction, Name: "Logout",
90+
FilePath: "internal/web/login.go", StartLine: 30, EndLine: 40,
91+
})
92+
// Two nodes referencing the same target, one of them also via a second identical tag.
93+
annotate(t, s, "web", loginID,
94+
"ccg://auth-svc/internal/auth/token.go#ValidateToken",
95+
"ccg://auth-svc/internal/auth/token.go#ValidateToken",
96+
)
97+
annotate(t, s, "web", logoutID, "ccg://auth-svc/internal/auth/token.go#ValidateToken")
98+
99+
syncNamespace(t, svc, "web")
100+
101+
if counting.resolveCalls != 1 {
102+
t.Fatalf("resolve round-trips = %d, want 1 for a single distinct target", counting.resolveCalls)
103+
}
104+
rows, err := s.ListOutboundCrossRefs(context.Background(), "web")
105+
if err != nil {
106+
t.Fatalf("list outbound: %v", err)
107+
}
108+
if len(rows) != 2 {
109+
t.Fatalf("outbound rows = %d, want 2 (one per declaring node, dedup of identical tag)", len(rows))
110+
}
111+
for _, row := range rows {
112+
if row.Status != graph.CrossRefStatusResolved || row.ResolvedNodeID == nil {
113+
t.Fatalf("row = %+v, want resolved", row)
114+
}
115+
}
116+
117+
// Inbound re-resolution of auth-svc: 2 rows, same target — one resolve round-trip.
118+
counting.resolveCalls = 0
119+
syncNamespace(t, svc, "auth-svc")
120+
if counting.resolveCalls != 1 {
121+
t.Fatalf("inbound resolve round-trips = %d, want 1 for a single distinct target", counting.resolveCalls)
122+
}
123+
}
124+
63125
func TestSyncNamespace_MaterializesResolvedAndDeadRefs(t *testing.T) {
64126
s := setupStore(t)
65127
svc := crossref.New(s)

0 commit comments

Comments
 (0)