All About Transactions

MVCC, Locks, XA, and Cross-Transaction Execution in MarkLogic

personClever Llamas
CleverLlamasMinimum Llamaverse Version: 2
databaseMinimum MarkLogic Version: 11

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 typeCore mechanismWhat matters most
QueryPoint-in-time snapshot (MVCC timestamp)Stable reads for the life of the transaction
UpdateReaders/writers lockingLatest 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 settingBehaviourTrade-off
contemporaneous (default)Uses the most recent eligible timestamp and may block brieflyTimelier reads, possible query wait
nonblockingUses a slightly older safe timestamp to avoid waitingLower 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:

  1. Faster lock/deadlock triage in xdmp:host-status output.
  2. Easier operational correlation with request logs and app-level diagnostics.
  3. 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 phasePurposeMarkLogic operational note
PrepareParticipant indicates it can commitBranch can remain in prepared state pending coordinator decision
Commit/RollbackCoordinator resolves globallyMarkLogic branch is completed accordingly
RecoveryResolve uncertain outcomes after failuresxdmp:xa-complete and xdmp:xa-forget exist for exceptional recovery workflows

Important guidance:

  1. Use XA only for true distributed consistency requirements.
  2. Keep transaction scope and lock lifetime small; distributed windows are naturally longer.
  3. Reserve xdmp:xa-complete / xdmp:xa-forget for 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:

  1. same-statement: same statement, same transaction context.
  2. 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:

  1. Caller takes a lock.
  2. Nested different-transaction update tries to lock the same URI.
  3. 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 levelTypical signalUse when
Baseline (warning / error)Timeouts, deadlock-related failures, rollback outcomesInitial incident detection
Operational (info)More request-level transaction lifecycle contextOngoing issue correlation
Deep diagnostics (debug/trace)Rich lock-wait and flow detail, high volumeShort, targeted troubleshooting windows

Escalate verbosity temporarily, capture evidence, then return to normal levels to avoid noisy logs.

Symptom-oriented troubleshooting table

SymptomLikely causeFirst checksFast mitigation
Request appears hungLock wait / deadlock pathxdmp:host-status, xdmp:transaction-locksCancel stuck request or rollback blocking transaction
Reads appear stale in long query flowQuery timestamp fixed at transaction startConfirm xdmp:request-timestamp() and transaction modeStart a new transaction boundary for fresher reads
Updates disappear after eval/invokedifferent-transaction + missing explicit commit in called codeCalled transaction mode and commit handlingAdd explicit commit where required
Security behaviour differs between caller and called codeAmp/role differences across execution boundaryeval/invoke options, amp assumptionsSet explicit amp handling and user context options

Practical Design Rules

  1. Keep update transactions compact.
  2. Name transactions that can survive beyond a trivial request.
  3. Use same-statement only when you truly need one transaction context.
  4. Use different-transaction deliberately, with explicit commit/update/isolation choices.
  5. Avoid deep anonymous nested transaction patterns.
  6. 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!