Skip to content

Concurrency

Mark Lauter edited this page Jun 2, 2026 · 1 revision

Concurrency

DynamoDbLite needs no application-level lock. SQLite serializes writers for you, and the Microsoft.Data.Sqlite driver makes a blocked writer wait rather than fail. This page covers how that works and how to tune it.

How writes serialize

DynamoDbLite opens a fresh SQLite connection per operation. Concurrent calls run on separate connections against the same database — in-memory stores share one database through Cache=Shared (see Getting Started).

Two mechanisms combine to serialize concurrent writers:

  1. SQLite's single-writer lock — the mutual exclusion. SQLite allows one writer at a time. A write transaction holds the database write lock (table-level under shared cache), so two writers are never inside a write transaction at the same instant. Microsoft.Data.Sqlite's default transaction is Serializable, which issues BEGIN IMMEDIATE — a writer takes the lock when the transaction begins, not at its first write.

  2. The driver's retry loop — the waiting. SQLite does not queue the loser. When a second writer asks for a held lock, SQLite returns a lock error immediately. Microsoft.Data.Sqlite catches it and retries the statement in a loop until the holder commits and the lock frees, or until CommandTimeout (default 30 seconds) elapses and it throws.

The result: writers run one at a time, and a contender waits its turn instead of failing — as long as the holder releases within the timeout. Reads do not enter this contest; many connections read at once. The contention that matters is writer against writer.

In-memory and file stores differ

The lock class — and therefore which timeout governs the wait — depends on the store:

  • In-memory (shared cache). A write conflict raises SQLITE_LOCKED_SHAREDCACHE. SQLite's busy handler is not invoked for shared-cache locks, so busy_timeout does nothing. The driver's retry, bounded by CommandTimeout, is the only thing that makes a writer wait.
  • File-backed. A write conflict raises SQLITE_BUSY. SQLite's busy handler is invoked, so busy_timeout is the right knob. With WAL enabled, readers also proceed alongside the writer.

Best practices

  1. Don't add an application-level lock. SQLite's write lock already gives you mutual exclusion; a second lock on top is redundant.
  2. Keep write transactions short. The lock is held for the duration of the transaction, and contending writers retry the whole time it is held. Shorter transactions mean less retrying and fewer timeouts.
  3. For in-memory, tune the wait with CommandTimeout, not busy_timeout. The in-memory wait budget is the driver's retry, governed by CommandTimeout. Set it through the connection string's Default Timeout keyword — for example, Default Timeout=5 for a five-second budget. busy_timeout is inert for shared-cache in-memory.
  4. For file-backed stores, use busy_timeout. There the conflict is SQLITE_BUSY, the busy handler is invoked, and busy_timeout is the right knob. Set it with WithPragma.
  5. Know the failure mode. A writer that cannot get the lock within the timeout throws SQLITE_LOCKED (in-memory) or SQLITE_BUSY (file) rather than blocking forever. The timeout is your backpressure limit, not a guarantee of eventual success.

In short: SQLite serializes, the driver's bounded retry makes contenders wait instead of fail, you tune the wait per store type — CommandTimeout for memory, busy_timeout for file — and you keep transactions short.

See also

Clone this wiki locally