diff --git a/.github/workflows/mutation-testing.yml b/.github/workflows/mutation-testing.yml new file mode 100644 index 0000000000..522b711cc5 --- /dev/null +++ b/.github/workflows/mutation-testing.yml @@ -0,0 +1,35 @@ +name: Mutation testing + +on: + schedule: + - cron: "17 3 * * 1" + workflow_dispatch: + +permissions: + contents: read + +jobs: + dbwriter: + name: DbWriter mutation testing + runs-on: ubuntu-latest + timeout-minutes: 60 + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.0.x + 9.0.x + + - name: Restore + run: dotnet restore CodeIndex.sln --locked-mode + + - name: Install Stryker.NET + run: dotnet tool install --global dotnet-stryker + + - name: Run DbWriter mutation tests + run: dotnet stryker --config-file stryker-config.json diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 472ddaefe4..9344a960b2 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -39,6 +39,13 @@ failures from pass-on-retry flakes. For test suite structure, shared helpers, state-isolation rules, timeout diagnostics, and test-writing conventions, see [TESTING_GUIDE.md](TESTING_GUIDE.md). +The weekly `Mutation testing` workflow runs Stryker.NET against +`src/CodeIndex/Database/DbWriter.cs` using `stryker-config.json`. Keep this +scope focused on transaction, savepoint, rollback, and batch-write behavior +unless the runtime budget is intentionally expanded. The mutation score gates +are high 75, low 70, and break 65 so changes that weaken rollback or savepoint +coverage fail outside the regular PR test path. + ## CI / Artifact Distribution Query commands accept `--read-only` (alias `--immutable`) to open an existing @@ -150,6 +157,16 @@ Incremental refreshes that mutate `fts_chunks` increment `codeindex_meta.fts_inc Successful writer sessions attempt `PRAGMA wal_checkpoint(TRUNCATE)` before closing a writable `DbContext`, so large WAL files are reclaimed after index, backfill, optimize, prune, and other DB-writing commands. `cdidx db schema [--json]` dumps `sqlite_master` entries plus `PRAGMA user_version` for schema inspection, and `cdidx db prune --dry-run|--apply [--json]` counts or deletes orphaned `symbol_references`, `reference_lines`, and `symbols` rows before running `PRAGMA optimize` on apply. +### Metadata invariants + +`DbWriter.SetMeta` participates in the caller's writer transaction when one is +active. When no writer transaction is active, it wraps the metadata UPSERT in a +SQLite savepoint so standalone stamps still have a commit boundary and calls +from raw SQL transactions do not attempt a nested `BEGIN`. Dependent metadata +and row rewrites that must succeed or fail together should be placed inside the +same `DbWriter.BeginTransaction()` scope; do not stamp readiness or schema +trust metadata before the dependent rows are written. + ### Extending the indexer Out-of-tree post-extraction hooks can implement `CodeIndex.Indexer.Hooks.IPostExtractionHook` in a `.dll` placed under `~/.config/cdidx/hooks/` (or the directory named by `CDIDX_HOOKS_DIR`). Hook assemblies are discovered in path order. Each concrete hook type is instantiated with a public parameterless constructor, then called after built-in symbol extraction and again after built-in reference extraction, before rows are persisted. Hooks receive a `FileContext` plus mutable `IList` / `IList` values, so they can annotate extracted records, add synthetic symbols, or add domain-specific references. diff --git a/changelog.d/unreleased/1669.internal.md b/changelog.d/unreleased/1669.internal.md new file mode 100644 index 0000000000..e1781eb5b6 --- /dev/null +++ b/changelog.d/unreleased/1669.internal.md @@ -0,0 +1,17 @@ +--- +category: internal +issues: + - 1669 +affected: + - stryker-config.json + - .github/workflows/mutation-testing.yml + - DEVELOPER_GUIDE.md +--- + +## English + +- **Added weekly DbWriter mutation testing (#1669)** — Stryker.NET now runs against the DbWriter transaction and rollback surface on a scheduled workflow with documented score gates. + +## 日本語 + +- **DbWriter の週次 mutation testing を追加しました (#1669)** — Stryker.NET が DbWriter の transaction / rollback 周辺を定期 workflow で検査し、score gate も文書化されました。 diff --git a/changelog.d/unreleased/1735.fixed.md b/changelog.d/unreleased/1735.fixed.md new file mode 100644 index 0000000000..232d2dc02e --- /dev/null +++ b/changelog.d/unreleased/1735.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 1735 +affected: + - src/CodeIndex/Database/DbWriter.cs + - tests/CodeIndex.Tests/PreparedCommandCacheTests.cs +--- + +## English + +- **Locked in atomic unchanged-file reuse (#1735)** — `GetUnchangedFileId` now has regression coverage ensuring checksum drift does not touch stale file metadata. + +## 日本語 + +- **未変更ファイル再利用の atomic 契約を固定しました (#1735)** — `GetUnchangedFileId` に checksum drift 時に古い file metadata を touch しない regression coverage を追加しました。 diff --git a/changelog.d/unreleased/1753.fixed.md b/changelog.d/unreleased/1753.fixed.md new file mode 100644 index 0000000000..47a44563e8 --- /dev/null +++ b/changelog.d/unreleased/1753.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 1753 +affected: + - src/CodeIndex/Database/DbWriter.cs + - tests/CodeIndex.Tests/DatabaseTests.cs + - DEVELOPER_GUIDE.md +--- + +## English + +- **Made metadata stamps participate in transaction boundaries (#1753)** — `SetMeta` now joins writer transactions or uses a SQLite savepoint for standalone writes so metadata and dependent rows can roll back together. + +## 日本語 + +- **metadata stamp が transaction 境界に参加するようにしました (#1753)** — `SetMeta` は writer transaction に参加し、単独書き込みでは SQLite savepoint を使うため、metadata と依存 row をまとめて rollback できます。 diff --git a/src/CodeIndex/Database/DbWriter.cs b/src/CodeIndex/Database/DbWriter.cs index 15f90e05d6..a12d5c2913 100644 --- a/src/CodeIndex/Database/DbWriter.cs +++ b/src/CodeIndex/Database/DbWriter.cs @@ -482,6 +482,8 @@ private void RunPassiveWalCheckpoint() if (!SymbolExtractorVersionMatchesCurrent(language)) return null; + // Keep the unchanged check and timestamp touch in one SQLite statement so + // concurrent row drift cannot slip between a SELECT and a later UPDATE (#1735). var cmd = RentCommand( @"UPDATE files SET modified = CASE @@ -3023,7 +3025,30 @@ public void SetMeta(string key, string? value) if (!HasMetaTable()) return; + if (!IsInTransaction()) + { + Execute("SAVEPOINT set_meta_atomic"); + try + { + SetMetaCore(key, value); + Execute("RELEASE SAVEPOINT set_meta_atomic"); + } + catch + { + try { Execute("ROLLBACK TO SAVEPOINT set_meta_atomic"); } catch (SqliteException) { /* best effort */ } + try { Execute("RELEASE SAVEPOINT set_meta_atomic"); } catch (SqliteException) { /* best effort */ } + throw; + } + return; + } + + SetMetaCore(key, value); + } + + private void SetMetaCore(string key, string? value) + { using var cmd = _conn.CreateCommand(); + cmd.Transaction = _activeTransaction; cmd.CommandText = @"INSERT INTO codeindex_meta (key, value) VALUES (@key, @value) ON CONFLICT(key) DO UPDATE SET value = excluded.value"; cmd.Parameters.AddWithValue("@key", key); diff --git a/stryker-config.json b/stryker-config.json new file mode 100644 index 0000000000..d020b80b5c --- /dev/null +++ b/stryker-config.json @@ -0,0 +1,22 @@ +{ + "stryker-config": { + "solution": "CodeIndex.sln", + "project": "src/CodeIndex/CodeIndex.csproj", + "test-projects": [ + "tests/CodeIndex.Tests/CodeIndex.Tests.csproj" + ], + "mutate": [ + "src/CodeIndex/Database/DbWriter.cs" + ], + "thresholds": { + "high": 75, + "low": 70, + "break": 65 + }, + "reporters": [ + "progress", + "cleartext", + "html" + ] + } +} diff --git a/tests/CodeIndex.Tests/DatabaseTests.cs b/tests/CodeIndex.Tests/DatabaseTests.cs index 458a754b2b..33d93a273f 100644 --- a/tests/CodeIndex.Tests/DatabaseTests.cs +++ b/tests/CodeIndex.Tests/DatabaseTests.cs @@ -2429,6 +2429,45 @@ public void DbContext_NewDatabaseRestrictsFileModeOnPosix() Assert.Equal("0600", DbContext.GetUnixFileModeString(_dbPath)); } + [Fact] + public void SetMeta_InsideWriterTransaction_RollsBackWithDependentRows_Issue1753() + { + using (var transaction = _writer.BeginTransaction()) + { + _writer.SetMeta("schema_phase", "new"); + _writer.UpsertFile(new FileRecord + { + Path = "src/partial.cs", + Lang = "csharp", + Size = 12, + Lines = 1, + Modified = new DateTime(2026, 5, 31, 0, 0, 0, DateTimeKind.Utc), + Checksum = "partial", + }); + } + + Assert.Null(ReadMeta("schema_phase")); + Assert.False(_writer.HasFileAtPath("src/partial.cs")); + } + + [Fact] + public void SetMeta_InsideRawSqlTransaction_UsesSavepointWithoutNestedBegin_Issue1753() + { + ExecuteNonQuery(_db.Connection, "BEGIN IMMEDIATE"); + try + { + _writer.SetMeta("raw_phase", "new"); + ExecuteNonQuery(_db.Connection, "ROLLBACK"); + } + catch + { + ExecuteNonQuery(_db.Connection, "ROLLBACK"); + throw; + } + + Assert.Null(ReadMeta("raw_phase")); + } + private void DeleteDbPath() { DeleteDbFiles(_dbPath); @@ -2476,6 +2515,14 @@ private string ExecuteScalarString(string sql) private long ExecuteScalarLong(string sql) => ExecuteScalarLong(_db.Connection, sql); + private string? ReadMeta(string key) + { + using var cmd = _db.Connection.CreateCommand(); + cmd.CommandText = "SELECT value FROM codeindex_meta WHERE key = @key"; + cmd.Parameters.AddWithValue("@key", key); + return cmd.ExecuteScalar() as string; + } + private static long ExecuteScalarLong(SqliteConnection connection, string sql) { using var cmd = connection.CreateCommand(); diff --git a/tests/CodeIndex.Tests/PreparedCommandCacheTests.cs b/tests/CodeIndex.Tests/PreparedCommandCacheTests.cs index fdc00d9771..79be0d33f9 100644 --- a/tests/CodeIndex.Tests/PreparedCommandCacheTests.cs +++ b/tests/CodeIndex.Tests/PreparedCommandCacheTests.cs @@ -346,6 +346,30 @@ public void DbWriter_WithCache_GetUnchangedFileIdTouchUpdatesTimestamp() Assert.Equal(touched, reader.GetDateTime(0)); } + [Fact] + public void DbWriter_WithCache_GetUnchangedFileIdDoesNotTouchWhenChecksumDrifts_Issue1735() + { + var writer = new DbWriter(_db); + var initial = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var touched = new DateTime(2025, 6, 1, 0, 0, 0, DateTimeKind.Utc); + + writer.UpsertFile(new FileRecord + { + Path = "src/drift.py", Lang = "python", Size = 1, Lines = 1, + Checksum = "old_checksum", Modified = initial, + }); + + Assert.Null(writer.GetUnchangedFileId("src/drift.py", touched, "new_checksum")); + + using var cmd = _db.Connection.CreateCommand(); + cmd.CommandText = "SELECT modified, checksum FROM files WHERE path = @p"; + cmd.Parameters.AddWithValue("@p", "src/drift.py"); + using var reader = cmd.ExecuteReader(); + Assert.True(reader.Read()); + Assert.Equal(initial, reader.GetDateTime(0)); + Assert.Equal("old_checksum", reader.GetString(1)); + } + [Fact] public void DbReader_WithCache_ReusesCSharpResolutionCommandsAcrossReaders() {