From 1c5b3f687e724e2042cc6cd0fd9c65b6d08366b9 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 2 Aug 2026 09:38:55 +0900 Subject: [PATCH] Stabilize parallel batch snapshot test (#5033) --- TESTING_GUIDE.md | 4 +- changelog.d/unreleased/5033.internal.md | 16 ++ .../QueryCommandRunnerBatchIssue4872Tests.cs | 157 +++++++++++++----- 3 files changed, 129 insertions(+), 48 deletions(-) create mode 100644 changelog.d/unreleased/5033.internal.md diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index aaa6e01d9..6ec9bd847 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -696,7 +696,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding - `QueryCommandRunnerBatchIssue4723Tests.cs` CLI batch coverage for structured command objects, configurable input/output budgets, bounded parallel read overlap, input-order result emission, per-item failure isolation, and cancellation/console restoration. The deterministic overlap test blocks the first worker until the second finishes through batch-only test seams; keep those seams reset in `finally` and do not replace the signal with timing assertions. - `QueryCommandRunnerBatchIssue4872Tests.cs` - Parallel batch session-reuse coverage. Keep the exact worker-slot session bound, serial/parallel result and input-order parity, between-item checkpointed-WAL snapshot and direct-session refresh, validation/reader-construction failure cleanup, hot-WAL snapshot fixture, warmup, and the generous 12-item/3-item ratio guard together. The benchmark injects deterministic database-open/schema work through a test seam; its fixed delay models phase cost only, while worker coordination remains signal-driven. + Parallel batch session-reuse coverage. Keep the exact worker-slot session bound, serial/parallel result and input-order parity, between-item checkpointed-WAL snapshot and direct-session refresh, validation/reader-construction failure cleanup, hot-WAL snapshot fixture, warmup, and the generous 12-item/3-item ratio guard together. The interactive refresh fixtures run the synchronous batch loop on a dedicated thread, await worker completion without blocking a thread-pool thread, and query only the lightweight JSON file count needed to observe the database generation; do not restore `Task.Run`, synchronous waits, or broad `status` diagnostics there. The benchmark injects deterministic database-open/schema work through a test seam; its fixed delay models phase cost only, while worker coordination remains signal-driven. - `PropertyBasedParserTests.cs` FsCheck-driven property tests for parser-heavy paths called out in issue #1572: `ArgHelper.WantsHelp` and `ProgramRunner.IsProjectPathArg` never throw on arbitrary inputs; `FileIndexer.NormalizePathSeparators` is idempotent under double application; the literal-safe FTS5 sanitizer (`DbReader.SanitizeFtsQuery`) always emits a query that a real in-memory FTS5 virtual table can parse. They complement, not replace, the example-based tests in `ArgHelperTests.cs` / `QueryCommandRunnerTests.cs`. - `TestProjectHelper.cs`, `TestDeterminism.cs`, `RepositoryTestPaths.cs`, `TestConsoleLock.cs` @@ -1648,7 +1648,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" - `QueryCommandRunnerBatchIssue4723Tests.cs` structured command object、設定可能な input / output budget、上限付き parallel read の重複実行、入力順の result 出力、item ごとの failure isolation、cancellation / console 復元を対象とする CLI batch test です。決定的な overlap test は batch 専用 test seam を通じて第 1 worker を第 2 worker の完了まで block します。seam は `finally` で必ず reset し、signal を timing assertion に置き換えないでください。 - `QueryCommandRunnerBatchIssue4872Tests.cs` - parallel batch の session 再利用を検証します。worker slot 数と一致する厳密な session 上限、serial / parallel の result と入力順 parity、item 間の checkpoint 済み WAL snapshot / direct session 更新、validation / reader 構築失敗時の cleanup、hot-WAL snapshot fixture、warmup、十分に余裕を持たせた 12-item / 3-item ratio guard を一緒に維持してください。benchmark は test seam から決定的な database-open / schema 作業を注入します。固定 delay は phase cost のモデル化だけに使い、worker coordination は引き続き signal で制御します。 + parallel batch の session 再利用を検証します。worker slot 数と一致する厳密な session 上限、serial / parallel の result と入力順 parity、item 間の checkpoint 済み WAL snapshot / direct session 更新、validation / reader 構築失敗時の cleanup、hot-WAL snapshot fixture、warmup、十分に余裕を持たせた 12-item / 3-item ratio guard を一緒に維持してください。interactive refresh fixture は同期 batch loop を専用 thread で実行し、thread-pool thread を block せず worker 完了を await し、database generation の観測に必要な軽量 JSON file count だけを query します。この箇所に `Task.Run`、同期 wait、広範な `status` diagnostics を戻さないでください。benchmark は test seam から決定的な database-open / schema 作業を注入します。固定 delay は phase cost のモデル化だけに使い、worker coordination は引き続き signal で制御します。 - `PropertyBasedParserTests.cs` issue #1572 で挙げられたパーサー系経路に対する FsCheck 駆動の property テスト: `ArgHelper.WantsHelp` と `ProgramRunner.IsProjectPathArg` が任意入力で例外を投げないこと、`FileIndexer.NormalizePathSeparators` が二重適用で idempotent であること、literal-safe な FTS5 サニタイザ (`DbReader.SanitizeFtsQuery`) が常にインメモリ FTS5 仮想テーブルで parse 可能なクエリを出力すること。`ArgHelperTests.cs` / `QueryCommandRunnerTests.cs` の例ベーステストを置き換えるものではなく補完します。 - `TestProjectHelper.cs`、`TestDeterminism.cs`、`RepositoryTestPaths.cs`、`TestConsoleLock.cs` diff --git a/changelog.d/unreleased/5033.internal.md b/changelog.d/unreleased/5033.internal.md new file mode 100644 index 000000000..e12d30091 --- /dev/null +++ b/changelog.d/unreleased/5033.internal.md @@ -0,0 +1,16 @@ +--- +category: internal +issues: + - 5033 +affected: + - tests/CodeIndex.Tests/QueryCommandRunnerBatchIssue4872Tests.cs + - TESTING_GUIDE.md +--- + +## English + +- **The parallel detached-snapshot fixture no longer depends on thread-pool timing or broad status diagnostics (#5033)** — its interactive batch loop now runs on a dedicated test thread, uses asynchronous completion signals, and checks generation refresh through lightweight file-count queries so the net8 test remains deterministic under suite load. + +## 日本語 + +- **parallel detached-snapshot fixture が thread-pool timing や広範な status diagnostics に依存しなくなりました (#5033)** — interactive batch loop を専用 test thread で実行し、非同期 completion signal と軽量な file-count query で generation refresh を確認するため、suite 負荷下でも net8 test が安定して完了します。 diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerBatchIssue4872Tests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerBatchIssue4872Tests.cs index 08ddae46c..c4166d8e6 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerBatchIssue4872Tests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerBatchIssue4872Tests.cs @@ -119,33 +119,34 @@ INSERT INTO files(path, lang, size, lines, checksum, modified) using var input = new InteractiveBatchTextReader(); using var stdout = new StringWriter(); using var stderr = new StringWriter(); - using var firstWaveCompleted = new CountdownEvent(2); + var firstWaveCompleted = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); using var cancellation = new CancellationTokenSource(); Task? runTask = null; var openedSessions = 0; + var completedFirstWaveCommands = 0; QueryCommandRunner.BatchParallelSessionOpenedForTesting = () => Interlocked.Increment(ref openedSessions); QueryCommandRunner.BatchParallelCommandCompletedForTesting = lineNumber => { - if (lineNumber <= 2) - firstWaveCompleted.Signal(); + if (lineNumber <= 2 + && Interlocked.Increment(ref completedFirstWaveCommands) == 2) + { + firstWaveCompleted.TrySetResult(); + } }; try { - runTask = Task.Run(() => - { - using var capture = ConsoleCapture.Start(stdout, stderr, input); - return QueryCommandRunner.RunBatch( - ["--db", dbPath, "--json-summary", "--parallel", "2"], - _jsonOptions, - cancellationToken: cancellation.Token); - }); - input.WriteLine("""{"command":"status","args":["--json"]}"""); - input.WriteLine("""{"command":"status","args":["--json"]}"""); - Assert.True( - firstWaveCompleted.Wait(TimeSpan.FromSeconds(60)), - "The first parallel batch wave did not complete."); + runTask = StartIssue4872InteractiveBatch( + dbPath, + input, + stdout, + stderr, + cancellation.Token); + input.WriteLine("""{"command":"files","args":["--format","count","--json"]}"""); + input.WriteLine("""{"command":"files","args":["--format","count","--json"]}"""); + await WaitForIssue4872FirstWaveAsync(firstWaveCompleted.Task, runTask); using (var update = writer.CreateCommand()) { @@ -159,22 +160,24 @@ INSERT INTO files(path, lang, size, lines, checksum, modified) Assert.Equal(initialDbLength, new FileInfo(dbPath).Length); Assert.Equal(0, new FileInfo(dbPath + "-wal").Length); - input.WriteLine("""{"command":"status","args":["--json"]}"""); - input.WriteLine("""{"command":"status","args":["--json"]}"""); + input.WriteLine("""{"command":"files","args":["--format","count","--json"]}"""); + input.WriteLine("""{"command":"files","args":["--format","count","--json"]}"""); input.Complete(); var exitCode = await runTask.WaitAsync(TimeSpan.FromSeconds(60)); - Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.True( + exitCode == CommandExitCodes.Success, + $"Parallel batch exited with code {exitCode}: {stdout}"); Assert.Equal(string.Empty, stderr.ToString()); var lines = ParseJsonLines(stdout.ToString()); try { Assert.Equal(5, lines.Count); - Assert.Equal(1, lines[0].RootElement.GetProperty("result").GetProperty("files").GetInt32()); - Assert.Equal(1, lines[1].RootElement.GetProperty("result").GetProperty("files").GetInt32()); - Assert.Equal(2, lines[2].RootElement.GetProperty("result").GetProperty("files").GetInt32()); - Assert.Equal(2, lines[3].RootElement.GetProperty("result").GetProperty("files").GetInt32()); + Assert.Equal(1, lines[0].RootElement.GetProperty("result").GetProperty("count").GetInt32()); + Assert.Equal(1, lines[1].RootElement.GetProperty("result").GetProperty("count").GetInt32()); + Assert.Equal(2, lines[2].RootElement.GetProperty("result").GetProperty("count").GetInt32()); + Assert.Equal(2, lines[3].RootElement.GetProperty("result").GetProperty("count").GetInt32()); Assert.Equal(4, Volatile.Read(ref openedSessions)); } finally @@ -198,6 +201,12 @@ INSERT INTO files(path, lang, size, lines, checksum, modified) catch (OperationCanceledException) when (cancellation.IsCancellationRequested) { } + catch (TimeoutException exception) + { + throw new TimeoutException( + "The dedicated parallel batch test thread did not drain after cancellation.", + exception); + } } } } @@ -239,33 +248,34 @@ INSERT INTO files(path, lang, size, lines, checksum, modified) using var input = new InteractiveBatchTextReader(); using var stdout = new StringWriter(); using var stderr = new StringWriter(); - using var firstWaveCompleted = new CountdownEvent(2); + var firstWaveCompleted = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); using var cancellation = new CancellationTokenSource(); Task? runTask = null; var openedSessions = 0; + var completedFirstWaveCommands = 0; QueryCommandRunner.BatchParallelSessionOpenedForTesting = () => Interlocked.Increment(ref openedSessions); QueryCommandRunner.BatchParallelCommandCompletedForTesting = lineNumber => { - if (lineNumber <= 2) - firstWaveCompleted.Signal(); + if (lineNumber <= 2 + && Interlocked.Increment(ref completedFirstWaveCommands) == 2) + { + firstWaveCompleted.TrySetResult(); + } }; try { - runTask = Task.Run(() => - { - using var capture = ConsoleCapture.Start(stdout, stderr, input); - return QueryCommandRunner.RunBatch( - ["--db", dbPath, "--json-summary", "--parallel", "2"], - _jsonOptions, - cancellationToken: cancellation.Token); - }); - input.WriteLine("""{"command":"status","args":["--json"]}"""); - input.WriteLine("""{"command":"status","args":["--json"]}"""); - Assert.True( - firstWaveCompleted.Wait(TimeSpan.FromSeconds(60)), - "The first parallel batch wave did not complete."); + runTask = StartIssue4872InteractiveBatch( + dbPath, + input, + stdout, + stderr, + cancellation.Token); + input.WriteLine("""{"command":"files","args":["--format","count","--json"]}"""); + input.WriteLine("""{"command":"files","args":["--format","count","--json"]}"""); + await WaitForIssue4872FirstWaveAsync(firstWaveCompleted.Task, runTask); using (var update = writer.CreateCommand()) { @@ -276,22 +286,24 @@ INSERT INTO files(path, lang, size, lines, checksum, modified) Assert.Equal(1, update.ExecuteNonQuery()); } - input.WriteLine("""{"command":"status","args":["--json"]}"""); - input.WriteLine("""{"command":"status","args":["--json"]}"""); + input.WriteLine("""{"command":"files","args":["--format","count","--json"]}"""); + input.WriteLine("""{"command":"files","args":["--format","count","--json"]}"""); input.Complete(); var exitCode = await runTask.WaitAsync(TimeSpan.FromSeconds(60)); - Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.True( + exitCode == CommandExitCodes.Success, + $"Parallel batch exited with code {exitCode}: {stdout}"); Assert.Equal(string.Empty, stderr.ToString()); var lines = ParseJsonLines(stdout.ToString()); try { Assert.Equal(5, lines.Count); - Assert.Equal(1, lines[0].RootElement.GetProperty("result").GetProperty("files").GetInt32()); - Assert.Equal(1, lines[1].RootElement.GetProperty("result").GetProperty("files").GetInt32()); - Assert.Equal(2, lines[2].RootElement.GetProperty("result").GetProperty("files").GetInt32()); - Assert.Equal(2, lines[3].RootElement.GetProperty("result").GetProperty("files").GetInt32()); + Assert.Equal(1, lines[0].RootElement.GetProperty("result").GetProperty("count").GetInt32()); + Assert.Equal(1, lines[1].RootElement.GetProperty("result").GetProperty("count").GetInt32()); + Assert.Equal(2, lines[2].RootElement.GetProperty("result").GetProperty("count").GetInt32()); + Assert.Equal(2, lines[3].RootElement.GetProperty("result").GetProperty("count").GetInt32()); Assert.Equal(4, Volatile.Read(ref openedSessions)); } finally @@ -315,6 +327,12 @@ INSERT INTO files(path, lang, size, lines, checksum, modified) catch (OperationCanceledException) when (cancellation.IsCancellationRequested) { } + catch (TimeoutException exception) + { + throw new TimeoutException( + "The dedicated parallel batch test thread did not drain after cancellation.", + exception); + } } } } @@ -528,4 +546,51 @@ private static string ReadJournalMode(SqliteConnection connection) command.CommandText = "PRAGMA journal_mode"; return Assert.IsType(command.ExecuteScalar()); } + + private Task StartIssue4872InteractiveBatch( + string dbPath, + InteractiveBatchTextReader input, + StringWriter stdout, + StringWriter stderr, + CancellationToken cancellationToken) + => Task.Factory.StartNew( + () => + { + using (var capture = ConsoleCapture.Start(stdout, stderr, input)) + { + return QueryCommandRunner.RunBatch( + ["--db", dbPath, "--json-summary", "--parallel", "2"], + _jsonOptions, + cancellationToken: cancellationToken); + } + }, + CancellationToken.None, + TaskCreationOptions.LongRunning | TaskCreationOptions.DenyChildAttach, + TaskScheduler.Default); + + private static async Task WaitForIssue4872FirstWaveAsync( + Task firstWaveCompleted, + Task runTask) + { + Task boundary; + try + { + boundary = await Task.WhenAny(firstWaveCompleted, runTask) + .WaitAsync(TimeSpan.FromSeconds(60)); + } + catch (TimeoutException exception) + { + throw new TimeoutException( + "The first parallel batch wave did not complete before its synchronization boundary.", + exception); + } + if (ReferenceEquals(boundary, runTask)) + { + var exitCode = await runTask; + Assert.Fail( + $"The parallel batch exited with code {exitCode} before the first wave completed."); + } + + await firstWaveCompleted; + } }