Skip to content

Commit cb7bcd8

Browse files
tae2089claude
andcommitted
Fix non-transactional incremental update delete protocol
updateGraphWithoutTx (the path taken by the MCP build_or_update_graph / parse_project tools, whose injected syncer is not transactional) passed the full existing-file set to the first normal batch. Because SyncWithExisting deletes existing files absent from the batch, a multi-record spool deleted files belonging to later batches and re-added them (churning node IDs, annotations, and stats); it also double-counted deletions against the explicit delete pass, and skipped deletions entirely when no normal batch ran. Mirror the transactional path: normal batches carry no existing files and deletions run once, unconditionally. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 836b701 commit cb7bcd8

3 files changed

Lines changed: 91 additions & 61 deletions

File tree

internal/mcp/handlers_test.go

Lines changed: 29 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -973,14 +973,12 @@ func TestBuildOrUpdateGraph_IncrementalIncludePaths_DefaultsToReplace(t *testing
973973
mockSync := &mockIncrementalSyncer{result: &incremental.SyncStats{}}
974974
deps.Incremental = mockSync
975975

976+
// Only handler.go is on disk; the out-of-scope other.go is missing. Under default replace
977+
// semantics the delete pass must consider all namespace files, so other.go is deletable.
976978
dir := t.TempDir()
977979
os.MkdirAll(filepath.Join(dir, "src", "api"), 0o755)
978-
os.MkdirAll(filepath.Join(dir, "src", "other"), 0o755)
979980
writeGoFile(t, filepath.Join(dir, "src", "api"), "handler.go", `package api
980981
func Handler() {}
981-
`)
982-
writeGoFile(t, filepath.Join(dir, "src", "other"), "other.go", `package other
983-
func Other() {}
984982
`)
985983

986984
callTool(t, deps, "build_or_update_graph", map[string]any{
@@ -994,16 +992,24 @@ func Other() {}
994992
if !mockSync.syncWithExisting {
995993
t.Fatal("expected Incremental.SyncWithExisting to be called")
996994
}
997-
if len(mockSync.existingCalls) == 0 {
998-
t.Fatal("expected at least one SyncWithExisting call")
995+
deleteExisting := deletePassExistingFiles(mockSync)
996+
if deleteExisting == nil {
997+
t.Fatalf("expected a delete pass for the missing out-of-scope file, calls=%v", mockSync.existingCalls)
999998
}
1000-
firstExisting := mockSync.existingCalls[0]
1001-
if len(firstExisting) != 2 {
1002-
t.Fatalf("expected default replace semantics to pass all namespace files, got %v", firstExisting)
999+
if !containsStringInSlice(deleteExisting, filepath.Join("src", "other", "other.go")) {
1000+
t.Fatalf("default replace must consider the out-of-scope file for deletion, got %v", deleteExisting)
10031001
}
1004-
if !containsStringInSlice(firstExisting, filepath.Join("src", "other", "other.go")) {
1005-
t.Fatalf("expected existingFiles to include out-of-scope file under default replace semantics, got %v", firstExisting)
1002+
}
1003+
1004+
// deletePassExistingFiles returns the existingFiles of the delete pass (the SyncWithExisting
1005+
// call that received no files), or nil when no delete pass ran.
1006+
func deletePassExistingFiles(m *mockIncrementalSyncer) []string {
1007+
for i := range m.filesCalls {
1008+
if len(m.filesCalls[i]) == 0 && len(m.existingCalls[i]) > 0 {
1009+
return m.existingCalls[i]
1010+
}
10061011
}
1012+
return nil
10071013
}
10081014

10091015
func TestBuildOrUpdateGraph_IncrementalIncludePaths_ReplaceFalsePreservesOutOfScopeFiles(t *testing.T) {
@@ -1020,14 +1026,13 @@ func TestBuildOrUpdateGraph_IncrementalIncludePaths_ReplaceFalsePreservesOutOfSc
10201026
mockSync := &mockIncrementalSyncer{result: &incremental.SyncStats{}}
10211027
deps.Incremental = mockSync
10221028

1029+
// The seeded handler.go and other.go are both missing from disk; a new in-scope file is
1030+
// added. With replace=false + include_paths, only the in-scope handler.go may be deleted,
1031+
// and the out-of-scope other.go must be scoped out of the existing-file set entirely.
10231032
dir := t.TempDir()
10241033
os.MkdirAll(filepath.Join(dir, "src", "api"), 0o755)
1025-
os.MkdirAll(filepath.Join(dir, "src", "other"), 0o755)
1026-
writeGoFile(t, filepath.Join(dir, "src", "api"), "handler.go", `package api
1027-
func Handler() {}
1028-
`)
1029-
writeGoFile(t, filepath.Join(dir, "src", "other"), "other.go", `package other
1030-
func Other() {}
1034+
writeGoFile(t, filepath.Join(dir, "src", "api"), "new.go", `package api
1035+
func New() {}
10311036
`)
10321037

10331038
callTool(t, deps, "build_or_update_graph", map[string]any{
@@ -1042,15 +1047,15 @@ func Other() {}
10421047
if !mockSync.syncWithExisting {
10431048
t.Fatal("expected Incremental.SyncWithExisting to be called")
10441049
}
1045-
if len(mockSync.existingCalls) == 0 {
1046-
t.Fatal("expected at least one SyncWithExisting call")
1050+
deleteExisting := deletePassExistingFiles(mockSync)
1051+
if deleteExisting == nil {
1052+
t.Fatalf("expected a delete pass for the missing in-scope file, calls=%v", mockSync.existingCalls)
10471053
}
1048-
firstExisting := mockSync.existingCalls[0]
1049-
if containsStringInSlice(firstExisting, filepath.Join("src", "other", "other.go")) {
1050-
t.Fatalf("expected replace=false to exclude out-of-scope file from existingFiles, got %v", firstExisting)
1054+
if containsStringInSlice(deleteExisting, filepath.Join("src", "other", "other.go")) {
1055+
t.Fatalf("replace=false must exclude the out-of-scope file from deletions, got %v", deleteExisting)
10511056
}
1052-
if !containsStringInSlice(firstExisting, filepath.Join("src", "api", "handler.go")) {
1053-
t.Fatalf("expected replace=false to keep in-scope file, got %v", firstExisting)
1057+
if !containsStringInSlice(deleteExisting, filepath.Join("src", "api", "handler.go")) {
1058+
t.Fatalf("replace=false must still delete the missing in-scope file, got %v", deleteExisting)
10541059
}
10551060
}
10561061

internal/service/indexer.go

Lines changed: 6 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1204,8 +1204,10 @@ func (s *GraphService) updateGraphWithoutTx(ctx context.Context, absDir string,
12041204
}
12051205

12061206
stats := &incremental.SyncStats{}
1207-
normalExistingFiles := existingFilesExcluding(existingFiles, forceFiles)
1208-
passedExistingFiles := false
1207+
// Normal batches must never carry existingFiles: SyncWithExisting deletes existing files
1208+
// absent from the batch, so a multi-record spool would delete files belonging to later
1209+
// batches (then re-add them, churning node IDs and stats). Deletions are handled once,
1210+
// explicitly, below — mirroring the transactional path (applyUpdateSpoolInTx).
12091211
for _, path := range spool.records {
12101212
record, err := spool.readRecord(path)
12111213
if err != nil {
@@ -1215,19 +1217,14 @@ func (s *GraphService) updateGraphWithoutTx(ctx context.Context, absDir string,
12151217
if len(normalFiles) == 0 {
12161218
continue
12171219
}
1218-
batchExistingFiles := []string(nil)
1219-
if !passedExistingFiles {
1220-
batchExistingFiles = normalExistingFiles
1221-
passedExistingFiles = true
1222-
}
1223-
batchStats, err := opts.Syncer.SyncWithExisting(ctx, normalFiles, batchExistingFiles)
1220+
batchStats, err := opts.Syncer.SyncWithExisting(ctx, normalFiles, nil)
12241221
if err != nil {
12251222
return nil, trace.Wrap(err, "incremental sync")
12261223
}
12271224
addSyncStats(stats, batchStats)
12281225
}
12291226
deletedFiles := existingFilesMissingFromSet(spool.currentFiles, existingFiles)
1230-
if len(deletedFiles) > 0 && passedExistingFiles {
1227+
if len(deletedFiles) > 0 {
12311228
batchStats, err := opts.Syncer.SyncWithExisting(ctx, nil, deletedFiles)
12321229
if err != nil {
12331230
return nil, trace.Wrap(err, "incremental delete sync")
@@ -1295,25 +1292,6 @@ func existingFilesMissingFromSet(currentFiles map[string]struct{}, existingFiles
12951292
return deleted
12961293
}
12971294

1298-
// existingFilesExcluding filters out paths excluded from the current sync pass.
1299-
// @intent keep incremental sync from revisiting files the caller already ruled out.
1300-
func existingFilesExcluding(existingFiles []string, exclude map[string]struct{}) []string {
1301-
if len(existingFiles) == 0 {
1302-
return nil
1303-
}
1304-
if len(exclude) == 0 {
1305-
return append([]string(nil), existingFiles...)
1306-
}
1307-
filtered := make([]string, 0, len(existingFiles))
1308-
for _, fp := range existingFiles {
1309-
if _, ok := exclude[fp]; ok {
1310-
continue
1311-
}
1312-
filtered = append(filtered, fp)
1313-
}
1314-
return filtered
1315-
}
1316-
13171295
// affectedNodeIDsForUpdate collects node IDs whose search documents must be refreshed for a given change set.
13181296
// @intent merge previously stored node IDs with newly created ones so the search index sees both removals and additions.
13191297
func affectedNodeIDsForUpdate(ctx context.Context, db *gorm.DB, existingNodesByFile map[string][]model.Node, changedFiles, deletedFiles []string) ([]uint, error) {

internal/service/indexer_test.go

Lines changed: 56 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2701,6 +2701,40 @@ func TestUpdate_MaxTotalParsedBytesRejectsBeforeSync(t *testing.T) {
27012701
}
27022702
}
27032703

2704+
func TestUpdateGraphWithoutTx_DeletesEvenWithNoNormalBatches(t *testing.T) {
2705+
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: gormlogger.Discard})
2706+
if err != nil {
2707+
t.Fatalf("open db: %v", err)
2708+
}
2709+
if err := db.AutoMigrate(&model.Node{}); err != nil {
2710+
t.Fatalf("migrate nodes: %v", err)
2711+
}
2712+
if err := db.Create(&model.Node{Namespace: ctxns.DefaultNamespace, FilePath: "gone.go"}).Error; err != nil {
2713+
t.Fatalf("seed node: %v", err)
2714+
}
2715+
2716+
svc := &GraphService{
2717+
DB: db,
2718+
Walkers: map[string]*treesitter.Walker{".go": treesitter.NewWalker(treesitter.GoSpec)},
2719+
Logger: slog.Default(),
2720+
}
2721+
2722+
// Empty working dir: nothing to parse, so there are no normal batches. The previously
2723+
// stored gone.go must still be deleted (regression: the delete pass was gated on a normal
2724+
// batch having run).
2725+
tmpDir := t.TempDir()
2726+
syncer := &recordingIncrementalSyncer{result: &incremental.SyncStats{}}
2727+
if _, err := svc.Update(context.Background(), UpdateOptions{BuildOptions: BuildOptions{Dir: tmpDir, SkipSearchRebuild: true}, Syncer: syncer}); err != nil {
2728+
t.Fatalf("Update: %v", err)
2729+
}
2730+
if len(syncer.calls) != 1 {
2731+
t.Fatalf("expected a single delete pass, got %d calls: %v", len(syncer.calls), syncer.calls)
2732+
}
2733+
if got, want := syncer.calls[0].existingFiles, []string{"gone.go"}; !reflect.DeepEqual(got, want) {
2734+
t.Fatalf("delete pass existingFiles = %v, want %v", got, want)
2735+
}
2736+
}
2737+
27042738
func TestUpdate_IncludePaths_FiltersExistingFilesWhenReplaceFalse(t *testing.T) {
27052739
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: gormlogger.Discard})
27062740
if err != nil {
@@ -2709,6 +2743,8 @@ func TestUpdate_IncludePaths_FiltersExistingFilesWhenReplaceFalse(t *testing.T)
27092743
if err := db.AutoMigrate(&model.Node{}); err != nil {
27102744
t.Fatalf("migrate nodes: %v", err)
27112745
}
2746+
// Both seeded files are missing from disk. handler.go is inside the include path and must be
2747+
// deleted; helper.go is outside it and must be scoped out of the existing-file set entirely.
27122748
if err := db.Create(&model.Node{Namespace: ctxns.DefaultNamespace, FilePath: filepath.Join("src", "api", "handler.go")}).Error; err != nil {
27132749
t.Fatalf("seed api node: %v", err)
27142750
}
@@ -2727,17 +2763,29 @@ func TestUpdate_IncludePaths_FiltersExistingFilesWhenReplaceFalse(t *testing.T)
27272763
if err := os.MkdirAll(apiDir, 0o755); err != nil {
27282764
t.Fatalf("mkdir api: %v", err)
27292765
}
2730-
if err := os.WriteFile(filepath.Join(apiDir, "handler.go"), []byte("package api\n\nfunc Handler() {}\n"), 0o644); err != nil {
2731-
t.Fatalf("write handler: %v", err)
2766+
// A new file under the include path so the update has something to parse; the seeded
2767+
// handler.go is absent from disk and should be detected as a deletion.
2768+
if err := os.WriteFile(filepath.Join(apiDir, "new.go"), []byte("package api\n\nfunc New() {}\n"), 0o644); err != nil {
2769+
t.Fatalf("write new: %v", err)
27322770
}
27332771

27342772
syncer := &recordingIncrementalSyncer{result: &incremental.SyncStats{}}
27352773
_, err = svc.Update(context.Background(), UpdateOptions{BuildOptions: BuildOptions{Dir: tmpDir, IncludePaths: []string{filepath.Join("src", "api")}}, Syncer: syncer, Replace: false})
27362774
if err != nil {
27372775
t.Fatalf("Update: %v", err)
27382776
}
2739-
if got, want := syncer.existingFiles, []string{filepath.Join("src", "api", "handler.go")}; !reflect.DeepEqual(got, want) {
2740-
t.Fatalf("existingFiles mismatch: got=%v want=%v", got, want)
2777+
2778+
var deleteCall *recordingSyncCall
2779+
for i := range syncer.calls {
2780+
if len(syncer.calls[i].files) == 0 && len(syncer.calls[i].existingFiles) > 0 {
2781+
deleteCall = &syncer.calls[i]
2782+
}
2783+
}
2784+
if deleteCall == nil {
2785+
t.Fatalf("expected a delete pass for the missing include-path file, calls=%v", syncer.calls)
2786+
}
2787+
if got, want := deleteCall.existingFiles, []string{filepath.Join("src", "api", "handler.go")}; !reflect.DeepEqual(got, want) {
2788+
t.Fatalf("delete pass existingFiles mismatch: got=%v want=%v (helper.go outside include path must be scoped out)", got, want)
27412789
}
27422790
}
27432791

@@ -3045,11 +3093,10 @@ func TestUpdateGraphWithoutTx_DoesNotDeleteForcedFilesDuringNormalSync(t *testin
30453093
if _, ok := first.files["target.go"]; !ok {
30463094
t.Fatalf("expected changed target.go in normal sync files, got %v", sortedIncrementalFileKeys(first.files))
30473095
}
3048-
if slices.Contains(first.existingFiles, "source.go") {
3049-
t.Fatalf("expected forced source.go to be excluded from normal sync existingFiles, got %v", first.existingFiles)
3050-
}
3051-
if !slices.Contains(first.existingFiles, "deleted.go") {
3052-
t.Fatalf("expected deleted.go to remain in normal sync existingFiles, got %v", first.existingFiles)
3096+
// Normal sync passes no existingFiles: deletions are handled by the separate delete pass,
3097+
// so a normal batch can never delete files that belong to other batches (mirrors the tx path).
3098+
if len(first.existingFiles) != 0 {
3099+
t.Fatalf("expected normal sync to pass no existingFiles, got %v", first.existingFiles)
30533100
}
30543101
second := syncer.calls[1]
30553102
if len(second.files) != 0 {

0 commit comments

Comments
 (0)