From 0b27763090b0032bd479a40f3e3f70af388130b9 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 31 May 2026 14:02:56 +0900 Subject: [PATCH 1/6] Checkpoint WAL after writes (#1644) --- changelog.d/unreleased/1644.fixed.md | 16 ++++++++++++++++ src/CodeIndex/Database/DbContext.cs | 23 +++++++++++++++++++++++ tests/CodeIndex.Tests/DatabaseTests.cs | 10 ++++++++++ 3 files changed, 49 insertions(+) create mode 100644 changelog.d/unreleased/1644.fixed.md diff --git a/changelog.d/unreleased/1644.fixed.md b/changelog.d/unreleased/1644.fixed.md new file mode 100644 index 0000000000..7be36cb591 --- /dev/null +++ b/changelog.d/unreleased/1644.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 1644 +affected: + - src/CodeIndex/Database/DbContext.cs + - tests/CodeIndex.Tests/DatabaseTests.cs +--- + +## English + +- **Successful writer sessions now truncate-checkpoint the SQLite WAL (#1644)** — writable DB contexts attempt `PRAGMA wal_checkpoint(TRUNCATE)` after write work so large `codeindex.db-wal` sidecars are reclaimed after successful maintenance and index runs. + +## 日本語 + +- **成功した writer session が SQLite WAL を truncate checkpoint するようになりました (#1644)** — 書き込みを行った DB context は `PRAGMA wal_checkpoint(TRUNCATE)` を試行し、成功した maintenance / index 実行後に肥大化した `codeindex.db-wal` sidecar を回収します。 diff --git a/src/CodeIndex/Database/DbContext.cs b/src/CodeIndex/Database/DbContext.cs index 8d9b18e2cb..5fc0d064bf 100644 --- a/src/CodeIndex/Database/DbContext.cs +++ b/src/CodeIndex/Database/DbContext.cs @@ -362,6 +362,27 @@ private static bool TryCheckpointWalBeforeReadOnlyFallback(string dbPath) } } + public bool TryCheckpointWalTruncate() + { + if (_isReadOnly) + return false; + + _walCheckpointAttempted = true; + try + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = "PRAGMA wal_checkpoint(TRUNCATE)"; + cmd.ExecuteNonQuery(); + _walCheckpointSucceeded = true; + return true; + } + catch (SqliteException) + { + _walCheckpointSucceeded = false; + return false; + } + } + public static string ToReadOnlyUri(string dbPath) { if (dbPath.StartsWith("file:", StringComparison.OrdinalIgnoreCase)) @@ -2437,6 +2458,8 @@ public void Dispose() _preparedCommands?.Dispose(); _preparedCommands = null; RunOptimizeOnCloseIfNeeded(); + if (_hasWriteWork) + TryCheckpointWalTruncate(); _connection.Dispose(); } } diff --git a/tests/CodeIndex.Tests/DatabaseTests.cs b/tests/CodeIndex.Tests/DatabaseTests.cs index 2a0df4d06f..d03536252d 100644 --- a/tests/CodeIndex.Tests/DatabaseTests.cs +++ b/tests/CodeIndex.Tests/DatabaseTests.cs @@ -434,6 +434,16 @@ public void OptimizeFts_ResetsIncrementalWriteCounterAndStampsTime() Assert.False(string.IsNullOrWhiteSpace(_db.GetMetaString(DbWriter.FtsLastOptimizedAtMetaKey))); } + [Fact] + public void TryCheckpointWalTruncate_OnWritableDb_ReportsAttemptAndSuccess() + { + var result = _db.TryCheckpointWalTruncate(); + + Assert.True(result); + Assert.True(_db.WalCheckpointAttempted); + Assert.True(_db.WalCheckpointSucceeded); + } + private long UpsertTestFile(string path, string checksum) => _writer.UpsertFile(new FileRecord { From 178251602b542d0ba33c2aebe3fc1320c965200d Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 31 May 2026 14:03:21 +0900 Subject: [PATCH 2/6] Add database schema and prune commands (#1646, #1728) --- DEVELOPER_GUIDE.md | 2 + README.md | 2 +- changelog.d/unreleased/1646.added.md | 17 ++ changelog.d/unreleased/1728.added.md | 17 ++ src/CodeIndex/Cli/ConsoleUi.cs | 4 +- src/CodeIndex/Cli/DbCommandRunner.cs | 271 +++++++++++++++++- src/CodeIndex/Cli/JsonOutputContracts.cs | 23 ++ src/CodeIndex/Cli/ProgramRunner.cs | 2 +- tests/CodeIndex.Tests/DbCommandRunnerTests.cs | 105 +++++++ 9 files changed, 435 insertions(+), 8 deletions(-) create mode 100644 changelog.d/unreleased/1646.added.md create mode 100644 changelog.d/unreleased/1728.added.md diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 92188ccd62..748f5b7485 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -124,6 +124,8 @@ Scoped `--files` / `--commits` refreshes reuse the same path filter as full scan Incremental refreshes that mutate `fts_chunks` increment `codeindex_meta.fts_incremental_writes_since_optimize`. When the counter reaches `DbWriter.DefaultFtsOptimizeIncrementalWriteThreshold`, the update path runs `INSERT INTO fts_chunks(fts_chunks) VALUES('optimize')`, resets the counter, and stamps `fts_last_optimized_at`. Users can run the same maintenance directly with `cdidx optimize --db ` or `cdidx index --optimize`; this may briefly hold the writer lock on large indexes. +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. + ### 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/README.md b/README.md index a8c54c1f4a..91ae695ce8 100644 --- a/README.md +++ b/README.md @@ -359,7 +359,7 @@ upgrade / downgrade 後はインストール済み補完 script を再生成し | MCP 連携 | Claude Code、Cursor、Windsurf などの AI クライアント向け MCP server。tools、インデックス済みファイル resources、starter prompts、ローカル引数検証用の schema constraints、text content block の `mimeType`、logging、stdio または HTTP `/events` stream 上の互換性用 server-side `notifications/initialized` ready signal、`cdidx languages` と同じ言語レジストリ由来の `Language support:` 説明を提供します。 | | freshness | `--parallelism` による parallel full-scan、`--files` / `--commits` による差分更新、`--watch` による継続更新、`status --check` による完全一致確認、`--stale-after` / `CDIDX_STALE_AFTER` による age threshold 上書きに対応します。 | | storage | `.cdidx/codeindex.db` に保存する local-first 設計。ネストしたディレクトリからの query コマンドは、current directory にフォールバックする前に最上位祖先の `.cdidx/codeindex.db` を優先します。既定の SQLite 保存先は `--data-dir `、`CDIDX_DATA_DIR`、`XDG_DATA_HOME` で workspace 外へ移せます。明示的な `--db ` は引き続き最優先です。 | -| DB maintenance | 新規 index DB は SQLite incremental auto-vacuum を使います。既存 DB は `cdidx vacuum` で free page を回収でき、legacy no-autovacuum DB は初回だけ full `VACUUM` で変換します。`status --json` は `db_pragma_settings` 配下に metrics を出力します。 | +| DB maintenance | 新規 index DB は SQLite incremental auto-vacuum を使います。成功した writer 実行は WAL を `TRUNCATE` checkpoint します。既存 DB は `cdidx vacuum` で free page を回収でき、legacy no-autovacuum DB は初回だけ full `VACUUM` で変換します。`cdidx db schema` は on-disk schema を出力し、`cdidx db prune --dry-run|--apply` は orphaned DB rows を検査・削除します。`status --json` は `db_pragma_settings` 配下に metrics を出力します。 | | security defaults | POSIX では `.cdidx` を `0700` 権限で作成します。`status --json` は利用可能な場合に実効 POSIX mode を `data_dir_mode` として報告します。 | | diagnostics | `status --config` は source attribution 付きの effective configuration を出力し、`status --explain ` は readiness field の意味と対処を説明します。read 系コマンドは `--profile`、`--slow-query-ms `、--trace=stderr|file|none に対応し、file trace は lifecycle log と同じ場所に日次 `query-trace-YYYYMMDD.jsonl` を書きます。 | | drift checks | `cdidx diff ` は schema、file、symbol、reference の差分を比較します。exit code は `0` identical、`1` drift、`2` schema mismatch、`3` unreadable DB です。 | diff --git a/changelog.d/unreleased/1646.added.md b/changelog.d/unreleased/1646.added.md new file mode 100644 index 0000000000..c9f6a06774 --- /dev/null +++ b/changelog.d/unreleased/1646.added.md @@ -0,0 +1,17 @@ +--- +category: added +issues: + - 1646 +affected: + - src/CodeIndex/Cli/DbCommandRunner.cs + - src/CodeIndex/Cli/JsonOutputContracts.cs + - tests/CodeIndex.Tests/DbCommandRunnerTests.cs +--- + +## English + +- **Added `cdidx db schema` for on-disk schema inspection (#1646)** — the new command prints SQLite schema entries and `PRAGMA user_version`, with `--json` for automation. + +## 日本語 + +- **on-disk schema を確認する `cdidx db schema` を追加しました (#1646)** — 新しいコマンドは SQLite schema entries と `PRAGMA user_version` を出力し、自動化向けに `--json` も提供します。 diff --git a/changelog.d/unreleased/1728.added.md b/changelog.d/unreleased/1728.added.md new file mode 100644 index 0000000000..15b9903886 --- /dev/null +++ b/changelog.d/unreleased/1728.added.md @@ -0,0 +1,17 @@ +--- +category: added +issues: + - 1728 +affected: + - src/CodeIndex/Cli/DbCommandRunner.cs + - src/CodeIndex/Cli/JsonOutputContracts.cs + - tests/CodeIndex.Tests/DbCommandRunnerTests.cs +--- + +## English + +- **Added `cdidx db prune --dry-run|--apply` for stale DB rows (#1728)** — the command counts or deletes orphaned `symbol_references`, `reference_lines`, and `symbols` rows, then runs `PRAGMA optimize` after apply. + +## 日本語 + +- **stale DB rows を処理する `cdidx db prune --dry-run|--apply` を追加しました (#1728)** — このコマンドは orphaned `symbol_references`、`reference_lines`、`symbols` rows を集計または削除し、apply 後に `PRAGMA optimize` を実行します。 diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index d9b935293e..70c597d218 100644 --- a/src/CodeIndex/Cli/ConsoleUi.cs +++ b/src/CodeIndex/Cli/ConsoleUi.cs @@ -83,7 +83,7 @@ private static readonly (string Command, string Usage)[] CommandUsageLines = ("outline", "cdidx outline [--db ] [--json] [--verbose]"), ("status", "cdidx status [--db ] [--json] [--verbose] [--check[=workspace,fold,graph,issues,hotspot,csharp,sql,newer]] [--stale-after ] [--explain ] [--log-path] [--config] [--check-updates]"), ("validate-config", "cdidx validate-config"), - ("db", "cdidx db --integrity-check [--db ] [--json]"), + ("db", "cdidx db --integrity-check|schema|prune [--dry-run|--apply] [--db ] [--json]"), ("diff", "cdidx diff [--json] [--summary-only] [--detailed] [--limit ]"), ("report", "cdidx report --output [--db ] [--json] [--log-lines ] [--no-log] [--include-args]"), ("validate", "cdidx validate [--db ] [--json] [--verbose] [--kind ] [--path ]"), @@ -747,6 +747,8 @@ private static void PrintCommandSummary() Console.WriteLine(" upgrade Check for and install the latest release via install.sh"); Console.WriteLine(" validate-config Validate .cdidx/config.json or .cdidxrc.json"); Console.WriteLine(" db --integrity-check Run SQLite `PRAGMA integrity_check` and report findings"); + Console.WriteLine(" db schema Dump SQLite schema entries and PRAGMA user_version"); + Console.WriteLine(" db prune --dry-run|--apply Count or delete orphaned DB rows"); Console.WriteLine(" diff Compare two index databases; exit 0 identical, 1 drift, 2 schema mismatch, 3 unreadable"); Console.WriteLine(" report --output Build a redacted crash-repro tarball (.tgz) for bug reports"); Console.WriteLine(" validate Report encoding issues (U+FFFD, BOM, null bytes, mixed line endings, UTF-16 BOM, likely non-UTF8)"); diff --git a/src/CodeIndex/Cli/DbCommandRunner.cs b/src/CodeIndex/Cli/DbCommandRunner.cs index e1a9e91e8d..5aec6f0dc5 100644 --- a/src/CodeIndex/Cli/DbCommandRunner.cs +++ b/src/CodeIndex/Cli/DbCommandRunner.cs @@ -5,12 +5,12 @@ namespace CodeIndex.Cli; /// -/// Runs `db` subcommands that operate directly on the SQLite file (integrity check, etc.). -/// SQLite ファイル本体に対する `db` サブコマンド(整合性チェックなど)を実行する。 +/// Runs `db` subcommands that operate directly on the SQLite file (integrity check, schema, prune). +/// SQLite ファイル本体に対する `db` サブコマンド(整合性チェック、schema、prune)を実行する。 /// public static class DbCommandRunner { - public static int RunIntegrityCheck(string[] cmdArgs, JsonSerializerOptions jsonOptions) + public static int Run(string[] cmdArgs, JsonSerializerOptions jsonOptions) { var options = ParseArgs(cmdArgs); if (options.ShowHelp) @@ -28,13 +28,22 @@ public static int RunIntegrityCheck(string[] cmdArgs, JsonSerializerOptions json "Run `cdidx db --integrity-check --help` to see the supported command shape.", CommandErrorCodes.UsageError); - if (!options.IntegrityCheck) + if (!options.IntegrityCheck && !options.Schema && !options.Prune) return WriteCommandError( options.Json, jsonOptions, "db requires a mode flag", CommandExitCodes.UsageError, - "Pass `--integrity-check` to run `PRAGMA integrity_check` on the database.", + "Pass `--integrity-check`, `schema`, or `prune --dry-run|--apply`.", + CommandErrorCodes.UsageError); + + if ((options.IntegrityCheck ? 1 : 0) + (options.Schema ? 1 : 0) + (options.Prune ? 1 : 0) > 1) + return WriteCommandError( + options.Json, + jsonOptions, + "db accepts exactly one mode", + CommandExitCodes.UsageError, + "Run one of `cdidx db --integrity-check`, `cdidx db schema`, or `cdidx db prune --dry-run|--apply`.", CommandErrorCodes.UsageError); var dbPath = options.DbPath; @@ -48,6 +57,19 @@ public static int RunIntegrityCheck(string[] cmdArgs, JsonSerializerOptions json "Point `--db` at an existing `codeindex.db`, or run `cdidx index ` first to create one.", CommandErrorCodes.DbNotFound); + if (options.Schema) + return RunSchema(options, jsonOptions, dbPath, isUri); + + if (options.Prune) + return RunPrune(options, jsonOptions, dbPath, isUri); + + return RunIntegrityCheck(options, jsonOptions, dbPath, isUri); + } + + public static int RunIntegrityCheck(string[] cmdArgs, JsonSerializerOptions jsonOptions) => Run(cmdArgs, jsonOptions); + + private static int RunIntegrityCheck(DbCommandOptions options, JsonSerializerOptions jsonOptions, string dbPath, bool isUri) + { try { var issues = RunIntegrityCheckPragma(dbPath); @@ -96,6 +118,124 @@ public static int RunIntegrityCheck(string[] cmdArgs, JsonSerializerOptions json } } + private static int RunSchema(DbCommandOptions options, JsonSerializerOptions jsonOptions, string dbPath, bool isUri) + { + try + { + var schema = ReadSchema(dbPath); + var fullPath = Path.GetFullPath(isUri ? dbPath : dbPath); + if (options.Json) + { + var jsonContext = CliJsonSerializerContextFactory.Create(jsonOptions); + Console.WriteLine(JsonSerializer.Serialize( + new DbSchemaJsonResult(fullPath, schema.UserVersion, schema.Entries), + jsonContext.DbSchemaJsonResult)); + } + else + { + Console.WriteLine("Database schema"); + Console.WriteLine($" database : {fullPath}"); + Console.WriteLine($" user_version: {schema.UserVersion}"); + foreach (var entry in schema.Entries) + { + Console.WriteLine(); + Console.WriteLine($"-- {entry.Type}: {entry.Name}"); + if (!string.IsNullOrWhiteSpace(entry.Sql)) + Console.WriteLine(entry.Sql); + } + } + + return CommandExitCodes.Success; + } + catch (Exception ex) + { + if (JsonOutputFailure.TryHandle(ex, out var exitCode)) + return exitCode; + + return WriteCommandError( + options.Json, + jsonOptions, + $"failed to read schema: {ex.Message}", + CommandExitCodes.DatabaseError, + "Retry `cdidx db schema`. If this persists, rebuild with `cdidx index --rebuild`.", + CommandErrorCodes.DbError); + } + } + + private static int RunPrune(DbCommandOptions options, JsonSerializerOptions jsonOptions, string dbPath, bool isUri) + { + if (!options.PruneApply && !options.PruneDryRun) + return WriteCommandError( + options.Json, + jsonOptions, + "db prune requires --dry-run or --apply", + CommandExitCodes.UsageError, + "Use `cdidx db prune --dry-run` to inspect stale rows, then `cdidx db prune --apply` to delete them.", + CommandErrorCodes.UsageError); + + if (options.PruneApply && options.PruneDryRun) + return WriteCommandError( + options.Json, + jsonOptions, + "db prune accepts only one of --dry-run or --apply", + CommandExitCodes.UsageError, + "Choose `--dry-run` or `--apply`.", + CommandErrorCodes.UsageError); + + if (isUri && DbPathResolver.UriRequestsReadOnly(dbPath)) + return WriteCommandError( + options.Json, + jsonOptions, + $"database must be writable for prune: {dbPath}", + CommandExitCodes.DatabaseError, + "Point `--db` at a writable filesystem path, or omit read-only URI parameters such as `immutable=1` / `mode=ro`.", + CommandErrorCodes.DbNotWritable); + + try + { + var result = PruneOrphans(dbPath, apply: options.PruneApply); + var fullPath = Path.GetFullPath(isUri ? dbPath : dbPath); + if (options.Json) + { + var jsonContext = CliJsonSerializerContextFactory.Create(jsonOptions); + Console.WriteLine(JsonSerializer.Serialize( + new DbPruneJsonResult( + "success", + fullPath, + options.PruneDryRun, + result.OrphanSymbolReferences, + result.OrphanReferenceLines, + result.OrphanSymbols, + result.Total), + jsonContext.DbPruneJsonResult)); + } + else + { + Console.WriteLine(options.PruneApply ? "Pruned database stale rows." : "Database prune dry run."); + Console.WriteLine($" database : {fullPath}"); + Console.WriteLine($" orphan symbol_references : {result.OrphanSymbolReferences:N0}"); + Console.WriteLine($" orphan reference_lines : {result.OrphanReferenceLines:N0}"); + Console.WriteLine($" orphan symbols : {result.OrphanSymbols:N0}"); + Console.WriteLine($" total : {result.Total:N0}"); + } + + return CommandExitCodes.Success; + } + catch (Exception ex) + { + if (JsonOutputFailure.TryHandle(ex, out var exitCode)) + return exitCode; + + return WriteCommandError( + options.Json, + jsonOptions, + $"failed to prune database: {ex.Message}", + CommandExitCodes.DatabaseError, + "Ensure no other writer is holding the database lock, then retry `cdidx db prune --dry-run`.", + CommandErrorCodes.DbError); + } + } + // PRAGMA integrity_check returns a single row `"ok"` when the file passes every consistency // probe, otherwise it returns up to N rows of corruption findings. The pragma itself only // reads the database, so a read-only connection is sufficient and avoids the WAL-mode @@ -126,11 +266,112 @@ private static List RunIntegrityCheckPragma(string dbPath) return rows.Count > 0 ? rows : new List { "ok" }; } + private static (int UserVersion, List Entries) ReadSchema(string dbPath) + { + using var connection = OpenConnection(dbPath, writable: false); + using var versionCmd = connection.CreateCommand(); + versionCmd.CommandText = "PRAGMA user_version"; + var rawVersion = versionCmd.ExecuteScalar(); + var userVersion = rawVersion is long l ? (int)l : (rawVersion is int i ? i : 0); + + using var cmd = connection.CreateCommand(); + cmd.CommandText = @" + SELECT type, name, tbl_name, sql + FROM sqlite_master + WHERE type IN ('table', 'index', 'trigger', 'view') + ORDER BY type, name"; + using var reader = cmd.ExecuteReader(); + var entries = new List(); + while (reader.Read()) + { + entries.Add(new DbSchemaEntryJsonResult( + reader.GetString(0), + reader.GetString(1), + reader.IsDBNull(2) ? null : reader.GetString(2), + reader.IsDBNull(3) ? null : reader.GetString(3))); + } + + return (userVersion, entries); + } + + private static (int OrphanSymbolReferences, int OrphanReferenceLines, int OrphanSymbols, int Total) PruneOrphans(string dbPath, bool apply) + { + using var connection = OpenConnection(dbPath, writable: apply); + using var transaction = apply ? connection.BeginTransaction() : null; + + var orphanSymbolReferences = Count(connection, transaction, @" + SELECT COUNT(*) + FROM symbol_references sr + LEFT JOIN files f ON f.id = sr.file_id + LEFT JOIN reference_lines rl ON rl.id = sr.reference_line_id + WHERE f.id IS NULL + OR (sr.reference_line_id IS NOT NULL AND rl.id IS NULL)"); + var orphanReferenceLines = Count(connection, transaction, @" + SELECT COUNT(*) + FROM reference_lines rl + LEFT JOIN files f ON f.id = rl.file_id + WHERE f.id IS NULL"); + var orphanSymbols = Count(connection, transaction, @" + SELECT COUNT(*) + FROM symbols s + LEFT JOIN files f ON f.id = s.file_id + WHERE f.id IS NULL"); + + if (apply) + { + Execute(connection, transaction, @" + DELETE FROM symbol_references + WHERE file_id NOT IN (SELECT id FROM files) + OR (reference_line_id IS NOT NULL AND reference_line_id NOT IN (SELECT id FROM reference_lines))"); + Execute(connection, transaction, "DELETE FROM reference_lines WHERE file_id NOT IN (SELECT id FROM files)"); + Execute(connection, transaction, "DELETE FROM symbols WHERE file_id NOT IN (SELECT id FROM files)"); + transaction!.Commit(); + Execute(connection, null, "PRAGMA optimize"); + } + + var total = orphanSymbolReferences + orphanReferenceLines + orphanSymbols; + return (orphanSymbolReferences, orphanReferenceLines, orphanSymbols, total); + } + + private static SqliteConnection OpenConnection(string dbPath, bool writable) + { + var connectionString = dbPath.StartsWith("file:", StringComparison.OrdinalIgnoreCase) + ? $"Data Source={dbPath}" + : new SqliteConnectionStringBuilder + { + DataSource = dbPath, + Mode = writable ? SqliteOpenMode.ReadWrite : SqliteOpenMode.ReadOnly, + }.ConnectionString; + var connection = new SqliteConnection(connectionString); + connection.Open(); + return connection; + } + + private static int Count(SqliteConnection connection, SqliteTransaction? transaction, string sql) + { + using var cmd = connection.CreateCommand(); + cmd.Transaction = transaction; + cmd.CommandText = sql; + return Convert.ToInt32(cmd.ExecuteScalar(), System.Globalization.CultureInfo.InvariantCulture); + } + + private static void Execute(SqliteConnection connection, SqliteTransaction? transaction, string sql) + { + using var cmd = connection.CreateCommand(); + cmd.Transaction = transaction; + cmd.CommandText = sql; + cmd.ExecuteNonQuery(); + } + internal static DbCommandOptions ParseArgs(string[] args) { var dbPath = Path.Combine(".cdidx", "codeindex.db"); var json = false; var integrityCheck = false; + var schema = false; + var prune = false; + var pruneDryRun = false; + var pruneApply = false; string? parseError = null; for (var i = 0; i < args.Length; i++) @@ -149,6 +390,18 @@ internal static DbCommandOptions ParseArgs(string[] args) case "--integrity-check": integrityCheck = true; break; + case "schema": + schema = true; + break; + case "prune": + prune = true; + break; + case "--dry-run": + pruneDryRun = true; + break; + case "--apply": + pruneApply = true; + break; case "--help" or "-h": return new DbCommandOptions { ShowHelp = true, DbPath = dbPath, Json = json }; default: @@ -168,6 +421,10 @@ internal static DbCommandOptions ParseArgs(string[] args) DbPath = dbPath, Json = json, IntegrityCheck = integrityCheck, + Schema = schema, + Prune = prune, + PruneDryRun = pruneDryRun, + PruneApply = pruneApply, ParseError = parseError, }; } @@ -195,5 +452,9 @@ internal sealed class DbCommandOptions public bool Json { get; init; } public bool ShowHelp { get; init; } public bool IntegrityCheck { get; init; } + public bool Schema { get; init; } + public bool Prune { get; init; } + public bool PruneDryRun { get; init; } + public bool PruneApply { get; init; } public string? ParseError { get; init; } } diff --git a/src/CodeIndex/Cli/JsonOutputContracts.cs b/src/CodeIndex/Cli/JsonOutputContracts.cs index 0159343e4c..002cf8cbe7 100644 --- a/src/CodeIndex/Cli/JsonOutputContracts.cs +++ b/src/CodeIndex/Cli/JsonOutputContracts.cs @@ -39,6 +39,26 @@ internal sealed record DbIntegrityCheckJsonResult( [property: JsonPropertyName("ok")] bool Ok, [property: JsonPropertyName("issues")] List Issues); +internal sealed record DbSchemaEntryJsonResult( + [property: JsonPropertyName("type")] string Type, + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("table_name")] string? TableName, + [property: JsonPropertyName("sql")] string? Sql); + +internal sealed record DbSchemaJsonResult( + [property: JsonPropertyName("db_path")] string DbPath, + [property: JsonPropertyName("user_version")] int UserVersion, + [property: JsonPropertyName("entries")] List Entries); + +internal sealed record DbPruneJsonResult( + [property: JsonPropertyName("status")] string Status, + [property: JsonPropertyName("db_path")] string DbPath, + [property: JsonPropertyName("dry_run")] bool DryRun, + [property: JsonPropertyName("orphan_symbol_references")] int OrphanSymbolReferences, + [property: JsonPropertyName("orphan_reference_lines")] int OrphanReferenceLines, + [property: JsonPropertyName("orphan_symbols")] int OrphanSymbols, + [property: JsonPropertyName("total")] int Total); + internal sealed record DiffSummaryJsonResult( [property: JsonPropertyName("left_file_count")] long LeftFileCount, [property: JsonPropertyName("right_file_count")] long RightFileCount, @@ -275,6 +295,9 @@ internal sealed record VersionInfoJsonResult( [JsonSerializable(typeof(CompactSearchResult[]))] [JsonSerializable(typeof(CommandErrorJsonResult))] [JsonSerializable(typeof(DbIntegrityCheckJsonResult))] +[JsonSerializable(typeof(DbPruneJsonResult))] +[JsonSerializable(typeof(DbSchemaEntryJsonResult))] +[JsonSerializable(typeof(DbSchemaJsonResult))] [JsonSerializable(typeof(DefinitionResult))] [JsonSerializable(typeof(Dictionary))] [JsonSerializable(typeof(Dictionary))] diff --git a/src/CodeIndex/Cli/ProgramRunner.cs b/src/CodeIndex/Cli/ProgramRunner.cs index 5523efde4f..09ed8c3367 100644 --- a/src/CodeIndex/Cli/ProgramRunner.cs +++ b/src/CodeIndex/Cli/ProgramRunner.cs @@ -252,7 +252,7 @@ internal static int Run( "optimize" => IndexCommandRunner.RunOptimizeFts(subArgs, jsonOptions), "vacuum" => QueryCommandRunner.RunVacuum(subArgs, jsonOptions), "validate-config" => CdidxConfigFile.RunValidate(subArgs, jsonOptions), - "db" => DbCommandRunner.RunIntegrityCheck(subArgs, jsonOptions), + "db" => DbCommandRunner.Run(subArgs, jsonOptions), "report" => ReportCommandRunner.Run(subArgs, jsonOptions, appVersion), _ when IsProjectPathArg(commandName) => IndexCommandRunner.Run(args, jsonOptions), diff --git a/tests/CodeIndex.Tests/DbCommandRunnerTests.cs b/tests/CodeIndex.Tests/DbCommandRunnerTests.cs index 3c8f6faec9..d209ea1f1b 100644 --- a/tests/CodeIndex.Tests/DbCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/DbCommandRunnerTests.cs @@ -26,6 +26,25 @@ public void ParseArgs_IntegrityCheckFlagSetsFlag() Assert.Null(options.ParseError); } + [Fact] + public void ParseArgs_SchemaSubcommandSetsFlag() + { + var options = DbCommandRunner.ParseArgs(["schema"]); + + Assert.True(options.Schema); + Assert.Null(options.ParseError); + } + + [Fact] + public void ParseArgs_PruneSubcommandSetsApplyFlag() + { + var options = DbCommandRunner.ParseArgs(["prune", "--apply"]); + + Assert.True(options.Prune); + Assert.True(options.PruneApply); + Assert.Null(options.ParseError); + } + [Fact] public void ParseArgs_HelpFlagSetsShowHelp() { @@ -145,6 +164,66 @@ public void Run_CleanDb_JsonReportsOkTrueAndEmptyIssues() } } + [Fact] + public void Run_Schema_JsonIncludesTablesAndUserVersion() + { + var dbPath = Path.Combine(Path.GetTempPath(), $"cdidx_db_schema_{Guid.NewGuid():N}.db"); + try + { + using (var db = new DbContext(dbPath)) + db.InitializeSchema(); + SqliteConnection.ClearAllPools(); + + var (exitCode, json) = RunAndCaptureJson(["schema", "--db", dbPath, "--json"]); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(Path.GetFullPath(dbPath), json.GetProperty("db_path").GetString()); + Assert.True(json.TryGetProperty("user_version", out _)); + Assert.Contains(json.GetProperty("entries").EnumerateArray(), entry => + entry.GetProperty("type").GetString() == "table" && + entry.GetProperty("name").GetString() == "files"); + } + finally + { + SqliteConnection.ClearAllPools(); + if (File.Exists(dbPath)) + File.Delete(dbPath); + } + } + + [Fact] + public void Run_Prune_DryRunCountsAndApplyDeletesOrphans() + { + var dbPath = Path.Combine(Path.GetTempPath(), $"cdidx_db_prune_{Guid.NewGuid():N}.db"); + try + { + using (var db = new DbContext(dbPath)) + db.InitializeSchema(); + SeedOrphans(dbPath); + SqliteConnection.ClearAllPools(); + + var (dryRunExit, dryRunJson) = RunAndCaptureJson(["prune", "--dry-run", "--db", dbPath, "--json"]); + Assert.Equal(CommandExitCodes.Success, dryRunExit); + Assert.True(dryRunJson.GetProperty("dry_run").GetBoolean()); + Assert.Equal(3, dryRunJson.GetProperty("total").GetInt32()); + + var (applyExit, applyJson) = RunAndCaptureJson(["prune", "--apply", "--db", dbPath, "--json"]); + Assert.Equal(CommandExitCodes.Success, applyExit); + Assert.False(applyJson.GetProperty("dry_run").GetBoolean()); + Assert.Equal(3, applyJson.GetProperty("total").GetInt32()); + + var (secondExit, secondJson) = RunAndCaptureJson(["prune", "--dry-run", "--db", dbPath, "--json"]); + Assert.Equal(CommandExitCodes.Success, secondExit); + Assert.Equal(0, secondJson.GetProperty("total").GetInt32()); + } + finally + { + SqliteConnection.ClearAllPools(); + if (File.Exists(dbPath)) + File.Delete(dbPath); + } + } + [Fact] public void Run_CorruptedDb_ReturnsDatabaseError() { @@ -191,4 +270,30 @@ public void Run_CorruptedDb_ReturnsDatabaseError() using var document = JsonDocument.Parse(capture.Out!.ToString()!); return (exitCode, document.RootElement.Clone()); } + + private static void SeedOrphans(string dbPath) + { + using var connection = new SqliteConnection(new SqliteConnectionStringBuilder + { + DataSource = dbPath, + Mode = SqliteOpenMode.ReadWrite, + }.ConnectionString); + connection.Open(); + using (var pragma = connection.CreateCommand()) + { + pragma.CommandText = "PRAGMA foreign_keys=OFF"; + pragma.ExecuteNonQuery(); + } + + Execute(connection, "INSERT INTO symbols(file_id, kind, name, line) VALUES (9001, 'function', 'Orphan', 1)"); + Execute(connection, "INSERT INTO reference_lines(file_id, line, context) VALUES (9002, 1, 'missing file')"); + Execute(connection, "INSERT INTO symbol_references(file_id, symbol_name, reference_kind, reference_line_id) VALUES (9003, 'Orphan', 'call', 9004)"); + } + + private static void Execute(SqliteConnection connection, string sql) + { + using var command = connection.CreateCommand(); + command.CommandText = sql; + command.ExecuteNonQuery(); + } } From 3d1cf04786226e29d996bf4879293bbac340a006 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 31 May 2026 14:28:47 +0900 Subject: [PATCH 3/6] Address database maintenance review findings (#1644, #1728) --- src/CodeIndex/Cli/DbCommandRunner.cs | 9 +++++-- src/CodeIndex/Database/DbContext.cs | 5 +++- tests/CodeIndex.Tests/DatabaseTests.cs | 25 +++++++++++++++++++ tests/CodeIndex.Tests/DbCommandRunnerTests.cs | 6 +++-- 4 files changed, 40 insertions(+), 5 deletions(-) diff --git a/src/CodeIndex/Cli/DbCommandRunner.cs b/src/CodeIndex/Cli/DbCommandRunner.cs index 5aec6f0dc5..663cadb829 100644 --- a/src/CodeIndex/Cli/DbCommandRunner.cs +++ b/src/CodeIndex/Cli/DbCommandRunner.cs @@ -304,8 +304,9 @@ SELECT COUNT(*) FROM symbol_references sr LEFT JOIN files f ON f.id = sr.file_id LEFT JOIN reference_lines rl ON rl.id = sr.reference_line_id + LEFT JOIN files rlf ON rlf.id = rl.file_id WHERE f.id IS NULL - OR (sr.reference_line_id IS NOT NULL AND rl.id IS NULL)"); + OR (sr.reference_line_id IS NOT NULL AND (rl.id IS NULL OR rlf.id IS NULL))"); var orphanReferenceLines = Count(connection, transaction, @" SELECT COUNT(*) FROM reference_lines rl @@ -322,7 +323,11 @@ FROM symbols s Execute(connection, transaction, @" DELETE FROM symbol_references WHERE file_id NOT IN (SELECT id FROM files) - OR (reference_line_id IS NOT NULL AND reference_line_id NOT IN (SELECT id FROM reference_lines))"); + OR (reference_line_id IS NOT NULL AND reference_line_id NOT IN ( + SELECT rl.id + FROM reference_lines rl + INNER JOIN files f ON f.id = rl.file_id + ))"); Execute(connection, transaction, "DELETE FROM reference_lines WHERE file_id NOT IN (SELECT id FROM files)"); Execute(connection, transaction, "DELETE FROM symbols WHERE file_id NOT IN (SELECT id FROM files)"); transaction!.Commit(); diff --git a/src/CodeIndex/Database/DbContext.cs b/src/CodeIndex/Database/DbContext.cs index 5fc0d064bf..22517ce769 100644 --- a/src/CodeIndex/Database/DbContext.cs +++ b/src/CodeIndex/Database/DbContext.cs @@ -44,6 +44,7 @@ public class DbContext : IDisposable internal static Action? OptimizePragmaExecutedForTesting { get; set; } internal static Action? PlannerStatisticsCommandExecutedForTesting { get; set; } + internal static Action? WalCheckpointTruncateExecutedForTesting { get; set; } public SqliteConnection Connection => _connection; public bool IsReadOnly => _isReadOnly; @@ -372,6 +373,7 @@ public bool TryCheckpointWalTruncate() { using var cmd = _connection.CreateCommand(); cmd.CommandText = "PRAGMA wal_checkpoint(TRUNCATE)"; + WalCheckpointTruncateExecutedForTesting?.Invoke(_connection.DataSource); cmd.ExecuteNonQuery(); _walCheckpointSucceeded = true; return true; @@ -2457,8 +2459,9 @@ public void Dispose() // connection teardown の競合を防ぐ。 _preparedCommands?.Dispose(); _preparedCommands = null; + var hadWriteWork = _hasWriteWork; RunOptimizeOnCloseIfNeeded(); - if (_hasWriteWork) + if (hadWriteWork) TryCheckpointWalTruncate(); _connection.Dispose(); } diff --git a/tests/CodeIndex.Tests/DatabaseTests.cs b/tests/CodeIndex.Tests/DatabaseTests.cs index d03536252d..fb09177645 100644 --- a/tests/CodeIndex.Tests/DatabaseTests.cs +++ b/tests/CodeIndex.Tests/DatabaseTests.cs @@ -444,6 +444,31 @@ public void TryCheckpointWalTruncate_OnWritableDb_ReportsAttemptAndSuccess() Assert.True(_db.WalCheckpointSucceeded); } + [Fact] + public void Dispose_AfterWriteWork_AttemptsWalCheckpoint() + { + var dbPath = Path.Combine(Path.GetTempPath(), $"cdidx_checkpoint_{Guid.NewGuid():N}.db"); + var checkpointAttempted = false; + DbContext.WalCheckpointTruncateExecutedForTesting = _ => checkpointAttempted = true; + try + { + using (var db = new DbContext(dbPath)) + { + db.InitializeSchema(); + db.MarkWriteWork(); + } + + Assert.True(checkpointAttempted); + } + finally + { + DbContext.WalCheckpointTruncateExecutedForTesting = null; + SqliteConnection.ClearAllPools(); + if (File.Exists(dbPath)) + File.Delete(dbPath); + } + } + private long UpsertTestFile(string path, string checksum) => _writer.UpsertFile(new FileRecord { diff --git a/tests/CodeIndex.Tests/DbCommandRunnerTests.cs b/tests/CodeIndex.Tests/DbCommandRunnerTests.cs index d209ea1f1b..0601b9385e 100644 --- a/tests/CodeIndex.Tests/DbCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/DbCommandRunnerTests.cs @@ -205,12 +205,12 @@ public void Run_Prune_DryRunCountsAndApplyDeletesOrphans() var (dryRunExit, dryRunJson) = RunAndCaptureJson(["prune", "--dry-run", "--db", dbPath, "--json"]); Assert.Equal(CommandExitCodes.Success, dryRunExit); Assert.True(dryRunJson.GetProperty("dry_run").GetBoolean()); - Assert.Equal(3, dryRunJson.GetProperty("total").GetInt32()); + Assert.Equal(4, dryRunJson.GetProperty("total").GetInt32()); var (applyExit, applyJson) = RunAndCaptureJson(["prune", "--apply", "--db", dbPath, "--json"]); Assert.Equal(CommandExitCodes.Success, applyExit); Assert.False(applyJson.GetProperty("dry_run").GetBoolean()); - Assert.Equal(3, applyJson.GetProperty("total").GetInt32()); + Assert.Equal(4, applyJson.GetProperty("total").GetInt32()); var (secondExit, secondJson) = RunAndCaptureJson(["prune", "--dry-run", "--db", dbPath, "--json"]); Assert.Equal(CommandExitCodes.Success, secondExit); @@ -288,6 +288,8 @@ private static void SeedOrphans(string dbPath) Execute(connection, "INSERT INTO symbols(file_id, kind, name, line) VALUES (9001, 'function', 'Orphan', 1)"); Execute(connection, "INSERT INTO reference_lines(file_id, line, context) VALUES (9002, 1, 'missing file')"); Execute(connection, "INSERT INTO symbol_references(file_id, symbol_name, reference_kind, reference_line_id) VALUES (9003, 'Orphan', 'call', 9004)"); + Execute(connection, "INSERT INTO files(id, path, lang, size, lines, modified, checksum) VALUES (1, 'src/live.cs', 'csharp', 1, 1, '2026-01-01T00:00:00Z', 'live')"); + Execute(connection, "INSERT INTO symbol_references(file_id, symbol_name, reference_kind, reference_line_id) VALUES (1, 'Live', 'call', 1)"); } private static void Execute(SqliteConnection connection, string sql) From eeae66cfbfda77d540c46d4dd81e8dd90237ee3a Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 31 May 2026 14:37:11 +0900 Subject: [PATCH 4/6] Checkpoint WAL after database prune (#1728) --- src/CodeIndex/Cli/DbCommandRunner.cs | 10 ++++++++++ tests/CodeIndex.Tests/DbCommandRunnerTests.cs | 4 ++++ 2 files changed, 14 insertions(+) diff --git a/src/CodeIndex/Cli/DbCommandRunner.cs b/src/CodeIndex/Cli/DbCommandRunner.cs index 663cadb829..1ae842eab9 100644 --- a/src/CodeIndex/Cli/DbCommandRunner.cs +++ b/src/CodeIndex/Cli/DbCommandRunner.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using CodeIndex.Database; using CodeIndex.Indexer; using Microsoft.Data.Sqlite; @@ -332,6 +333,7 @@ FROM reference_lines rl Execute(connection, transaction, "DELETE FROM symbols WHERE file_id NOT IN (SELECT id FROM files)"); transaction!.Commit(); Execute(connection, null, "PRAGMA optimize"); + RunWalCheckpointTruncate(connection); } var total = orphanSymbolReferences + orphanReferenceLines + orphanSymbols; @@ -368,6 +370,14 @@ private static void Execute(SqliteConnection connection, SqliteTransaction? tran cmd.ExecuteNonQuery(); } + private static void RunWalCheckpointTruncate(SqliteConnection connection) + { + using var cmd = connection.CreateCommand(); + cmd.CommandText = "PRAGMA wal_checkpoint(TRUNCATE)"; + DbContext.WalCheckpointTruncateExecutedForTesting?.Invoke(connection.DataSource); + cmd.ExecuteNonQuery(); + } + internal static DbCommandOptions ParseArgs(string[] args) { var dbPath = Path.Combine(".cdidx", "codeindex.db"); diff --git a/tests/CodeIndex.Tests/DbCommandRunnerTests.cs b/tests/CodeIndex.Tests/DbCommandRunnerTests.cs index 0601b9385e..e8bf2c0f80 100644 --- a/tests/CodeIndex.Tests/DbCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/DbCommandRunnerTests.cs @@ -207,10 +207,13 @@ public void Run_Prune_DryRunCountsAndApplyDeletesOrphans() Assert.True(dryRunJson.GetProperty("dry_run").GetBoolean()); Assert.Equal(4, dryRunJson.GetProperty("total").GetInt32()); + var checkpointAttempted = false; + DbContext.WalCheckpointTruncateExecutedForTesting = _ => checkpointAttempted = true; var (applyExit, applyJson) = RunAndCaptureJson(["prune", "--apply", "--db", dbPath, "--json"]); Assert.Equal(CommandExitCodes.Success, applyExit); Assert.False(applyJson.GetProperty("dry_run").GetBoolean()); Assert.Equal(4, applyJson.GetProperty("total").GetInt32()); + Assert.True(checkpointAttempted); var (secondExit, secondJson) = RunAndCaptureJson(["prune", "--dry-run", "--db", dbPath, "--json"]); Assert.Equal(CommandExitCodes.Success, secondExit); @@ -218,6 +221,7 @@ public void Run_Prune_DryRunCountsAndApplyDeletesOrphans() } finally { + DbContext.WalCheckpointTruncateExecutedForTesting = null; SqliteConnection.ClearAllPools(); if (File.Exists(dbPath)) File.Delete(dbPath); From 61aff44a4737236129dee9ed71989348caa15c27 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 31 May 2026 15:19:18 +0900 Subject: [PATCH 5/6] Keep WAL checkpoint cleanup best effort (#1644 #1728) --- src/CodeIndex/Cli/DbCommandRunner.cs | 15 +++++++++++---- src/CodeIndex/Database/DbContext.cs | 2 +- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/CodeIndex/Cli/DbCommandRunner.cs b/src/CodeIndex/Cli/DbCommandRunner.cs index 1ae842eab9..3abe0987fc 100644 --- a/src/CodeIndex/Cli/DbCommandRunner.cs +++ b/src/CodeIndex/Cli/DbCommandRunner.cs @@ -372,10 +372,17 @@ private static void Execute(SqliteConnection connection, SqliteTransaction? tran private static void RunWalCheckpointTruncate(SqliteConnection connection) { - using var cmd = connection.CreateCommand(); - cmd.CommandText = "PRAGMA wal_checkpoint(TRUNCATE)"; - DbContext.WalCheckpointTruncateExecutedForTesting?.Invoke(connection.DataSource); - cmd.ExecuteNonQuery(); + try + { + using var cmd = connection.CreateCommand(); + cmd.CommandText = "PRAGMA wal_checkpoint(TRUNCATE)"; + DbContext.WalCheckpointTruncateExecutedForTesting?.Invoke(connection.DataSource); + cmd.ExecuteNonQuery(); + } + catch (Exception) + { + // WAL truncation is opportunistic cleanup. Prune has already committed. + } } internal static DbCommandOptions ParseArgs(string[] args) diff --git a/src/CodeIndex/Database/DbContext.cs b/src/CodeIndex/Database/DbContext.cs index 22517ce769..eb0a12fb1c 100644 --- a/src/CodeIndex/Database/DbContext.cs +++ b/src/CodeIndex/Database/DbContext.cs @@ -378,7 +378,7 @@ public bool TryCheckpointWalTruncate() _walCheckpointSucceeded = true; return true; } - catch (SqliteException) + catch (Exception) { _walCheckpointSucceeded = false; return false; From d642c0d1a4b88e96ee8c3e2b19780a5b2443779d Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 31 May 2026 18:19:44 +0900 Subject: [PATCH 6/6] Avoid checkpointing schema-only writes (#1644) --- src/CodeIndex/Database/DbContext.cs | 12 +++++++++--- src/CodeIndex/Database/DbWriter.cs | 2 +- tests/CodeIndex.Tests/DatabaseTests.cs | 24 ++++++++++++++++++++++++ 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/src/CodeIndex/Database/DbContext.cs b/src/CodeIndex/Database/DbContext.cs index eb0a12fb1c..780c97acba 100644 --- a/src/CodeIndex/Database/DbContext.cs +++ b/src/CodeIndex/Database/DbContext.cs @@ -40,6 +40,7 @@ public class DbContext : IDisposable private PreparedCommandCache? _preparedCommands; private bool _suppressWriteWorkTracking = true; private bool _hasWriteWork; + private bool _hasWalCheckpointableWriteWork; private bool _rebuildFtsAfterSchemaMigration; internal static Action? OptimizePragmaExecutedForTesting { get; set; } @@ -2030,7 +2031,7 @@ private void Execute(string sql) cmd.Transaction = _activeMigrationTransaction; cmd.CommandText = sql; cmd.ExecuteNonQuery(); - MarkWriteWork(); + MarkWriteWork(walCheckpointable: false); } private void EnsureForeignKeysEnabled() @@ -2414,10 +2415,14 @@ INSERT INTO codeindex_meta (key, value) VALUES ('codeindex_meta_schema_version', stamp.ExecuteNonQuery(); } - internal void MarkWriteWork() + internal void MarkWriteWork(bool walCheckpointable = true) { if (!_isReadOnly && !_suppressWriteWorkTracking) + { _hasWriteWork = true; + if (walCheckpointable) + _hasWalCheckpointableWriteWork = true; + } } internal void RunPlannerStatisticsMaintenance(bool forceAnalyze) @@ -2460,8 +2465,9 @@ public void Dispose() _preparedCommands?.Dispose(); _preparedCommands = null; var hadWriteWork = _hasWriteWork; + var hadWalCheckpointableWriteWork = _hasWalCheckpointableWriteWork; RunOptimizeOnCloseIfNeeded(); - if (hadWriteWork) + if (hadWalCheckpointableWriteWork) TryCheckpointWalTruncate(); _connection.Dispose(); } diff --git a/src/CodeIndex/Database/DbWriter.cs b/src/CodeIndex/Database/DbWriter.cs index c3b2722ff6..d1b3f42f5a 100644 --- a/src/CodeIndex/Database/DbWriter.cs +++ b/src/CodeIndex/Database/DbWriter.cs @@ -71,7 +71,7 @@ public DbWriter(DbContext context) : this( (context ?? throw new ArgumentNullException(nameof(context))).Connection, context.IsReadOnly ? null : context.PreparedCommands, - context.MarkWriteWork) + () => context.MarkWriteWork()) { } diff --git a/tests/CodeIndex.Tests/DatabaseTests.cs b/tests/CodeIndex.Tests/DatabaseTests.cs index fb09177645..6d46753d90 100644 --- a/tests/CodeIndex.Tests/DatabaseTests.cs +++ b/tests/CodeIndex.Tests/DatabaseTests.cs @@ -469,6 +469,30 @@ public void Dispose_AfterWriteWork_AttemptsWalCheckpoint() } } + [Fact] + public void Dispose_AfterSchemaInitializationOnly_DoesNotCheckpointWal() + { + var dbPath = Path.Combine(Path.GetTempPath(), $"cdidx_schema_checkpoint_{Guid.NewGuid():N}.db"); + var checkpointAttempted = false; + DbContext.WalCheckpointTruncateExecutedForTesting = _ => checkpointAttempted = true; + try + { + using (var db = new DbContext(dbPath)) + { + db.InitializeSchema(); + } + + Assert.False(checkpointAttempted); + } + finally + { + DbContext.WalCheckpointTruncateExecutedForTesting = null; + SqliteConnection.ClearAllPools(); + if (File.Exists(dbPath)) + File.Delete(dbPath); + } + } + private long UpsertTestFile(string path, string checksum) => _writer.UpsertFile(new FileRecord {