using System.Collections.Concurrent;
using Microsoft.Data.Sqlite;
namespace ClawdDotNet.Core.Storage;
///
/// Zentrale Stelle für den Zugang zur Instanz-Datenbank.
///
/// Die frühere Fassung öffnete pro Aufruf eine Verbindung ohne weitere Einstellungen —
/// ohne WAL und ohne Wartezeit bei Sperren. Sobald mehrere Agenten gleichzeitig
/// schreiben, quittiert SQLite das mit "database is locked". Das sah nach einer Grenze
/// von SQLite aus, war aber nur fehlende Konfiguration.
///
/// - WAL erlaubt beliebig viele Leser parallel zu einem Schreiber.
/// - busy_timeout lässt einen Schreiber kurz warten, statt sofort zu scheitern.
/// - Connection-Pooling vermeidet den Aufbau je Aufruf.
///
public sealed class SqliteStorage
{
private readonly string _connectionString;
private readonly SemaphoreSlim _writeGate = new(1, 1);
/// Every database opened in this process: full path → connection string.
private static readonly ConcurrentDictionary OpenedDatabases = new(StringComparer.OrdinalIgnoreCase);
public string DatabasePath { get; }
public SqliteStorage(string databasePath)
{
DatabasePath = databasePath;
var directory = Path.GetDirectoryName(Path.GetFullPath(databasePath));
if (!string.IsNullOrEmpty(directory))
Directory.CreateDirectory(directory);
_connectionString = new SqliteConnectionStringBuilder
{
DataSource = databasePath,
Mode = SqliteOpenMode.ReadWriteCreate,
Cache = SqliteCacheMode.Shared,
Pooling = true,
DefaultTimeout = 30
}.ToString();
OpenedDatabases[Path.GetFullPath(databasePath)] = _connectionString;
Initialize();
// #575: SQLite legt die Datenbank mit der Standardmaske an (auf der Test-VM 0644),
// und WAL- und Shared-Memory-Datei erben deren Rechte. Darin stehen Chatverläufe,
// Gedächtnis und die Argumente jedes Werkzeugaufrufs. Auch für bestehende Dateien,
// die noch aus der Zeit vor dieser Zeile stammen.
foreach (var datei in new[] { databasePath, databasePath + "-wal", databasePath + "-shm" })
{
if (File.Exists(datei))
AppPaths.RestrictToOwner(datei);
}
}
///
/// Closes the pooled connections of the databases at — a
/// database file, or a directory and everything below it — so the files can be deleted
/// or replaced.
///
/// Not : 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 ApplyPragmas, one in every
/// few full runs, always in a different test.
///
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()
{
var connection = new SqliteConnection(_connectionString);
connection.Open();
ApplyPragmas(connection);
return connection;
}
public async Task OpenConnectionAsync(CancellationToken ct)
{
var connection = new SqliteConnection(_connectionString);
await connection.OpenAsync(ct);
ApplyPragmas(connection);
return connection;
}
///
/// Serialisiert Schreibvorgänge im Prozess. SQLite lässt ohnehin nur einen Schreiber
/// zu — die Warteschlange hier ist verlässlicher als das Zurückweisen durch die
/// Datenbank und macht Fehlerbilder reproduzierbar.
///
public async Task WriteAsync(Func> action, CancellationToken ct)
{
await _writeGate.WaitAsync(ct);
try
{
await using var connection = await OpenConnectionAsync(ct);
return await action(connection);
}
finally
{
_writeGate.Release();
}
}
public async Task WriteAsync(Func action, CancellationToken ct)
=> await WriteAsync