There is 1 comment.
 
 
XWiki Platform / cid:jira-generated-image-avatar-bbc064b9-04a4-4a79-9e22-b1aa1f8ae084 XWIKI-22421 Open

XWikiHibernateBaseStore related error when editing in realtime

 
View issue   ·   Add comment
 

1 comment

 
cid:jira-generated-image-avatar-a7616646-fc8d-4c2a-81ed-99248ce00b81 Marius Dumitru Florea on 26/Aug/26 16:45
 

I asked Claude to analyze this issue:

# XWIKI-22421 — `OptimisticLockException` on `delete from xwikilock`

## Context

`XWikiHibernateBaseStore` logs an ERROR stack trace
(`OptimisticLockException: … actual row count: 0; expected: 1; statement executed: delete from xwikilock where XWL_DOC_ID=?`)
whenever a user leaves an edit session. Reported since 2024 (XWIKI-22421), still present on 18.x, with
systematic reproduction steps in XWIKI-22778 (closed as duplicate). Reporters agree it only happens when
the realtime plugin is enabled, and Nikita Petrenko reports orphan rows accumulating in `xwikilock`.

## Root cause

Two independent defects combine.

### 1. The client sends *several* concurrent `cancel` (unlock) requests for the same document

`XWiki.DocumentLock` ([lock.js:41-42](xwiki-platform-core/xwiki-platform-web/xwiki-platform-web-war/src/main/webapp/resources/uicomponents/lock/lock.js#L41-L42))
registers `unload` + `pagehide` handlers that `navigator.sendBeacon(cancel)`. The `_locked` flag makes that
idempotent **per instance** — but several instances exist as soon as realtime is on:

- Standalone WYSIWYG + realtime: `lock.js` `init()` creates instance A and locks it; then
  [wysiwygEditor.js:103](xwiki-platform-core/xwiki-platform-realtime/xwiki-platform-realtime-wysiwyg/xwiki-platform-realtime-wysiwyg-webjar/src/main/webjar/wysiwygEditor.js#L103)
  does `XWiki.EditLock = new XWiki.DocumentLock()` — instance B, with its own listeners and its own
  `_locked`. Instance A is never unregistered. → **2 beacons on unload.**
- In-place editing + realtime: [InplaceEditing.xml:705](xwiki-platform-core/xwiki-platform-edit/xwiki-platform-edit-ui/src/main/resources/XWiki/InplaceEditing.xml#L705)
  has its *own* `unload pagehide` → `unlock()` beacon, plus the realtime `XWiki.DocumentLock` instance. → **2 beacons.**
- `lockDocument()` is called again on every `userList.change` where somebody left
  ([wysiwygEditor.js:548-556](xwiki-platform-core/xwiki-platform-realtime/xwiki-platform-realtime-wysiwyg/xwiki-platform-realtime-wysiwyg-webjar/src/main/webjar/wysiwygEditor.js#L548-L556)),
  leaking one more listener-bearing instance each time. → **N beacons in a long session.**

This is why disabling the realtime plugin makes the error disappear entirely.
(The same leak explains the workaround comment at wysiwygEditor.js:548: `actionButtons.js:177` only clears
`XWiki.EditLock` — the *current* reference — so the stale instances still fire a cancel after Save.)

### 2. `removeLock` is a non-atomic check-then-delete, and `deleteLock` demands exactly 1 affected row

[XWikiDocument.removeLock](xwiki-platform-core/xwiki-platform-oldcore/src/main/java/com/xpn/xwiki/doc/XWikiDocument.java#L7520-L7526)
= `loadLock()` (transaction 1) + `deleteLock()` (transaction 2). `CancelAction` adds a third read
([CancelAction.java:65-71](xwiki-platform-core/xwiki-platform-oldcore/src/main/java/com/xpn/xwiki/web/CancelAction.java#L65-L71)),
widening the window. [XWikiHibernateStore.deleteLock](xwiki-platform-core/xwiki-platform-oldcore/src/main/java/com/xpn/xwiki/store/XWikiHibernateStore.java#L2070-L2082)
then does `session.delete(detachedLock)`, and Hibernate's `BasicExpectation` treats a 0-row delete as a
`StaleStateException`. Two concurrent beacons ⇒ both load the lock, one deletes it, the loser blows up.

`saveLock` has the same TOCTOU shape (`select` then `save`/`update`).

### 3. The failure is invisible to the caller and surfaces as a raw ERROR

`session.delete()` only queues the delete; it throws at flush, i.e. inside `endTransaction`, so neither
`deleteLock`'s `catch` nor `CancelAction`'s `catch (Exception ex) { /* locks aren't critical */ }` sees it.
It is caught and logged in the `finally` of
[XWikiHibernateBaseStore.execute](xwiki-platform-core/xwiki-platform-oldcore/src/main/java/com/xpn/xwiki/store/XWikiHibernateBaseStore.java#L833-L841)
as `LOGGER.error("Exception while closing the transaction", e)`. Hence the alarming stack trace for a
condition that is, in the common case, benign.

## Impact on end users

Usually **log noise only**: the losing transaction contains nothing but the lock delete, and the row is gone
anyway, so the net state is correct. The response is still `204`. But it is not harmless:

- Alarming ERROR + 80-line stack trace in production logs on a routine action; drowns real problems.
- Breaks Docker functional tests, which fail the build on unexpected ERRORs in the logs (already observed by
  Dorian on the realtime ITs).
- **Lock stealing** (same root cause 2, different interleaving): a `cancel` that loaded lock L can delete a
  *newer* lock inserted meanwhile — e.g. the realtime "re-lock when a collaborator leaves" of
  wysiwygEditor.js:548. The user who just acquired the lock silently loses it, and a third user then edits
  with no "locked by X" warning ⇒ increased risk of concurrent edits / merge conflicts.
- **Stale lock rows** (Nikita's observation): the mirror interleaving — a `lock` request landing *after* the
  departing user's `cancel` beacons — leaves a row nobody owns. Other users then get a spurious
  "This page is locked by X" confirmation until it expires (`lock_Timeout`, default 30 min) and is lazily
  cleaned by the next `getLock`. For a page never edited again the row leaks in `xwikilock` forever.