feat(localdb): SqliteLocalDb host (WAL, UDF-registered connections, HLC recovery, consumer SQL API)

This commit is contained in:
Joseph Doherty
2026-07-17 21:03:39 -04:00
parent fb8f751fac
commit 83f384247c
4 changed files with 339 additions and 0 deletions
@@ -0,0 +1,39 @@
using Microsoft.Data.Sqlite;
namespace ZB.MOM.WW.LocalDb;
/// <summary>
/// Consumer SQL API over the embedded SQLite local database. Every connection this
/// hands out has the <c>zb_hlc_next()</c> UDF registered, so capture triggers on
/// registered tables can stamp writes fail-closed.
/// </summary>
public interface ILocalDb
{
/// <summary>Executes a non-query statement; returns the affected row count.</summary>
Task<int> ExecuteAsync(string sql, object? parameters = null, CancellationToken ct = default);
/// <summary>Executes a query and projects each row through <paramref name="map"/>.</summary>
Task<IReadOnlyList<T>> QueryAsync<T>(string sql, Func<SqliteDataReader, T> map, object? parameters = null, CancellationToken ct = default);
/// <summary>Begins a transaction pinned to a single UDF-registered connection.</summary>
Task<ILocalDbTransaction> BeginTransactionAsync(CancellationToken ct = default);
/// <summary>Escape hatch: an open, UDF-registered connection the caller owns and disposes.</summary>
SqliteConnection CreateConnection();
}
/// <summary>A transaction scope over the local database. Dispose without commit rolls back.</summary>
public interface ILocalDbTransaction : IAsyncDisposable
{
/// <summary>Executes a non-query statement inside the transaction.</summary>
Task<int> ExecuteAsync(string sql, object? parameters = null, CancellationToken ct = default);
/// <summary>Executes a query inside the transaction.</summary>
Task<IReadOnlyList<T>> QueryAsync<T>(string sql, Func<SqliteDataReader, T> map, object? parameters = null, CancellationToken ct = default);
/// <summary>Commits the transaction.</summary>
Task CommitAsync(CancellationToken ct = default);
/// <summary>Rolls the transaction back.</summary>
Task RollbackAsync(CancellationToken ct = default);
}
@@ -0,0 +1,168 @@
using System.Collections.Concurrent;
using System.Globalization;
using System.Reflection;
using Microsoft.Data.Sqlite;
using ZB.MOM.WW.LocalDb.Hlc;
namespace ZB.MOM.WW.LocalDb.Internal;
/// <summary>
/// SQLite-backed <see cref="ILocalDb"/> host: opens/creates the file in WAL mode, ensures the
/// library schema, recovers the HLC from durable state, and hands out UDF-registered connections.
/// </summary>
internal sealed class SqliteLocalDb : ILocalDb, IDisposable
{
private static readonly ConcurrentDictionary<Type, PropertyInfo[]> PropertyCache = new();
private readonly LocalDbOptions _options;
private readonly string _connectionString;
private readonly SqliteConnection _master;
private readonly HybridLogicalClock _clock;
private readonly string _nodeId;
internal HybridLogicalClock Clock => _clock;
internal string NodeId => _nodeId;
public SqliteLocalDb(LocalDbOptions options)
{
_options = options;
_connectionString = new SqliteConnectionStringBuilder { DataSource = options.Path }.ToString();
_master = new SqliteConnection(_connectionString);
_master.Open();
ExecuteRaw(_master,
$"PRAGMA journal_mode=WAL; PRAGMA synchronous={_options.Synchronous}; " +
$"PRAGMA busy_timeout={_options.BusyTimeoutMs}; PRAGMA foreign_keys=ON;");
LocalDbSchema.EnsureCreated(_master);
var initial = ScalarLong(_master,
"SELECT MAX(" +
"(SELECT hlc_high_water FROM __localdb_meta WHERE id=1)," +
"COALESCE((SELECT MAX(hlc) FROM __localdb_oplog),0)," +
"COALESCE((SELECT MAX(hlc) FROM __localdb_row_version),0))");
_clock = new HybridLogicalClock(initial);
(_nodeId, _) = LocalDbSchema.ReadMeta(_master);
}
public SqliteConnection CreateConnection()
{
var conn = new SqliteConnection(_connectionString);
conn.Open();
// journal_mode is a durable DB property already set on the file; synchronous is per-connection.
ExecuteRaw(conn,
$"PRAGMA synchronous={_options.Synchronous}; " +
$"PRAGMA busy_timeout={_options.BusyTimeoutMs}; PRAGMA foreign_keys=ON;");
conn.CreateFunction("zb_hlc_next", () => _clock.Next());
return conn;
}
public async Task<int> ExecuteAsync(string sql, object? parameters = null, CancellationToken ct = default)
{
await using var conn = CreateConnection();
await using var cmd = conn.CreateCommand();
cmd.CommandText = sql;
Bind(cmd, parameters);
return await cmd.ExecuteNonQueryAsync(ct);
}
public async Task<IReadOnlyList<T>> QueryAsync<T>(string sql, Func<SqliteDataReader, T> map, object? parameters = null, CancellationToken ct = default)
{
await using var conn = CreateConnection();
await using var cmd = conn.CreateCommand();
cmd.CommandText = sql;
Bind(cmd, parameters);
return await ReadAllAsync(cmd, map, ct);
}
public async Task<ILocalDbTransaction> BeginTransactionAsync(CancellationToken ct = default)
{
var conn = CreateConnection();
try
{
var tx = (SqliteTransaction)await conn.BeginTransactionAsync(ct);
return new Transaction(conn, tx);
}
catch
{
await conn.DisposeAsync();
throw;
}
}
public void Dispose()
{
LocalDbSchema.FlushHlcHighWater(_master, _clock.Current);
_master.Dispose();
}
private static async Task<IReadOnlyList<T>> ReadAllAsync<T>(SqliteCommand cmd, Func<SqliteDataReader, T> map, CancellationToken ct)
{
var results = new List<T>();
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
results.Add(map((SqliteDataReader)reader));
return results;
}
private static void Bind(SqliteCommand cmd, object? parameters)
{
if (parameters is null)
return;
foreach (var prop in PropertiesOf(parameters.GetType()))
{
var value = prop.GetValue(parameters);
cmd.Parameters.AddWithValue("@" + prop.Name, value ?? DBNull.Value);
}
}
private static PropertyInfo[] PropertiesOf(Type type) =>
PropertyCache.GetOrAdd(type, static t => t.GetProperties(BindingFlags.Public | BindingFlags.Instance));
private static void ExecuteRaw(SqliteConnection conn, string sql)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = sql;
cmd.ExecuteNonQuery();
}
private static long ScalarLong(SqliteConnection conn, string sql)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = sql;
return Convert.ToInt64(cmd.ExecuteScalar(), CultureInfo.InvariantCulture);
}
private sealed class Transaction(SqliteConnection connection, SqliteTransaction transaction) : ILocalDbTransaction
{
public async Task<int> ExecuteAsync(string sql, object? parameters = null, CancellationToken ct = default)
{
await using var cmd = connection.CreateCommand();
cmd.Transaction = transaction;
cmd.CommandText = sql;
Bind(cmd, parameters);
return await cmd.ExecuteNonQueryAsync(ct);
}
public async Task<IReadOnlyList<T>> QueryAsync<T>(string sql, Func<SqliteDataReader, T> map, object? parameters = null, CancellationToken ct = default)
{
await using var cmd = connection.CreateCommand();
cmd.Transaction = transaction;
cmd.CommandText = sql;
Bind(cmd, parameters);
return await ReadAllAsync(cmd, map, ct);
}
public Task CommitAsync(CancellationToken ct = default) => transaction.CommitAsync(ct);
public Task RollbackAsync(CancellationToken ct = default) => transaction.RollbackAsync(ct);
public async ValueTask DisposeAsync()
{
await transaction.DisposeAsync();
await connection.DisposeAsync();
}
}
}
@@ -0,0 +1,14 @@
namespace ZB.MOM.WW.LocalDb;
/// <summary>Host configuration for the embedded SQLite local database.</summary>
public sealed class LocalDbOptions
{
/// <summary>Filesystem path to the SQLite database file.</summary>
public string Path { get; set; } = "";
/// <summary>SQLite <c>busy_timeout</c> pragma, in milliseconds.</summary>
public int BusyTimeoutMs { get; set; } = 5000;
/// <summary>SQLite <c>synchronous</c> pragma level (e.g. OFF / NORMAL / FULL).</summary>
public string Synchronous { get; set; } = "NORMAL";
}