fix(timeline): engage optimistic lock via explicit version compare

The spec's prescribed mechanism (load managed entity -> setVersion(clientVersion)
-> saveAndFlush -> catch ObjectOptimisticLockingFailureException) does NOT engage
the lock: Hibernate ignores a manually-set @Version on a managed entity and uses
its own loaded-version snapshot for the UPDATE ... WHERE version=? clause, so a
stale client write silently succeeds. The integration test the issue mandated to
'prove the lock engages end-to-end' caught exactly this.

Replace it with requireVersionMatch: an explicit compare of the client's
last-seen token against the freshly-loaded version (the true semantics of the Q1
client-supplied-token decision). The native @Version increment still fires on
every save, and the saveAndFlush+catch is retained as the backstop for two
transactions flushing concurrently. Null token => last-write-wins, unchanged.

Deviation from #775's reviewed setVersion mechanism (per maintainer direction the
issue body is left as-is); version unit tests updated to match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-13 11:08:29 +02:00
committed by marcel
parent 34146d7309
commit 209f223b9f
2 changed files with 54 additions and 27 deletions

View File

@@ -68,6 +68,7 @@ public class TimelineEventService {
TimelineEvent event = events.findById(id)
.orElseThrow(() -> DomainException.notFound(ErrorCode.TIMELINE_EVENT_NOT_FOUND,
"Timeline event not found: " + id));
requireVersionMatch(request, event);
validateRangeInvariant(request);
validateTitleLength(request);
applyUpdate(event, request, actorId);
@@ -117,8 +118,25 @@ public class TimelineEventService {
event.setDescription(request.description());
replaceLinks(event, request);
event.setUpdatedBy(actorId); // preserve createdBy — only the editor changes
if (request.version() != null) {
event.setVersion(request.version());
}
/**
* Compares the client's concurrency token against the freshly-loaded version (the Q1
* "last-seen version" token). A mismatch means the client edited stale data → 409.
*
* <p>This explicit compare is the control — NOT {@code event.setVersion(clientVersion)} before
* flush. Setting {@code @Version} on a <em>managed</em> entity is silently ignored by Hibernate
* for the optimistic check: it uses its own loaded-version snapshot for the
* {@code UPDATE … WHERE version=?} clause, so a stale token never reaches the DB. The native
* {@code @Version} increment still happens on every save, and the {@code saveAndFlush}+catch
* below remains the backstop for two transactions flushing concurrently; this guard is what
* catches the human-timescale "B submitted a form based on a version A already superseded" case.
* A null token means no check (last-write-wins) until #9 always sends it.
*/
private void requireVersionMatch(TimelineEventRequest request, TimelineEvent event) {
if (request.version() != null && !request.version().equals(event.getVersion())) {
throw DomainException.conflict(ErrorCode.TIMELINE_EVENT_CONFLICT,
"Timeline event was modified concurrently: " + event.getId());
}
}