All About Transactions
MVCC, Locks, XA, and Cross-Transaction Execution in MarkLogic
MarkLogic is ACID, but practical behaviour becomes clear when you separate two execution models:
- Query transactions run at a stable snapshot timestamp.
- Update transactions use readers/writers locks and do not run at a fixed request timestamp.
That distinction explains most production surprises. Teams typically go wrong when they map relational isolation labels directly onto MarkLogic without first accounting for timestamped query evaluation versus lock-coordinated update evaluation.
The examples in this article assume the llamaverse (v2.0+) is deployed. The llamaverse sample data is freely available from github.com/cleverllamas/llamaverse — see the llamaverse article for full setup instructions.
Transaction Model in One Table
| Transaction type | Core mechanism | What matters most |
|---|---|---|
| Query | Point-in-time snapshot (MVCC timestamp) | Stable reads for the life of the transaction |
| Update | Readers/writers locking | Latest visible version at first access, locks held until transaction end |
Neither model is "weaker". They solve different concurrency problems.
Introducing MVCC Properly
MarkLogic uses multi-version concurrency control (MVCC) for lock-free query snapshots. Each query transaction runs at one system timestamp and reads fragment versions valid at that time.
Query transactions and MVCC timestamps
A query transaction sees content as of one timestamp. That timestamp remains constant while the transaction runs, even if concurrent updates commit during that same wall-clock period.
xquery version "1.0-ml";
(: Query transactions have a stable request timestamp. :)
let $before := xdmp:request-timestamp()
let $sample := fn:count(cts:search(fn:collection("wild-llamas"), cts:word-query("hiking"))[1 to 10])
let $after := xdmp:request-timestamp()
return (
concat("before:", $before),
concat("sample:", $sample),
concat("after:", $after)
)
before:17845896602996960
sample:10
after:17845896602996960
xquery version "1.0-ml";
let $uri := "/cleverllamas/llamaverse/content/llama-movement/llama_location_history.json"
let $before := xdmp:request-timestamp()
let $count-1 := xdmp:estimate(cts:search(fn:doc(), cts:document-query($uri)))
let $_ := xdmp:sleep(300)
let $after := xdmp:request-timestamp()
let $count-2 := xdmp:estimate(cts:search(fn:doc(), cts:document-query($uri)))
return (
"before timestamp: " || $before,
"after timestamp: " || $after,
"first count: " || $count-1,
"second count: " || $count-2,
"same timestamp: " || ($before = $after)
)
before timestamp: 17845896602996960
after timestamp: 17845896602996960
first count: 1
second count: 1
same timestamp: true
If xdmp:request-timestamp() remains unchanged across multiple reads in the same query transaction, you are observing MVCC snapshot stability directly.
MVCC server setting: contemporaneous vs nonblocking
The App Server multi-version concurrency control setting influences how aggressively query requests wait for the freshest possible timestamp.
| MVCC setting | Behaviour | Trade-off |
|---|---|---|
contemporaneous (default) | Uses the most recent eligible timestamp and may block briefly | Timelier reads, possible query wait |
nonblocking | Uses a slightly older safe timestamp to avoid waiting | Lower query latency, potentially less current view |
nonblocking can be useful when query latency is more critical than read freshness, including some XA-heavy environments where prepare/commit cycles can keep "latest" timestamps in flux.
Statement boundaries and update visibility
Update visibility depends on statement boundaries. Within a single statement, a read can still miss an update performed earlier in that same statement. After statement boundaries are crossed, subsequent statements in the same explicit transaction can observe prior updates.
xquery version "1.0-ml";
declare option xdmp:commit "explicit";
declare option xdmp:update "true";
let $uri := fn:concat(
"/cleverllamas/llamaverse/extensions/transaction-isolation/txn-visibility-demo-",
xs:string(xdmp:random()),
".xml"
)
return (
xdmp:document-insert($uri, <txn-demo><state>created</state></txn-demo>),
(: same statement read; update may not be visible until statement boundary :)
fn:concat("same statement read: ", fn:string(fn:doc($uri)/txn-demo/state)),
xdmp:commit()
)
Update Transactions: Lock-Coordinated Writes
Update transactions can read and write, but they do not expose a fixed request timestamp via xdmp:request-timestamp().
'use strict';
declareUpdate();
// Update transactions do not have a fixed request timestamp.
xdmp.documentInsert(
'/cleverllamas/llamaverse/work/transactions/aurora.json',
{ name: 'Aurora', note: 'transaction demo' },
{
collections: ['work-transactions'],
permissions: [xdmp.permission('cleverllamas-llamaverse-writer', 'update')]
}
);
xdmp.requestTimestamp();
()
This is expected behaviour and a useful diagnostic signal: if xdmp:request-timestamp() returns an empty sequence, you are in an update transaction.
A Clever Llama Always Names Their Transactions
You can assign names with xdmp:set-transaction-name, which makes host-level diagnostics much more useful.
xquery version "1.0-ml";
declare namespace hs = "http://marklogic.com/xdmp/status/host";
declare option xdmp:commit "explicit";
declare option xdmp:update "true";
let $name := "llamaverse-maintenance-2026-06"
let $host := xdmp:host()
return (
xdmp:set-transaction-name($name),
xdmp:document-insert(
"/cleverllamas/llamaverse/extensions/transaction-isolation/txn-name-demo.xml",
<txn><name>{$name}</name></txn>
),
xdmp:host-status($host)//hs:transaction[hs:transaction-name = $name]/(
hs:transaction-id,
hs:transaction-name,
hs:transaction-mode,
hs:transaction-state
),
xdmp:commit()
)
Practical benefits:
- Faster lock/deadlock triage in
xdmp:host-statusoutput. - Easier operational correlation with request logs and app-level diagnostics.
- Clear ownership in shared environments.
Avoid leaving long-lived, anonymous transactions in production. Named transactions are easier to find, explain, and close safely.
XA Transactions (What Matters)
XA coordinates MarkLogic with external resources through a transaction manager (2-phase commit). In normal operation, let the transaction manager drive outcome decisions.
| XA phase | Purpose | MarkLogic operational note |
|---|---|---|
| Prepare | Participant indicates it can commit | Branch can remain in prepared state pending coordinator decision |
| Commit/Rollback | Coordinator resolves globally | MarkLogic branch is completed accordingly |
| Recovery | Resolve uncertain outcomes after failures | xdmp:xa-complete and xdmp:xa-forget exist for exceptional recovery workflows |
Important guidance:
- Use XA only for true distributed consistency requirements.
- Keep transaction scope and lock lifetime small; distributed windows are naturally longer.
- Reserve
xdmp:xa-complete/xdmp:xa-forgetfor unusual recovery situations.
Visual: complete vs incomplete XA outcomes
Complete (committed) flow:
Incomplete (rolled back) flow:
Uncertain prepared state and recovery flow:
Eval/Invoke, XSLT Eval, and Amped Roles
Cross-boundary execution is where many transaction and security misunderstandings start.
Isolation semantics
xdmp:eval, xdmp:invoke, and xdmp:xslt-eval can run either:
same-statement: same statement, same transaction context.different-transaction(default for eval/invoke family): separate session and transaction.
When running in different-transaction, transaction mode is not inherited in the same way and explicit commit/update choices matter.
Amp behaviour through boundaries
AMP expectations can diverge across eval/invoke boundaries and transforms. For xdmp:eval() and xdmp:invoke(), you can explicitly disable amps with the ignoreAmps option when you want the called code to run without inherited amp elevation.
'use strict';
// Demonstrates explicit isolation and amp behaviour controls across eval boundaries.
const uri = '/cleverllamas/llamaverse/extensions/transaction-isolation/eval-isolation-demo.json';
const code = `
declareUpdate();
xdmp.documentInsert('${uri}', { source: 'eval', updatedAt: fn.currentDateTime().toString() });
({ txn: xdmp.transaction(), uri: '${uri}' })
`;
const result = xdmp.eval(code, null, {
isolation: 'different-transaction',
update: 'true',
commit: 'auto',
preventDeadlocks: true,
ignoreAmps: true
});
const resultItems = [];
for (const item of result) {
resultItems.push(item);
}
const evalResult = resultItems[0] || {};
({
callerTxn: xdmp.transaction(),
evalTxn: evalResult.txn,
uri: evalResult.uri
});
Use explicit options for isolation, commit, update, preventDeadlocks, and amp handling. Do not rely on defaults when crossing execution boundaries.
This same caution applies to XSLT execution paths as well because they can run in a different transaction context depending on how they are invoked. However, the xdmp.xsltEval() docs I checked do not show an amp-specific option like ignoreAmps, so I would not claim the same behaviour there without a separate code test.
Avoid Deep Anonymous Transaction Chains
Nested anonymous eval/invoke/spawn patterns make production behaviour hard to reason about, and in some MarkLogic versions too many nested anonymous functions can behave unpredictably in the field.
Common failure mode:
- Caller takes a lock.
- Nested
different-transactionupdate tries to lock the same URI. - Request stalls until timeout unless cancelled.
Prefer explicit orchestration with named transaction boundaries and clear isolation choices. If you must nest, keep depth shallow and document exactly which layer owns commit/rollback.
Transaction Locks and Troubleshooting
Lock inspection from code
Use host status plus lock inspection to see active locks and waits.
xquery version "1.0-ml";
declare namespace hs = "http://marklogic.com/xdmp/status/host";
let $host := xdmp:host()
let $txns := xdmp:host-status($host)//hs:transaction[hs:transaction-state = "active"]
for $t in $txns
let $id := xs:unsignedLong($t/hs:transaction-id)
return
<transaction-lock-report>
<transaction-id>{$id}</transaction-id>
<name>{data($t/hs:transaction-name)}</name>
<mode>{data($t/hs:transaction-mode)}</mode>
{xdmp:transaction-locks($host, $id)}
</transaction-lock-report>
xdmp:transaction-locks exposes read/write locks and waiting locks for a transaction.
Logging and message detail levels
Different log levels produce different diagnostic detail. Treat this as a controlled escalation path.
| Observability level | Typical signal | Use when |
|---|---|---|
Baseline (warning / error) | Timeouts, deadlock-related failures, rollback outcomes | Initial incident detection |
Operational (info) | More request-level transaction lifecycle context | Ongoing issue correlation |
Deep diagnostics (debug/trace) | Rich lock-wait and flow detail, high volume | Short, targeted troubleshooting windows |
Escalate verbosity temporarily, capture evidence, then return to normal levels to avoid noisy logs.
Symptom-oriented troubleshooting table
| Symptom | Likely cause | First checks | Fast mitigation |
|---|---|---|---|
| Request appears hung | Lock wait / deadlock path | xdmp:host-status, xdmp:transaction-locks | Cancel stuck request or rollback blocking transaction |
| Reads appear stale in long query flow | Query timestamp fixed at transaction start | Confirm xdmp:request-timestamp() and transaction mode | Start a new transaction boundary for fresher reads |
| Updates disappear after eval/invoke | different-transaction + missing explicit commit in called code | Called transaction mode and commit handling | Add explicit commit where required |
| Security behaviour differs between caller and called code | Amp/role differences across execution boundary | eval/invoke options, amp assumptions | Set explicit amp handling and user context options |
Practical Design Rules
- Keep update transactions compact.
- Name transactions that can survive beyond a trivial request.
- Use
same-statementonly when you truly need one transaction context. - Use
different-transactiondeliberately, with explicit commit/update/isolation choices. - Avoid deep anonymous nested transaction patterns.
- Treat XA as a specialised tool, not a default architecture.
Final Takeaway
Robust MarkLogic transaction design comes from explicit choices: choose snapshot semantics when you need deterministic reads, choose lock-coordinated updates when you need safe writes, name important transactions, and make cross-transaction execution behaviour explicit.
Operational checklist:
1. Decide whether workflow is read-only or read-write.
2. Use query transactions for stable snapshots.
3. Use update transactions for writes and lock-protected consistency.
4. Avoid assuming in-statement read-your-own-write visibility.
5. Test long-running reads against concurrent updates.
Need Some Help?
Looking for more information on this subject or any other topic related to MarkLogic? Contact Us (info@cleverllamas.com) to find out how we can assist you with consulting or training!
- Transaction Model in One Table
- Introducing MVCC Properly
- MVCC server setting: contemporaneous vs nonblocking
- Statement boundaries and update visibility
- Update Transactions: Lock-Coordinated Writes
- A Clever Llama Always Names Their Transactions
- XA Transactions (What Matters)
- Visual: complete vs incomplete XA outcomes
- Eval/Invoke, XSLT Eval, and Amped Roles
- Isolation semantics
- Amp behaviour through boundaries
- Avoid Deep Anonymous Transaction Chains
- Transaction Locks and Troubleshooting
- Lock inspection from code
- Symptom-oriented troubleshooting table
- Practical Design Rules
- Final Takeaway