diff --git a/changelog.d/unreleased/2642.fixed.md b/changelog.d/unreleased/2642.fixed.md new file mode 100644 index 0000000000..f0c5d6d5da --- /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 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 しない一方で、非 index 対象になったファイルの行は削除します。 diff --git a/src/CodeIndex/Cli/IndexCommandRunner.cs b/src/CodeIndex/Cli/IndexCommandRunner.cs index d411f5da32..4ea678371d 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.cs @@ -492,15 +492,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); @@ -3218,13 +3209,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(); @@ -3609,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 723640728c..c227d2575e 100644 --- a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs @@ -3642,13 +3642,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 { @@ -3658,6 +3659,98 @@ 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_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() {