Tests: Verbindungspools nur der eigenen Datenbank leeren statt aller

Die zufaelligen roten Laeufe der vollen Suite (jedes Mal ein anderer Test,
zuletzt PromptHistoryTests mit einer reinen Funktion) hatten eine Ursache:
Zwanzig Testklassen riefen beim Aufraeumen SqliteConnection.ClearAllPools()
auf. Das ist prozessweit und nahm einem parallel laufenden Test die
Verbindung mitten im Oeffnen weg - ObjectDisposedException in ApplyPragmas.

- SqliteStorage.ReleasePooledConnections(pfad) leert nur die Pools der
  Datenbanken unter einer Datei oder einem Verzeichnis.
- BackupService oeffnet die Quelle fuer VACUUM INTO ohne Pool: eine
  einmalige Kopie soll die Datei danach nicht offen halten.
- PoolReleaseTests: die freigegebene Datenbank laesst sich loeschen, die
  andere arbeitet weiter.

Vier volle Laeufe hintereinander gruen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-24 15:27:33 +02:00
co-authored by Claude Opus 5.5
parent f8d0ce94fe
commit 5b8656d969
23 changed files with 119 additions and 23 deletions
+4 -1
View File
@@ -157,7 +157,10 @@ public sealed class BackupService
var connectionString = new SqliteConnectionStringBuilder var connectionString = new SqliteConnectionStringBuilder
{ {
DataSource = sourcePath, DataSource = sourcePath,
Mode = SqliteOpenMode.ReadOnly Mode = SqliteOpenMode.ReadOnly,
// Eine einmalige Kopie: Aus dem Pool heraus hielte die Verbindung die Datei
// danach offen, und unter Windows ließe sie sich nicht mehr ersetzen oder löschen.
Pooling = false
}.ToString(); }.ToString();
using var conn = new SqliteConnection(connectionString); using var conn = new SqliteConnection(connectionString);
@@ -1,3 +1,4 @@
using System.Collections.Concurrent;
using Microsoft.Data.Sqlite; using Microsoft.Data.Sqlite;
namespace ClawdDotNet.Core.Storage; namespace ClawdDotNet.Core.Storage;
@@ -19,6 +20,9 @@ public sealed class SqliteStorage
private readonly string _connectionString; private readonly string _connectionString;
private readonly SemaphoreSlim _writeGate = new(1, 1); private readonly SemaphoreSlim _writeGate = new(1, 1);
/// <summary>Every database opened in this process: full path → connection string.</summary>
private static readonly ConcurrentDictionary<string, string> OpenedDatabases = new(StringComparer.OrdinalIgnoreCase);
public string DatabasePath { get; } public string DatabasePath { get; }
public SqliteStorage(string databasePath) public SqliteStorage(string databasePath)
@@ -37,6 +41,7 @@ public sealed class SqliteStorage
Pooling = true, Pooling = true,
DefaultTimeout = 30 DefaultTimeout = 30
}.ToString(); }.ToString();
OpenedDatabases[Path.GetFullPath(databasePath)] = _connectionString;
Initialize(); Initialize();
@@ -51,6 +56,33 @@ public sealed class SqliteStorage
} }
} }
/// <summary>
/// Closes the pooled connections of the databases at <paramref name="path"/> — a
/// database file, or a directory and everything below it — so the files can be deleted
/// or replaced.
///
/// <para>Not <see cref="SqliteConnection.ClearAllPools"/>: that is process-wide and also
/// takes the connections of every other database in use. The tests used it to clean up
/// their temporary databases, and a test running in parallel then lost its connection
/// while opening it — an ObjectDisposedException in <c>ApplyPragmas</c>, one in every
/// few full runs, always in a different test.</para>
/// </summary>
public static void ReleasePooledConnections(string path)
{
var full = Path.GetFullPath(path);
var below = full.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar;
foreach (var (database, connectionString) in OpenedDatabases)
{
if (!string.Equals(database, full, StringComparison.OrdinalIgnoreCase)
&& !database.StartsWith(below, StringComparison.OrdinalIgnoreCase))
continue;
using var connection = new SqliteConnection(connectionString);
SqliteConnection.ClearPool(connection);
}
}
public SqliteConnection OpenConnection() public SqliteConnection OpenConnection()
{ {
var connection = new SqliteConnection(_connectionString); var connection = new SqliteConnection(_connectionString);
@@ -28,7 +28,7 @@ public sealed class TaskAdministrationTests : IDisposable
public void Dispose() public void Dispose()
{ {
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_dir);
try { Directory.Delete(_dir, recursive: true); } try { Directory.Delete(_dir, recursive: true); }
catch { /* Aufraeumen ist Nebensache */ } catch { /* Aufraeumen ist Nebensache */ }
} }
@@ -23,7 +23,7 @@ public sealed class AuditRepositoryTests : IDisposable
public void Dispose() public void Dispose()
{ {
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); } try { Directory.Delete(_directory, recursive: true); }
catch { /* Aufräumen ist Nebensache */ } catch { /* Aufräumen ist Nebensache */ }
} }
@@ -35,7 +35,7 @@ public sealed class BackupServiceTests : IDisposable
public void Dispose() public void Dispose()
{ {
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_root);
try { Directory.Delete(_root, recursive: true); } try { Directory.Delete(_root, recursive: true); }
catch { /* Aufräumen ist Nebensache */ } catch { /* Aufräumen ist Nebensache */ }
} }
@@ -95,7 +95,7 @@ public sealed class BackupServiceTests : IDisposable
}, default); }, default);
// Verbindungen schließen, damit VACUUM INTO auf eine ruhige Datei trifft. // Verbindungen schließen, damit VACUUM INTO auf eine ruhige Datei trifft.
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_instanceDir);
} }
private string ZipPath => Path.Combine(_root, "sicherung.zip"); private string ZipPath => Path.Combine(_root, "sicherung.zip");
@@ -40,7 +40,7 @@ public sealed class LicenseBackupTests : IDisposable
public void Dispose() public void Dispose()
{ {
SecretKeyStore.UseDirectory(null); SecretKeyStore.UseDirectory(null);
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_root);
try { Directory.Delete(_root, recursive: true); } try { Directory.Delete(_root, recursive: true); }
catch { /* Aufräumen ist Nebensache */ } catch { /* Aufräumen ist Nebensache */ }
} }
@@ -25,7 +25,7 @@ public sealed class BudgetGuardTests : IDisposable
public void Dispose() public void Dispose()
{ {
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); } try { Directory.Delete(_directory, recursive: true); }
catch { /* Aufräumen ist Nebensache */ } catch { /* Aufräumen ist Nebensache */ }
} }
@@ -220,7 +220,7 @@ public sealed class UsageRepositoryTests : IDisposable
public void Dispose() public void Dispose()
{ {
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); } try { Directory.Delete(_directory, recursive: true); }
catch { } catch { }
} }
@@ -29,7 +29,7 @@ public sealed class MemoryRepositoryTests : IDisposable
public void Dispose() public void Dispose()
{ {
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); } try { Directory.Delete(_directory, recursive: true); }
catch { /* Aufräumen ist Nebensache */ } catch { /* Aufräumen ist Nebensache */ }
} }
@@ -23,7 +23,7 @@ public sealed class StagingRepositoryTests : IDisposable
public void Dispose() public void Dispose()
{ {
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); } try { Directory.Delete(_directory, recursive: true); }
catch { /* Aufräumen ist Nebensache */ } catch { /* Aufräumen ist Nebensache */ }
} }
@@ -35,7 +35,7 @@ public sealed class StagingServiceTests : IDisposable
public void Dispose() public void Dispose()
{ {
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); } try { Directory.Delete(_directory, recursive: true); }
catch { /* Aufräumen ist Nebensache */ } catch { /* Aufräumen ist Nebensache */ }
} }
@@ -33,7 +33,7 @@ public sealed class WerkzeugrechteTests : IDisposable
public void Dispose() public void Dispose()
{ {
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); } try { Directory.Delete(_directory, recursive: true); }
catch { /* Aufräumen ist Nebensache */ } catch { /* Aufräumen ist Nebensache */ }
} }
@@ -0,0 +1,61 @@
using ClawdDotNet.Core.Storage;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Storage;
/// <summary>
/// Releasing the pool of one database must leave every other database alone. The tests used
/// the process-wide ClearAllPools to clean up, and a test running in parallel lost its
/// connection in the middle of opening it — one red run in every few, always another test.
/// </summary>
public sealed class PoolReleaseTests : IDisposable
{
private readonly string _root = Path.Combine(Path.GetTempPath(), "clawd-tests", "pool-" + Guid.NewGuid().ToString("N")[..8]);
public void Dispose()
{
SqliteStorage.ReleasePooledConnections(_root);
try { Directory.Delete(_root, recursive: true); }
catch { /* cleaning up is secondary */ }
}
private static async Task TouchAsync(SqliteStorage storage)
=> await storage.WriteAsync(async connection =>
{
await using var cmd = connection.CreateCommand();
cmd.CommandText = "INSERT OR REPLACE INTO ToolState (Key, Value) VALUES ('k', 'v')";
await cmd.ExecuteNonQueryAsync();
}, default);
[Fact]
public async Task Releasing_one_directory_frees_its_files_and_nothing_else()
{
var first = new SqliteStorage(Path.Combine(_root, "a", "state.db"));
var second = new SqliteStorage(Path.Combine(_root, "b", "state.db"));
await TouchAsync(first);
await TouchAsync(second);
SqliteStorage.ReleasePooledConnections(Path.Combine(_root, "a"));
// The released database can go — on Windows an open handle would prevent that.
Should.NotThrow(() => Directory.Delete(Path.Combine(_root, "a"), recursive: true));
// The other one keeps working, and so does its pool.
await TouchAsync(second);
await using var connection = await second.OpenConnectionAsync(default);
await using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT Value FROM ToolState WHERE Key = 'k'";
(await cmd.ExecuteScalarAsync()).ShouldBe("v");
}
[Fact]
public async Task A_database_file_can_be_named_directly()
{
var path = Path.Combine(_root, "c", "state.db");
await TouchAsync(new SqliteStorage(path));
SqliteStorage.ReleasePooledConnections(path);
Should.NotThrow(() => File.Delete(path));
}
}
@@ -32,7 +32,7 @@ public sealed class BudgetMissTests : IDisposable
public void Dispose() public void Dispose()
{ {
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); } try { Directory.Delete(_directory, recursive: true); }
catch { /* cleanup is secondary */ } catch { /* cleanup is secondary */ }
} }
@@ -35,7 +35,7 @@ public sealed class CoordinationMigrationTests : IDisposable
public void Dispose() public void Dispose()
{ {
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); } try { Directory.Delete(_directory, recursive: true); }
catch { /* Aufräumen ist Nebensache */ } catch { /* Aufräumen ist Nebensache */ }
} }
@@ -30,7 +30,7 @@ public sealed class ScannerCapacityTests : IDisposable
public void Dispose() public void Dispose()
{ {
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); } try { Directory.Delete(_directory, recursive: true); }
catch { /* cleanup is secondary */ } catch { /* cleanup is secondary */ }
} }
@@ -27,7 +27,7 @@ public sealed class TaskRepositoryTests : IDisposable
public void Dispose() public void Dispose()
{ {
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); } try { Directory.Delete(_directory, recursive: true); }
catch { /* Aufräumen ist Nebensache */ } catch { /* Aufräumen ist Nebensache */ }
} }
@@ -27,7 +27,7 @@ public sealed class TaskScannerTests : IDisposable
public void Dispose() public void Dispose()
{ {
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); } try { Directory.Delete(_directory, recursive: true); }
catch { /* Aufräumen ist Nebensache */ } catch { /* Aufräumen ist Nebensache */ }
} }
@@ -28,7 +28,7 @@ public sealed class TaskboardServiceTests : IDisposable
public void Dispose() public void Dispose()
{ {
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); } try { Directory.Delete(_directory, recursive: true); }
catch { /* Aufräumen ist Nebensache */ } catch { /* Aufräumen ist Nebensache */ }
} }
@@ -26,7 +26,7 @@ public sealed class PromptHistoryTests : IDisposable
public void Dispose() public void Dispose()
{ {
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); } try { Directory.Delete(_directory, recursive: true); }
catch { /* cleanup is best effort */ } catch { /* cleanup is best effort */ }
} }
@@ -34,7 +34,7 @@ public sealed class ManagerBackupsTests : IAsyncDisposable
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
{ {
await Task.CompletedTask; await Task.CompletedTask;
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_root);
try { Directory.Delete(_root, recursive: true); } try { Directory.Delete(_root, recursive: true); }
catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ } catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ }
} }
@@ -28,7 +28,7 @@ public sealed class MemoryToolTests : IDisposable
public void Dispose() public void Dispose()
{ {
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_dir);
try { Directory.Delete(_dir, recursive: true); } try { Directory.Delete(_dir, recursive: true); }
catch { /* Aufraeumen ist Nebensache */ } catch { /* Aufraeumen ist Nebensache */ }
} }
@@ -43,7 +43,7 @@ public sealed class AssignmentReachTests : IDisposable
public void Dispose() public void Dispose()
{ {
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); } try { Directory.Delete(_directory, recursive: true); }
catch { /* cleanup is secondary */ } catch { /* cleanup is secondary */ }
} }
@@ -35,7 +35,7 @@ public sealed class DublettenTests : IDisposable
public void Dispose() public void Dispose()
{ {
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_verzeichnis);
try { Directory.Delete(_verzeichnis, recursive: true); } try { Directory.Delete(_verzeichnis, recursive: true); }
catch { /* Aufräumen ist Nebensache */ } catch { /* Aufräumen ist Nebensache */ }
} }