Offline Concurrency Control
Purpose
Handle the concurrency that database transactions cannot reach. A transaction protects a
unit of work measured in milliseconds; the business problem is two people editing the same
order over ten minutes. No isolation level addresses that, and reaching for one is the most
common wrong turn in this area.
The second failure this prevents is treating a conflict as an infrastructure error: an
OptimisticLockException surfacing as a 500 with a stack trace, or being silently retried
so that the later write wins after all — which reintroduces exactly the lost update the
version column was added to stop.
The problem, precisely
t0 User A reads order v7 t0 User B reads order v7
t1 ... thinks for 4 minutes t1 edits quantity, saves → v8
t2 edits address, saves → writes over v8 with data derived from v7
Nothing here is a database anomaly: both writes are perfectly serialisable transactions.
The loss happens between them, in application time. The four patterns below are the
available answers.
The four patterns
Optimistic offline lock detect the conflict at write time by comparing a
version. No lock across think time. Conflict is a business
outcome to present, not an error to swallow.
Pessimistic offline lock prevent the conflict by recording ownership before
the edit begins. Needs an owner, an acquisition
time and abandonment recovery, because owners crash.
Coarse-grained lock one version or lock for a whole aggregate, so
related changes share one concurrency boundary;
independent edits may conflict spuriously.
Implicit lock the mechanism is applied by the framework or a
base class rather than by each developer, so it
is harder to omit — at the cost of being
invisible when it fires.
Workflow
- Establish that the conflict spans transactions. If both writes are in one
transaction, this is an isolation or row-locking question
(
enterprise-transactions), not an offline one.
- Measure or estimate the conflict rate on the actual data. Two users editing the
same order or jobs touching the same summary row have workload-dependent overlap.
Combine observed conflict frequency with the cost of discarded work and waiting.
- Choose the lock granularity from the invariant, not from the table layout: whatever
must stay consistent together should be versioned together.
- Design the conflict experience before the mechanism. What does the user see, and
what can they do about it? A pattern that produces an unusable error is not implemented.
- Make the mechanism implicit once chosen — a mapped superclass, a repository base, a
framework feature — and audit bypass paths such as bulk/native writes. Make it observable so it can
still be diagnosed.
- Verify with a concurrent test, not by reasoning. Two threads, real transactions,
synchronized after both load the same version, asserting exactly one commits.
Decision rules
Conflicts are rare; users can redo the work; edits are short
→ optimistic. Default choice; costs one column and one branch.
Conflicts are frequent, or the work lost on conflict is expensive
(a long form, a document, a manual reconciliation)
→ pessimistic. The user is told up front the record is busy,
instead of after the effort is spent.
Conflicts are frequent AND the work is cheap to redo
→ consider optimistic merge or retry when intent remains valid on fresh state.
Several people must work on different parts of one consistent whole
→ coarse-grained lock on the aggregate. Accept that they will
conflict or wait; that is the invariant's concurrency boundary.
An unattended process (batch, integration) competes with users
→ optimistic for the process too; retry only valid intent in fresh
transactions. Any pessimistic checkout needs abandonment recovery.
The mechanism can be forgotten on a new write path
→ make it implicit, and add a test that fails when a versioned
type is written by a path that bypasses it.
Rules
- Optimistic locking detects, it does not prevent. Its value is entirely in what
happens next: a conflict must reach the user or the calling system as a meaningful
outcome ("this order changed while you were editing; here is what changed"), never as a
500 and never as a silent overwrite.
- Do not blindly retry an optimistic conflict. A retry that re-reads and re-applies the
user's intent may be correct after domain revalidation and effect deduplication. A retry that re-applies the user's stale data is a lost
update with extra steps, and it is the most common misuse of
@Retryable in this area.
- A version column must be checked in the
WHERE clause of the update and the update's
affected-row count must be tested. Normal versioned entity writes get this from the ORM; hand-written SQL and bulk
updates need explicit participation. Incrementing the version invalidates old snapshots,
but does not replace a predicate protecting the bulk operation's own expected state (orm-behavioral-patterns).
- Pessimistic offline locks need ownership and abandonment recovery. A lease uses acquisition time,
expiry and safe renewal; a durable checkout may instead require explicit release plus an audited
administrative recovery procedure. Expiry is valuable but unsafe if work can outlive it without
fencing, because two owners may then act concurrently.
- Do not implement a pessimistic offline lock with a database transaction held open across
requests. It holds a pooled connection for a human's thinking time, and it will exhaust
the pool long before it will protect data.
- Lock granularity follows the invariant. Versioning rows independently reduces conflicts but can
permit combinations that violate an aggregate-wide invariant. One root version protects the
invariant but can create false conflicts between independent edits
(
domain-logic-organization).
- Coarse granularity trades throughput for correctness, and the trade is real: one version
on a hot aggregate makes its writers compete. If that hurts, measure contention and
reconsider boundaries only where the required invariant remains enforceable.
- Implicit locking is a safety property, not a convenience. Its cost is diagnosability:
when a conflict fires, the reason is in a superclass or an interceptor and not in the
code being read. Pay that cost back with logging that names the entity, the version
expected and the version found when known; a later read observes a later state.
- Optimistic locking and idempotency solve different problems and are frequently confused.
Versioning stops a stale write; an idempotency key stops a duplicate write. After the first
successful update increments the version, a duplicate carrying the old version normally fails
optimistic locking rather than returning the original result
(
idempotency).
- Test concurrency with concurrency. A unit test with a mocked repository cannot observe a
lost update; two threads against a real database can.
Before proposing a change, inspect the Java toolchain, ORM/provider, database dialect and
isolation level, client version contract and all affected write paths. Return the chosen
concurrency boundary, conflict/recovery behavior and a test exposing stale-client or
stale-owner writes. Treat missing mapping or database evidence as a reason to keep the
implementation recommendation conditional, not to assume a generic SQL/JPA guarantee.
References
- Optimistic and pessimistic offline locks —
partial Java/JPA examples, version-check SQL, conflict presentation and merge,
conditional retry, a lease protocol with ownership validation, and reproducible
integration-test recipes. Read when implementing or
reviewing either mechanism.
- Granularity, implicit locks and their failure modes
— choosing what to version together, root-version bumping for child changes, contention
and deadlock arising from lock ordering across aggregates, making locking implicit
without making it invisible, bulk-write bypasses and cache-related stale reads.
Read when conflicts are frequent, spurious, or absent when they should not be.
1---2name: offline-concurrency-control3description: Protecting data from concurrent edits that span more than one transaction: optimistic offline lock, pessimistic offline lock, coarse-grained locking at the aggregate, and implicit locking applied by the framework. Use when two users overwrite each other's edits, when a version column is being added or removed, when OptimisticLockException reaches the user as a stack trace, when a bulk update silently bypasses versioning, when a lock is held across thinking time by a database transaction, when a lock table has no expiry, or when retry is proposed as the answer to a conflict. Does not cover boundaries and isolation within one transaction (enterprise-transactions), in-process thread locking (java-memory-model), or repeat-safety of a request (idempotency).4---56# Offline Concurrency Control78## Purpose910Handle the concurrency that database transactions cannot reach. A transaction protects a11unit of work measured in milliseconds; the business problem is two people editing the same12order over ten minutes. No isolation level addresses that, and reaching for one is the most13common wrong turn in this area.1415The second failure this prevents is treating a conflict as an infrastructure error: an16`OptimisticLockException` surfacing as a 500 with a stack trace, or being silently retried17so that the later write wins after all — which reintroduces exactly the lost update the18version column was added to stop.1920## The problem, precisely2122```text23t0 User A reads order v7 t0 User B reads order v724t1 ... thinks for 4 minutes t1 edits quantity, saves → v825t2 edits address, saves → writes over v8 with data derived from v726```2728Nothing here is a database anomaly: both writes are perfectly serialisable transactions.29The loss happens between them, in application time. The four patterns below are the30available answers.3132## The four patterns3334```text35Optimistic offline lock detect the conflict at write time by comparing a36 version. No lock across think time. Conflict is a business37 outcome to present, not an error to swallow.3839Pessimistic offline lock prevent the conflict by recording ownership before40 the edit begins. Needs an owner, an acquisition41 time and abandonment recovery, because owners crash.4243Coarse-grained lock one version or lock for a whole aggregate, so44 related changes share one concurrency boundary;45 independent edits may conflict spuriously.4647Implicit lock the mechanism is applied by the framework or a48 base class rather than by each developer, so it49 is harder to omit — at the cost of being50 invisible when it fires.51```5253## Workflow54551. **Establish that the conflict spans transactions.** If both writes are in one56 transaction, this is an isolation or row-locking question57 (`enterprise-transactions`), not an offline one.582. **Measure or estimate the conflict rate** on the actual data. Two users editing the59 same order or jobs touching the same summary row have workload-dependent overlap.60 Combine observed conflict frequency with the cost of discarded work and waiting.613. **Choose the lock granularity from the invariant**, not from the table layout: whatever62 must stay consistent together should be versioned together.634. **Design the conflict experience before the mechanism.** What does the user see, and64 what can they do about it? A pattern that produces an unusable error is not implemented.655. **Make the mechanism implicit** once chosen — a mapped superclass, a repository base, a66 framework feature — and audit bypass paths such as bulk/native writes. Make it observable so it can67 still be diagnosed.686. **Verify with a concurrent test**, not by reasoning. Two threads, real transactions,69 synchronized after both load the same version, asserting exactly one commits.7071## Decision rules7273```text74Conflicts are rare; users can redo the work; edits are short75 → optimistic. Default choice; costs one column and one branch.7677Conflicts are frequent, or the work lost on conflict is expensive78(a long form, a document, a manual reconciliation)79 → pessimistic. The user is told up front the record is busy,80 instead of after the effort is spent.8182Conflicts are frequent AND the work is cheap to redo83 → consider optimistic merge or retry when intent remains valid on fresh state.8485Several people must work on different parts of one consistent whole86 → coarse-grained lock on the aggregate. Accept that they will87 conflict or wait; that is the invariant's concurrency boundary.8889An unattended process (batch, integration) competes with users90 → optimistic for the process too; retry only valid intent in fresh91 transactions. Any pessimistic checkout needs abandonment recovery.9293The mechanism can be forgotten on a new write path94 → make it implicit, and add a test that fails when a versioned95 type is written by a path that bypasses it.96```9798## Rules99100- Optimistic locking **detects**, it does not prevent. Its value is entirely in what101 happens next: a conflict must reach the user or the calling system as a meaningful102 outcome ("this order changed while you were editing; here is what changed"), never as a103 500 and never as a silent overwrite.104- **Do not blindly retry an optimistic conflict.** A retry that re-reads and re-applies the105 user's _intent_ may be correct after domain revalidation and effect deduplication. A retry that re-applies the user's _stale data_ is a lost106 update with extra steps, and it is the most common misuse of `@Retryable` in this area.107- A version column must be checked in the `WHERE` clause of the update and the update's108 affected-row count must be tested. Normal versioned entity writes get this from the ORM; hand-written SQL and bulk109 updates need explicit participation. Incrementing the version invalidates old snapshots,110 but does not replace a predicate protecting the bulk operation's own expected state (`orm-behavioral-patterns`).111- Pessimistic offline locks need ownership and abandonment recovery. A lease uses acquisition time,112 expiry and safe renewal; a durable checkout may instead require explicit release plus an audited113 administrative recovery procedure. Expiry is valuable but unsafe if work can outlive it without114 fencing, because two owners may then act concurrently.115- Do not implement a pessimistic offline lock with a database transaction held open across116 requests. It holds a pooled connection for a human's thinking time, and it will exhaust117 the pool long before it will protect data.118- Lock granularity follows the invariant. Versioning rows independently reduces conflicts but can119 permit combinations that violate an aggregate-wide invariant. One root version protects the120 invariant but can create false conflicts between independent edits121 (`domain-logic-organization`).122- Coarse granularity trades throughput for correctness, and the trade is real: one version123 on a hot aggregate makes its writers compete. If that hurts, measure contention and124 reconsider boundaries only where the required invariant remains enforceable.125- **Implicit locking is a safety property, not a convenience.** Its cost is diagnosability:126 when a conflict fires, the reason is in a superclass or an interceptor and not in the127 code being read. Pay that cost back with logging that names the entity, the version128 expected and the version found when known; a later read observes a later state.129- Optimistic locking and idempotency solve different problems and are frequently confused.130 Versioning stops a _stale_ write; an idempotency key stops a _duplicate_ write. After the first131 successful update increments the version, a duplicate carrying the old version normally fails132 optimistic locking rather than returning the original result133 (`idempotency`).134- Test concurrency with concurrency. A unit test with a mocked repository cannot observe a135 lost update; two threads against a real database can.136137Before proposing a change, inspect the Java toolchain, ORM/provider, database dialect and138isolation level, client version contract and all affected write paths. Return the chosen139concurrency boundary, conflict/recovery behavior and a test exposing stale-client or140stale-owner writes. Treat missing mapping or database evidence as a reason to keep the141implementation recommendation conditional, not to assume a generic SQL/JPA guarantee.142143## References144145- [Optimistic and pessimistic offline locks](references/optimistic-and-pessimistic.md) —146 partial Java/JPA examples, version-check SQL, conflict presentation and merge,147 conditional retry, a lease protocol with ownership validation, and reproducible148 integration-test recipes. Read when implementing or149 reviewing either mechanism.150- [Granularity, implicit locks and their failure modes](references/lock-granularity-and-implicit-locks.md)151 — choosing what to version together, root-version bumping for child changes, contention152 and deadlock arising from lock ordering across aggregates, making locking implicit153 without making it invisible, bulk-write bypasses and cache-related stale reads.154 Read when conflicts are frequent, spurious, or absent when they should not be.