Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions changelog.d/unreleased/2642.fixed.md
Original file line number Diff line number Diff line change
@@ -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 対象になったファイルの行は削除します。
22 changes: 7 additions & 15 deletions src/CodeIndex/Cli/IndexCommandRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -492,15 +492,6 @@ void RecordDryRunScanErrors(IEnumerable<FileIndexer.ScanError> 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);

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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))
Expand Down
99 changes: 96 additions & 3 deletions tests/CodeIndex.Tests/IndexCommandRunnerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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()
{
Expand Down
Loading