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