# 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.
This message was sent by Atlassian Jira (v9.3.0#930000-sha1:287aeb6)
If image attachments aren't displayed, see this article.