Skip to content

Optimistic Locking

Petrus Pradella edited this page Jun 18, 2026 · 2 revisions

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.


The 30-second version

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 — CompletionException whose getCause() is the OptimisticLockException. 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).


Why optimistic, not pessimistic

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.


Three ways to declare it (mutually exclusive)

Pick exactly one. Combining any two of them throws at build().

1. @OptimisticLock on a field — preferred

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 it null for never-persisted entities; the scanner reads a null version as 0. A primitive long defaulting to 0 works identically.

2. .versioned() — when the entity implements Versioned

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 that V implements Versioned at build time; the cast lives inside the wired lambdas, so a mismatch surfaces as a ClassCastException on the first save, not at build(). The annotation route has no such trap.

3. .version(getter, setter) — fully explicit

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).


Validation rules (all enforced at build())

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.


What happens on a conflict

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 an OptimisticLockException (the exception still propagates) so the next read reloads the current backend state. See Caching & References.


Backend enforcement matrix

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

⚠️ GotchaH2 deliberately opts out. A versioned descriptor on H2 never throws OptimisticLockException; it behaves like a normal upsert. This is intentional (H2 is the embedded/dev engine) and documented by a test in H2StorageTest. 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.


See also

  • The Async API — how the conflict surfaces (CompletionExceptionOptimisticLockException).
  • CRUD Operations — the save/saveAll upsert 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 & ReferencessaveAndCache auto-evicts the cache on a lock conflict.
  • Gotchas & Pitfalls — the H2 silent-degrade trap and the .versioned() late-cast trap.

Clone this wiki locally