-
Notifications
You must be signed in to change notification settings - Fork 1
Optimistic Locking
What this page covers: opting an entity into optimistic locking so concurrent writers can't
silently overwrite each other — how to declare a version field (@OptimisticLock, .versioned(),
or .version(...)), the validation rules enforced at build(), what OptimisticLockException
means, and exactly which backends enforce the check versus degrade silently.
Annotate one long/Long field with @OptimisticLock and the descriptor wires itself up:
import br.com.finalcraft.everydatabase.*;
import br.com.finalcraft.everydatabase.codec.JacksonJsonCodec;
import br.com.finalcraft.everydatabase.versioned.OptimisticLock;
import br.com.finalcraft.everydatabase.versioned.OptimisticLockException;
import java.util.UUID;
public class Account {
private UUID id;
private long balance;
@OptimisticLock
private Long lockVersion; // managed by the backend — never touch it manually
// getters/setters…
}
EntityDescriptor<UUID, Account> ACCOUNTS = EntityDescriptor.builder(UUID.class, Account.class)
.collection("accounts")
.keyExtractor(Account::getId)
.codec(new JacksonJsonCodec<>(Account.class))
.build(); // @OptimisticLock detected automatically — nothing else to declare
// Read, mutate, save. If someone else saved in between, the save fails.
Account acc = repo.find(id).join().orElseThrow();
acc.setBalance(acc.getBalance() - 100);
try {
repo.save(acc).join();
} catch (java.util.concurrent.CompletionException e) {
if (e.getCause() instanceof OptimisticLockException conflict) {
// reload + retry, or surface to the user
}
}The version starts at 0 on the first insert and is incremented by the backend on every successful
update. If the in-memory version no longer matches what's stored, the save fails with
OptimisticLockException instead of clobbering the newer record.
📌 Note — every I/O call returns a
CompletableFuture; the examples use.join()for brevity. A conflict surfaces as the future completing exceptionally —CompletionExceptionwhosegetCause()is theOptimisticLockException. See The Async API for the composition and error model.
This page mirrors the real test entity VersionedTestPlayer (which uses the .versioned()
interface route below).
There are no locks held on the backend and no rows are blocked. Each writer reads, works on its own
copy, and the save is the only synchronization point: it succeeds only if the stored version still
equals the one the writer started from. This is exactly what makes safe co-editing of the same
entity across applications and backends possible — two servers can both hold an Account, and the
second one to save loses cleanly with an exception instead of overwriting the first.
It is opt-in per descriptor. A descriptor without versioning keeps plain upsert semantics unchanged — last write wins, no exceptions.
Pick exactly one. Combining any two of them throws at build().
build() scans the entity class (and its superclasses, stopping before Object) for the
annotation, validates the field, and wires the getter/setter via reflection. No interface, no builder
call:
public class Account {
@OptimisticLock
private Long lockVersion; // a still-null Long reads as version 0
}💡 Tip — use
Long(boxed) and leave itnullfor never-persisted entities; the scanner reads anullversion as0. A primitivelongdefaulting to0works identically.
For when you'd rather express the contract as an interface (or can't add the annotation):
public class Account implements Versioned {
private long lockVersion = 0L;
public long getLockVersion() { return lockVersion; }
public void setLockVersion(long v) { this.lockVersion = v; }
}
EntityDescriptor<UUID, Account> ACCOUNTS = EntityDescriptor.builder(UUID.class, Account.class)
.collection("accounts")
.keyExtractor(Account::getId)
.codec(new JacksonJsonCodec<>(Account.class))
.versioned() // wires getLockVersion / setLockVersion
.build();.versioned() is just sugar for .version(v -> ((Versioned) v).getLockVersion(), (v, ver) -> ((Versioned) v).setLockVersion(ver)).
⚠️ Gotcha —.versioned()does not check thatVimplementsVersionedat build time; the cast lives inside the wired lambdas, so a mismatch surfaces as aClassCastExceptionon the first save, not atbuild(). The annotation route has no such trap.
For any pair of accessors, including ones that don't match the Versioned interface:
.version(Account::getLockVersion, Account::setLockVersion)Signature: Builder<K,V> version(Function<V, Long> getter, BiConsumer<V, Long> setter).
The scan and checks live in versioned/OptimisticLockScanner and run inside build(), so mistakes
fail fast — at descriptor construction, never deep in a save.
| Mistake | Exception |
|---|---|
Annotated field is not long/Long
|
IllegalArgumentException |
Annotated field is static
|
IllegalArgumentException |
Annotated field is final
|
IllegalArgumentException |
Two fields carry @OptimisticLock (incl. inherited) |
IllegalStateException |
@OptimisticLock combined with .versioned() / .version(...)
|
IllegalStateException |
📌 Note — the field may live on a superclass: the scanner walks the whole hierarchy (most-derived first, stopping before
Object), so an inherited version field is picked up — and still counts toward the "at most one" rule across the hierarchy.
OptimisticLockException is an unchecked RuntimeException. It carries everything you need to
decide how to recover:
public class OptimisticLockException extends RuntimeException {
public Class<?> getEntityType(); // the entity type whose save failed
public Object getKey(); // the entity's key
public long getExpectedVersion();// the version the caller held in memory
public long getActualVersion(); // the version found in the backend (-1 = row was absent)
}The typical recovery is reload + retry (or reload + merge):
CompletableFuture<Void> saveWithRetry(Repository<UUID, Account> repo, UUID id, long delta) {
return repo.find(id).thenCompose(opt -> {
Account acc = opt.orElseThrow();
acc.setBalance(acc.getBalance() + delta);
return repo.save(acc);
}).exceptionallyCompose(err -> {
Throwable cause = err instanceof CompletionException ? err.getCause() : err;
if (cause instanceof OptimisticLockException) {
return saveWithRetry(repo, id, delta); // reload the current version and try again
}
return CompletableFuture.failedFuture(err);
});
}💡 Tip — using the manager module,
CachingManager.saveAndCache(...)auto-evicts the stale cache entry on anOptimisticLockException(the exception still propagates) so the next read reloads the current backend state. See Caching & References.
Not every backend enforces the version check. Where it isn't enforced, a versioned descriptor stays
valid and simply degrades to plain upsert — no error at storage-creation time, no
OptimisticLockException ever thrown.
| Backend | Enforces the version check? | Behavior with a versioned descriptor |
|---|---|---|
| MySQL / MariaDB | ✅ |
lock_version column; conflicting update → OptimisticLockException
|
| PostgreSQL | ✅ | same as MySQL/MariaDB |
| MongoDB | ✅ | enforced; a first-insert race surfaces as OptimisticLockException too (never a raw MongoWriteException) |
| H2 | ❌ (opts out by design) | silently degrades to plain upsert; H2SqlRepository.supportsVersioning() returns false
|
| Local Files | ❌ | plain upsert |
| In-Memory | ❌ | plain upsert |
⚠️ Gotcha — H2 deliberately opts out. A versioned descriptor on H2 never throwsOptimisticLockException; it behaves like a normal upsert. This is intentional (H2 is the embedded/dev engine) and documented by a test inH2StorageTest. If you develop on H2 and deploy on MariaDB/PostgreSQL/Mongo, the enforcement appears only in production — test conflict handling against a server-grade backend. Use one when concurrent writers actually matter.
📌 Note — versioning works across backends: the version semantics are identical whether you annotated, used
.versioned(), or wired.version(...). Swapping a descriptor from MariaDB to Postgres or Mongo needs no code change; only the enforcement differs per the table above.
-
The Async API — how the conflict surfaces (
CompletionException→OptimisticLockException). -
CRUD Operations — the
save/saveAllupsert semantics versioning layers onto. - Transactions — combine a transaction with versioning for multi-entity atomic updates.
- Choosing a Backend — the capability matrix, including which backends enforce versioning.
-
Caching & References —
saveAndCacheauto-evicts the cache on a lock conflict. -
Gotchas & Pitfalls — the H2 silent-degrade trap and the
.versioned()late-cast trap.
EveryDatabase · Home · made by Petrus Pradella
Getting Started
Core Concepts
Working with Data
Backends
Manager Module
- Caching & References
- Typed References (Ref)
- Caching Managers
- Cache Policies & Freshness
- Cross-Process Cache Sync
- One Entity, Many Databases
Operations
Advanced
Reference
Contributing