Tutorials/Optimistic locking

Optimistic locking with immutable entities

Two users edit the same record; the second save must not silently erase the first. Both JPA and Storm solve this with a version column. The difference is where the check runs and what you hold in your hands when it fails.

Series · JPA to Storm5 min readKotlin

01The task

An owner record is open in two browser tabs. Both edit, both save. Without a guard, the last write wins and the first edit vanishes without a trace. Optimistic locking detects the collision and turns it into an error you can handle.

02The JPA way

JPA solves this with @Version on a managed entity. The mechanics work, but notice where everything happens:

OwnerService.kt Kotlin · JPA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Entity
class Owner(
    @Id @GeneratedValue var id: Int? = null,
    var firstName: String = "",
    var lastName: String = "",
    @Version var version: Int = 0,
)

@Transactional
fun rename(id: Int, lastName: String) {
    val owner = ownerRepository.findById(id).orElseThrow()
    owner.lastName = lastName
    // no save call: the UPDATE happens at flush, the version check
    // at commit, and a conflict surfaces far from this line
}

The write is implicit: mutating the managed entity schedules an UPDATE for whenever the persistence context flushes. The version check runs at that flush, so the failure appears at commit time, at the end of the transaction, often wrapped by Spring into ObjectOptimisticLockingFailureException somewhere above the code that made the change. And because the check belongs to the session, handling a conflict across requests means detached entities and merge(), which is its own chapter of subtleties.

03The Storm way

Storm has no flush and no managed state, so the version check happens in the only place left: the UPDATE statement itself, on the line where you call it:

OwnerService.kt Kotlin · Storm Show SQL
1
2
3
4
5
6
7
8
9
10
11
12
data class Owner(
    @PK val id: Int = 0,
    val firstName: String,
    val lastName: String,
    @Version val version: Int = 0,
) : Entity<Int>

val owners = orm.entity<Owner, _>()

val owner = owners.getById(1)
val updated = owners.updateAndFetch(owner.copy(lastName = "Smith"))
// the UPDATE ran here, on this line, and updated.version is incremented
generated sql
-- updateAndFetch(): the version is part of the WHERE clause
UPDATE owner SET first_name = ?, last_name = ?, version = ?
WHERE id = ? AND version = ?
-- zero rows matched means another writer won: OptimisticLockException, thrown here

The update is explicit, the exception is immediate, and there is no session for the failure to hide behind. copy() expresses the change; updateAndFetch() returns the new state with the incremented version, ready for the next edit.

04Handling the conflict

Because entities are immutable values, a conflict leaves you with something unusually useful: the stale copy you tried to write and the current copy from the database, both plain data classes that you can diff field by field:

OwnerService.kt Kotlin · Storm
1
2
3
4
5
6
7
try {
    owners.update(stale.copy(lastName = "Smith"))
} catch (exception: OptimisticLockException) {
    val current = owners.getById(stale.id)
    // stale and current are plain values: diff them, merge, retry,
    // or surface the conflict to the user
}

There is nothing to detach, reattach, or merge. Retry logic is ordinary code operating on ordinary values.

05Timestamp versions

If the schema tracks a last-modified timestamp anyway, it can serve as the version:

Visit.kt Kotlin · Storm
1
2
3
4
5
6
7
// A timestamp works as the version too
data class Visit(
    @PK val id: Int = 0,
    val visitDate: LocalDate,
    val description: String? = null,
    @Version val timestamp: Instant?,
) : Entity<Int>

06Side by side

JPA with HibernateStorm
When the check runsAt flush or commit, wherever that happens to beOn the update call, on that line
Failure surfaces asObjectOptimisticLockingFailureException at commitOptimisticLockException at the call site
State modelManaged, mutable entities; writes are implicitImmutable values; every write is a visible call
Conflict handlingDetach, reload, mergeStale and current copies coexist as plain values

07Keep going

The reference documentation covers the mechanics in depth: