Storage providers
Millrace's core package has no database dependencies at all. Storage is a contract, and a provider is a package that implements it. Choosing one is the only decision Millrace forces on you at registration.
If you want to implement the contract yourself, see Writing a provider.
What ships today
| Provider | Package | Use it for |
|---|---|---|
| In-memory | Millrace (built in) |
Development, samples, tests. Not durable. |
| SQLite | Millrace.Storage.Sqlite |
Durability without a server: single-node deployments, development, tests that outlive a restart. |
| PostgreSQL | Millrace.Storage.PostgreSql |
Production. The best queue semantics of the four. |
| SQL Server | Millrace.Storage.SqlServer |
Production, where SQL Server is what you already run. |
All four pass the same conformance suite. The differences below are about performance and operational shape, not correctness.
In-memory
builder.Services.AddMillrace(millrace => millrace.UseInMemoryStorage());
Included in the core package. It is a real implementation — it passes the same conformance suite the SQL providers do — and it holds everything in process memory, so a restart loses the lot.
Right for: development, the sample, unit and integration tests (though
Millrace.Testing is better for those). Wrong for anything you care about.
SQLite
dotnet add package Millrace.Storage.Sqlite
builder.Services.AddMillrace(millrace => millrace.UseSqliteStorage("Data Source=millrace.db"));
The gap between in-memory and running a database server. Jobs survive a restart, there is nothing to deploy or operate, and the whole store is one file you can copy, back up or delete.
The trade is concurrency. SQLite has one writer at a time and no row locks, so claims cannot step
over each other the way SKIP LOCKED and READPAST do — instead every write path takes the writer
lock up front and the second claimer waits. That is still exclusive, which is all the contract asks,
and it is why the SQLite provider passes the same suite. But throughput does not improve by adding
workers the way it does on PostgreSQL, and past a certain MaxParallelism they simply queue.
Right for: single-node deployments, desktop and edge applications, CI, and development where you want a restart to keep its jobs. Wrong for: several application nodes sharing one store, or any workload where write contention is the bottleneck. That is the point to move to PostgreSQL — the storage contract is the same, so it is a registration change.
Options
millrace.UseSqliteStorage("Data Source=millrace.db", options =>
{
options.AutoCreateSchema = true; // default
options.UseWriteAheadLog = true; // default
options.BusyTimeout = TimeSpan.FromSeconds(30); // default
});
UseWriteAheadLog lets reads run alongside the single writer, which is what keeps the dashboard from
queueing behind a claim. Turn it off only where WAL cannot work — it needs shared memory, so some
network file systems refuse it.
BusyTimeout is how long a connection waits for the writer lock before failing. It is a contention
budget rather than a statement timeout: every write here is a short transaction, so waiting is almost
always better than surfacing an error to a worker that would just retry. If claims start timing out,
that is the signal to move to a server-backed provider rather than to raise it further.
In-memory databases
Data Source=:memory: works, and the provider holds one connection open for its lifetime so the
database survives between operations. It is durable in the sense that nothing is lost while the
process lives, and lost entirely when it exits — so it is a curiosity next to
Millrace.Testing, which is what you actually want for tests.
Wakeups are in-process
The provider advertises the notification capability, but the channel is in-process: SQLite has no cross-process notification mechanism. One application sees pushed wakeups; a second process sharing the same file falls back to its poll interval. That is a latency difference and nothing more — notifications are best-effort by contract, and a worker's liveness rests on the poll ceiling rather than on any signal arriving.
PostgreSQL
dotnet add package Millrace.Storage.PostgreSql
builder.Services.AddMillrace(millrace => millrace.UsePostgreSqlStorage(connectionString));
The recommended provider, for two concrete reasons:
FOR UPDATE SKIP LOCKEDgives an exclusive claim without contention. Workers step over each other's locked rows instead of queueing behind them, so adding workers adds throughput.LISTEN/NOTIFYlets storage push a wakeup when work arrives. Workers do not have to poll to discover a new job, which is most of the latency difference between the two SQL providers.
Options
millrace.UsePostgreSqlStorage(connectionString, options =>
{
options.Schema = "millrace"; // default
options.AutoCreateSchema = true; // default
});
Bringing your own data source
The single-argument overload creates and owns an NpgsqlDataSource. An application that already
builds its own — to configure logging, type mappings or pooling — should use the factory overload,
so there is one data source rather than two:
millrace.UsePostgreSqlStorage(sp => sp.GetRequiredService<NpgsqlDataSource>());
SQL Server
dotnet add package Millrace.Storage.SqlServer
builder.Services.AddMillrace(millrace => millrace.UseSqlServerStorage(connectionString));
Claims use READPAST for the same non-blocking exclusivity as PostgreSQL's SKIP LOCKED.
The difference is wakeups: SQL Server has no LISTEN/NOTIFY, so the provider advertises no
notification capability and workers fall back to adaptive polling — fast while busy, backing off to
a ceiling when idle. What differs from PostgreSQL is wakeup latency, not correctness. Tune it
with MinPollDelay and MaxPollDelay if the default idle ceiling of 5 seconds is too slow for you.
Options
millrace.UseSqlServerStorage(connectionString, options =>
{
options.Schema = "millrace"; // default
options.AutoCreateSchema = true; // default
});
Schema management
All three durable providers create and upgrade their schema on startup when AutoCreateSchema is
left on. Upgrades are idempotent — safe to run from every node in a deployment simultaneously, and
safe to run against a database that is already current.
Set AutoCreateSchema = false where your organisation requires migrations to be applied by a
separate process with elevated rights. You are then responsible for the schema being present and
current before the application starts.
Registration is last-wins
Calling a Use...Storage method after another replaces it rather than conflicting. That is what
lets a test host override whatever the composition root configured:
builder.Services.AddMillrace(millrace =>
{
millrace.UsePostgreSqlStorage(connectionString);
millrace.UseInMemoryStorage(); // wins
});
What a provider has to guarantee
Worth knowing even if you never write one, because it is what makes any of the above safe:
- Claim is exclusive. Two concurrent claims never return the same job.
- Claim sets a lease. A claimed job is invisible to others until it expires; expired leases make it claimable again, which is how a crashed worker's jobs come back.
- Transitions are all-or-nothing. State change plus side effects commit together or not at all.
- A bookmark is consumed at most once, so a signal resumes exactly one waiting instance.
- Due recurring jobs are fenced, so an occurrence fires on exactly one node.
- Idempotency keys are unique among active jobs, scoped by tenant.
- A workflow checkpoint commits with its transition, in the same atom.
None of this is prose the provider author is trusted to have read. It is an executable conformance kit, and "supported" means "passes the suite".
Roadmap
Four providers ship, and the fourth was the interesting one: SQLite has no SKIP LOCKED, one writer
and no server, so it was the cheapest test of whether the storage contract frozen in 1.0 could still
be implemented by something shaped differently. It could, unchanged.
Community providers — Mongo, Redis, others — became viable the day the conformance kit shipped: the bar is public, executable, and the same one the official providers clear.