Skip to content

Wait for metadata post-processing when closing a metadata-only transaction - #553

Merged
liunyl merged 3 commits into
mainfrom
fix/metadata-only-tx-close
Aug 12, 2026
Merged

Wait for metadata post-processing when closing a metadata-only transaction#553
liunyl merged 3 commits into
mainfrom
fix/metadata-only-tx-close

Conversation

@liunyl

@liunyl liunyl commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #551, which merged before these two commits landed on its branch.

1. CommitTx must not fire-and-forget a metadata-only transaction

CommitTx's fast path discards the caller's CommitTxRequest and fires the txm-owned commit_tx_req_ without waiting, returning true unconditionally. For a transaction whose only state is a catalog (metadata) read that is wrong on three counts:

  • an abort reports success — the fast path returns true even when to_commit_ is false;
  • the caller's request is never finished, so CommitTx()'s wrapper reads an ErrorCode that was never set;
  • the catalog read locks are still held when CommitTx returns, because releasing them is part of the asynchronous post-processing the caller no longer waits for.

The third is the one that bites. A command that closes and immediately reopens a transaction — writeConflictRetry doing exactly that, with no backoff at all for the first four attempts — can keep a catalog entry's reader count permanently non-zero, so a DDL write intent never upgrades and the retry loop cannot converge.

Excluding MetaDataReadSetSize() from the fast-path guard routes those transactions through the waiting path, so the locks are released before CommitTx returns. The guard's other terms already cover the remaining state: ForwardWriteCnt() > 0 implies WriteSetSize() > 0 (both increment sites iterate the write set), and cmd_set_ is the object-command path that only EloqKV drives.

Measured on EloqDoc's jstest suite (Debug, rocksdb, single node), before → after:

before after
write conflicts across the run 63,955 0
worst single createIndexes 4,755 retries
jstests step 120 min (timed out) completes
835-test common subset 2.2x faster than main

TestNodeSmoke gains coverage for both close modes of a metadata-only transaction.

2. Corrected rationale for the catalog fail-fast (docs only)

The read-local comment and 03-concurrency-control said the catalog entry is "where DDL and DML meet". It is not: every caller that asks for a catalog read-for-write is a DDL path (create/drop collection, create/drop indexes, rename), while DML and queries read the catalog without the flag. The cycle the fail-fast breaks is DDL against DDL on the same table. Behaviour is unchanged; only the description was wrong.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Fixed transactions containing only metadata reads being incorrectly treated as empty during commit.
    • Improved handling of catalog lock conflicts so operations fail promptly instead of entering unnecessary wait states.
    • Ensured close requests complete successfully and return the expected result for metadata-only transactions.
  • Documentation

    • Clarified catalog locking behavior, lock escalation, and retry handling for schema changes.

liunyl and others added 2 commits August 12, 2026 13:10
CommitTx's fast path discards the caller's CommitTxRequest and fires the
txm-owned commit_tx_req_ without waiting, returning true unconditionally.
For a transaction whose only state is a metadata (catalog) read that is
wrong on three counts: an abort reports success, the caller's request is
never finished so CommitTx()'s wrapper reads an ErrorCode that was never
set, and -- most importantly -- the catalog read locks are still held when
CommitTx returns, because releasing them is part of the asynchronous
post-processing the caller no longer waits for. A retry loop that closes
and immediately reopens a transaction can then keep a catalog entry's
reader count permanently non-zero, so a DDL write intent never upgrades.

Excluding MetaDataReadSetSize() from the fast-path guard routes those
transactions through the waiting path, so the locks are released before
CommitTx returns. TestNodeSmoke covers both close modes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The read-local comment and 03-concurrency-control claimed the catalog entry
is where DDL and DML meet. It is not: every caller that asks for a
catalog read-for-write is a DDL path (create/drop collection, create/drop
indexes, rename), while DML and queries read the catalog without the flag.
The cycle the fail-fast breaks is DDL against DDL on the same table. The
behaviour and the reason to fail fast are unchanged; only the description
was wrong.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
liunyl added a commit to eloqdata/eloqdoc that referenced this pull request Aug 12, 2026
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>
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@liunyl, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 24 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: d62eaef5-0ba5-4d82-9609-08690048a117

📥 Commits

Reviewing files that changed from the base of the PR and between e4b6903 and c18dd39.

📒 Files selected for processing (1)
  • tx_service/src/tx_execution.cpp

Walkthrough

The commit path now treats metadata reads as non-empty transactions. Smoke tests cover metadata-only commits in both close modes. Catalog locking comments and documentation describe DDL-specific intent handling and retry behavior.

Changes

Metadata transaction commit handling

Layer / File(s) Summary
Metadata-aware commit path and protocol coverage
tx_service/src/tx_execution.cpp, tx_service/tests/TestNodeSmoke-Test.cpp, docs/03-concurrency-control.md
CommitTx checks the metadata read set before using the immediate commit path. Smoke tests validate metadata-only transactions for both close modes. Catalog locking comments and documentation describe DDL intent escalation and caller retries.

Estimated code review effort: 2 (Simple) | ~10 minutes

Possibly related PRs

Suggested reviewers: thweetkomputer

Poem

A rabbit checks the metadata gate,
Two close modes finish cleanly and straight.
Catalog locks explain their careful dance,
Failed waits return for another chance.
Hop, commit, and tests report great!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description explains the problem, behavior change, implementation, tests, and documentation update, but it omits several required template sections and exact commands. Add the missing design decisions, risk assessment, rollback plan, reviewer guide, follow-up work, and exact test commands with results.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the main change: waiting for metadata post-processing when closing metadata-only transactions.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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 docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/metadata-only-tx-close

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: 1

🧹 Nitpick comments (1)
tx_service/src/tx_execution.cpp (1)

1832-1847: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Separate the lock paths in this comment: ReaderWriterCntl::AddWriter rejects a different existing writer with WriteConflict and returns WritePending only when existing readers remain. It does not inspect CcProtocol. Attribute enqueue-versus-fail behavior to NonBlockingLock::AcquireWriteIntent, which enqueues for Locking and fails fast for OCC.

🤖 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 `@tx_service/src/tx_execution.cpp` around lines 1832 - 1847, Revise the comment
around the catalog read-for-write lock acquisition to separate lock
responsibilities: describe ReaderWriterCntl::AddWriter as rejecting conflicting
writers with WriteConflict and returning WritePending only for remaining
readers, without attributing behavior to CcProtocol. Attribute Locking’s
enqueue-versus-OCC’s fail-fast behavior to NonBlockingLock::AcquireWriteIntent,
and retain the deadlock rationale.
🤖 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 `@tx_service/src/tx_execution.cpp`:
- Around line 565-568: Update the CommitTx fast-path guard to also verify that
locked_db_ contains no outstanding catalog-read locks before returning
immediately. Preserve the existing checks and add a concise comment documenting
that this boundary prevents read-only object-command transactions from bypassing
Reset() and leaving catalog locks unreleased.

---

Nitpick comments:
In `@tx_service/src/tx_execution.cpp`:
- Around line 1832-1847: Revise the comment around the catalog read-for-write
lock acquisition to separate lock responsibilities: describe
ReaderWriterCntl::AddWriter as rejecting conflicting writers with WriteConflict
and returning WritePending only for remaining readers, without attributing
behavior to CcProtocol. Attribute Locking’s enqueue-versus-OCC’s fail-fast
behavior to NonBlockingLock::AcquireWriteIntent, and retain the deadlock
rationale.
🪄 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: 8dbfb811-737b-4ac9-8bb5-02e265907dde

📥 Commits

Reviewing files that changed from the base of the PR and between b04468e and e4b6903.

📒 Files selected for processing (3)
  • docs/03-concurrency-control.md
  • tx_service/src/tx_execution.cpp
  • tx_service/tests/TestNodeSmoke-Test.cpp

Comment thread tx_service/src/tx_execution.cpp
Catalog read locks live in two places, and the fast path only accounted for
one. Besides the metadata read set, Process(ObjectCommandOp) stores a
catalog lock per database in locked_db_, released by ReleaseCatalogsRead()
from Reset(). A transaction holding only those would still take the
immediate path and return before they were released.

Only EloqKV populates locked_db_ (object commands, FLAGS_cmd_read_catalog
defaults on), and its auto-commit commands never reach CommitTx -- the
already_committed branch of PostProcess(ObjectCommandOp) resets directly,
which releases the locks synchronously. So this affects explicit
multi-command transactions, where the extra wait is proportionate to the
work they already do.

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

liunyl commented Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

rw_set_.MetaDataReadSetSize() does not cover catalog locks stored in locked_db_.

Confirmed and fixed in c18dd39 — the guard now also requires every locked_db_ slot to be empty, with a comment naming both places a catalog read lock can live (meta_data_rset_, released by ReleaseMetaDataReadLock() during post-processing; locked_db_, released by ReleaseCatalogsRead() from Reset()) and why returning while either is held matters.

Two notes on the blast radius, since the change moves work off the immediate path:

  • locked_db_ is populated only by Process(ObjectCommandOp&), i.e. the EloqKV object-command path; EloqDoc never reaches it. FLAGS_cmd_read_catalog does default to true, so it is live there.
  • EloqKV's auto-commit commands do not reach CommitTx at all: the already_committed branch of PostProcess(ObjectCommandOp&) calls Reset() directly, which releases those locks synchronously. So the additional wait applies to explicit multi-command transactions, not to the per-command hot path.

@liunyl
liunyl merged commit 5a76896 into main Aug 12, 2026
10 checks passed
@liunyl
liunyl deleted the fix/metadata-only-tx-close branch August 12, 2026 14:05
liunyl added a commit to eloqdata/eloqdoc that referenced this pull request Aug 12, 2026
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 added a commit to eloqdata/eloqdoc that referenced this pull request Aug 12, 2026
…e, cursor lifetime (#491)

* fix: surface catalog DDL conflicts as retryable WriteConflictException

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>

* fix: reference-count cached Collections and pin holders across catalog 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>

* fix: cursor lifetime across wire operations and concurrent DDL

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>

* build: bump data_substrate to fix/catalog-write-intent-fail-fast

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>

* fix: abandon the snapshot before write-conflict backoff

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>

* build: bump data_substrate for metadata-only tx close fix

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>

* fix: close a stashed cursor's scan at detach, not at every save

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>

* build: bump data_substrate for corrected catalog fail-fast rationale

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>

* build: repoint data_substrate at the post-merge follow-up branch

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>

* build: bump data_substrate to tx_service main

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>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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.

1 participant