From 435b6618014be43838e65e76efb1d2759cfe22f4 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 25 May 2026 04:41:55 +0900 Subject: [PATCH 1/3] Fix full-scan interruption rollback for #2642 --- changelog.d/unreleased/2642.fixed.md | 16 ++++++++++++++++ src/CodeIndex/Cli/IndexCommandRunner.cs | 11 ++++++----- tests/CodeIndex.Tests/IndexCommandRunnerTests.cs | 7 ++++--- 3 files changed, 26 insertions(+), 8 deletions(-) create mode 100644 changelog.d/unreleased/2642.fixed.md diff --git a/changelog.d/unreleased/2642.fixed.md b/changelog.d/unreleased/2642.fixed.md new file mode 100644 index 0000000000..67464e0452 --- /dev/null +++ b/changelog.d/unreleased/2642.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 2642 +affected: + - src/CodeIndex/Cli/IndexCommandRunner.cs + - tests/CodeIndex.Tests/IndexCommandRunnerTests.cs +--- + +## English + +- **Interrupted full-scan indexing no longer demotes a healthy index after rollback (#2642)** — the full-scan batch marker is now written inside the same rollback boundary as readiness demotion and file updates, so a cooperative Ctrl-C/SIGTERM interruption preserves the previous trusted index state. + +## 日本語 + +- **full-scan index の中断後に rollback 済みの正常な index が degraded にならないようにしました (#2642)** — full-scan の batch marker を readiness 降格や file 更新と同じ rollback 境界内で書くようにし、協調的な Ctrl-C/SIGTERM 中断では以前の信頼済み index 状態を保持します。 diff --git a/src/CodeIndex/Cli/IndexCommandRunner.cs b/src/CodeIndex/Cli/IndexCommandRunner.cs index 50ca078fcf..4701909265 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.cs @@ -3191,13 +3191,14 @@ void ThrowIfFullScanCancelled(int filesProcessed, int? filesTotal) } // Full-scan commits to mutating the DB from here on. Keep the whole write phase in - // one outer transaction so Ctrl-C/SIGTERM can roll back the readiness demotion, - // stale-file purge, and per-file writes instead of leaving a half-cleared index. - // full-scan の書き込み全体を outer transaction に入れ、中断時に readiness clear / - // purge / per-file write をまとめて rollback する。 + // one outer transaction so Ctrl-C/SIGTERM can roll back the batch marker, + // readiness demotion, stale-file purge, and per-file writes instead of leaving a + // half-cleared index. + // full-scan の書き込み全体を outer transaction に入れ、中断時に batch marker / + // readiness clear / purge / per-file write をまとめて rollback する。 ThrowIfFullScanCancelled(0, files.Count); - writer.MarkBatchInProgress(); using var fullScanTxn = writer.BeginTransaction(); + writer.MarkBatchInProgress(); writer.ClearReadyFlags(); writer.ClearHotspotFamilyReady(); writer.ClearMetadataTargetReady(); diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs index 40a2119581..404da88a9d 100644 --- a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs @@ -3601,13 +3601,14 @@ public void Run_FullScan_CancelledAfterReadinessDemotion_RollsBackExistingIndex( Assert.True(hookInvoked); Assert.Equal(CommandExitCodes.Interrupted, interruptedExitCode); - var recoveryWarning = ConsoleCapture.CaptureError(() => + var reopenWarning = ConsoleCapture.CaptureError(() => { using var db = new DbContext(dbPath); - Assert.Equal(0, db.GetUserVersion()); + Assert.Equal(initialReadiness, db.GetUserVersion()); }); - Assert.Contains("Last batch did not complete", recoveryWarning); + Assert.DoesNotContain("Last batch did not complete", reopenWarning); Assert.DoesNotContain("later.cs", ReadIndexedPaths(dbPath)); + Assert.Contains("app.cs", ReadIndexedPaths(dbPath)); } finally { From adff8a9d93572d1a273c7faf96f3327b4f80abfc Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 25 May 2026 04:55:05 +0900 Subject: [PATCH 2/3] Preserve rebuild index on interruption for #2642 --- changelog.d/unreleased/2642.fixed.md | 4 +- src/CodeIndex/Cli/IndexCommandRunner.cs | 9 --- .../IndexCommandRunnerTests.cs | 63 +++++++++++++++++++ 3 files changed, 65 insertions(+), 11 deletions(-) diff --git a/changelog.d/unreleased/2642.fixed.md b/changelog.d/unreleased/2642.fixed.md index 67464e0452..43a160bbc9 100644 --- a/changelog.d/unreleased/2642.fixed.md +++ b/changelog.d/unreleased/2642.fixed.md @@ -9,8 +9,8 @@ affected: ## English -- **Interrupted full-scan indexing no longer demotes a healthy index after rollback (#2642)** — the full-scan batch marker is now written inside the same rollback boundary as readiness demotion and file updates, so a cooperative Ctrl-C/SIGTERM interruption preserves the previous trusted index state. +- **Interrupted full-scan and rebuild indexing no longer demote or empty a healthy index after rollback (#2642)** — the full-scan batch marker is now written inside the same rollback boundary as readiness demotion and file updates, and `--rebuild` no longer drops the existing index before the transactional write phase. ## 日本語 -- **full-scan index の中断後に rollback 済みの正常な index が degraded にならないようにしました (#2642)** — full-scan の batch marker を readiness 降格や file 更新と同じ rollback 境界内で書くようにし、協調的な Ctrl-C/SIGTERM 中断では以前の信頼済み index 状態を保持します。 +- **full-scan / rebuild index の中断後に rollback 済みの正常な index が degraded や空にならないようにしました (#2642)** — full-scan の batch marker を readiness 降格や file 更新と同じ rollback 境界内で書くようにし、`--rebuild` は transactional な書き込み phase より前に既存 index を drop しないようにしました。 diff --git a/src/CodeIndex/Cli/IndexCommandRunner.cs b/src/CodeIndex/Cli/IndexCommandRunner.cs index 4701909265..5f9f14e820 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.cs @@ -490,15 +490,6 @@ void RecordDryRunScanErrors(IEnumerable scanErrors) // まだ clear しない。update モードの preflight が失敗しただけで healthy な DB を // 縮退状態に落とさないよう、clear は実際に書き込み直前で行う。 - if (options.Rebuild) - { - db.ClearReadyFlags(); - var rebuildWriter = new DbWriter(db); - rebuildWriter.ClearHotspotFamilyReady(); - rebuildWriter.ClearMetadataTargetReady(); - db.DropAll(); - } - db.InitializeSchema(); AddToGitExclude(options.ProjectPath, dbPath); diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs index 404da88a9d..e047a9aaf8 100644 --- a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs @@ -3618,6 +3618,69 @@ public void Run_FullScan_CancelledAfterReadinessDemotion_RollsBackExistingIndex( } } + [Fact] + public void Run_Rebuild_CancelledAfterReadinessDemotion_PreservesExistingIndex() + { + var projectRoot = CreateTempProject(); + try + { + File.WriteAllText(Path.Combine(projectRoot, "app.cs"), "public class App { public void Run() { } }\n"); + + var initialExitCode = IndexCommandRunner.Run([projectRoot, "--json"], _jsonOptions); + Assert.Equal(CommandExitCodes.Success, initialExitCode); + + var dbPath = Path.Combine(projectRoot, ".cdidx", "codeindex.db"); + int initialReadiness; + using (var db = new DbContext(dbPath)) + initialReadiness = db.GetUserVersion(); + Assert.Equal(DbContext.CurrentSchemaVersion, initialReadiness); + Assert.Contains("app.cs", ReadIndexedPaths(dbPath)); + + File.WriteAllText(Path.Combine(projectRoot, "later.cs"), "public class Later { }\n"); + using var cancellation = new CancellationTokenSource(); + var hookInvoked = false; + IndexCommandRunner.FullScanWritePhaseStartedForTesting = () => + { + hookInvoked = true; + cancellation.Cancel(); + }; + + int interruptedExitCode; + lock (TestConsoleLock.Gate) + { + var originalOut = Console.Out; + using var stdout = new StringWriter(); + try + { + Console.SetOut(stdout); + interruptedExitCode = IndexCommandRunner.Run([projectRoot, "--rebuild", "--yes", "--json"], _jsonOptions, cancellation); + } + finally + { + Console.SetOut(originalOut); + IndexCommandRunner.FullScanWritePhaseStartedForTesting = null; + } + } + + Assert.True(hookInvoked); + Assert.Equal(CommandExitCodes.Interrupted, interruptedExitCode); + var reopenWarning = ConsoleCapture.CaptureError(() => + { + using var db = new DbContext(dbPath); + Assert.Equal(initialReadiness, db.GetUserVersion()); + }); + Assert.DoesNotContain("Last batch did not complete", reopenWarning); + Assert.DoesNotContain("later.cs", ReadIndexedPaths(dbPath)); + Assert.Contains("app.cs", ReadIndexedPaths(dbPath)); + } + finally + { + IndexCommandRunner.FullScanWritePhaseStartedForTesting = null; + SqliteConnection.ClearAllPools(); + DeleteDirectory(projectRoot); + } + } + [Fact] public void Run_UpdateMode_WithOversizedFile_PrintsSkipWarningWithoutRecoveryWarning() { From 8d5519f6211f24fbad2fee766ed73fedc84a1f91 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 25 May 2026 05:03:55 +0900 Subject: [PATCH 3/3] Handle skipped files during rebuild for #2642 --- changelog.d/unreleased/2642.fixed.md | 4 +-- src/CodeIndex/Cli/IndexCommandRunner.cs | 2 +- .../IndexCommandRunnerTests.cs | 29 +++++++++++++++++++ 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/changelog.d/unreleased/2642.fixed.md b/changelog.d/unreleased/2642.fixed.md index 43a160bbc9..f0c5d6d5da 100644 --- a/changelog.d/unreleased/2642.fixed.md +++ b/changelog.d/unreleased/2642.fixed.md @@ -9,8 +9,8 @@ affected: ## English -- **Interrupted full-scan and rebuild indexing no longer demote or empty a healthy index after rollback (#2642)** — the full-scan batch marker is now written inside the same rollback boundary as readiness demotion and file updates, and `--rebuild` no longer drops the existing index before the transactional write phase. +- **Interrupted full-scan and rebuild indexing no longer demote or empty a healthy index after rollback (#2642)** — the full-scan batch marker is now written inside the same rollback boundary as readiness demotion and file updates, and `--rebuild` no longer drops the existing index before the transactional write phase while still removing rows for files that become non-indexable. ## 日本語 -- **full-scan / rebuild index の中断後に rollback 済みの正常な index が degraded や空にならないようにしました (#2642)** — full-scan の batch marker を readiness 降格や file 更新と同じ rollback 境界内で書くようにし、`--rebuild` は transactional な書き込み phase より前に既存 index を drop しないようにしました。 +- **full-scan / rebuild index の中断後に rollback 済みの正常な index が degraded や空にならないようにしました (#2642)** — full-scan の batch marker を readiness 降格や file 更新と同じ rollback 境界内で書くようにし、`--rebuild` は transactional な書き込み phase より前に既存 index を drop しない一方で、非 index 対象になったファイルの行は削除します。 diff --git a/src/CodeIndex/Cli/IndexCommandRunner.cs b/src/CodeIndex/Cli/IndexCommandRunner.cs index 57735d43b8..4ea678371d 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.cs @@ -3601,7 +3601,7 @@ void StopJsonHeartbeat() ResumeIndexSpinnerAfterConsoleWrite(); } - if (!options.Rebuild && writer.HasFileAtPath(currentJsonIndexFile)) + if (writer.HasFileAtPath(currentJsonIndexFile)) { using var deleteTxn = writer.BeginTransaction(); if (writer.DeleteFileByPath(currentJsonIndexFile)) diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs index d62a90458a..5a46bb333d 100644 --- a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs @@ -3722,6 +3722,35 @@ public void Run_Rebuild_CancelledAfterReadinessDemotion_PreservesExistingIndex() } } + [Fact] + public void Run_Rebuild_WhenIndexedFileBecomesBinary_RemovesStaleRow() + { + var projectRoot = CreateTempProject(); + try + { + var sourcePath = Path.Combine(projectRoot, "app.py"); + File.WriteAllText(sourcePath, "def run():\n return 1\n"); + + var initialExitCode = IndexCommandRunner.Run([projectRoot, "--json"], _jsonOptions); + Assert.Equal(CommandExitCodes.Success, initialExitCode); + + var dbPath = Path.Combine(projectRoot, ".cdidx", "codeindex.db"); + Assert.Contains("app.py", ReadIndexedPaths(dbPath)); + + File.WriteAllBytes(sourcePath, [0, 1, 2, 3]); + + var rebuildExitCode = IndexCommandRunner.Run([projectRoot, "--rebuild", "--yes", "--json"], _jsonOptions); + + Assert.Equal(CommandExitCodes.Success, rebuildExitCode); + Assert.DoesNotContain("app.py", ReadIndexedPaths(dbPath)); + } + finally + { + SqliteConnection.ClearAllPools(); + DeleteDirectory(projectRoot); + } + } + [Fact] public void Run_UpdateMode_WithOversizedFile_PrintsSkipWarningWithoutRecoveryWarning() {