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:
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user