Chapter 20 · SQLite Provider Deep Dive and Embedded-Database Constraints

Concurrency Without rowversion: Application Tokens, Locking Behavior, WAL Awareness, and Retries

Separate ServiceHub optimistic concurrency through its application-managed Revision token from SQLite file locking, busy/locked errors, write serialization, command timeout behavior, and WAL operational choices.

Advanced160–210 minutesoptimistic conflict + WAL/lock labEF Core 10.0.11 · SQLite provider 10.0.11 · Microsoft.Data.Sqlite 10.0.11 · .NET 10.0.11 · SDK 10.0.400Free local SQLite file · native engine version captured at runtimeProvider deep-dive reviewed: August 2026

Learning outcomes

01

Use ServiceHub Revision as an application-managed optimistic concurrency token because SQLite has no rowversion equivalent.

02

Distinguish DbUpdateConcurrencyException from SQLite SQLITE_BUSY/SQLITE_LOCKED file/transaction contention.

03

Explain why WAL can improve reader/writer coexistence without creating multiple concurrent SQLite writers.

04

Use command/default timeouts and evidence-based retry policy without layering blind retries over business conflicts.

05

Build deterministic multi-context tests for token conflicts and separate lock-contention tests for engine behavior.

06

Record journal mode, timeout, connection topology, native SQLite version, and workload before making concurrency claims.

1. Two different concurrency systems are operating at once

ServiceHub already protects WorkOrder with an application-managed Guid Revision. That is EF optimistic concurrency: the original token becomes part of the write predicate and zero affected rows becomes DbUpdateConcurrencyException. SQLite locking is different. It controls concurrent access to the database file and can produce busy/locked waits/errors before EF’s token predicate even gets a chance to execute.

Signal Layer Typical meaning Response
DbUpdateConcurrencyException EF/domain Key+original token matched zero rows Reload/merge/store-wins/client-wins/domain policy
SQLITE_BUSY / code 5 SQLite engine Database resource is busy/another writer holds required lock Wait/timeout/retry only if operation is safe
SQLITE_LOCKED / code 6 SQLite engine/connection topology Locked table/schema/shared-cache interaction Inspect topology; do not treat as lost update
Deadlock-style server error Other server providers Server lock cycle victim Provider-specific transient policy; SQLite has different locking mechanics

2. SQLite has no database-generated rowversion; keep Revision application-managed

The SQLite provider explicitly lists database-generated concurrency tokens as unsupported. The course mapping therefore remains:

csharp · existing ServiceHub concurrency mapping
builder.Property(x => x.Revision)    .HasColumnName("revision")    .IsConcurrencyToken();
csharp · domain mutation advances the token
// Existing Chapter 06 domain behavior.workOrder.ReviseSummary("Escalated after field inspection");await db.SaveChangesAsync(ct);
sql · representative SQLite update predicate
UPDATE "work_orders"SET "summary" = @p0,    "revision" = @p1WHERE "work_order_id" = @id  AND "revision" = @original_revisionRETURNING 1;

Do not map Revision as a SQL Server-style rowversion. SQLite has no engine feature that automatically changes such a token on every row update.

3. Reproduce a true optimistic conflict with two short-lived contexts

A deterministic lost-update test must let both contexts read the same original revision before the first write commits.

csharp · two contexts, same original token
await using var a = factory.CreateDbContext();await using var b = factory.CreateDbContext();var fromA = await a.WorkOrders.SingleAsync(w => w.Id == id, ct);var fromB = await b.WorkOrders.SingleAsync(w => w.Id == id, ct);var commonRevision = fromA.Revision;Debug.Assert(fromB.Revision == commonRevision);fromA.ReviseSummary("Writer A");await a.SaveChangesAsync(ct);fromB.ReviseSummary("Writer B");try{    await b.SaveChangesAsync(ct);    throw new InvalidOperationException("Expected an optimistic concurrency conflict.");}catch (DbUpdateConcurrencyException){    // Expected: writer A changed the stored revision first.}

This is not a lock timeout. Writer B successfully reaches an UPDATE whose original revision no longer matches the stored row. Chapter 13’s merge/retry policy applies.

4. SQLite serializes pending writes even when the application has many contexts

SQLite allows only one transaction to have changes pending at a time. A second writer may wait until the first transaction releases its lock and then continue, or time out. More DbContext instances do not create server-style parallel writers because they still coordinate through the same database file.

csharp · lock-contention probe with short timeout
var cs = "Data Source=servicehub-sqlite-deepdive.db;Default Timeout=1";using var first = new SqliteConnection(cs);using var second = new SqliteConnection(cs);first.Open();second.Open();using var tx = first.BeginTransaction();using (var hold = first.CreateCommand()){    hold.Transaction = tx;    hold.CommandText = "UPDATE work_orders SET customer_name = customer_name WHERE work_order_id = $id";    hold.Parameters.AddWithValue("$id", id);    hold.ExecuteNonQuery();}// A write on 'second' can wait and eventually surface SqliteException// if the first transaction holds the write lock beyond Default Timeout.

Capture SqliteException.SqliteErrorCode and SqliteExtendedErrorCode. Do not catch every database exception and label it an optimistic conflict.

5. WAL improves concurrency shape, not writer multiplicity

Write-ahead logging (WAL) lets readers continue against a stable snapshot while a writer appends to the WAL file in many common workloads. It often reduces reader/writer interference, but SQLite still serializes writes. Microsoft.Data.Sqlite recommends WAL for concurrent applications and notes that EF-created databases enable WAL by default; verify the actual journal mode rather than assuming.

csharp · verify/enable WAL on the disposable database
await using var connection = new SqliteConnection(    "Data Source=servicehub-sqlite-deepdive.db");await connection.OpenAsync(ct);await using var command = connection.CreateCommand();command.CommandText = "PRAGMA journal_mode = WAL;";var mode = (string?)await command.ExecuteScalarAsync(ct);Console.WriteLine($"journal_mode={mode}");
Do not combine WAL folklore with Cache=Shared

Microsoft.Data.Sqlite connection-string guidance discourages mixing shared-cache mode with WAL. Use normal separate connections/pooling unless a measured, documented reason says otherwise.

6. WAL adds operational files and checkpoint behavior

With WAL, SQLite can create -wal and -shm side files next to the main database. A “single file database” therefore does not mean every live deployment moment consists of exactly one file. Backups, container volume handling, disk monitoring and recovery procedures must account for SQLite’s documented backup/checkpoint behavior rather than copying the main file during arbitrary writes.

sql · observe WAL state, not just the main file
PRAGMA journal_mode;PRAGMA wal_checkpoint(PASSIVE);

Do not force aggressive checkpoints from request code without understanding latency and writer impact. For a normal small application, SQLite defaults are often reasonable; changes require measurement.

7. Microsoft.Data.Sqlite already waits/retries busy/locked operations until timeout

When Microsoft.Data.Sqlite encounters busy/locked conditions it automatically retries until the command timeout is reached. The default command timeout is 30 seconds; a value of 0 disables the timeout. Default Timeout also governs implicit commands such as transaction begin. An application-level retry loop layered blindly on top can multiply latency and repeat non-idempotent work.

csharp · configure an explicit bounded wait for this workload
var builder = new SqliteConnectionStringBuilder{    DataSource = "servicehub-sqlite-deepdive.db",    ForeignKeys = true,    DefaultTimeout = 5};services.AddDbContext<ServiceHubContext>(options =>    options.UseSqlite(builder.ToString()));

Five seconds is a lab value, not a universal recommendation. In production, choose timeouts from user/request budgets, transaction length and measured contention.

8. Retry policy differs for a lock wait and a business conflict

A busy error may be transient infrastructure contention. A DbUpdateConcurrencyException says the database state changed since this unit of work read it. Retrying the second case without merge/reload policy can overwrite a newer decision.

Case Automatic retry? Required reasoning
Busy/locked before commit Maybe, bounded and idempotent Was the operation executed? What is total latency budget? Is a shorter transaction the real fix?
Optimistic token conflict No blind retry Compare current/original/database values; apply domain merge/store/client policy
Unknown/ambiguous commit Verify before replay Was the write committed? Can business key/idempotency detect duplicates?

9. Failure case: calling every writer collision DbUpdateConcurrencyException

A test starts two long transactions and assumes the loser will throw DbUpdateConcurrencyException. Instead SQLite reports busy/locked because the second writer cannot acquire the file/database lock. The test has measured engine contention, not EF token conflict.

Repair: use ordered commits for the optimistic-token test so both actors read first and writer B executes only after writer A commits. Use a separate lock-contention test with an intentionally held transaction and short timeout. Assert different exception types and log evidence.

10. Mandatory lab: prove both concurrency mechanisms independently

  1. Record SQLite version, journal mode and timeout settings.
  2. Run the deterministic two-context Revision race and assert exactly one DbUpdateConcurrencyException.
  3. Log original/current/database revisions from the conflict entry before choosing store/client/merge policy.
  4. Run a separate raw-connection lock test: hold one write transaction, attempt a second write, and record wait duration plus SQLite error codes.
  5. Enable/verify WAL on the disposable database and repeat one reader-during-writer observation; do not claim it enables concurrent writers.
  6. Shorten the first transaction and re-measure contention before adding any application retry loop.
  7. Reset/delete the disposable database and any -wal/-shm files after all connections close.

11. Production judgment and bridge

Use optimistic tokens for business lost-update protection and SQLite timeout/WAL/transaction design for file-level concurrency. They solve different problems. Keep transactions short, capture busy/locked metrics, avoid shared DbContext concurrency, and test the actual topology. WAL can improve an embedded workload dramatically, but it does not turn SQLite into a multi-writer database server. Lesson 4 now examines another set of abstraction leaks: JSON, date/time, decimal, collation, foreign-key state and translated functions.

Check your understanding

  1. Why does ServiceHub keep an application-managed Guid Revision on SQLite?
  2. What does DbUpdateConcurrencyException mean in this chapter?
  3. Does WAL permit multiple concurrent SQLite writers?
  4. What does Microsoft.Data.Sqlite do for busy/locked conditions?
  5. Why should token conflicts and lock errors have separate tests?
  6. Why is Cache=Shared not a default WAL tuning?
Review the answers

1. SQLite has no database-generated rowversion equivalent, so the application must change the concurrency token when protected state changes.

2. EF executed a concurrency-sensitive write and the key/original-token predicate affected zero rows.

3. No. It improves reader/writer coexistence, but writes are still serialized.

4. It retries until the configured command/default timeout is reached, then surfaces the database error.

5. They arise at different layers, have different exception/evidence shapes, and require different recovery policies.

6. Microsoft.Data.Sqlite guidance discourages mixing shared-cache mode with WAL; use measured/default connection behavior unless a specific reason exists.

Authoritative references

Concurrency requires EF token semantics plus actual SQLite transaction/journal behavior.

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only Ethereum or ERC-20 compatible assets to this address.

\n