Skip to content

Commit fabb421

Browse files
rafalmaciagclaude
andcommitted
Merge branch 'feature/uniqueness-sqlite-deployment-hardening'
SQLite deployment hardening for the Uniqueness library: WAL + busy_timeout enforced on every connection open, with a read-back guard that fails the boot if SQLite silently refuses WAL. All three assertions mutation-proven load-bearing (M32/M33/M34 each RED). 41/41. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012eBz8Xt5TA1LbF94tdbMZV
2 parents 97d076f + 4a2ba45 commit fabb421

8 files changed

Lines changed: 502 additions & 8 deletions

File tree

src/MicroPlumberd.Services.Uniqueness.Sqlite/MicroPlumberd.Services.Uniqueness.Sqlite.csproj

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,14 @@
1111

1212
<ItemGroup>
1313
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.7" />
14+
<!--
15+
Pins the native SQLite bundle off GHSA-2m69-gcr7-jv3q (High): SQLitePCLRaw.lib.e_sqlite3 <= 2.1.11
16+
has a vulnerable dependency on SQLite. EF Core Sqlite 10.0.7 resolves the family to 2.1.11
17+
transitively, so without this pin the vulnerability ships to every consumer of this package.
18+
The bundle drags core/lib/provider with it, so one pin lifts all four.
19+
Remove once Microsoft.EntityFrameworkCore.Sqlite depends on 2.1.12 or later by itself.
20+
-->
21+
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="2.1.12" />
1422
</ItemGroup>
1523

1624
<ItemGroup>

src/MicroPlumberd.Services.Uniqueness.Sqlite/ServiceCollectionExtensions.cs

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,29 +8,66 @@ namespace MicroPlumberd.Services.Uniqueness;
88
public static class SqliteUniquenessServiceCollectionExtensions
99
{
1010
/// <summary>
11-
/// Registers <see cref="IUniqueNameReservation{TCategory}"/> for a category, backed by SQLite.
11+
/// Registers <see cref="IUniqueNameReservation{TCategory}"/> for a category, backed by SQLite — the
12+
/// light, serverless option for a host whose instances share a volume. No server to run or operate.
1213
/// </summary>
1314
/// <remarks>
14-
/// <b>Development, test and single-instance hosts only — NOT production.</b> SQLite is file-local and
15-
/// single-writer, so it cannot arbitrate across replicas: each instance would enforce uniqueness
16-
/// against its own file, which amounts to no uniqueness at all. Two replicas would happily accept the
17-
/// same name. The production default is PostgreSQL — see
18-
/// <c>MicroPlumberd.Services.Uniqueness.Postgres</c>.
15+
/// <b>SAME HOST ONLY. Read the boundary below before deploying this.</b>
16+
/// <para>
17+
/// <b>SUPPORTED — one host.</b> A docker named volume or bind mount, shared by any number of containers
18+
/// on ONE machine. SQLite's POSIX advisory locks arbitrate correctly between separate processes, so N
19+
/// containers sharing the file get real uniqueness. Verified: 16 independent OS processes racing to
20+
/// insert the same value against one file left exactly one row.
21+
/// </para>
22+
/// <para>
23+
/// <b>PROHIBITED — across hosts.</b> NFS, SMB, or any cloud file share. SQLite's locking is unreliable
24+
/// on network filesystems, and the failure mode is <b>DATABASE CORRUPTION</b>, not merely lost
25+
/// uniqueness — strictly worse than the bug this library exists to prevent. Use
26+
/// <c>MicroPlumberd.Services.Uniqueness.Postgres</c> for any deployment spanning hosts.
27+
/// </para>
28+
/// <para>
29+
/// That boundary is enforced, not merely documented. This registration requires WAL, and WAL needs a
30+
/// shared-memory (-shm) file, which needs every process on one kernel — so it cannot be enabled over a
31+
/// network filesystem. A cross-host deployment therefore FAILS LOUDLY at startup instead of corrupting
32+
/// silently. (The same-host mechanism is verified; the cross-host failure is reasoned from SQLite's
33+
/// documented WAL constraints and has not been observed here.)
34+
/// </para>
35+
/// <para>
36+
/// A busy timeout is also applied to every connection, so a writer meeting a held lock waits in SQLite's
37+
/// own busy handler. Note this is defence in depth rather than a fix for instant failures:
38+
/// Microsoft.Data.Sqlite already retries a locked write at the command level for <c>Default Timeout</c>
39+
/// (30s by default), so contended writers wait even without it. What it buys is that the wait no longer
40+
/// depends on an ADO-layer retry outside SQLite's contract, and is explicit rather than inherited.
41+
/// </para>
1942
/// </remarks>
2043
/// <typeparam name="TCategory">The uniqueness category; names its table.</typeparam>
2144
/// <param name="services">The service collection.</param>
2245
/// <param name="connectionString">The SQLite connection string, e.g. <c>Data Source=uniqueness.db</c>.</param>
2346
/// <param name="ensureSchema">Create the table on start if absent (idempotent). Turn off when the
2447
/// schema is owned by migrations or the database user cannot issue DDL.</param>
48+
/// <param name="busyTimeout">How long a writer waits in SQLite's busy handler for a held lock. Default
49+
/// 5s; costs nothing when uncontended. Keep it BELOW the connection string's <c>Default Timeout</c>
50+
/// (30s unless you set it): a larger busy timeout silently defeats the command timeout for lock waits,
51+
/// because the native handler blocks inside SQLite and never surfaces the busy the provider would time
52+
/// out on.</param>
2553
/// <returns>The service collection for chaining.</returns>
2654
public static IServiceCollection AddUniquenessSqlite<TCategory>(
2755
this IServiceCollection services,
2856
string connectionString,
29-
bool ensureSchema = true)
57+
bool ensureSchema = true,
58+
TimeSpan? busyTimeout = null)
3059
{
3160
ArgumentException.ThrowIfNullOrWhiteSpace(connectionString);
3261

62+
var timeout = busyTimeout ?? TimeSpan.FromSeconds(5);
63+
if (timeout <= TimeSpan.Zero)
64+
throw new ArgumentOutOfRangeException(nameof(busyTimeout), timeout,
65+
"A busy timeout of zero means a writer fails the instant it meets a held lock, which is the " +
66+
"default this registration exists to correct.");
67+
3368
services.TryAddEnumerable(ServiceDescriptor.Singleton<IUniquenessDialect, SqliteUniquenessDialect>());
34-
return services.AddUniqueness<TCategory>(o => o.UseSqlite(connectionString), ensureSchema);
69+
return services.AddUniqueness<TCategory>(
70+
o => o.UseSqlite(connectionString).AddInterceptors(new SqlitePragmaInterceptor(timeout)),
71+
ensureSchema);
3572
}
3673
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
using System.Data.Common;
2+
using Microsoft.EntityFrameworkCore.Diagnostics;
3+
4+
namespace MicroPlumberd.Services.Uniqueness;
5+
6+
/// <summary>
7+
/// Applies the pragmas the shared-volume deployment depends on to EVERY connection: a busy timeout, then
8+
/// WAL.
9+
/// </summary>
10+
/// <remarks>
11+
/// <para>
12+
/// Why an interceptor and not the connection string: Microsoft.Data.Sqlite rejects <c>journal_mode</c> and
13+
/// <c>busy_timeout</c> as connection-string keywords outright ("Connection string keyword 'busy_timeout'
14+
/// is not supported"), so there is no declarative route. Its <c>Default Timeout</c> keyword is a different
15+
/// mechanism — an ADO-level command retry, not sqlite3_busy_timeout — and would leave
16+
/// <c>PRAGMA busy_timeout</c> reading 0.
17+
/// </para>
18+
/// <para>
19+
/// Why on every OPEN and not once at startup: <c>busy_timeout</c> is PER-CONNECTION state, so a one-shot
20+
/// pragma at boot would configure one connection and leave every later pooled connection at the default.
21+
/// <c>journal_mode</c> is by contrast PERSISTENT in the file, so setting it per-open is a cheap no-op after
22+
/// the first — worth it to keep one code path and to re-assert the WAL check below.
23+
/// </para>
24+
/// <para>
25+
/// What <c>busy_timeout</c> does and does NOT buy, MEASURED — do not restate the folklore. It is often said
26+
/// that at <c>busy_timeout=0</c> a writer meeting a held lock fails instantly. That is NOT true on this
27+
/// stack: Microsoft.Data.Sqlite retries SQLITE_BUSY at the command level for <c>Default Timeout</c>
28+
/// (default 30s), so with the shipped connection string a contended writer WAITS and succeeds even at
29+
/// <c>busy_timeout=0</c> (measured: 3.2s against a 3s lock holder). Setting it buys three real things
30+
/// instead: the wait happens in SQLite's own busy handler rather than the provider's 150ms poll loop; the
31+
/// behaviour stops depending on an ADO-layer retry that is not part of SQLite's contract; and the wait
32+
/// becomes explicit rather than inherited from an unrelated <c>Default Timeout</c> default.
33+
/// </para>
34+
/// <para>
35+
/// CAVEAT worth knowing before raising it: a busy timeout LARGER than <c>Default Timeout</c> silently
36+
/// defeats the command timeout for lock waits. The native busy handler blocks inside sqlite3_step and never
37+
/// returns SQLITE_BUSY, so the provider's timeout check never fires — measured: <c>busy_timeout=5000</c>
38+
/// with <c>Default Timeout=1</c> waited the full 3s and succeeded, while <c>busy_timeout=0</c> with the
39+
/// same command timeout failed at 1.1s. Keep the busy timeout below <c>Default Timeout</c> unless that
40+
/// override is what you want.
41+
/// </para>
42+
/// <para>
43+
/// ORDER IS LOAD-BEARING: the busy timeout is set FIRST. Switching to WAL needs a brief exclusive lock, so
44+
/// with N containers booting against one file simultaneously the WAL switch is itself contended — at the
45+
/// default timeout of 0 it would fail instantly on whichever container lost.
46+
/// </para>
47+
/// </remarks>
48+
class SqlitePragmaInterceptor(TimeSpan busyTimeout) : DbConnectionInterceptor
49+
{
50+
public override void ConnectionOpened(DbConnection connection, ConnectionEndEventData eventData)
51+
{
52+
Execute(connection, $"PRAGMA busy_timeout={(int)busyTimeout.TotalMilliseconds}");
53+
RequireWal(Execute(connection, "PRAGMA journal_mode=WAL"));
54+
}
55+
56+
public override async Task ConnectionOpenedAsync(DbConnection connection, ConnectionEndEventData eventData,
57+
CancellationToken cancellationToken = default)
58+
{
59+
await ExecuteAsync(connection, $"PRAGMA busy_timeout={(int)busyTimeout.TotalMilliseconds}", cancellationToken);
60+
RequireWal(await ExecuteAsync(connection, "PRAGMA journal_mode=WAL", cancellationToken));
61+
}
62+
63+
static object? Execute(DbConnection connection, string sql)
64+
{
65+
using var cmd = connection.CreateCommand();
66+
cmd.CommandText = sql;
67+
return cmd.ExecuteScalar();
68+
}
69+
70+
static async Task<object?> ExecuteAsync(DbConnection connection, string sql, CancellationToken cancellationToken)
71+
{
72+
await using var cmd = connection.CreateCommand();
73+
cmd.CommandText = sql;
74+
return await cmd.ExecuteScalarAsync(cancellationToken);
75+
}
76+
77+
/// <summary>
78+
/// Fails loudly when WAL did not take effect.
79+
/// </summary>
80+
/// <remarks>
81+
/// This is the point of requiring WAL. It needs shared memory (the -shm file), and shared memory needs
82+
/// all processes on ONE HOST — so WAL cannot be enabled over a network filesystem. SQLite reports that
83+
/// by leaving the journal mode alone rather than by failing, which means an unchecked WAL switch would
84+
/// hand back a working-looking database whose locking is unreliable across hosts. That failure mode is
85+
/// CORRUPTION, not merely lost uniqueness — strictly worse than the bug this library prevents — so the
86+
/// only safe response is to refuse to start.
87+
/// <para>
88+
/// In-memory databases report "memory" and are accepted: they are single-process by construction, so
89+
/// the cross-host hazard cannot arise.
90+
/// </para>
91+
/// </remarks>
92+
static void RequireWal(object? journalMode)
93+
{
94+
var mode = journalMode as string ?? journalMode?.ToString() ?? "(null)";
95+
if (string.Equals(mode, "wal", StringComparison.OrdinalIgnoreCase)) return;
96+
if (string.Equals(mode, "memory", StringComparison.OrdinalIgnoreCase)) return;
97+
98+
throw new InvalidOperationException(
99+
$"Uniqueness: SQLite refused to enable WAL on this database (journal_mode is '{mode}'). The " +
100+
"usual cause is that the file is on a NETWORK filesystem (NFS / SMB / a cloud file share), " +
101+
"where WAL's shared-memory file cannot work. SQLite's locking is unreliable there and the " +
102+
"failure mode is DATABASE CORRUPTION, so this configuration is refused rather than run. Put " +
103+
"the file on a volume local to ONE host (a docker named volume or bind mount is fine, and may " +
104+
"be shared by any number of containers on that host), or use " +
105+
"MicroPlumberd.Services.Uniqueness.Postgres for a deployment that spans hosts.");
106+
}
107+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<!-- A real OS process used by UQ-SQLITE-01. Test-only: never packed, never shipped. -->
4+
<PropertyGroup>
5+
<OutputType>Exe</OutputType>
6+
<TargetFramework>net10.0</TargetFramework>
7+
<ImplicitUsings>enable</ImplicitUsings>
8+
<Nullable>enable</Nullable>
9+
<IsPackable>false</IsPackable>
10+
<RootNamespace>MicroPlumberd.Services.Uniqueness.Tests.Worker</RootNamespace>
11+
</PropertyGroup>
12+
13+
<ItemGroup>
14+
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.7" />
15+
</ItemGroup>
16+
17+
<ItemGroup>
18+
<ProjectReference Include="..\MicroPlumberd.Services.Uniqueness.Sqlite\MicroPlumberd.Services.Uniqueness.Sqlite.csproj" />
19+
</ItemGroup>
20+
21+
</Project>
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
using Microsoft.Extensions.DependencyInjection;
2+
using Microsoft.Extensions.Hosting;
3+
using Microsoft.Extensions.Logging;
4+
using MicroPlumberd.Services.Uniqueness;
5+
6+
// A REAL OS process that reserves a name through the shipped library (UQ-SQLITE-01).
7+
//
8+
// This exists because threads cannot test what the shared-volume SQLite deployment rests on. Threads in
9+
// one process share a connection pool and an in-process lock manager; N containers on a shared volume
10+
// share NEITHER. Only separate processes exercise cross-process file locking, so this is a separate
11+
// executable rather than a Task.
12+
//
13+
// Usage: <connectionString> <name> <sourceGuid> <startAtUtcTicks>
14+
// Exit codes are the test's assertion surface — see UqExit.
15+
16+
if (args.Length != 4)
17+
{
18+
Console.Error.WriteLine("usage: <connectionString> <name> <sourceGuid> <startAtUtcTicks>");
19+
return UqExit.BadUsage;
20+
}
21+
22+
var (connectionString, name, source, startAt) =
23+
(args[0], args[1], Guid.Parse(args[2]), new DateTime(long.Parse(args[3]), DateTimeKind.Utc));
24+
25+
var builder = Host.CreateApplicationBuilder();
26+
builder.Services.AddLogging(l => l.ClearProviders());
27+
// ensureSchema:false — the test creates the table once up front. This process is here to race the
28+
// INSERT, not the DDL.
29+
builder.Services.AddUniquenessSqlite<CompanyNip>(connectionString, ensureSchema: false);
30+
31+
using var host = builder.Build();
32+
await host.StartAsync();
33+
34+
var uq = host.Services.GetRequiredService<IUniqueNameReservation<CompanyNip>>();
35+
36+
// All processes are released at the same instant, so they genuinely contend. Without this the spawn
37+
// cost (tens of ms each) would serialise them and the "race" would prove nothing.
38+
var wait = startAt - DateTime.UtcNow;
39+
if (wait > TimeSpan.Zero) await Task.Delay(wait);
40+
while (DateTime.UtcNow < startAt) { /* tighten the last moment */ }
41+
42+
try
43+
{
44+
await uq.Reserve(name, source);
45+
Console.Out.WriteLine($"WON {source}");
46+
return UqExit.Won;
47+
}
48+
catch (UniqueNameConflictException ex)
49+
{
50+
// The correct way to lose: someone else holds the name and we were told who.
51+
Console.Out.WriteLine($"CONFLICT heldBy={ex.HeldBy}");
52+
return UqExit.Conflict;
53+
}
54+
catch (Microsoft.Data.Sqlite.SqliteException ex)
55+
{
56+
// The failure mode the deployment must NOT exhibit: contention surfacing as SQLITE_BUSY (5) /
57+
// SQLITE_LOCKED (6) instead of waiting. Reported distinctly so the test can tell them apart.
58+
Console.Error.WriteLine($"SQLITE {ex.SqliteErrorCode}/{ex.SqliteExtendedErrorCode}: {ex.Message}");
59+
return UqExit.SqliteError;
60+
}
61+
catch (Exception ex)
62+
{
63+
Console.Error.WriteLine($"OTHER {ex.GetType().Name}: {ex.Message}");
64+
return UqExit.OtherError;
65+
}
66+
67+
/// <summary>Exit codes: the contract between this worker and the test that spawns it.</summary>
68+
static class UqExit
69+
{
70+
public const int Won = 0;
71+
public const int Conflict = 3;
72+
public const int SqliteError = 4; // SQLITE_BUSY and friends — a failure, never acceptable
73+
public const int OtherError = 5;
74+
public const int BadUsage = 64;
75+
}
76+
77+
/// <summary>Must match the test's category type NAME — that is what names the table.</summary>
78+
record CompanyNip;

src/MicroPlumberd.Services.Uniqueness.Tests/MicroPlumberd.Services.Uniqueness.Tests.csproj

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@
2929
<ProjectReference Include="..\MicroPlumberd.Services.Uniqueness.Postgres\MicroPlumberd.Services.Uniqueness.Postgres.csproj" />
3030
<ProjectReference Include="..\MicroPlumberd.Services.Uniqueness.SqlServer\MicroPlumberd.Services.Uniqueness.SqlServer.csproj" />
3131
<ProjectReference Include="..\MicroPlumberd.Testing\MicroPlumberd.Testing.csproj" />
32+
<!-- UQ-SQLITE-01 spawns this as real OS processes; referenced so it is always built alongside. -->
33+
<ProjectReference Include="..\MicroPlumberd.Services.Uniqueness.Tests.Worker\MicroPlumberd.Services.Uniqueness.Tests.Worker.csproj"
34+
ReferenceOutputAssembly="false" />
3235
</ItemGroup>
3336

3437
</Project>

0 commit comments

Comments
 (0)