|
| 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 | +} |
0 commit comments