PepperX.SqliteGate 1.0.0

dotnet add package PepperX.SqliteGate --version 1.0.0
                    
NuGet\Install-Package PepperX.SqliteGate -Version 1.0.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="PepperX.SqliteGate" Version="1.0.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="PepperX.SqliteGate" Version="1.0.0" />
                    
Directory.Packages.props
<PackageReference Include="PepperX.SqliteGate" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add PepperX.SqliteGate --version 1.0.0
                    
#r "nuget: PepperX.SqliteGate, 1.0.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package PepperX.SqliteGate@1.0.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=PepperX.SqliteGate&version=1.0.0
                    
Install as a Cake Addin
#tool nuget:?package=PepperX.SqliteGate&version=1.0.0
                    
Install as a Cake Tool

Part of PepperX Ecosystem

PepperX.SqliteGate Logo

PepperX.SqliteGate

NuGet Version .NET License

Stop SQLite Error 5: 'database is locked' in a multi-threaded .NET app. Writes queue, reads never block, and you configure nothing.


๐Ÿ˜– The problem

Your app works. You ship it. Then two users show up at the same time.

// Any web app under load: every request opens its own connection
await Parallel.ForEachAsync(Enumerable.Range(0, 200), async (i, ct) =>
{
    await using var connection = new SqliteConnection("Data Source=app.db");
    await connection.OpenAsync(ct);

    await using var command = connection.CreateCommand();
    command.CommandText = "INSERT INTO Todos (Title) VALUES ($title)";
    command.Parameters.AddWithValue("$title", $"Todo {i}");
    await command.ExecuteNonQueryAsync(ct);
});
Microsoft.Data.Sqlite.SqliteException (0x80004005): SQLite Error 5: 'database is locked'.

Nothing is wrong with your code. SQLite allows many readers but only one writer, and ADO.NET opens connections that know nothing about each other. Two requests, one file, one loser.

โœ… The fix

var gate = SqliteGate.For("app.db");          // โ† the only new line

await Parallel.ForEachAsync(Enumerable.Range(0, 200), async (i, ct) =>
{
    await gate.WriteAsync(async (connection, token) =>
    {
        await using var command = connection.CreateCommand();
        command.CommandText = "INSERT INTO Todos (Title) VALUES ($title)";
        command.Parameters.AddWithValue("$title", $"Todo {i}");
        await command.ExecuteNonQueryAsync(token);
    }, ct);
});

// 200 rows. Zero exceptions. No pragmas, no retry loop, no lock statement.

Both snippets are real tests that run on every build โ€” BaselineWithoutGateTests asserts the first one fails and the second one doesn't.


๐Ÿค” Why this exists

Everyone tries the same three fixes first. Here is why each one leaks.

The usual fix Why it isn't enough
PRAGMA busy_timeout=5000 Makes writers wait instead of failing โ€” but it's per connection, so it silently vanishes on every new connection your pool opens. And without WAL, a writer still blocks every reader.
lock (_sync) { โ€ฆ } around your data access Holds until one code path forgets, a background job is added, or someone calls Dapper directly. It also can't help EF Core, which opens its own connections.
A retry loop on SqliteException Turns contention into latency, and a retried half-finished transaction re-applies the rows it already wrote.

There's also a trap nobody finds until they measure: Microsoft.Data.Sqlite runs its own hidden busy-retry loop, bounded by CommandTimeout โ€” 30 seconds by default, unaffected by PRAGMA busy_timeout. Your carefully tuned settings sit behind it doing nothing. (And Default Timeout=0 means wait forever, not "don't wait" โ€” that one hangs.)

SqliteGate handles all of it in one place: one write queue per database file, WAL so reads never block, the right pragmas re-applied to every connection, and the provider's hidden timeout brought under your control.


๐ŸŽฏ Is this the package you need?

How you talk to SQLite Install
Raw ADO.NET (Microsoft.Data.Sqlite) This package โœ…
Dapper PepperX.SqliteGate.Dapper
EF Core PepperX.SqliteGate.EFCore
A mix of them Install each โ€” they share one gate per file automatically

There is no separate .Ado package: this one is the ADO.NET experience, and it is the engine the other two are built on.

Skip it if: your app only ever writes from one thread, or you need to coordinate writes between separate processes โ€” see Scope.


โšก At a glance

  • ๐Ÿšช One gate per database file, process-wide โ€” every caller shares one write queue.
  • ๐Ÿ“ Single-writer queue โ€” one write in flight per file, with observable depth.
  • ๐Ÿ“– Free concurrent reads โ€” a bounded pool of reader connections that never waits for a writer.
  • โš™๏ธ Automatic pragmas โ€” WAL, synchronous, busy_timeout, foreign_keys, on every connection.
  • ๐Ÿ” Retry with backoff and jitter โ€” for the residual contention a queue cannot remove.
  • ๐Ÿ“Š Metrics and logging โ€” queue depth, wait time, retries, through System.Diagnostics.Metrics.
  • ๐Ÿงต Async-first โ€” CancellationToken honoured while queued and while backing off. No sync-over-async.

Install

dotnet add package PepperX.SqliteGate

Getting a gate

var gate = SqliteGate.For("app.db", options =>
{
    options.BusyTimeout = TimeSpan.FromSeconds(5);
    options.MaxRetryAttempts = 5;
});

Or from dependency injection:

services.AddSqliteGate("app.db", options => options.MaxRetryAttempts = 5);

// and for an app with more than one database:
services.AddKeyedSqliteGate("orders", "orders.db");
services.AddKeyedSqliteGate("audit",  "audit.db");

Both routes hand back the same instance for the same file. That is deliberate, and it is the detail the whole library rests on โ€” see One gate per file.

Writing

await gate.WriteAsync(async (connection, ct) =>
{
    await using var command = connection.CreateCommand();
    command.CommandText = "INSERT INTO Todos (Title) VALUES ($title)";
    command.Parameters.AddWithValue("$title", "Buy milk");
    await command.ExecuteNonQueryAsync(ct);
});

The delegate runs inside an implicit transaction, and commands created from the supplied connection join it automatically. Writes can return values:

var id = await gate.WriteAsync(async (connection, ct) =>
{
    await using var command = connection.CreateCommand();
    command.CommandText = "INSERT INTO Todos (Title) VALUES ($title) RETURNING Id";
    command.Parameters.AddWithValue("$title", "Buy milk");
    return (long)(await command.ExecuteScalarAsync(ct))!;
});
Method Transaction Use it for
WriteAsync Implicit, committed for you Almost everything
WriteTransactionAsync Handed to you explicitly Libraries that need the transaction passed in โ€” Dapper, for one
WriteWithoutTransactionAsync None VACUUM, PRAGMA journal_mode, and the few statements SQLite refuses to run in a transaction

Why writes are transactional by default

Because retrying is only safe if a failed attempt leaves nothing behind. A write that inserted three rows and then met SQLITE_BUSY on the fourth would, on a naive retry, insert those three rows a second time. Wrapping the delegate means the attempt is rolled back in full before the next one starts.

WriteWithoutTransactionAsync is the exception, and it says so: with nothing to roll back, a retry re-runs your delegate over whatever the failed attempt already committed. Keep those idempotent, or set MaxRetryAttempts = 0.

Reading

var count = await gate.ReadAsync(async (connection, ct) =>
{
    await using var command = connection.CreateCommand();
    command.CommandText = "SELECT COUNT(*) FROM Todos";
    return (long)(await command.ExecuteScalarAsync(ct))!;
});

Reads borrow a connection from a bounded pool and never wait on the write queue. Under WAL a reader works from a snapshot taken when its transaction starts, so it neither waits for the writer nor makes the writer wait.

Read and write intent is always explicit

The gate will not inspect your SQL to work out which path an operation belongs on. String-matching for SELECT looks reasonable until you meet the statements where it is wrong โ€” INSERT โ€ฆ RETURNING starts with a write and produces rows; a SELECT over a virtual table can write; a CTE can hide either inside it. A misclassified write is a write that skipped the queue, which brings back exactly the errors the library exists to prevent, in the hardest possible way to debug.

So you say which you meant. ReadAsync or WriteAsync. That is the entire rule.

One gate per file: the load-bearing detail

Gates are keyed on the canonical full path of the database, in a process-wide registry.

SqliteGate.For("app.db")                        // โŽซ
SqliteGate.For("./nested/../app.db")            // โŽฌ all the same gate
SqliteGate.For("/srv/app/app.db")               // โŽช
SqliteGate.For("Data Source=app.db")            // โŽญ

If this were keyed on the string you passed, or if each package built its own, you would get several perfectly well-behaved write queues all feeding one file that accepts a single writer. Nothing would look broken: each queue's metrics would be healthy and each package's tests would pass. You would just get SQLITE_BUSY in production. RegistryTests guards every spelling above, and that dependency injection and the static entry point return the identical instance.

A value containing = is treated as a connection string; anything else is treated as a path.

The pragmas, and why they are re-applied

Pragma Scope Default here
journal_mode=WAL Database file โ€” written once into the header Required (RequireWriteAheadLog)
busy_timeout Connection 5000 ms
synchronous Connection NORMAL
foreign_keys Connection ON

Three of those four reset to SQLite's defaults on every new connection. This is why "we set our pragmas at startup" silently stops being true the moment a connection pool grows past its first connection โ€” a fresh connection arrives with foreign_keys off and no busy timeout. The gate applies them to every connection it opens, and the EF Core adapter applies them to the connections EF Core opens for itself.

WAL is not optional by default. Without it a writer blocks every reader, so free concurrent reads โ€” the library's central promise โ€” quietly stop holding. If WAL cannot be enabled (some network filesystems), the gate throws rather than degrading in silence. Set RequireWriteAheadLog = false to accept blocking reads knowingly.

Retries

Even with a queue, SQLite can still report contention: another process holds the file, a WAL checkpoint collides with a reader, a long-lived read snapshot invalidates a writer. Those are transient, so they are retried โ€” with exponential backoff and jitter โ€” and nothing else is.

options.MaxRetryAttempts = 5;                              // retries after the first attempt
options.InitialRetryDelay = TimeSpan.FromMilliseconds(50); // doubles each time
options.MaxRetryDelay = TimeSpan.FromSeconds(1);           // ceiling for one delay
options.MaxTotalRetryDuration = TimeSpan.FromSeconds(30);  // ceiling for the whole operation
options.RetryJitter = 0.25;                                // ยฑ25%, so writers don't retry in lockstep

Only SQLITE_BUSY (5) and SQLITE_LOCKED (6) are retried. A constraint violation, a syntax error or a missing table is a real answer and is rethrown immediately. When the budget runs out, the original SqliteException is rethrown rather than a wrapper, so existing error handling keeps working.

The timeout that would otherwise win

Microsoft.Data.Sqlite runs a busy-retry loop of its own, in managed code, bounded by CommandTimeout โ€” and its default is 30 seconds. It applies to plain commands and to the BEGIN issued by BeginTransaction, and PRAGMA busy_timeout does not affect it.

Left alone, that loop sits in front of everything this library configures: a contended write would block for half a minute inside the provider before the gate's retry policy ever saw a SqliteException. So the gate writes Default Timeout into its connection string, following BusyTimeout unless you set CommandTimeout explicitly.

Two sharp edges came out of measuring this, and are worth knowing if you tune it yourself: the setting is a whole number of seconds, so sub-second values round up to one; and a value of zero means wait forever, not "do not wait". The gate never writes zero.

Options

Option Default What it does
BusyTimeout 5 s PRAGMA busy_timeout on every connection
CommandTimeout follows BusyTimeout Bounds the provider's own busy-retry loop
Synchronous Normal PRAGMA synchronous
EnableForeignKeys true PRAGMA foreign_keys
RequireWriteAheadLog true Fail rather than run without WAL
MaxReaderConnections CPU count, clamped 4โ€“16 Size of the reader pool
MaxRetryAttempts 5 Retries after a contention failure
InitialRetryDelay / MaxRetryDelay 50 ms / 1 s Backoff growth and ceiling
MaxTotalRetryDuration 30 s Whole-operation retry budget
RetryJitter 0.25 Randomisation of each backoff
WriteQueueTimeout null (wait, but cancellably) Give up queuing after this long
EnableMetrics true Report to the PepperX.SqliteGate meter
LoggerFactory none Where queue waits and retries are logged
AdditionalPragmas empty Extra pragmas for every connection
TimeProvider TimeProvider.System Time source, for deterministic tests

Options are applied by whichever call creates the gate; a later caller naming the same file gets the existing one. One database cannot have two sets of concurrency settings.

Observability

Subscribe to the PepperX.SqliteGate meter โ€” for example from OpenTelemetry:

Instrument Type Meaning
sqlitegate.write_queue.depth UpDownCounter Writes queued or executing
sqlitegate.write.wait.duration Histogram (ms) Time spent queued before the slot was granted
sqlitegate.write.duration Histogram (ms) Time spent executing while holding the slot
sqlitegate.read.duration Histogram (ms) Time spent reading, including pool wait
sqlitegate.retry.count Counter Retries caused by contention
sqlitegate.operation.count Counter Completed operations, tagged read/write and success/failure

All tagged with db.name. gate.PendingWriters exposes the same queue depth synchronously.

Why a semaphore rather than a channel of write requests

Both give mutual exclusion. SemaphoreSlim(1, 1) keeps the caller on their own execution path: a channel would need a pump task, and every write would then be marshalled through a TaskCompletionSource, costing a thread hop per write, rewriting exception stack traces, and losing the ambient context (activity, culture, async-local state) that callers expect to still be there inside their delegate. WaitAsync already supports cancellation and timeout natively, and its asynchronous waiters are queued in a linked list drained in arrival order, so ordering is first-in-first-out in practice.

That last point is an implementation property rather than a documented contract. If you need strict, guaranteed FIFO โ€” a fair scheduler rather than a fast one โ€” a channel-with-pump is the design worth paying the overhead for. For serializing writes to a file, it is not.

A note on threads

SQLite has no asynchronous I/O. ExecuteNonQueryAsync and friends do their work on the calling thread and hand back an already-completed task, so a burst of concurrent gated operations genuinely occupies that many thread-pool threads for a moment. The pool injects new threads slowly once its minimum is exhausted, so a service that expects large write bursts may want to raise ThreadPool.SetMinThreads. This repository's own test host does exactly that.

In-memory databases

An :memory: database lives inside its connection: a second connection is a second, empty database. The gate detects this and serializes reads through the write slot on a single shared connection, which is correct, and a reminder that concurrency has to be proven on disk โ€” there are no file locks in memory to get wrong.

Scope

This is in-process concurrency: many threads or requests inside one running application hitting the same file. That covers the overwhelming majority of "database is locked" reports.

It is not a distributed lock manager. If a separate process โ€” a worker, a migration tool, a sqlite3 shell โ€” writes to the same file, coordination still falls to SQLite's own OS-level locking plus busy_timeout, and the gate's retry policy backs off and tries again rather than failing on the first collision. One process cannot serialize another one's writes, and no in-process library can promise otherwise. See the root README.

๐Ÿค Contributing & License

This project is part of the PepperX Ecosystem.

Licensed under the MIT License โ€” see LICENSE.

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages (2)

Showing the top 2 NuGet packages that depend on PepperX.SqliteGate:

Package Downloads
PepperX.SqliteGate.EFCore

Stop "database is locked" (SQLITE_BUSY) when SaveChangesAsync runs concurrently on SQLite. Every scoped DbContext gets its own connection, so under load they arrive together and SQLite lets exactly one of them write โ€” a failure that looks random, worsens with traffic, and never reproduces on your laptop. Change UseSqlite to UseSqliteGate and EF Core writes queue behind a single write slot while LINQ queries keep reading freely on WAL. SaveChanges (both async and sync paths), Database.Migrate and Database.EnsureCreated are all gated, including the migration lock EF Core takes before it runs any command. Works with AddDbContextPool and leaves change tracking untouched. A thin adapter over PepperX.SqliteGate: the gate is keyed on the database file, so EF Core, Dapper and raw ADO.NET in the same process share one write queue rather than colliding with each other. See the README for the two cases that need an explicit gated transaction: ExecuteUpdate/ExecuteDelete, and read-modify-write.

PepperX.SqliteGate.Dapper

Stop "database is locked" (SQLITE_BUSY) when using Dapper with SQLite. Dapper is a mapper, not a scheduler: it hands your command to a connection that has no idea another one is mid-write against the same file. This package gives you the Dapper methods you already write, as extensions on ISqliteGate instead of on a connection โ€” Query* runs on a pool of reader connections that never waits for a writer, Execute* goes through a single-writer queue inside a transaction, so a retry after contention rolls back cleanly instead of applying half the work twice. The routing is decided by the method name, never by parsing your SQL. A thin adapter over PepperX.SqliteGate: the queue, WAL setup, pragmas, retries and metrics all live there, and the gate is keyed on the database file, so Dapper, raw ADO.NET and EF Core in the same process share one write queue rather than colliding with each other.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.0.0 82 8/14/2026