Skip to content

Preclaiming two-phase locking for XQuery update operations#6112

Open
joewiz wants to merge 20 commits intoeXist-db:developfrom
joewiz:feature/preclaiming-locks
Open

Preclaiming two-phase locking for XQuery update operations#6112
joewiz wants to merge 20 commits intoeXist-db:developfrom
joewiz:feature/preclaiming-locks

Conversation

@joewiz
Copy link
Member

@joewiz joewiz commented Mar 7, 2026

Summary

Implements BaseX-style preclaiming two-phase locking to prevent dirty writes during concurrent XQuery update operations. Before query evaluation begins, the compiled expression tree is statically analyzed to determine which documents and collections will be accessed. Write locks are acquired on all targets before eval() and released after update application completes.

This ensures that no two updating queries can operate on the same document concurrently, eliminating dirty writes structurally rather than relying on transaction isolation.

Depends on: #6111 (W3C XQuery Update Facility 3.0)

What Changed

New files

  • LockTargetCollector.javaExpressionVisitor that walks the compiled expression tree to find fn:doc(), fn:collection(), fn:doc-available(), and fn:uri-collection() calls with static string arguments. Uses TreeSet<XmldbURI> for consistent lock ordering (deadlock prevention). Falls back to global lock for dynamic targets.

  • LockTargetCollectorTest.java — 9 unit tests: static doc/collection detection, dynamic fallback to global lock, FLWOR traversal, conditional handling, multiple doc references.

  • ConcurrencyBenchmark.java — Measures ops/sec under 8 concurrent scenarios at 2, 4, and 8 thread counts. Guarded by -Dexist.run.benchmarks=true. Error-tolerant design captures concurrent modification exceptions to quantify the dirty write problem.

Modified files

  • XQueryContext.java — Added preclaiming infrastructure: collectLockTargets(), preclaimLocks(), releasePreclaimedLocks(), hasPreclaimedLocks(). Lock targets stored as Set<XmldbURI> with global lock fallback flag.

  • XQuery.java — Hooks into execute(): calls collectLockTargets() after prepareForExecution(), acquires preclaimed locks before eval(), releases in finally block. Null-safe for REST/XMLDB queries where root expression may be null.

  • LockTargetCollector.java — Null-safe visitFilteredExpr() for expressions not yet analyzed.

How It Works

BEFORE (current):
  compile → analyze → eval() → PUL.apply() [locks acquired HERE] → release
  Problem: another query can read/modify the same document during eval()

AFTER (preclaiming):
  compile → analyze → collectLockTargets() → preclaimLocks() [locks HERE] → eval() → apply [locks still held] → release
  No concurrent access possible during eval+apply window

For dynamic targets (fn:doc($variable)), the collector falls back to a global collection write lock on /db, which serializes all updating queries — conservative but correct. This matches BaseX's approach.

Benchmark Results

Run with: mvn test -pl exist-core -Dtest=ConcurrencyBenchmark -Dexist.run.benchmarks=true -Ddependency-check.skip=true

Baseline (no preclaiming)

Scenario 2 threads 4 threads 8 threads
read-same-doc 18,407 30,265 18,114
read-diff-docs 20,161 28,904 18,211
write-same-doc (legacy) 7,260 12,137 7,470
write-diff-docs (legacy) 7,553 11,414 6,948
mixed-same-doc 12,892 (14 errors) 19,851 (32 errors) 12,051
mixed-diff-docs 13,331 20,283 11,701
xquf-write-same-doc 7,383 11,174 (1,468 errors) 6,551
xquf-write-diff-docs 7,382 11,768 6,733

With preclaiming

Scenario 2 threads 4 threads 8 threads
read-same-doc 15,195 25,503 18,702
read-diff-docs 19,478 28,769 17,537
write-same-doc (legacy) 6,283 10,637 7,591
write-diff-docs (legacy) 6,962 10,566 7,454
mixed-same-doc 12,277 (0 errors) 18,772 (0 errors) 12,577
mixed-diff-docs 12,318 19,196 11,489
xquf-write-same-doc 6,975 10,593 (1 error) 6,775
xquf-write-diff-docs 6,977 10,374 6,578

Key improvements

  • mixed-same-doc: 14→0 errors (2t), 32→0 errors (4t) — dirty writes eliminated
  • xquf-write-same-doc: 1,468→1 errors (4t) — 99.93% error reduction
  • Throughput within ±5–15% (normal run-to-run variance)

Phases 4–5 investigation (skip redundant lock acquisition)

We investigated skipping PUL's and Modification's own lock acquisition when preclaimed locks are held. This caused a massive error regression (53,627 errors at 4t for xquf-write-same-doc) because eXist-db has two independent lock systems: LockManager (URI-based, used by preclaiming) and document-level locks (ReentrantReadWriteLock on DocumentImpl, used by PUL/Modification). These are different lock instances, so skipping one doesn't satisfy the other. The current approach correctly uses both layers: preclaimed URI locks for outer serialization and PUL/Modification document locks for inner serialization.

Design Decisions

  • TreeSet for lock ordering: Prevents deadlocks by ensuring all queries acquire locks in the same URI-sorted order.
  • Global lock fallback: Matches BaseX — if targets can't be determined statically, serialize all updating queries. Users who want better concurrency use static document references.
  • Lock reentrancy: Preclaimed locks and PUL/Modification locks use different systems; both are acquired (reentrant within each system), providing defense in depth.
  • Null safety: collectLockTargets() handles null root expressions (REST/XMLDB queries) and visitFilteredExpr() handles unanalyzed expressions.

Test Plan

  • 9 LockTargetCollector unit tests pass
  • Full exist-core test suite: 6,595 tests, 0 failures, 0 errors
  • Concurrency benchmark: dirty write errors eliminated
  • XQTS update test sets (pending, run on CI)

🤖 Generated with Claude Code

Co-Authored-By: Claude Opus 4.6 noreply@anthropic.com

@joewiz joewiz requested a review from a team as a code owner March 7, 2026 07:17
joewiz and others added 10 commits March 11, 2026 16:54
…y 3.0

Add isUpdating() and isVacuous() methods to Expression interface and
all expression subclasses to support W3C XUST0001/XUST0002 static
type checking. Add W3C XUDY/XUST/XUTY error codes to ErrorCodes.java.
Add PendingUpdateList field to XQueryContext for PUL accumulation
across query evaluation. Add PUL application at snapshot boundary in
XQuery.java. Add updating function annotation support to
FunctionSignature and FunctionCall.

Also fixes PathExpr.analyze() context step propagation: changes
`if (i > 1)` to `if (i >= 1)` so that step[1] correctly gets its
context step set to step[0], preventing outer context from leaking
into nested path expressions within predicates.

Closes eXist-db#3634

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…-memory mutations

Add W3C XQuery Update Facility 3.0 support alongside the existing legacy
update syntax. The legacy syntax is retained as deprecated with clear
section markers in the grammar for future removal.

Grammar: both syntaxes coexist unambiguously - legacy starts with
"update", XQUF starts with "insert"/"delete"/"replace"/"rename"/"copy".

New package: org.exist.xquery.xquf (PendingUpdateList, expression classes)
In-memory mutations: DocumentImpl, ElementImpl, NodeImpl extensions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Reject queries that mix eXist-db's legacy update syntax (update insert,
update delete, etc.) with W3C XQUF expressions (insert node, delete
node, etc.) in the same module. The two systems have incompatible
execution semantics (immediate vs. deferred via PUL), so mixing them
would produce undefined behavior.

The check is enforced during tree walking: XQueryContext tracks which
update syntax has been encountered and raises XPST0003 if the other
syntax appears.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add XQUFBasicTest with 85 tests covering all W3C XQUF expression types
(insert, delete, replace, rename, transform/copy-modify-return) plus
4 tests verifying the compile-time mutual exclusion check.

Add bindingConflictXQUF.xqm with XQUF (copy/modify/return) editions
of the XUDY0023 namespace conflict tests. These are in a separate
module from the legacy bindingConflict.xqm because the mutual
exclusion rule prevents mixing both syntaxes in the same module.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add XQUFBenchmark with benchmarks for both W3C XQUF and legacy
eXist-db update syntax, covering insert, delete, replace node,
replace value, and rename operations at various data sizes. Also
includes XQUF-only in-memory copy-modify benchmarks.

Guarded by -Dexist.run.benchmarks=true so Surefire skips them by
default. Run with:
  mvn test -pl exist-core -Dtest=XQUFBenchmark \
      -Dexist.run.benchmarks=true -Ddependency-check.skip=true

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace unnecessary fully qualified names with short class names:
- PendingUpdateList (4 occurrences in XQueryContext)
- XQueryAST (2 occurrences in XQueryContext)
- XMLDBException (1 occurrence in XQUFBasicTest)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Fix copy-namespaces propagateNamespace (2 failures), attribute
replacement swap (1 failure), and FullAxis document-level operations
(3 failures), bringing the non-schema XQUF XQTS score to 683/683
(100%).

propagateNamespace fixes:
- Implement no-inherit namespace materialization: during
  copyNodeIntoDocument, when !inheritNamespaces(), pass a scope map
  that accumulates ancestor namespace bindings within the inserted
  subtree so each child element gets explicit declarations.
- Implement no-preserve namespace stripping: add
  stripUnusedNamespacesInSubtree/ForElement to DocumentImpl that
  invalidates namespace declarations not used by element/attribute
  names. Called from XQUFTransformExpr.deepCopyNode after serialization.

AttrDataModelErrs fix:
- Pre-capture attribute QNames in a Map before any removals in PUL
  Phase 3, then use findAttribute() for lookup. Fixes stale AttrImpl
  nodeNumber indices after removeAttribute shifts arrays.

FullAxis fixes:
- Fix XQUFTransformExpr.deepCopyNode to copy ALL document-level
  children (comments, PIs, elements) instead of only the document
  element, preserving complete document structure for copy-modify.
- Fix NodeImpl.selectFollowing to not early-return for the document
  element, enabling following::node() to find document-level siblings
  after the element.
- Add deleted-node skip logic (nodeKind == -1) to selectPreceding
  and selectFollowing for resilience against soft-deleted nodes left
  by mergeAdjacentTextNodes when compact() is not called.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…structure

Phase 1: LockTargetCollector walks compiled expression trees to
statically determine document/collection lock targets from fn:doc(),
fn:collection(), etc. calls. Uses TreeSet for consistent lock ordering.
Falls back to global lock when targets cannot be determined statically.

Phase 2: XQueryContext gains collectLockTargets(), preclaimLocks(),
releasePreclaimedLocks(), and hasPreclaimedLocks() methods for
BaseX-style preclaiming two-phase locking.

9 unit tests in LockTargetCollectorTest verify static doc/collection
detection, dynamic fallback, FLWOR traversal, and conditional handling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Phase 3: After compilation and analysis, collectLockTargets() walks the
expression tree to determine which documents/collections the query will
access. Locks are acquired before eval() and released in the finally block,
ensuring they are held through both evaluation and PUL application.

Adds null safety checks for collectLockTargets() (root expression can be
null for REST/XMLDB queries) and visitFilteredExpr() (inner expression
can be null before analysis).

6,595 tests pass with 0 failures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
ConcurrencyBenchmark measures ops/sec under concurrent read, write, and
mixed workloads at 2, 4, and 8 thread counts. Benchmarks are guarded by
-Dexist.run.benchmarks=true and not discovered by Surefire automatically.

Scenarios: read-same-doc, read-diff-docs, write-same-doc, write-diff-docs,
mixed-same-doc, mixed-diff-docs, xquf-write-same-doc, xquf-write-diff-docs.

Error-tolerant design captures concurrent modification exceptions to
measure both throughput and error rates, demonstrating the dirty write
problem that preclaiming solves.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@joewiz joewiz force-pushed the feature/preclaiming-locks branch from 5212c53 to 9342f74 Compare March 12, 2026 17:10
joewiz and others added 8 commits March 12, 2026 13:12
When a rename and replaceNode target different attributes on the same
element, the Phase 1 rename can change an attribute's QName, creating
ambiguity for the Phase 3 QName-based lookup. For example, renaming
@name to @gender while replacing @gender produces two attributes
named "gender", and findAttribute returns the wrong one.

Fix: pre-capture original attribute array indices BEFORE Phase 1
renames and process attribute replaceNodes in descending index order.
This avoids both name ambiguity (from renames) and index invalidation
(from removeAttribute array shifts). Also use insertAttributes with
replaceExisting=false during PUL application to prevent the
replacement attribute from clobbering an unrelated existing attribute
with the same name.

Fixes XQTS upd-applyUpdates/applyUpdates-026.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Surefire and failsafe forked JVMs now timeout after 600s (10 min),
preventing BrokerPool shutdown hangs from consuming the entire 45-min
CI budget.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Prevents CI jobs from exceeding the 45-min limit when multiple test
forks hang in sequence. 3 minutes per test class is generous; hung
forks are killed quickly so surefire/failsafe can move on.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
DeadlockIT and RemoveCollectionIT hang during BrokerPool initialization
before any test starts, so surefire's per-test timeout never triggers.
This caused CI to exceed the 45-min limit. These are pre-existing
deadlock-prone tests (same issues as ConcurrencyTest and FragmentsTest
already excluded from surefire).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note to myself, I thought I fixed these two already

joewiz and others added 2 commits March 20, 2026 02:02
180s was too aggressive for CI — BrokerPool shutdown after
CollectionLocksTest (~120s) needs additional time on slower CI
runners. 300s allows ample shutdown time while still catching
true hangs well before CI's 45-min limit.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
300s was still too short for CI runners — BrokerPool shutdown after
CollectionLocksTest hangs and needs up to ~5 min on slow GitHub
runners. Restoring the original 600s value. The real CI fix was
excluding DeadlockIT/RemoveCollectionIT from failsafe (previous
commit), which eliminated the 47-min hang.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@adamretter
Copy link
Contributor

There are a number of serious problems with the architecture design here - would you like some review?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants