Choosing an Adapter

A decision guide across the networked, embedded, and in-memory stores, with a capability matrix for durability, host scope, and transactional enqueue.


BackWave ships five implementations of one Storage Contract, and the right one for you falls out of two questions: how many machines run your Workers, and which database you already operate. Every implementation honors the entire Contract, so they behave identically where correctness is concerned. They differ in deployment shape, durability, the mechanism that wakes a Worker when new work lands, and how much you have to provision and run.

Settle one question before the rest: do you need durability? The In-Memory Store persists nothing and runs in one process, so it cannot honor BackWave's execution guarantee. Its home is tests and local development. If you want the same no-server deployment with durability, take SQLite instead - it costs a file path.

The capability matrix#

PostgresSQL ServerOracleSQLiteIn-Memory Store
DurableYesYesYesYesNo
Host scope / scale-outNetworked, many hostsNetworked, many hostsNetworked, many hostsSingle host, many processes on itSingle process
Transactional enqueueYesYesYes, via OracleTransactionYes, co-resident onlyYes (test scope)
Wake-up / latencyCross-host hints, lowest latencyPolling onlyCross-host hints, opt-inIn-process hints, then pollingVirtual Time
SchemaA named schemaA named schemaThe owning userA filen/a
Native AOTYesNoNoNoYes
Operational overheadA Postgres serverA SQL ServerAn Oracle database (19c or later)Just a file pathNone
Intended useDefault networked choiceNetworked, SQL Server shopsNetworked, Oracle shopsSingle-host apps, edge, desktopTests and local dev

How you register a store#

There is no AddPostgres()-style shortcut. You construct a store object and hand it to the builder's UseStore. That keeps every connection-string and tuning decision in one explicit place, and it is why the adapter choice is a code decision rather than a configuration toggle.

Program.cs
services.AddBackWave(b => b
    .UseStore(new PostgresJobStore(new PostgresStoreOptions
    {
        ConnectionString = "Host=localhost;Database=app;Username=app;Password=secret",
        AutoMigrate = true, // dev convenience
    }))
    .UseJobs(BackWaveJobs.Module));

If you call AddBackWave and never call UseStore, startup throws an InvalidOperationException telling you a store is required. There is no implicit default; you choose, every time.

Durability: the headline difference#

Postgres, SQL Server, Oracle, and SQLite are all durable, Conformance-verified Storage Adapters. A Job you enqueue survives a process crash, a host restart, and a redeploy, and a Worker picks it back up. This is what makes BackWave's at-least-once execution guarantee mean anything: the guarantee rests on the store remembering the Job.

The In-Memory Store keeps all state in process memory and persists nothing. When the process exits, every Scheduled Job, every in-flight Lease, and every bit of history is gone. That is exactly what you want for a fast, deterministic test. It is also why the store cannot honor the execution guarantee: there is nothing left to pick the Job back up from. The store is a real, Conformance-verified implementation and it is thread-safe, so it will run under a production host - it just cannot make the promise the rest of BackWave makes.

If your "simple deployment" can tolerate losing in-flight work on a restart, take SQLite rather than the In-Memory Store. Both need no server and no provisioning, and SQLite gives you durability for the cost of a file path. There is little left to gain by giving that up.

Host scope and scale-out#

This is the axis that splits the durable four.

Postgres, SQL Server, and Oracle are Networked Adapters. Many Worker processes across many machines share one database safely. Claims, cluster-wide concurrency limits, and lease expiry all coordinate through the shared database, so you scale out horizontally by adding hosts that point at the same connection string.

SQLite is the Embedded Adapter, bounded to a single host. Any number of threads, pumps, and processes on that host share the file safely: concurrent claims, cluster-wide concurrency limits, and lease expiry all stay live across those processes. What you cannot do is point two separate machines at the same file and expect coordinated job processing. SQLite's file locking is unsafe over network filesystems, so putting the database on an NFS or SMB share that several machines mount is the one forbidden SQLite topology. One host, as many processes as you like; never two hosts.

The In-Memory Store is single process. Its state lives in in-process dictionaries, so it cannot coordinate across processes at all, even two on the same machine.

Wake-up and latency#

When a Job becomes due, a Worker has to notice. Polling is the source of truth for every adapter: each Worker group polls its queues on its configured interval and claims what is ready. A Wake-Up Hint is an optional, latency-only nudge on top of that. Hints are never correctness-bearing; the system behaves identically, minus some latency, if every hint is dropped. An adapter that has no notification primitive simply does not emit hints, and latency degrades gracefully to the poll interval.

This is where the four production adapters diverge, and it is the single functional asymmetry between them.

  • Postgres emits cross-host hints. It uses the database server's own notification channel, so a Job enqueued on one host can wake a Worker pump on another almost immediately. The hint is transactional: under transactional enqueue it fires on your commit, or never, so a pump is never woken to a row it cannot yet see. This is the lowest-latency option BackWave offers.
  • SQL Server has no wake-up hints. It is polling only. There is no push acceleration, so the time to pick up newly enqueued work is bounded by the Worker group's poll interval. If you run SQL Server and care about sub-poll-interval pickup latency, tighten the poll interval; there is no hint to lean on.
  • Oracle emits cross-host hints, but only when you opt in. Set EnableWakeUpHints = true and the store signals DBMS_ALERT on commit, so a pump on another host wakes in milliseconds. The option is off by default because it needs an EXECUTE grant on SYS.DBMS_ALERT and parks one extra session per pump. With it off, Oracle is polling only, like SQL Server.
  • SQLite emits in-process hints, on by default. They nudge Worker pumps in the same process the enqueue happened in. A second process on the same host gets no hint and waits for its next poll. The hints are best-effort and never fire on a transactional enqueue you commit yourself.
  • The In-Memory Store runs on Virtual Time. It is deterministic and clock-free, so there is no wall-clock latency to accelerate; the test advances the clock and the work happens.

Transactional enqueue#

Every shipped store reports the single capability flag, SupportsTransactionalEnqueue, as true. That flag governs whether EnqueueAsync can enlist in an ADO.NET transaction you already own, so the business write and the Job commit or roll back as a unit. (If you pass a transaction to a store whose flag is false, which is only possible with a custom store since all five shipped stores return true, the client throws a NotSupportedException before touching the store. The transaction parameter is a DbTransaction?.)

The flag being true is necessary but not sufficient. The deployment has to make it reachable.

  • Postgres, SQL Server, and Oracle support it fully. The Job and your business writes commit atomically, with no outbox table and no relay process, as long as BackWave's tables live in the same database your business writes target. Each adapter enlists in its provider's native transaction type and throws an ArgumentException if you hand it the wrong one.
  • SQLite is the nuanced case, and the connection string decides it. Point ConnectionString at your application's own database file and you get the co-resident deployment: Jobs and business writes share one file and one transaction, the tightest transactional enqueue there is. Point it at a separate file and you get the dedicated deployment: still fully supported and durable, but transactional enqueue is unavailable in that shape, because no single transaction spans two database engines, even though the flag still reads true.

For the co-resident SQLite case there is a guard worth knowing about. On a transactional enqueue, BackWave verifies that the connection you pass is attached to the same on-disk file it is configured to use. A mismatch throws SqliteSameFileMismatchException (a public exception carrying ConfiguredPath and ConnectionPath) rather than committing the Job invisibly into the wrong file, which would be a silent split-brain. In-memory and temporary databases are skipped by the check.

The In-Memory Store offers a transactional-enqueue scope too, but it is a test affordance, not a real database transaction.

For the working pattern, see Transactional Enqueue with EF Core.

Operational overhead#

StoreWhat you provisionEngine requirement
PostgresA Postgres server you operateNpgsql 9.0.3
SQL ServerA SQL Server you operateMicrosoft.Data.SqlClient 6.1.1
OracleAn Oracle database you operate, 19c or laterOracle.ManagedDataAccess.Core 23.9.1
SQLiteNothing beyond a file path — no server, no Docker, nothing to provisionSQLite engine 3.35.0 or newer; Microsoft.Data.Sqlite 9.0.9
In-MemoryNone — new InMemoryJobStore()None

The networked adapters cost you a database to run and operate. SQLite costs you a file: it runs in your process, manages its own write serialization in WAL mode, and forces the connection pragmas it needs without you configuring them. The In-Memory Store costs nothing.

All four durable adapters share the same schema discipline. On Oracle the schema is the owning user, so SchemaName names the user that owns the tables. BackWave verifies its schema on first use, and a missing or mismatched schema stops the Worker before any job state can be corrupted, rather than running against a schema it does not understand. You enable AutoMigrate for development convenience, or run the public migrator from your deploy pipeline for production. See Schema migrations.

Options you can set#

Each adapter takes a public sealed record options object with a required ConnectionString and a shared set of knobs:

OptionTypeDefaultMeaning
ConnectionStringstring (required)—The provider connection string. For SQLite, its path selects co-resident vs dedicated.
AutoMigrateboolfalseRun schema scripts on first use. Leave off in production.
BoundsStoreBoundsStoreBounds.DefaultSize and batch limits. The defaults are identical on every adapter. SQL Server and Oracle cap the two tag-length bounds at their column widths, 200 and 256.
HistoryPolicyJobHistoryPolicyTransitionsAndFailureDetailHow much history to record: Off, Transitions, or TransitionsAndFailureDetail.
CoordinateMigrationbooltrueMake an auto-migrate safe for a fleet that boots at once. Only applies when AutoMigrate is on. See coordinated migration.
LoggerFactoryILoggerFactory?nullTurns on the store's own log events, such as the migration log. With null, the store is silent.

Oracle adds one more: EnableWakeUpHints, off by default. The Oracle page describes it. On Oracle the two tag bounds can only be tightened, never widened past 256.

SQLite adds two more:

OptionTypeDefaultMeaning
BusyTimeoutTimeSpan5 secondsHow long a writer waits for SQLite's single write lock before giving up.
EnableInProcessHintsbooltrueThe in-process pump nudge described above; best-effort, never correctness-bearing.

SQLite allows one writer at a time. Under heavy multi-process contention a too-short BusyTimeout can cause a worker to lose its lease and a Job to run twice, which at-least-once tolerates but you would rather avoid. The generous 5-second default keeps that rare; leave it alone unless you have measured a reason not to.

The StoreBounds defaults are the same on every adapter, so they do not factor into the choice. See Limits and defaults for the full set, and Configuration for HistoryPolicy.

Putting it together#

Choose Postgres as the default networked store. You get the lowest latency through cross-host transactional hints and full transactional enqueue. Reach for it whenever you run Postgres.

Program.cs
services.AddBackWave(b => b
    .UseStore(new PostgresJobStore(new PostgresStoreOptions
    {
        ConnectionString = connectionString,
        AutoMigrate = true,
    }))
    .UseJobs(BackWaveJobs.Module));

Choose SQL Server when SQL Server is the database you already run. You get full transactional enqueue with the same atomic semantics as Postgres. The one tradeoff: no wake-up hints, so pickup latency is bounded by the poll interval.

Program.cs
services.AddBackWave(backwave =>
{
    backwave.UseStore(new SqlServerJobStore(new SqlServerStoreOptions
    {
        ConnectionString = connectionString,
    }));
});

Choose Oracle when Oracle is the database you already run. You get full transactional enqueue through OracleTransaction, and cross-host wake-up hints once you set EnableWakeUpHints. The package is not Native AOT compatible.

Program.cs
services.AddBackWave(backwave =>
{
    backwave.UseStore(new OracleJobStore(new OracleStoreOptions
    {
        ConnectionString = connectionString,
    }));
});

Choose SQLite (co-resident) for single-host applications, edge deployments, desktop apps, and small services where you want zero operational overhead and the tightest transactional enqueue there is: Jobs and business writes in one file, one transaction. Do not share the file across hosts.

Program.cs
var store = new SqliteJobStore(new SqliteStoreOptions
{
    ConnectionString = "Data Source=app.db",
    AutoMigrate = true,
});
builder.Services.AddBackWave(b => b.UseStore(store));

Choose SQLite (dedicated) — BackWave on its own file, single host — when you want durability and zero ops but your business data lives elsewhere. It is fully supported; it simply forgoes transactional enqueue, because no transaction spans two engines.

Choose the In-Memory Store for tests and local development. It is deterministic, runs on Virtual Time, needs no database, and pairs with the testing harness. It is not durable and it is single process, so it cannot carry the execution guarantee. For a deployment that needs durability, SQLite is the equally zero-ops choice.

Tests.cs
var store = new InMemoryJobStore();

Where to go next#

Found a problem on this page? Report an issue