Skip to content

Fix DDL catalog livelock fallout: conflict typing, Collection lifetime, cursor lifetime - #491

Merged
liunyl merged 10 commits into
mainfrom
fix/ddl-catalog-livelock
Aug 12, 2026
Merged

Fix DDL catalog livelock fallout: conflict typing, Collection lifetime, cursor lifetime#491
liunyl merged 10 commits into
mainfrom
fix/ddl-catalog-livelock

Conversation

@liunyl

@liunyl liunyl commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

What

The mongo-layer half of the DDL catalog work (tx_service half: eloqdata/tx_service#551, pulled in by the submodule bump commit). Three themes, one commit each:

  1. Retryable conflict typing. Tx-layer conflicts surfaced as Status{WriteConflict} became ExceptionFor<WriteConflict> — a sibling type writeConflictRetry never catches — so retryable DDL/DML conflicts were fatal to commands, and one collMod-path InternalError reached a MultiIndexBlock cleanup fassert that killed the node. ThrowIfWriteConflict() centralizes the mapping (TxErrorCodeToMongoStatus routes through it so they cannot drift); the catalog record-store retry loops, table create/drop/update paths, and the separate-transaction RecoveryUnit swaps are now exception-correct, with unit tests asserting the exception type, the conflict-group membership, and that transient READ_CATALOG_FAIL stays in-loop.
  2. Collection lifetime. getCollection() rebuilds cached Collections on every catalog version move while callers hold raw pointers across coroutine yields and into onCommit callbacks (EloqLockerNoop gives none of upstream's lock-manager exclusion). Destroy-on-refresh was a proven UAF; the interim mitigation leaked every evicted object. The maps now hold shared_ptr, every hand-out pins on the RecoveryUnit until the pooled OperationContext is recycled (pins follow stashed transactions), a mutex covers the pure map operations that background threads (TTLMonitor — two observed crashes) race, stale entries are evicted on vanished tables, and collections() returns a by-value snapshot (the shared _collectionsView and forView mode are gone).
  3. Cursor lifetime. Parked cursors under DDL churn exposed three defects: globally-managed aggregation cursors whose executors embed unpinned raw Collection* (fixed with enable_shared_from_this + pins at ClientCursor and DocumentSourceCursor, covering $lookup/$graphLookup foreign pipelines); cleanupExecutor() re-resolving the namespace on the no-throw disposal path (EloqDoc's getCollection throws under DDL — Pipeline::dispose() turns that into std::terminate; observed node death) — it now uses the captured, pinned manager; and EloqRecordStoreCursor::save() keeping its scan open across wire operations, driving a committed-and-recycled txm from the next getMore (observed TxProcessor SIGSEGV on a freed stack-allocated request) — both cursor types now close at save and lazily re-seek on the current transaction, matching the pre-existing saveUnpositioned() idiom.

Verification

  • Deterministic smoke: parked find (natural fwd/rev) and indexed cursors drained across wire ops, aggregation and $lookup drains across createIndexes, drop/recreate staleness, multikey, TTL sweep, and an explicit assertion that concurrent DDL kills collection-managed cursors with CursorNotFound (the designed semantic).
  • 30-minute DDL fuzz, 3-node cluster, ttlMonitorEnabled: true, with a parked-aggregation op (50% $lookup): the final run completed with zero crashes, zero cores, zero wedges (no operation ever exceeded 120 s), conflict-class 0.07%, canaries 29/29, per-minute completions never zero. Full run history, crash/wedge forensics, and review-gate record: DDL_CATALOG_LIVELOCK_PLAN.md §13.
  • Review: seven adversarial rounds (Codex + Kimi concurrency/database-transactions/distributed-state) across the collection/cursor design, final states double-PASS.

Notes

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Improved cursor and aggregation reliability during collection changes and subsequent getMore operations.
    • Preserved collection availability while active cursors are in use, reducing lifecycle and cleanup errors.
    • Improved transaction-conflict handling, including more consistent retries and error reporting.
    • Strengthened catalog and collection-cache consistency during collection creation, removal, invalidation, and recovery.
    • Improved cursor save and restore behavior by safely closing scans before repositioning.
    • Added safer rollback and cleanup for interrupted catalog and storage operations.
  • Tests
    • Added coverage for transaction-conflict classification and error conversion.

@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: e64e4467-3cc8-4da5-8843-dcd56059d9a8

📥 Commits

Reviewing files that changed from the base of the PR and between 467593f and 2ea4f34.

📒 Files selected for processing (1)
  • src/mongo/db/modules/eloq/data_substrate
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/mongo/db/modules/eloq/data_substrate

Walkthrough

This PR changes collection caches from unique to shared ownership. It adds synchronized snapshots and lifetime pins. It updates cursor cleanup and Eloq cursor reuse. It propagates Eloq write conflicts as retryable exceptions, improves recovery cleanup, adds utility tests, and updates a submodule.

Changes

Catalog lifetime and Eloq transaction fixes

Layer / File(s) Summary
Shared collection cache and ownership
src/mongo/db/catalog/collection.h, src/mongo/db/catalog/collection_impl.cpp, src/mongo/db/catalog/database.*, src/mongo/db/storage/recovery_unit.h, src/mongo/db/modules/eloq/src/eloq_recovery_unit.cpp
Collections use shared pointers, mutex-protected cache snapshots, conditional eviction, RecoveryUnit pins, and notifier captures that do not retain CollectionImpl.
Cursor lifetime and transaction reuse
src/mongo/db/clientcursor.*, src/mongo/db/commands/run_aggregate.cpp, src/mongo/db/pipeline/document_source_cursor.*, src/mongo/db/modules/eloq/src/eloq_index.cpp, src/mongo/db/modules/eloq/src/eloq_record_store.cpp
Cursors retain collection and cursor-manager state. Eloq scans close on save and re-seek after restore.
Recovery-unit safety and rollback
src/mongo/db/storage/kv/kv_collection_catalog_entry.cpp, src/mongo/db/modules/eloq/src/eloq_recovery_unit.cpp
Temporary recovery units restore the caller’s unit and abort active work during exception unwinding. Unready-table metadata is restored on rollback.
Conflict propagation and validation
src/mongo/db/modules/eloq/src/base/eloq_util.*, src/mongo/db/modules/eloq/src/eloq_record_store.cpp, src/mongo/db/modules/eloq/src/eloq_recovery_unit.cpp, src/mongo/db/modules/eloq/src/base/eloq_util_test.cpp, src/mongo/db/modules/eloq/SConscript, src/mongo/db/modules/eloq/data_substrate
Specified transaction errors now throw WriteConflictException. Tests cover conflict classification and status conversion.

Estimated code review effort: 5 (Critical) | ~90 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Aggregate
  participant Collection
  participant ClientCursor
  participant DocumentSourceCursor
  participant CursorManager
  Aggregate->>Collection: resolve input collection
  Aggregate->>ClientCursor: transfer collection pin
  ClientCursor->>DocumentSourceCursor: retain executor and collection state
  DocumentSourceCursor->>CursorManager: dispose through captured manager
Loading

Possibly related PRs

Suggested reviewers: lzxddz, xiexiaoy

Poem

A rabbit pins each collection tight,
Cursors keep their owners in sight.
Conflicts hop to retry,
Recovery cleans nearby.
Safe scans leap and land,
Eloq works as planned.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 15.38% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main changes: conflict handling, Collection lifetime, cursor lifetime, and their relation to DDL catalog livelock.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/ddl-catalog-livelock

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 8

🧹 Nitpick comments (3)
src/mongo/db/catalog/database_impl.cpp (1)

813-847: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Document or handle the null-RecoveryUnit eviction path.

If opCtx->recoveryUnit() returns null at line 845, evicted is destroyed when this function returns. That destroys the Collection immediately, which is the exact hazard the pinning scheme prevents. getCollection() carries an explicit comment for the same condition; this function does not.

Add the same note here, or make the absence of a RecoveryUnit an invariant on this path so a future RU-less caller fails loudly instead of freeing a live object.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/mongo/db/catalog/database_impl.cpp` around lines 813 - 847, Handle the
null-RecoveryUnit branch in DatabaseImpl::_clearCollectionCache: either document
that immediate destruction is safe for RU-less callers, matching
getCollection(), or enforce an invariant that a RecoveryUnit is required before
evicted is released. Ensure future callers cannot silently destroy a live
Collection without the pinning scheme.
src/mongo/db/catalog/collection_impl.cpp (1)

406-423: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Deduplicate the three identical capped-notifier commit callbacks.

The same lambda body and the same 12-line rationale comment appear at Lines 337-354, 406-423, and 506-523. Extract one private helper, for example void CollectionImpl::_scheduleCappedNotifyOnCommit(OperationContext* opCtx), and keep the rationale in that single place. This prevents the three copies from drifting.

♻️ Proposed helper
+void CollectionImpl::_scheduleCappedNotifyOnCommit(OperationContext* opCtx) {
+    // Capture the notifier, not the Collection: Eloq can refresh the cached Collection before
+    // RecoveryUnit callbacks run, so a captured `this` may already be freed. A shared_ptr copy
+    // keeps the notifier alive independently. Non-capped collections hold a null notifier.
+    opCtx->recoveryUnit()->onCommit([notifier = _cappedNotifier](boost::optional<Timestamp>) {
+        if (notifier) {
+            notifier->notifyAll();
+        }
+    });
+}

Then replace each of the three call sites with _scheduleCappedNotifyOnCommit(opCtx);.

Also applies to: 506-523

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/mongo/db/catalog/collection_impl.cpp` around lines 406 - 423, Extract the
duplicated capped-notifier RecoveryUnit callback and its rationale into a
private CollectionImpl helper, such as
_scheduleCappedNotifyOnCommit(OperationContext* opCtx). Have the helper capture
_cappedNotifier and perform the existing null-checked notifyAll() behavior, then
replace the callback blocks at all three call sites with helper calls.
src/mongo/db/clientcursor.h (1)

322-326: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider std::shared_ptr<const Collection> instead of std::shared_ptr<const void>.

The void erasure removes all type checking on what gets pinned, and src/mongo/db/pipeline/document_source_cursor.h Line 219 already uses a typed std::shared_ptr<Collection> for the same purpose. A forward declaration of Collection is enough here, so the typed form costs nothing and makes the two pins consistent.

Note one consequence of the const qualifier: std::shared_ptr<const void> does not convert to the std::shared_ptr<void> parameter of RecoveryUnit::pinResource(). That is fine today, because no code feeds this member there. Keep it in mind if the pin is ever forwarded to a recovery unit.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/mongo/db/clientcursor.h` around lines 322 - 326, Change
ClientCursor::_collectionPin from std::shared_ptr<const void> to
std::shared_ptr<const Collection>, adding or reusing a Collection forward
declaration as needed. Keep its declaration before _exec and preserve the
existing lifetime-pinning behavior; do not forward this const pointer to
RecoveryUnit::pinResource().
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@DDL_CATALOG_LIVELOCK_PLAN.md`:
- Line 25: Update all four fenced code blocks in DDL_CATALOG_LIVELOCK_PLAN.md to
include an explicit language tag: use text for log or trace output and text or
cpp for the transaction-flow snippet, resolving markdownlint MD040 without
changing the block contents.
- Around line 3-14: Update the document’s implementation status to reflect the
implemented fixes and successful Run 6, revise §13.8 to show its “not patched
here” DataSync disposition is superseded by §13.9, and add the exact eloqdoc
commit plus data_substrate gitlink used for Run 6.
- Around line 426-450: Audit the accepted-scope claims around read-local write
requests rather than treating them as unaffected: trace
EloqRecoveryUnit::readCatalog callers in eloq_record_store.cpp, EloqKV
forwarding in eloq_kv_engine.cpp, and the corresponding tx_service, EloqKV,
EloqSQL, and mixed-version paths. Verify whether each can set read_local_ with
is_for_write_ true and depend on blocking WriteIntent; update the scope decision
and affected-caller inventory to reflect the findings, or adjust the
implementation to preserve their required behavior.

In `@src/mongo/db/catalog/collection_impl.cpp`:
- Around line 337-354: Preserve the haveCappedWaiters() optimization in the
onCommit callback without capturing CollectionImpl or risking a dangling this
pointer. Capture or record waiter presence separately before registering the
callback, then only call notifier->notifyAll() when waiters were present and the
notifier remains valid; retain the existing null-notifier behavior.

In `@src/mongo/db/catalog/database_impl.cpp`:
- Around line 897-904: Guard the UUID optional in the version-mismatch branch
before calling UUIDCatalog::removeUUIDCatalogEntry. Reuse the established
conditional pattern from the nearby check in the same function, removing the
catalog entry only when found->uuid() is engaged, while preserving the
subsequent _clearCollectionCache call.

In `@src/mongo/db/catalog/database.h`:
- Around line 133-136: Update catalog::closeCatalog() and repairDatabase() to
operate on cached Collection instances rather than the fresh objects returned by
collections(), preserving _minVisibleSnapshot set through
setMinimumVisibleSnapshot(). Avoid rebuilding all collections for each
listCollections command; reuse the existing cached collection state or
explicitly transfer it when a snapshot is required.

In `@src/mongo/db/modules/eloq/src/eloq_record_store.cpp`:
- Around line 296-307: The insertRecord catalog-read path must retry transient
READ_CATALOG_FAIL results instead of immediately returning InternalError. Update
the flow around ru->readCatalog and ThrowIfWriteConflict to use the same bounded
retry behavior as deleteRecord() and updateRecord(), while preserving existing
handling for non-retryable errors. Add a regression test that produces
READ_CATALOG_FAIL once, then a successful catalog read, and verifies
insertRecord succeeds.

In `@src/mongo/db/pipeline/document_source_cursor.cpp`:
- Around line 255-273: Update the second cleanupExecutor overload to pass the
constructor-captured _cursorManager to _exec->dispose, matching the overload
shown here. Remove its call-time readLock.getCollection()->getCursorManager()
lookup while preserving the caller-provided read-lock discipline.

---

Nitpick comments:
In `@src/mongo/db/catalog/collection_impl.cpp`:
- Around line 406-423: Extract the duplicated capped-notifier RecoveryUnit
callback and its rationale into a private CollectionImpl helper, such as
_scheduleCappedNotifyOnCommit(OperationContext* opCtx). Have the helper capture
_cappedNotifier and perform the existing null-checked notifyAll() behavior, then
replace the callback blocks at all three call sites with helper calls.

In `@src/mongo/db/catalog/database_impl.cpp`:
- Around line 813-847: Handle the null-RecoveryUnit branch in
DatabaseImpl::_clearCollectionCache: either document that immediate destruction
is safe for RU-less callers, matching getCollection(), or enforce an invariant
that a RecoveryUnit is required before evicted is released. Ensure future
callers cannot silently destroy a live Collection without the pinning scheme.

In `@src/mongo/db/clientcursor.h`:
- Around line 322-326: Change ClientCursor::_collectionPin from
std::shared_ptr<const void> to std::shared_ptr<const Collection>, adding or
reusing a Collection forward declaration as needed. Keep its declaration before
_exec and preserve the existing lifetime-pinning behavior; do not forward this
const pointer to RecoveryUnit::pinResource().
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: d521966e-64e8-4201-98ef-a4c1f379b429

📥 Commits

Reviewing files that changed from the base of the PR and between 4485b4b and 50148e5.

📒 Files selected for processing (24)
  • DDL_CATALOG_LIVELOCK_PLAN.md
  • src/mongo/db/catalog/collection.h
  • src/mongo/db/catalog/collection_impl.cpp
  • src/mongo/db/catalog/database.h
  • src/mongo/db/catalog/database_impl.cpp
  • src/mongo/db/catalog/database_impl.h
  • src/mongo/db/catalog/index_catalog_entry_impl.cpp
  • src/mongo/db/clientcursor.cpp
  • src/mongo/db/clientcursor.h
  • src/mongo/db/commands/run_aggregate.cpp
  • src/mongo/db/modules/eloq/SConscript
  • src/mongo/db/modules/eloq/data_substrate
  • src/mongo/db/modules/eloq/src/base/eloq_util.cpp
  • src/mongo/db/modules/eloq/src/base/eloq_util.h
  • src/mongo/db/modules/eloq/src/base/eloq_util_test.cpp
  • src/mongo/db/modules/eloq/src/eloq_index.cpp
  • src/mongo/db/modules/eloq/src/eloq_record_store.cpp
  • src/mongo/db/modules/eloq/src/eloq_recovery_unit.cpp
  • src/mongo/db/pipeline/document_source_cursor.cpp
  • src/mongo/db/pipeline/document_source_cursor.h
  • src/mongo/db/storage/kv/kv_collection_catalog_entry.cpp
  • src/mongo/db/storage/recovery_unit.h
  • tests/jstests/eloq_basic/multikey_catalog_commit_stress.js
  • tests/jstests/eloq_basic/update_index_validation.js

Comment thread DDL_CATALOG_LIVELOCK_PLAN.md Outdated
Comment on lines +3 to +14
Status: proposed, not implemented. Revision 9, after adversarial review rounds
1-9 plus a live reproduction.

Baseline: tx_service `main` at `b146a514`, which already contains the
`NonBlockingLock` upgrade-at-head fix (eloqdata/tx_service#543, closing #541).

The change spans two repositories. Paths are labelled by repository and are
relative to that repository's root:

* **tx_service** — `eloqdata/tx_service`, consumed by EloqDoc as the
`src/mongo/db/modules/eloq/data_substrate` submodule.
* **eloqdoc** — `eloqdata/eloqdoc`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu
file="DDL_CATALOG_LIVELOCK_PLAN.md"
if [ -f "$file" ]; then
  printf '%s\n' '--- header ---'
  sed -n '1,30p' "$file"
  printf '%s\n' '--- final sections ---'
  sed -n '1650,1710p' "$file"
  printf '%s\n' '--- revision/status references ---'
  rg -n -i 'Run 6|data_substrate|gitlink|eloqdoc revision|revision|implemented|not implemented|submitted binary|DataSync|clean run|zero wedges' "$file" | tail -n 160
else
  printf 'MISSING: %s\n' "$file"
  git ls-files | rg 'DDL_CATALOG_LIVELOCK_PLAN\.md$'
fi

Repository: eloqdata/eloqdoc

Length of output: 7948


🏁 Script executed:

#!/bin/bash
set -eu
file="DDL_CATALOG_LIVELOCK_PLAN.md"

printf '%s\n' '--- implementation record ---'
sed -n '940,990p' "$file"
printf '%s\n' '--- Run 6 context ---'
sed -n '1688,1715p' "$file"
printf '%s\n' '--- all SHA-like values in document ---'
rg -o -n '\b[0-9a-f]{7,40}\b' "$file" | sort -u
printf '%s\n' '--- revision/run/build metadata ---'
rg -n -i 'revision|commit|sha|gitlink|submodule|HEAD|Run [0-9]|binary|build|implemented|submitted|final state' "$file" | sed -n '1,240p'
printf '%s\n' '--- repository revisions ---'
git rev-parse HEAD
git status --short
git ls-tree HEAD -- src/mongo/db/modules/eloq/data_substrate
printf '%s\n' '--- gitlink path candidates ---'
git ls-files -s | rg 'data_substrate|eloq'

Repository: eloqdata/eloqdoc

Length of output: 21679


🏁 Script executed:

#!/bin/bash
set -eu
file="DDL_CATALOG_LIVELOCK_PLAN.md"

printf '%s\n' '--- bfa7ab3402 context ---'
rg -n -C 8 'bfa7ab3402' "$file" || true

printf '%s\n' '--- implementation/build context ---'
sed -n '990,1165p' "$file"

printf '%s\n' '--- Run 5 through Run 6 ---'
sed -n '1460,1715p' "$file"

printf '%s\n' '--- document commit metadata ---'
git show -s --format='HEAD=%H%nsubject=%s%ncommitter=%cI' HEAD
git log -n 5 --format='%H %s' -- DDL_CATALOG_LIVELOCK_PLAN.md

Repository: eloqdata/eloqdoc

Length of output: 26270


Reconcile implementation status and Run 6 provenance.

  • Replace Status: proposed, not implemented; Part II and §13.9 record implemented fixes and a successful Run 6.
  • Mark §13.8’s “not patched here” DataSync disposition as superseded; §13.9 includes those fixes in the tested binary.
  • Record the exact eloqdoc commit and data_substrate gitlink used for Run 6. The current document does not identify them.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@DDL_CATALOG_LIVELOCK_PLAN.md` around lines 3 - 14, Update the document’s
implementation status to reflect the implemented fixes and successful Run 6,
revise §13.8 to show its “not patched here” DataSync disposition is superseded
by §13.9, and add the exact eloqdoc commit plus data_substrate gitlink used for
Run 6.

Comment thread DDL_CATALOG_LIVELOCK_PLAN.md Outdated

Ground truth from run 3 (2026-08-04, node-a glog plus `db.currentOp`):

```

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Specify languages for all fenced code blocks.

markdownlint-cli2 reports MD040 at these four fences. Use text for log and trace output, and text or cpp for the transaction-flow snippet.

Also applies to: 173-173, 1092-1092, 1119-1119

🧰 Tools
🪛 markdownlint-cli2 (0.23.2)

[warning] 25-25: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@DDL_CATALOG_LIVELOCK_PLAN.md` at line 25, Update all four fenced code blocks
in DDL_CATALOG_LIVELOCK_PLAN.md to include an explicit language tag: use text
for log or trace output and text or cpp for the transaction-flow snippet,
resolving markdownlint MD040 without changing the block contents.

Source: Linters/SAST tools

Comment thread DDL_CATALOG_LIVELOCK_PLAN.md Outdated
Comment on lines +426 to +450
**Accepted scope decision.** This keys the behaviour on `is_for_write_` rather
than a new per-request protocol field. Review round 1 raised that this changes
a generic `read_local_ && is_for_write_` semantic shared with EloqKV and
EloqSQL, which have not been audited. The alternative was designed and then
rejected by the maintainer in favour of the simpler form. This is a deliberate
decision, not an oversight:

* Every in-repository `ReadLocal` caller passes `is_for_write=false` and is
therefore unaffected: all five `sequences.cpp` catalog reads,
`ReadLocalOperation`, `LockWriteRangeBucketsOp`, the scan-next range lock,
and `BatchReadOperation`.
* No recovery, replay, standby, or checkpoint path constructs an affected
request.
* The residual exposure is EloqKV and EloqSQL, which are out of scope here.

**Known gap: read-local scans.** `Process(ScanOpenOperation&)`
(`tx_service/src/tx_execution.cpp:2347-2363`) is a separate path that also
hardcodes `CcProtocol::Locking` and passes `is_for_write` through; the scanner
deduces `WriteIntent` under that protocol
(`tx_service/src/cc/local_cc_handler.cpp:1105-1112`). A read-local scan for
write would still take a blocking write intent and could reproduce this
livelock shape. EloqDoc does construct `ScanOpenTxRequest`
(`src/mongo/db/modules/eloq/src/eloq_cursor.h:91`, reset at
`eloq_cursor.cpp:73`) but passes `is_read_local=false`, so no read-local scan
exists there today. Left unchanged and recorded rather than fixed

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

git -C src/mongo/db/modules/eloq/data_substrate grep -n -E \
  'ReadLocal|is_for_write_|read_local_|ScanOpenTxRequest' -- '*.h' '*.cc' '*.cpp'

rg -n -C 3 \
  'ReadLocal|is_for_write_|read_local_|ScanOpenTxRequest' \
  src/mongo/db/modules/eloq

Repository: eloqdata/eloqdoc

Length of output: 154


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf '%s\n' '== repository paths =='
git ls-files | rg '(^|/)(tx_execution\.cpp|local_cc_handler\.cpp|eloq_cursor\.(h|cpp)|.*(sequences|operation).*\.(h|cc|cpp)|DDL_CATALOG_LIVELOCK_PLAN\.md)$' | head -200

printf '%s\n' '== read-local symbols and request fields =='
rg -n -C 4 --hidden -g '!node_modules' -g '!build' -g '!dist' \
  'ReadLocal|is_for_write_|read_local_|ScanOpenTxRequest' . | head -1200

Repository: eloqdata/eloqdoc

Length of output: 11815


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf '%s\n' '== submodules and repository metadata =='
git submodule status 2>/dev/null || true
git ls-files | rg '(^|/)(\.gitmodules|data_substrate|tx_service|EloqKV|EloqSQL)' || true

printf '%s\n' '== EloqDoc scan request construction and assignments =='
rg -n -C 8 \
  'ScanOpenTxRequest|is_read_local_|is_for_write_|OpenTxScan' \
  src/mongo/db/modules/eloq/src/eloq_cursor.cpp \
  src/mongo/db/modules/eloq/src/eloq_cursor.h

printf '%s\n' '== all source request constructions in the checked-out tree =='
rg -n -C 3 --hidden -g '*.{h,hpp,cc,cpp,cxx}' \
  'ReadTxRequest|ScanOpenTxRequest|read_local_|is_for_write_' . | head -1600

Repository: eloqdata/eloqdoc

Length of output: 11907


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf '%s\n' '== submodule definition and pinned revision =='
cat .gitmodules
git ls-tree HEAD src/mongo/db/modules/eloq/data_substrate

printf '%s\n' '== EloqDoc ReadTxRequest and BatchReadTxRequest arguments =='
sed -n '350,390p;405,435p;475,510p;560,595p' \
  src/mongo/db/modules/eloq/src/eloq_recovery_unit.cpp

printf '%s\n' '== EloqDoc ScanOpenTxRequest initialization =='
sed -n '35,85p' src/mongo/db/modules/eloq/src/eloq_cursor.cpp

printf '%s\n' '== exact local request-field assignments in EloqDoc =='
rg -n '(^|[^[:alnum:]_])(read_local_|is_read_local_|is_for_write_)[[:space:]]*=' \
  src/mongo/db/modules/eloq --glob '*.{h,hpp,cc,cpp,cxx}'

Repository: eloqdata/eloqdoc

Length of output: 9093


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf '%s\n' '== readCatalog call sites and arguments =='
rg -n -C 5 'readCatalog\s*\(' src/mongo/db --glob '*.{h,hpp,cc,cpp,cxx}' | head -1200

printf '%s\n' '== EloqRecoveryUnit read methods and parameter flow =='
rg -n -C 8 \
  'readCatalog|batchReadCatalog|getKV|isForWrite|isForShare|readLocal' \
  src/mongo/db/modules/eloq/src/eloq_recovery_unit.h \
  src/mongo/db/modules/eloq/src/eloq_recovery_unit.cpp | head -1800

printf '%s\n' '== request constructor declarations in the available submodule index =='
git ls-tree -r HEAD src/mongo/db/modules/eloq/data_substrate | head -100

Repository: eloqdata/eloqdoc

Length of output: 36655


Audit the affected catalog write callers before accepting this scope.

EloqRecoveryUnit::readCatalog() sets read_local_ to true and forwards isForWrite as is_for_write_. eloq_record_store.cpp calls it with true at lines 229, 296, and 359. eloq_kv_engine.cpp also forwards isForWrite at line 311. These callers contradict the claim that every in-repository caller passes false. Audit their dependence on blocking WriteIntent, plus the tx_service, EloqKV, EloqSQL, and mixed-version paths.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@DDL_CATALOG_LIVELOCK_PLAN.md` around lines 426 - 450, Audit the
accepted-scope claims around read-local write requests rather than treating them
as unaffected: trace EloqRecoveryUnit::readCatalog callers in
eloq_record_store.cpp, EloqKV forwarding in eloq_kv_engine.cpp, and the
corresponding tx_service, EloqKV, EloqSQL, and mixed-version paths. Verify
whether each can set read_local_ with is_for_write_ true and depend on blocking
WriteIntent; update the scope decision and affected-caller inventory to reflect
the findings, or adjust the implementation to preserve their required behavior.

Comment on lines 337 to +354
opCtx->recoveryUnit()->onCommit(
[this](boost::optional<Timestamp>) { notifyCappedWaitersIfNeeded(); });
[notifier = _cappedNotifier](boost::optional<Timestamp>) {
// Capture the notifier, not the Collection. Eloq refreshes the cached Collection
// while committing catalog metadata, before RecoveryUnit callbacks run, so a
// captured `this` can already be freed here -- the same hazard the eloq guard in
// IndexCatalogEntryImpl::setMultikey was added for. Holding a shared_ptr copy keeps
// the notifier alive independently of the Collection.
//
// The haveCappedWaiters() short-circuit is dropped along with `this`: its use_count
// test cannot be expressed once the Collection may be gone, and it was only an
// optimisation. notifyAll() on a notifier nobody waits on takes one uncontended
// mutex and bumps a version that no waiter reads; on a killed notifier it is
// likewise harmless, because waiters exit on the _dead flag. Non-capped collections
// have a null notifier and do nothing at all.
if (notifier) {
notifier->notifyAll();
}
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find remaining callers of the capped-waiter helpers.
rg -nP -C3 '\b(haveCappedWaiters|notifyCappedWaitersIfNeeded)\s*\(' --type=cpp --type=cpp-header

Repository: eloqdata/eloqdoc

Length of output: 193


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- helper definitions and callers ---'
rg -nP -C4 '\b(haveCappedWaiters|notifyCappedWaitersIfNeeded)\s*\(' --glob '*.cpp' --glob '*.h' --glob '*.hpp' --glob '*.inl' .
printf '%s\n' '--- definitions in collection implementation ---'
rg -n -C12 'haveCappedWaiters|notifyCappedWaitersIfNeeded' src/mongo/db/catalog/collection_impl.cpp src/mongo/db/catalog/collection_impl.h

Repository: eloqdata/eloqdoc

Length of output: 31521


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- oplog caller context ---'
sed -n '1665,1720p' src/mongo/db/repl/oplog.cpp
printf '%s\n' '--- WiredTiger oplog manager caller context ---'
sed -n '165,265p' src/mongo/db/storage/wiredtiger/wiredtiger_oplog_manager.cpp
printf '%s\n' '--- record-store capped callback plumbing ---'
rg -n -C5 'setCappedCallback|getCappedInsertNotifier|CappedCallback|_cappedNotifier' \
  src/mongo/db/catalog/collection_impl.cpp \
  src/mongo/db/catalog/collection_impl.h \
  src/mongo/db/storage \
  src/mongo/db/repl
printf '%s\n' '--- notifier implementation and use_count-related behavior ---'
rg -n -C8 'class CappedInsertNotifier|notifyAll\(|_dead|waitUntil' src/mongo/db

Repository: eloqdata/eloqdoc

Length of output: 50373


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- _localOplogCollection declarations and assignments ---'
rg -n -C4 '_localOplogCollection' src/mongo/db/repl/oplog.cpp src/mongo/db/repl/oplog.h
printf '%s\n' '--- production call sites only ---'
rg -nP '\b(haveCappedWaiters|notifyCappedWaitersIfNeeded)\s*\(' src/mongo \
  --glob '!**/*test*' --glob '!src/mongo/db/catalog/collection_impl.cpp' \
  --glob '!src/mongo/db/catalog/collection_impl.h' --glob '!src/mongo/db/catalog/collection.h'
printf '%s\n' '--- CollectionImpl notifier initialization and callback registration ---'
rg -n -C8 '_cappedNotifier|setCappedCallback|CappedCallback' \
  src/mongo/db/catalog/collection_impl.cpp src/mongo/db/catalog/collection_impl.h
printf '%s\n' '--- CollectionImpl insert method signatures and callback regions ---'
sed -n '285,430p' src/mongo/db/catalog/collection_impl.cpp

Repository: eloqdata/eloqdoc

Length of output: 29648


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- WiredTiger oplog manager decision and notification flow ---'
sed -n '175,258p' src/mongo/db/storage/wiredtiger/wiredtiger_oplog_manager.cpp
printf '%s\n' '--- WiredTiger record-store callback methods ---'
sed -n '1265,1312p' src/mongo/db/storage/wiredtiger/wiredtiger_record_store.cpp
printf '%s\n' '--- callback setup and wait lifecycle references ---'
rg -n -C6 'setCappedCallback|haveCappedWaiters\(\)|notifyCappedWaitersIfNeeded\(\)' \
  src/mongo/db/storage/wiredtiger src/mongo/db/catalog/collection_impl.cpp \
  --glob '!**/*test*'
printf '%s\n' '--- commit callback registration semantics ---'
rg -n -C6 'onCommit\(' src/mongo/db/recovery_unit.h src/mongo/db/storage/recovery_unit.h \
  src/mongo/db/modules/eloq/src

Repository: eloqdata/eloqdoc

Length of output: 23084


🏁 Script executed:

#!/bin/bash
set -e
python3 - <<'PY'
from pathlib import Path

collection = Path("src/mongo/db/catalog/collection_impl.cpp").read_text()
record_store = Path("src/mongo/db/storage/wiredtiger/wiredtiger_record_store.cpp").read_text()
oplog_manager = Path("src/mongo/db/storage/wiredtiger/wiredtiger_oplog_manager.cpp").read_text()
oplog = Path("src/mongo/db/repl/oplog.cpp").read_text()

assert collection.count("[notifier = _cappedNotifier]") == 3
assert "return _cappedNotifier.use_count() > 1;" in collection
assert "if (haveCappedWaiters())" in collection
assert "return _cappedCallback && _cappedCallback->haveCappedWaiters();" in record_store
assert "oplogRecordStore->haveCappedWaiters()" in oplog_manager
assert "oplogRecordStore->notifyCappedWaitersIfNeeded();" in oplog_manager
assert "_localOplogCollection->notifyCappedWaitersIfNeeded();" in oplog

print("pending commit notifier copies are counted by CollectionImpl::haveCappedWaiters")
print("WiredTigerOplogManager reaches CollectionImpl::haveCappedWaiters through WiredTigerRecordStore")
print("the waiter check controls the oplog journal-delay loop")
print("the notification path also invokes CollectionImpl::haveCappedWaiters")
PY

Repository: eloqdata/eloqdoc

Length of output: 453


Preserve the haveCappedWaiters() optimization
A pending commit callback makes CollectionImpl::haveCappedWaiters() return true without an await-data cursor. WiredTigerOplogManager uses this result to bypass its journal delay, which can cause unnecessary synchronization. Keep the waiter check in a lifetime-safe form, or track waiter presence separately from notifier ownership.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/mongo/db/catalog/collection_impl.cpp` around lines 337 - 354, Preserve
the haveCappedWaiters() optimization in the onCommit callback without capturing
CollectionImpl or risking a dangling this pointer. Capture or record waiter
presence separately before registering the callback, then only call
notifier->notifyAll() when waiters were present and the notifier remains valid;
retain the existing null-notifier behavior.

Comment on lines 897 to +904
} else {
MONGO_LOG(1) << "nss: " << nss.toStringData()
<< " version changed. old: " << found->catalogVersion()
<< ", new: " << version;
auto& uuidCatalog = UUIDCatalog::get(opCtx);
uuidCatalog.removeUUIDCatalogEntry(found->uuid().get());
_clearCollectionCache(opCtx, nss.ns(), "collection version changed", true);
_clearCollectionCache(
opCtx, nss.ns(), "collection version changed", true, found.get());

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Guard the optional UUID before removeUUIDCatalogEntry.

Line 902 calls found->uuid().get() without checking that the optional is engaged. Line 887 in the same function checks it with if (auto uuid = found->uuid()), so a disengaged UUID is reachable on this code path too. A collection without a UUID makes this dereference a disengaged boost::optional.

The version-mismatch path is now taken constantly under concurrent DDL, which increases exposure.

🐛 Proposed fix
             auto& uuidCatalog = UUIDCatalog::get(opCtx);
-            uuidCatalog.removeUUIDCatalogEntry(found->uuid().get());
+            if (auto foundUuid = found->uuid()) {
+                uuidCatalog.removeUUIDCatalogEntry(foundUuid.get());
+            }
             _clearCollectionCache(
                 opCtx, nss.ns(), "collection version changed", true, found.get());
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
} else {
MONGO_LOG(1) << "nss: " << nss.toStringData()
<< " version changed. old: " << found->catalogVersion()
<< ", new: " << version;
auto& uuidCatalog = UUIDCatalog::get(opCtx);
uuidCatalog.removeUUIDCatalogEntry(found->uuid().get());
_clearCollectionCache(opCtx, nss.ns(), "collection version changed", true);
_clearCollectionCache(
opCtx, nss.ns(), "collection version changed", true, found.get());
} else {
MONGO_LOG(1) << "nss: " << nss.toStringData()
<< " version changed. old: " << found->catalogVersion()
<< ", new: " << version;
auto& uuidCatalog = UUIDCatalog::get(opCtx);
if (auto foundUuid = found->uuid()) {
uuidCatalog.removeUUIDCatalogEntry(foundUuid.get());
}
_clearCollectionCache(
opCtx, nss.ns(), "collection version changed", true, found.get());
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/mongo/db/catalog/database_impl.cpp` around lines 897 - 904, Guard the
UUID optional in the version-mismatch branch before calling
UUIDCatalog::removeUUIDCatalogEntry. Reuse the established conditional pattern
from the nearby check in the same function, removing the catalog entry only when
found->uuid() is engaged, while preserving the subsequent _clearCollectionCache
call.

Comment thread src/mongo/db/catalog/database.h
Comment on lines 296 to +307
auto [exist, errorCode] = ru->readCatalog(catalogKey, catalogRecord, true);
if (errorCode != txservice::TxErrorCode::NO_ERROR) {
if (errorCode == txservice::TxErrorCode::WRITE_WRITE_CONFLICT) {
MONGO_LOG(1) << "Eloq readCatalog error with write intent. Another transaction "
"may do DDL on the same table.";
return {ErrorCodes::WriteConflict,
"[Create Table] Another transaction may do DDL on the same table"};
} else {
MONGO_LOG(0) << "Eloq readCatalog error with write intent."
<< txservice::TxErrorMessage(errorCode);
return {ErrorCodes::InternalError, txservice::TxErrorMessage(errorCode)};
}
// The whole conflict group has to leave as a WriteConflictException. Returning a
// Status carrying ErrorCodes::WriteConflict looks equivalent but is not: at the
// first uassertStatusOK it becomes ExceptionFor<ErrorCodes::WriteConflict>, a
// sibling type that no writeConflictRetry catches. The previous form also mapped
// DEAD_LOCK_ABORT and UPSERT_TABLE_ACQUIRE_WRITE_INTENT_FAIL to InternalError,
// which no caller retries at all.
ThrowIfWriteConflict(errorCode);
MONGO_LOG(0) << "Eloq readCatalog error with write intent."
<< txservice::TxErrorMessage(errorCode);
return {ErrorCodes::InternalError, txservice::TxErrorMessage(errorCode)};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Retry transient catalog reads before failing insertRecord.

readCatalog() runs once here. ThrowIfWriteConflict() returns for READ_CATALOG_FAIL. Lines 305-307 then return InternalError.

This makes collection creation fail when the catalog fetch is in progress. Match the bounded retry behavior in deleteRecord() and updateRecord(). Add a regression test that returns READ_CATALOG_FAIL before a successful catalog read.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/mongo/db/modules/eloq/src/eloq_record_store.cpp` around lines 296 - 307,
The insertRecord catalog-read path must retry transient READ_CATALOG_FAIL
results instead of immediately returning InternalError. Update the flow around
ru->readCatalog and ThrowIfWriteConflict to use the same bounded retry behavior
as deleteRecord() and updateRecord(), while preserving existing handling for
non-retryable errors. Add a regression test that produces READ_CATALOG_FAIL
once, then a successful catalog read, and verifies insertRecord succeeds.

Comment thread src/mongo/db/pipeline/document_source_cursor.cpp
@liunyl
liunyl force-pushed the fix/ddl-catalog-livelock branch from 50148e5 to fc6fc42 Compare August 11, 2026 04:42
liunyl and others added 4 commits August 11, 2026 04:55
Tx-layer conflict errors surfaced as Status{WriteConflict} became
ExceptionFor<WriteConflict> at the first uassertStatusOK -- a sibling type
of mongo::WriteConflictException that writeConflictRetry never catches, so
command-level retry loops treated retryable DDL/DML conflicts as fatal.

- New ThrowIfWriteConflict(TxErrorCode) throws the catchable type for the
  conflict group (WW/RW conflicts, gap lock, OCC validation failures,
  deadlock abort); TxErrorCodeToMongoStatus calls it first so the mapping
  cannot drift.
- updateRecord/deleteRecord retry loops and insertRecord conflict handling
  route through it; transient READ_CATALOG_FAIL stays in-loop.
- createTable/dropTable/updateTable UpsertResult::Failed branches throw the
  retryable type instead of returning InternalError (the collMod path's
  InternalError reached ~MultiIndexBlockImpl's cleanup, which fasserts on
  anything but WriteConflictException and killed the node).
- eraseUnreadyTable registers a rollback restore inside a unit of work so
  an aborted index build retains its staged unready-index metadata for the
  in-process retry.
- The RecoveryUnit swaps around separate-transaction UpsertTable calls are
  now exception-safe (guards restore the original unit; the in-unit flag is
  cleared before commitUnitOfWork, matching its early _inUnitOfWork clear).
- Unit tests cover the exception type, the conflict-group membership, and
  that READ_CATALOG_FAIL is not thrown.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…g refresh

getCollection() rebuilds the cached Collection whenever the catalog version
moves -- constantly under concurrent DDL -- while operations hold raw
Collection* across coroutine yields and into RecoveryUnit onCommit
callbacks, and EloqLockerNoop provides none of the exclusion upstream's
lock manager gave this code. Destroy-on-refresh was a use-after-free, and
the interim mitigation leaked every evicted Collection.

- Database collection maps hold shared_ptr<Collection>. Eviction is a map
  erase; the object dies when its last holder finishes.
- RecoveryUnit::pinResource() retains resources until the pooled
  OperationContext is recycled (onCreateOperationContext ->
  resetRecoveryUnit -> reset(), cleared last so commit/rollback callbacks
  never outlive their Collection). Pins deduplicate against the live set
  and follow stashed transactions. Every path handing out a raw
  Collection* pins first.
- _collectionsMutex serialises the pure in-memory map operations --
  background threads such as the TTLMonitor mutate the maps concurrently --
  and is never held across a coroutine yield.
- getCollection() on a vanished table now evicts the stale entry, so a
  re-created namespace cannot be served the old object.
- collections() returns an operation-owned snapshot by value; the shared
  _collectionsView member and the forView mode are gone (map-iterator
  invalidation that shared_ptr entries cannot cure).
- The capped-insert onCommit callbacks capture the CappedInsertNotifier
  instead of the Collection.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three cursor-lifetime defects reachable from parked cursors (batchSize
smaller than the result set) under concurrent DDL:

1. Globally-managed aggregation cursors never re-resolve the collection on
   getMore, so no per-operation pin covers the raw Collection* their
   executors embed; a catalog refresh could destroy it mid-drain.
   Collection is now enable_shared_from_this; ClientCursor holds a
   collection pin captured in runAggregate, and DocumentSourceCursor pins
   the collection each embedded executor was built over -- covering
   $lookup/$graphLookup foreign pipelines uniformly (their sub-pipelines
   outlive the outer operation under the $unwind optimization).
2. DocumentSourceCursor::cleanupExecutor() re-resolved the namespace via
   Database::getCollection() to find the CursorManager -- an API upstream
   chose because theirs cannot throw. EloqDoc's performs a transactional
   catalog read that throws under concurrent DDL, and Pipeline::dispose()
   turns any exception into std::terminate. Re-resolution could also
   return a rebuilt Collection whose manager never saw the executor. The
   captured manager (kept alive by the pin) is used instead; upstream's
   lock discipline around dispose is preserved.
3. EloqRecordStoreCursor::save() kept its scan open across wire operations,
   unlike saveUnpositioned() and the index cursor: a stashed cursor's next
   getMore drove an EloqCursor bound to the txm of the operation that
   opened the scan -- by then committed and recycled to another
   transaction, whose request queue accepted the zombie's stack-allocated
   batch request. Both cursor types now close the scan at save time, while
   the opening transaction is live, and lazily re-seek from the recorded
   resume key on the current transaction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Pulls the tx_service half of the DDL catalog work (eloqdata/tx_service
PR #551): catalog write-intent fail-fast with acquire-all error
propagation, CatalogCcMap End() guards, and the DataSync meta-lock
deadlock fixes. The two halves were validated together by the 30-minute
DDL fuzz described in the PRs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@liunyl
liunyl force-pushed the fix/ddl-catalog-livelock branch from fc6fc42 to ac58d0b Compare August 11, 2026 04:56
Under Eloq the abandoned snapshot is a transaction whose accumulated
catalog/key intents are exactly what the conflict winner is waiting to
drain. The upstream order -- sleep, then abandonSnapshot -- parks the loser
on top of its locks for the whole 1-10 ms backoff window and leaves the
winner only the instant between the abandon and the retry's
re-acquisition, so contended DDL kept losing that race and retrying.
Abandoning first hands the winner the full backoff window.

For conflicts thrown inside a WriteUnitOfWork the transaction is already
aborted during unwind, so the reorder only changes conflicts thrown
outside one (the catalog read fail-fast path). The catch runs only at the
outermost retry loop, outside any unit of work, so abandonSnapshot's
!_inUnitOfWork invariant holds as before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
src/mongo/db/concurrency/write_conflict_exception.h (1)

94-99: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Update the retry-order documentation.

The function comment states that retry handling waits before cleanup. Lines 94-99 now call abandonSnapshot() before WriteConflictException::logAndBackoff(). Update the comment to describe the new order.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/mongo/db/concurrency/write_conflict_exception.h` around lines 94 - 99,
Update the retry-order documentation in the function comment surrounding
abandonSnapshot() to state that the snapshot is abandoned before
WriteConflictException::logAndBackoff(), rather than describing cleanup after
the wait. Keep the existing explanation of releasing the loser’s locks before
backoff aligned with this order.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/mongo/db/concurrency/write_conflict_exception.h`:
- Around line 94-99: Update the retry-order documentation in the function
comment surrounding abandonSnapshot() to state that the snapshot is abandoned
before WriteConflictException::logAndBackoff(), rather than describing cleanup
after the wait. Keep the existing explanation of releasing the loser’s locks
before backoff aligned with this order.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 2176b8ee-f106-4f9b-9d64-48931a0988f4

📥 Commits

Reviewing files that changed from the base of the PR and between ac58d0b and 467593f.

📒 Files selected for processing (1)
  • src/mongo/db/concurrency/write_conflict_exception.h

liunyl and others added 5 commits August 12, 2026 06:51
Picks up eloqdata/tx_service b627ae7: CommitTx now waits for metadata
post-processing when the transaction's only state is a catalog read, so
its catalog read locks are released before the close returns.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Closing the tx scan in save() cost one scan close and re-seek per deleted
document: DeleteStage calls child()->saveState() inside its per-document
loop, so a 20k-document deleteMany went from 275 ms to 67.8 s over an index
scan (246x) and from 86 ms to 11.3 s over a collection scan. TTL, which
deletes through the same planner path, stopped keeping up entirely.

detachFromOperationContext() is the point where the cursor actually leaves
its operation -- the stash-across-wire-operations case the close exists for
-- and the transaction that opened the scan is still live there, so moving
the close preserves the guarantee that no stashed cursor keeps an EloqCursor
bound to a txm that is about to be recycled. Intra-operation save/restore no
longer touches the scan; the resume path is unchanged.

Measured after the move: 281 ms and 123 ms, i.e. back to pre-change levels.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Doc-only change in eloqdata/tx_service 10b2f37: the read-local comment and
03-concurrency-control described the catalog entry as where DDL and DML
meet, but every catalog read-for-write comes from a DDL path. No behaviour
change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
eloqdata/tx_service#551 merged, so this repoints the submodule from that
branch onto main plus the two commits that landed after the merge
(eloqdata/tx_service#553): the metadata-only transaction close fix and the
corrected catalog fail-fast rationale.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
eloqdata/tx_service#553 merged, so the submodule now tracks main (5a76896)
rather than a feature branch: the metadata-only transaction close fix, the
locked_db_ addition to the same guard, and the corrected catalog fail-fast
rationale are all upstream.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@liunyl
liunyl merged commit d9c752a into main Aug 12, 2026
17 checks passed
@liunyl
liunyl deleted the fix/ddl-catalog-livelock branch August 12, 2026 16:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants