Skip to content

Commit 701b3bc

Browse files
committed
perf: reconcile bulk updates before resolving edges
1 parent 50af510 commit 701b3bc

10 files changed

Lines changed: 799 additions & 139 deletions

File tree

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
// @index Session-local parsed-edge spool for staged incremental reconciliation.
2+
package incremental
3+
4+
import (
5+
"encoding/gob"
6+
"fmt"
7+
"log/slog"
8+
"os"
9+
"path/filepath"
10+
11+
"github.com/tae2089/trace"
12+
13+
"github.com/tae2089/code-context-graph/internal/domain/graph"
14+
)
15+
16+
// deferredEdgeFile holds one file's parsed edges after its nodes are persisted.
17+
// @intent retain only the edge-resolution input needed after source bytes are released.
18+
type deferredEdgeFile struct {
19+
FilePath string
20+
Edges []graph.Edge
21+
}
22+
23+
// deferredEdgeRecord is one bounded disk record used between node and edge phases.
24+
// @intent keep large staged updates independent of the total parsed edge count in memory.
25+
type deferredEdgeRecord struct {
26+
Files []deferredEdgeFile
27+
}
28+
29+
// deferredEdgeSpool owns temporary edge records for one staged reconciliation invocation.
30+
// @intent preserve parsed cross-batch edges until every changed node has been applied.
31+
type deferredEdgeSpool struct {
32+
dir string
33+
records []string
34+
}
35+
36+
// newDeferredEdgeSpool creates an invocation-local directory for parsed edge records.
37+
// @intent isolate temporary staged-update data so cleanup cannot affect persistent graph state.
38+
// @sideEffect creates a temporary directory under the operating system temp root.
39+
func newDeferredEdgeSpool() (*deferredEdgeSpool, error) {
40+
dir, err := os.MkdirTemp("", "ccg-deferred-edge-spool-*")
41+
if err != nil {
42+
return nil, trace.Wrap(err, "create deferred edge spool")
43+
}
44+
return &deferredEdgeSpool{dir: dir}, nil
45+
}
46+
47+
// writeRecord persists a bounded parsed-edge batch for later resolution.
48+
// @intent defer cross-file edge resolution until all batch-local node replacements are complete.
49+
// @sideEffect creates a gob record inside the invocation-local spool directory.
50+
func (s *deferredEdgeSpool) writeRecord(record deferredEdgeRecord) error {
51+
if len(record.Files) == 0 {
52+
return nil
53+
}
54+
path := filepath.Join(s.dir, fmt.Sprintf("%06d.gob", len(s.records)))
55+
file, err := os.Create(path)
56+
if err != nil {
57+
return trace.Wrap(err, "create deferred edge spool record")
58+
}
59+
encodeErr := gob.NewEncoder(file).Encode(record)
60+
closeErr := file.Close()
61+
if encodeErr != nil {
62+
return trace.Wrap(encodeErr, "encode deferred edge spool record")
63+
}
64+
if closeErr != nil {
65+
return trace.Wrap(closeErr, "close deferred edge spool record")
66+
}
67+
s.records = append(s.records, path)
68+
return nil
69+
}
70+
71+
// readRecord streams one parsed-edge batch back into the resolution phase.
72+
// @intent let edge resolution remain bounded by the original source batch size.
73+
func (s *deferredEdgeSpool) readRecord(path string) (deferredEdgeRecord, error) {
74+
var record deferredEdgeRecord
75+
file, err := os.Open(path)
76+
if err != nil {
77+
return record, trace.Wrap(err, "open deferred edge spool record")
78+
}
79+
decodeErr := gob.NewDecoder(file).Decode(&record)
80+
closeErr := file.Close()
81+
if decodeErr != nil {
82+
return record, trace.Wrap(decodeErr, "decode deferred edge spool record")
83+
}
84+
if closeErr != nil {
85+
return record, trace.Wrap(closeErr, "close deferred edge spool record")
86+
}
87+
return record, nil
88+
}
89+
90+
// cleanup removes all temporary parsed-edge records for this invocation.
91+
// @intent ensure successful and failed staged updates do not retain temporary source-derived data.
92+
// @sideEffect deletes the invocation-local spool directory.
93+
func (s *deferredEdgeSpool) cleanup(logger *slog.Logger) {
94+
if s == nil || s.dir == "" {
95+
return
96+
}
97+
if err := os.RemoveAll(s.dir); err != nil && logger != nil {
98+
logger.Warn("cleanup deferred edge spool failed", "dir", s.dir, "error", err)
99+
}
100+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// @index Transaction-local import lookup decorator for staged incremental edge replay.
2+
package incremental
3+
4+
import (
5+
"context"
6+
7+
"github.com/tae2089/code-context-graph/internal/app/ingest/resolve"
8+
"github.com/tae2089/code-context-graph/internal/domain/graph"
9+
)
10+
11+
// importFileNodeLister is an optional store capability for one-shot import lookup indexing.
12+
// @intent avoid expanding the legacy incremental Store contract for lightweight test doubles.
13+
type importFileNodeLister interface {
14+
ListImportFileNodes(ctx context.Context) ([]graph.Node, error)
15+
}
16+
17+
// fileSuffixLookup is the legacy import lookup capability implemented by production graph stores.
18+
// @intent keep staged reconciliation compatible with custom stores that do not expose the bulk file-node query.
19+
type fileSuffixLookup interface {
20+
GetFileNodesByPathSuffix(ctx context.Context, suffix string) ([]graph.Node, error)
21+
}
22+
23+
// edgeReader preserves existing implements lookup when the decorated store supports it.
24+
// @intent keep staged resolution behavior aligned with the underlying graph store capabilities.
25+
type edgeReader interface {
26+
GetEdgesToNodes(ctx context.Context, nodeIDs []uint) ([]graph.Edge, error)
27+
}
28+
29+
// importIndexedLookup shares one immutable import-file index across staged edge replay.
30+
// @intent replace repeated suffix database scans with one transaction-local file-node snapshot.
31+
type importIndexedLookup struct {
32+
Store
33+
fileNodesBySuffix map[string][]graph.Node
34+
index *resolve.ImportFileIndex
35+
}
36+
37+
// newImportIndexedLookup creates a lookup decorator for a single staged edge-resolution phase.
38+
// @intent scope cached import paths to one update transaction and avoid stale store-wide state.
39+
func newImportIndexedLookup(store Store) *importIndexedLookup {
40+
return &importIndexedLookup{
41+
Store: store,
42+
fileNodesBySuffix: make(map[string][]graph.Node),
43+
}
44+
}
45+
46+
// GetFileNodesByPathSuffix returns cached import candidates, initializing an immutable index once when supported.
47+
// @intent preserve the legacy lookup fallback while avoiding repeated scans for staged bulk updates.
48+
func (l *importIndexedLookup) GetFileNodesByPathSuffix(ctx context.Context, suffix string) ([]graph.Node, error) {
49+
if err := ctx.Err(); err != nil {
50+
return nil, err
51+
}
52+
if nodes, ok := l.fileNodesBySuffix[suffix]; ok {
53+
return nodes, nil
54+
}
55+
if l.index == nil {
56+
lister, ok := l.Store.(importFileNodeLister)
57+
if !ok {
58+
legacyLookup, ok := l.Store.(fileSuffixLookup)
59+
if !ok {
60+
return nil, nil
61+
}
62+
return legacyLookup.GetFileNodesByPathSuffix(ctx, suffix)
63+
}
64+
nodes, err := lister.ListImportFileNodes(ctx)
65+
if err != nil {
66+
return nil, err
67+
}
68+
l.index = resolve.NewImportFileIndex(nodes)
69+
}
70+
nodes := l.index.Find(suffix)
71+
l.fileNodesBySuffix[suffix] = nodes
72+
return nodes, nil
73+
}
74+
75+
// GetEdgesToNodes forwards existing-edge reads when the underlying store supports them.
76+
// @intent retain historical implements resolution while the lookup decorates import lookups.
77+
func (l *importIndexedLookup) GetEdgesToNodes(ctx context.Context, nodeIDs []uint) ([]graph.Edge, error) {
78+
reader, ok := l.Store.(edgeReader)
79+
if !ok {
80+
return nil, nil
81+
}
82+
return reader.GetEdgesToNodes(ctx, nodeIDs)
83+
}

internal/app/ingest/incremental/incremental.go

Lines changed: 165 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import (
88
"strconv"
99
"strings"
1010

11+
"github.com/tae2089/trace"
12+
1113
"github.com/tae2089/code-context-graph/internal/app/ingest"
1214
"github.com/tae2089/code-context-graph/internal/app/ingest/binding"
1315
"github.com/tae2089/code-context-graph/internal/app/ingest/resolve"
@@ -143,6 +145,25 @@ func (s *Syncer) SyncWithExistingStore(ctx context.Context, syncStore ingest.Gra
143145
return s.syncWithExisting(ctx, target, files, existingFiles)
144146
}
145147

148+
// SyncBatchesWithExisting stages every source batch before resolving any parsed edges.
149+
// @intent prevent spool-record ordering from removing edges whose endpoints are both replaced in one bulk update.
150+
// @param source bounded current-source batch replay owned by the workflow layer
151+
// @param deletedFiles persisted paths absent from source that must be removed before edge replay
152+
// @ensures edge resolution observes all current nodes after source staging completes
153+
func (s *Syncer) SyncBatchesWithExisting(ctx context.Context, source ingest.FileBatchSource, deletedFiles []string) (*SyncStats, error) {
154+
return s.syncBatchesWithExisting(ctx, s.store, source, deletedFiles)
155+
}
156+
157+
// SyncBatchesWithExistingStore stages reconciliation using the active transaction-scoped graph store.
158+
// @intent keep bulk update node, edge, package, and search writes within one transaction.
159+
func (s *Syncer) SyncBatchesWithExistingStore(ctx context.Context, syncStore ingest.GraphStore, source ingest.FileBatchSource, deletedFiles []string) (*SyncStats, error) {
160+
var target Store = syncStore
161+
if syncStore == nil {
162+
target = s.store
163+
}
164+
return s.syncBatchesWithExisting(ctx, target, source, deletedFiles)
165+
}
166+
146167
// syncWithExisting performs the actual diff-and-apply pass against the supplied store.
147168
// @intent compare hashes for known files, parse new/changed ones, and remove deleted entries in one pass.
148169
// @sideEffect upserts nodes/edges/annotations and deletes removed files through syncStore.
@@ -221,7 +242,7 @@ func (s *Syncer) syncWithExisting(ctx context.Context, syncStore Store, files ma
221242
}
222243
}
223244
}
224-
if err := s.resolveAndUpsertEdges(ctx, syncStore, parsedFiles, stats); err != nil {
245+
if err := s.resolveAndUpsertEdges(ctx, syncStore, syncStore, parsedFiles, stats); err != nil {
225246
return nil, err
226247
}
227248
for _, parsed := range parsedFiles {
@@ -253,14 +274,154 @@ func (s *Syncer) syncWithExisting(ctx context.Context, syncStore Store, files ma
253274
return stats, nil
254275
}
255276

277+
// syncBatchesWithExisting performs bounded node staging, deletion, then deferred edge replay.
278+
// @intent make cross-file edge resolution independent of source batch ordering without retaining all parsed edges in memory.
279+
// @sideEffect mutates nodes, annotations, edges, and temporary local spool files through syncStore.
280+
// @domainRule no edge is resolved until every supplied source batch and deletion has completed.
281+
func (s *Syncer) syncBatchesWithExisting(ctx context.Context, syncStore Store, source ingest.FileBatchSource, deletedFiles []string) (*SyncStats, error) {
282+
if source == nil {
283+
return nil, trace.New("incremental batch source is required")
284+
}
285+
286+
stats := &SyncStats{}
287+
edgeSpool, err := newDeferredEdgeSpool()
288+
if err != nil {
289+
return nil, err
290+
}
291+
defer edgeSpool.cleanup(s.logger)
292+
293+
s.logger.Info("staged sync started", "deleted_count", len(deletedFiles))
294+
if err := source(func(files map[string]FileInfo) error {
295+
return s.stageBatch(ctx, syncStore, files, edgeSpool, stats)
296+
}); err != nil {
297+
return nil, err
298+
}
299+
300+
for _, filePath := range deletedFiles {
301+
if err := syncStore.DeleteNodesByFile(ctx, filePath); err != nil {
302+
return nil, err
303+
}
304+
s.logger.Debug("file deleted", "file", filePath)
305+
stats.Deleted++
306+
}
307+
308+
lookup := newImportIndexedLookup(syncStore)
309+
for _, path := range edgeSpool.records {
310+
record, err := edgeSpool.readRecord(path)
311+
if err != nil {
312+
return nil, err
313+
}
314+
parsedFiles := make([]parsedSyncFile, 0, len(record.Files))
315+
for _, file := range record.Files {
316+
parsedFiles = append(parsedFiles, parsedSyncFile{filePath: file.FilePath, edges: file.Edges})
317+
}
318+
if err := s.resolveAndUpsertEdges(ctx, syncStore, lookup, parsedFiles, stats); err != nil {
319+
return nil, err
320+
}
321+
}
322+
323+
s.logger.Info("staged sync completed",
324+
"added", stats.Added,
325+
"modified", stats.Modified,
326+
"skipped", stats.Skipped,
327+
"deleted", stats.Deleted,
328+
"unresolved_edges", stats.Unresolved.DroppedCount,
329+
"unresolved_by_kind", formatEdgeKindCounts(stats.Unresolved.ByKind),
330+
"unresolved_by_file", stats.Unresolved.ByFile,
331+
"unresolved_by_reason", stats.Unresolved.ByReason,
332+
"unresolved_samples", stats.Unresolved.Samples,
333+
)
334+
return stats, nil
335+
}
336+
337+
// stageBatch applies one bounded source batch and spools its parsed edges for the later resolution phase.
338+
// @intent release source content after node and annotation writes while preserving only edges required for cross-file resolution.
339+
// @sideEffect deletes and upserts graph nodes and annotations, then writes an invocation-local edge spool record.
340+
func (s *Syncer) stageBatch(ctx context.Context, syncStore Store, files map[string]FileInfo, edgeSpool *deferredEdgeSpool, stats *SyncStats) error {
341+
filePaths := make([]string, 0, len(files))
342+
for filePath := range files {
343+
filePaths = append(filePaths, filePath)
344+
}
345+
existingByFile, err := syncStore.GetNodesByFiles(ctx, filePaths)
346+
if err != nil {
347+
return err
348+
}
349+
350+
orderedPaths := sortedFilePaths(files)
351+
parsedFiles := make([]parsedSyncFile, 0, len(files))
352+
for _, filePath := range orderedPaths {
353+
info := files[filePath]
354+
existing := existingByFile[filePath]
355+
parser := s.resolveParser(filePath)
356+
if parser == nil {
357+
s.logger.Debug("file skipped (no parser)", "file", filePath)
358+
stats.Skipped++
359+
releaseContent(files, filePath)
360+
continue
361+
}
362+
if len(existing) > 0 && existing[0].Hash == info.Hash && !info.Force {
363+
s.logger.Debug("file skipped (unchanged)", "file", filePath)
364+
stats.Skipped++
365+
releaseContent(files, filePath)
366+
continue
367+
}
368+
369+
parsed := parsedSyncFile{filePath: filePath, info: info, hadExisting: len(existing) > 0}
370+
if annotatingParser, ok := parser.(AnnotatingParser); ok {
371+
parsed.nodes, parsed.edges, parsed.comments, err = annotatingParser.ParseWithComments(ctx, filePath, info.Content)
372+
parsed.language = annotatingParser.Language()
373+
} else {
374+
parsed.nodes, parsed.edges, err = parser.Parse(filePath, info.Content)
375+
}
376+
if err != nil {
377+
return err
378+
}
379+
setNodeHashes(parsed.nodes, info.Hash)
380+
parsedFiles = append(parsedFiles, parsed)
381+
}
382+
383+
for _, parsed := range parsedFiles {
384+
if !parsed.hadExisting {
385+
s.logger.Debug("file added", "file", parsed.filePath)
386+
stats.Added++
387+
continue
388+
}
389+
if err := syncStore.DeleteNodesByFile(ctx, parsed.filePath); err != nil {
390+
return err
391+
}
392+
s.logger.Debug("file modified", "file", parsed.filePath)
393+
stats.Modified++
394+
}
395+
396+
edgeRecord := deferredEdgeRecord{Files: make([]deferredEdgeFile, 0, len(parsedFiles))}
397+
for i := range parsedFiles {
398+
parsed := &parsedFiles[i]
399+
if len(parsed.nodes) > 0 {
400+
if err := syncStore.UpsertNodes(ctx, parsed.nodes); err != nil {
401+
return err
402+
}
403+
if len(parsed.comments) > 0 {
404+
if err := s.restoreAnnotations(ctx, syncStore, parsed.filePath, parsed.info.Content, parsed.nodes, parsed.comments, parsed.language); err != nil {
405+
return err
406+
}
407+
}
408+
}
409+
if len(parsed.edges) > 0 {
410+
edgeRecord.Files = append(edgeRecord.Files, deferredEdgeFile{FilePath: parsed.filePath, Edges: parsed.edges})
411+
}
412+
releaseContent(files, parsed.filePath)
413+
}
414+
return edgeSpool.writeRecord(edgeRecord)
415+
}
416+
256417
// resolveAndUpsertEdges resolves parsed edges in dependency-safe phases before persisting them.
257418
// @intent preserve interface dispatch and import-backed call resolution during incremental sync updates.
258419
// @sideEffect upserts resolved graph edges through the sync store.
259420
// @mutates graph edges, stats.Unresolved
260-
func (s *Syncer) resolveAndUpsertEdges(ctx context.Context, syncStore Store, parsedFiles []parsedSyncFile, stats *SyncStats) error {
421+
func (s *Syncer) resolveAndUpsertEdges(ctx context.Context, syncStore Store, lookup resolve.NodeLookup, parsedFiles []parsedSyncFile, stats *SyncStats) error {
261422
implementsEdges, otherByFile := partitionParsedSyncEdges(parsedFiles)
262423
for _, edgeChunk := range splitEdgeChunks(implementsEdges) {
263-
resolved, err := resolve.ResolveWithOptions(ctx, syncStore, edgeChunk, s.opts)
424+
resolved, err := resolve.ResolveWithOptions(ctx, lookup, edgeChunk, s.opts)
264425
if err != nil {
265426
return err
266427
}
@@ -278,7 +439,7 @@ func (s *Syncer) resolveAndUpsertEdges(ctx context.Context, syncStore Store, par
278439
edges := otherByFile[parsed.filePath]
279440
for _, edgeChunk := range splitEdgeChunks(edges) {
280441
resolveInput := chunkWithImportWarmup(edgeChunk, importsByFile[parsed.filePath])
281-
resolved, err := resolve.ResolveWithOptions(ctx, syncStore, resolveInput, s.opts)
442+
resolved, err := resolve.ResolveWithOptions(ctx, lookup, resolveInput, s.opts)
282443
if err != nil {
283444
return err
284445
}

0 commit comments

Comments
 (0)