Skip to content

fix: retain scan reads until transaction finalization - #526

Merged
liunyl merged 34 commits into
mainfrom
codex/issue-508-defer-read-release
Jul 18, 2026
Merged

fix: retain scan reads until transaction finalization#526
liunyl merged 34 commits into
mainfrom
codex/issue-508-defer-read-release

Conversation

@liunyl

@liunyl liunyl commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Context

Fixes #508.

This PR fixes two scan-ownership defects:

  1. Scan close/drain paths could release semantic reads and scanner-only pins before transaction commit or abort.
  2. A later hash-scan batch ignored a completed CC-map memory source and scanned it again while store buckets were still unfinished. That rescan could materialize the same CCE again and add another ReadIntent that was not part of the intended batch ownership.

The second defect is not the previously proposed KV-callback/continuation race. A live in-batch continuation exists only while the memory pass is unfinished; memory_scan_is_finished_ becomes true only after the pass reaches the CC-map end.

Behavior before and after

Before:

  • scan close/drain could remove read ownership while the transaction was still active;
  • after a core completed its memory pass, a later client batch could enter that CC map again whenever at least one KV bucket was still unfinished;
  • the redundant memory pass could add another ReadIntent and duplicate memory-source work.

After:

  • semantic scan reads and scanner-only pins remain accounted for until transaction finalization;
  • a core whose memory source is still marked finished starts any required KV fetches, skips the CC map, and adds no new memory-source ReadIntent;
  • existing transaction-owned intents remain until commit or abort.

Implementation

  • Remove the scan-specific early-release operation and keep returned locking reads in the transaction read set.
  • Consolidate remaining, trailing, range-resume, and error-path scanner ownership in DrainScanner, using release-only entries for implementation pins that must not participate in OCC version validation.
  • Preserve transaction-lifetime backing for scan table-name views instead of duplicating table-name state.
  • In both local and remote hash-scan Execute paths:
    • keep term validation and the original terminal checks at the top;
    • launch fetches for unfinished KV buckets as before;
    • on a first-entry path with a finished memory source, call SetFinish before constructing a CC-map iterator;
    • retain exact DecrReadIntent cleanup for a real in-batch continuation.
  • Document that FetchBucketData fills a separate KV scan cache rather than the CC map.
  • Keep local docs/superpowers/ working notes ignored and outside the PR.

Design decisions and alternatives

HashParitionCcScanner::Merge already provides the required frontier invariant. If it trims memory tuples behind the new pause key, it resets memory_scan_is_finished_ to false. If the flag remains true, the snapshot-like memory source has no unconsumed tuples for the next batch, so rescanning it is unnecessary.

This reuses the existing progress flag and SetFinish fetch-wait/merge path. It does not add generation tracking and does not reset finished memory progress on every request reset. Table read-lock lifetime already prevents concurrent drop/recreate while the scan accesses the table.

The local and remote paths use their existing equivalent flags: BucketScanProgress::memory_scan_is_finished_ and RemoteScanNextBatch::memory_is_drained_.

Test plan

  • Unit/CTest coverage
  • Parent-project integration or manual validation
  • Formatting/build checks
  • Recovery, compatibility, or performance validation, when relevant
  • Documentation updated, when behavior changed

Commands and results:

env LD_LIBRARY_PATH=/data/workspace/eloqkv/data_substrate/third_party/install/lib \
  cmake --build bld --parallel 4
# passed

env LD_LIBRARY_PATH=/data/workspace/eloqkv/data_substrate/third_party/install/lib \
  ./bld/tx_service/tests/TxConsistency-Test [tx]
# 121 assertions in 1 test case passed

env LD_LIBRARY_PATH=/data/workspace/eloqkv/data_substrate/third_party/install/lib \
  ctest --test-dir bld --output-on-failure --parallel 4
# 51/51 tests passed in 27.31 s

/home/ubuntu/.local/bin/clang-format --dry-run --Werror \
  tx_service/include/cc/template_cc_map.h \
  tx_service/tests/TxConsistency-Test.cpp
git diff --check
# passed

TDD evidence for the finished-memory regression:

  • RED on the prior implementation: the supposedly finished memory source was scanned and its cache size was 1 instead of 0.
  • GREEN after the skip: the memory cache stays empty, the existing semantic intent count stays at 1, and commit releases it to 0.

Not run locally: a dedicated remote/failover runtime fixture, a real asynchronous-KV callback/re-drive fixture, recovery/WAL compatibility tests, and performance benchmarks. Parent EloqKV integration is triggered separately by advancing its submodule pointer.

Risk assessment

  • Treating a completed hash memory source as snapshot-like means later CC-map mutations are not reopened after Merge preserves the finished flag; this is the intended ScanBatch behavior.
  • Keeping scan ownership until finalization can increase read-set footprint and writer blocking for long locking scans.
  • The local and remote skip paths are symmetric and compiled, but the remote async fetch/re-drive path has no dedicated unit fixture.
  • No WAL, storage format, network protocol, or configuration changes.

Rollback plan

Revert this PR. No migration or configuration rollback is required.

Reviewer guide

  1. tx_service/include/cc/template_cc_map.h: local and remote finished-memory skip after KV fetch launch and before CC-map iteration.
  2. tx_service/include/cc/ccm_scanner.h: existing Merge frontier reset that makes the skip safe.
  3. tx_service/tests/TxConsistency-Test.cpp: real CC-map regression proving empty memory cache and unchanged intent ownership.
  4. tx_service/src/tx_execution.cpp: transaction-final scanner ownership transfer.
  5. docs/03-concurrency-control.md and docs/04-transaction-execution.md: scan-source and ownership invariants.

Follow-up work

Add a dedicated async store-handler fixture if callback/re-drive behavior needs direct unit coverage.

@coderabbitai

coderabbitai Bot commented Jul 14, 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

Walkthrough

The scanner lifecycle now retains eligible read ownership through transaction cleanup, removes the extra-lock release operation, updates local and remote continuation handling, and adds documentation, mock scanner support, and consistency tests for commit, abort, mismatch, and resume behavior.

Changes

Scan ownership and continuation cleanup

Layer / File(s) Summary
Ownership contract and scan cleanup model
tx_service/include/cc/cc_entry.h, tx_service/include/read_write_set.h, tx_service/include/tx_execution.h, tx_service/include/tx_operation.h, tx_service/include/tx_request.h, tx_service/src/tx_execution.cpp, tx_service/src/tx_operation.cpp, docs/04-transaction-execution.md
Scan draining retains eligible read intents, range scans preserve resume tuples, and the obsolete extra-lock operation and drain buffer are removed. Scan request documentation and table-name construction are updated.
Local and remote continuation cleanup
tx_service/include/cc/template_cc_map.h, docs/03-concurrency-control.md
Local and remote scan continuation paths use DecrReadIntent, defer shard completion checks until tuple processing, and distinguish internal pins from range resume ownership.
Scanner construction and ownership validation
tx_service/tests/include/mock/mock_catalog_factory.h, tx_service/tests/TxConsistency-Test.cpp, .gitignore
The mock catalog creates a primary hash scanner, while consistency tests verify ownership retention, mismatch cleanup, continuation read-intent counts, and commit/abort cleanup.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant TransactionExecution
  participant CcHandler
  participant NonBlockingLock
  Client->>TransactionExecution: Close transaction scan
  TransactionExecution->>TransactionExecution: DrainScanner(retain_range_resume_tuple)
  TransactionExecution->>NonBlockingLock: Retain eligible read intents
  TransactionExecution->>CcHandler: Close scanner
  CcHandler-->>Client: Complete scan close
Loading

Possibly related PRs

Poem

I’m a rabbit guarding each scan,
Keeping read intents in the plan.
Close the scan, let cleanup wait,
Commit or abort decides its fate.
Hop through resumes, locks stay bright! 🐇

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The .gitignore update for docs/superpowers appears unrelated to #508 and the scan-retention objectives. Remove the .gitignore change unless it is required for this PR, or explain its direct relationship to the scan-retention fix.
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes address #508 by retaining scan/read-intent ownership until commit or abort cleanup and adding regression coverage.
Title check ✅ Passed The title clearly summarizes the main change: scan reads are retained until transaction finalization.
Description check ✅ Passed The description follows the template closely and covers context, behavior, implementation, tests, risks, rollback, reviewer guide, and follow-up work.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/issue-508-defer-read-release

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.

Comment thread tx_service/tests/TxConsistency-Test.cpp
@liunyl
liunyl marked this pull request as ready for review July 14, 2026 05:49

@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: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
tx_service/src/tx_execution.cpp (1)

6251-6254: 🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

Pass kickout_data_op to the trace macro.

Line 6253 references kickout_data_all_op, which is not in scope and breaks trace-enabled builds.

Proposed fix
     TX_TRACE_ACTION_WITH_CONTEXT(
         this,
-        &kickout_data_all_op,
+        &kickout_data_op,
🤖 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 6251 - 6254, Update the
TX_TRACE_ACTION_WITH_CONTEXT invocation in the kickout-data execution flow to
pass the in-scope kickout_data_op symbol instead of kickout_data_all_op.
Preserve the existing trace context lambda and macro behavior.
🧹 Nitpick comments (1)
tx_service/tests/TxConsistency-Test.cpp (1)

250-293: 🩺 Stability & Availability | 🔵 Trivial | 🏗️ Heavy lift

Add lifecycle tests for scanner-only cleanup entries.

These scenarios cover a returned primary tuple, but not the changed range-middle last-tuple, trailing-tuple, or error-drain paths. Add deterministic commit/abort cases verifying those release-only entries remain owned until finalization.

🤖 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/tests/TxConsistency-Test.cpp` around lines 250 - 293, Add
deterministic lifecycle scenarios covering scanner-only cleanup entries for
range-middle last-tuple, trailing-tuple, and error-drain paths, in addition to
the existing returned-primary cases. Mirror the Scenario 4 commit and Scenario 5
abort structure, verify each captured scan read remains owned before
finalization, and assert ownership is released after Commit or Abort.
🤖 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 `@docs/04-transaction-execution.md`:
- Around line 40-45: Update the transaction read-set description to qualify that
only returned tuples requiring locking are retained; tuples returned with NoLock
are excluded unless separately pinned for scanner cleanup. Preserve the existing
explanation of scanner cleanup CCEs and commit/abort release behavior.

In `@tx_service/tests/TxConsistency-Test.cpp`:
- Around line 98-120: Clear the reused batch vector before each subsequent scan
request in the loop around ScanBatchTxRequest, ensuring ScanNextOperation
receives an empty scan_batch and preserving the existing target-search logic.

---

Outside diff comments:
In `@tx_service/src/tx_execution.cpp`:
- Around line 6251-6254: Update the TX_TRACE_ACTION_WITH_CONTEXT invocation in
the kickout-data execution flow to pass the in-scope kickout_data_op symbol
instead of kickout_data_all_op. Preserve the existing trace context lambda and
macro behavior.

---

Nitpick comments:
In `@tx_service/tests/TxConsistency-Test.cpp`:
- Around line 250-293: Add deterministic lifecycle scenarios covering
scanner-only cleanup entries for range-middle last-tuple, trailing-tuple, and
error-drain paths, in addition to the existing returned-primary cases. Mirror
the Scenario 4 commit and Scenario 5 abort structure, verify each captured scan
read remains owned before finalization, and assert ownership is released after
Commit or Abort.
🪄 Autofix (Beta)

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

Run ID: c9d27a79-aeb6-4979-9307-afc96f553ed6

📥 Commits

Reviewing files that changed from the base of the PR and between a1162d3 and 5338dab.

📒 Files selected for processing (11)
  • docs/04-transaction-execution.md
  • docs/superpowers/plans/2026-07-14-defer-data-read-release.md
  • docs/superpowers/specs/2026-07-14-defer-data-read-release-design.md
  • tx_service/include/cc/cc_entry.h
  • tx_service/include/read_write_set.h
  • tx_service/include/tx_execution.h
  • tx_service/include/tx_operation.h
  • tx_service/src/tx_execution.cpp
  • tx_service/src/tx_operation.cpp
  • tx_service/tests/TxConsistency-Test.cpp
  • tx_service/tests/include/mock/mock_catalog_factory.h
💤 Files with no reviewable changes (3)
  • tx_service/include/tx_operation.h
  • tx_service/include/cc/cc_entry.h
  • tx_service/src/tx_operation.cpp

Comment thread docs/04-transaction-execution.md Outdated
Comment thread tx_service/tests/TxConsistency-Test.cpp
Comment thread tx_service/tests/TxConsistency-Test.cpp
@liunyl

liunyl commented Jul 14, 2026

Copy link
Copy Markdown
Contributor Author

Reviewed the remaining outside-diff and nitpick feedback:

  • The trace-enabled KickoutDataOp symbol finding was valid and is fixed in bb6a5bb.
  • Direct range-middle, trailing-only, and injected error-drain lifecycle fixtures would be useful additional coverage, but the current TestNode harness only exposes a hash primary scanner and has no deterministic range or scan-error injection path. Building those facilities would be a separate engine-test project rather than a focused fix. This PR retains the deterministic commit/abort reproduction of the original race, records the exact fixture gap in the PR follow-up, and routes the reviewed cleanup paths through the same RetainScanReadForRelease and RetainScanTrailingReads helpers.

Comment thread tx_service/tests/TxConsistency-Test.cpp
Comment thread tx_service/tests/TxConsistency-Test.cpp
Comment thread tx_service/src/tx_execution.cpp Outdated
Comment thread tx_service/src/tx_execution.cpp Outdated
Comment thread tx_service/include/tx_operation.h Outdated
Comment thread tx_service/src/tx_execution.cpp Outdated
Comment thread tx_service/src/tx_execution.cpp
{
std::vector<const ScanTuple *> last_tuples;
scanner->MemoryShardCacheLastTuples(&last_tuples);
for (const ScanTuple *tuple : last_tuples)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

does hash partition scan also put a read intent lock on the last tuple for resume? If so we need to handle that too. The old code commented the resume tuple handling logic for some reason but I'm not sure if that's right

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Hash scans do acquire read intents, but they are not last-tuple pins that survive a completed ScanNextBatchCc. The current hash path uses intents only across an internal block/self-reenqueue and releases the prior_cce/end_it intents on re-entry. If the cache is full, it finishes without acquiring a new resume intent; the next transaction-level batch resumes from BucketScanProgress::pause_key_, not a CCE address.

Range scans are different: ScanSliceCc explicitly acquires a read intent on last_cce for SlicePosition::Middle, stores its lock address in the last tuple, and the next request passes that address as prior_cce_lock. Therefore only the range path needs the special ScanClose retention. The old hash block was commented out by the hash-partition scan refactor (#149), when cross-batch resume changed to the pause key. I will qualify the code/doc wording; no hash ScanClose branch is needed.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

There is a separate issue that needs to be resolved: evaluating scan next of hash-partitioned cc map may leave orphaned locks on the cc map side.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

You were right; my previous reply conflated client-visible batch resume with resume inside one ScanNextBatchCc.

A hash scan does not retain the last returned tuple as a cross-batch resume anchor—the next client batch starts from pause_key_. However, when one memory pass reaches the 128-entry budget, the same CC request self-reenqueues and holds counted ReadIntents on the next unprocessed CCE and any finite-end CCE in blocking_info_.

That internal pin could be orphaned. The deterministic interleaving is: stale memory_scan_is_finished_ == true with KV still unfinished; the memory pass yields and pins; the final KV callback runs ahead of the queued continuation and marks the shard drained; the continuation then returns through ShardIsDrained() -> SetFinish() before the old normal-release block. This is a serialized same-shard queue-order race, not a C++ data race.

The updated branch fixes the lifecycle rather than adding a hash ScanClose last-tuple branch:

  • SetFinish/SetError consume pending continuation/end ownership exactly once for local and remote scans.
  • Normal and terminal paths use exact DecrReadIntent, so another intent count owned by the transaction is preserved; blocked paths release the actually granted lock type.
  • Reset clears stale memory-finished/error state unless memory and all KV buckets are truly complete.
  • Each saved wrapper address carries its generation. A transient term error can still release a matching live pin, while teardown/reuse cannot consume ownership belonging to the new object at the same address.
  • Scan-error scanner ownership is retained in the transaction read set for final commit/abort cleanup.

TDD: the original reproducer was RED at 203/205 assertions; an error-code-only term fix was RED at 402/403; disabling the normal-resume generation guard was RED at 406/408. The final focused test is GREEN at 408/408, repeated 10/10, and the full suite passes 51/51. The branch and PR description are now updated at b2d794e.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Corrected in f48bff6.

The orphan risk is real, but my previous diagnosis and the generation-based fix were unnecessarily broad. In particular, memory_scan_is_finished_ is cross-batch scan progress, not stale request state that should be reset. The scan may enter the CC map again while KV backfill is still incomplete, and a continuation may then be pending when the shard becomes drained. This does not require recreating the table, and it is not limited to inserting 128 new keys: the 128-entry visit budget is one way to self-reenqueue, while lock/future blocking can also create a pending continuation.

The actual ownership bug is the order inside Execute: ShardIsDrained()/SetFinish() ran before the existing code that consumes blocking_info_. Therefore a resumed request could finish without decrementing its continuation and finite-end ReadIntent references.

The final fix is intentionally small:

  • keep the term check first, so a term mismatch never dereferences old lock addresses;
  • when a continuation address exists, run the existing resume cleanup before the terminal checks;
  • use DecrReadIntent to consume exactly this request's reference, preserving any semantic intent held by the transaction;
  • make the same ordering change in the local and remote paths;
  • leave memory_scan_is_finished_ unchanged and remove all generation/reset machinery from the final diff.

TDD on the original ordering was RED: the terminal path left the intent count at 2 instead of 1 (117/118 assertions). The fixed path is GREEN at 121/121, and the full suite passes 51/51.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Corrected again in d8b1493 after tracing Merge, SetFinish, and both local/remote hash-scan paths end to end.

My previous callback/continuation race diagnosis was wrong. A live NoBlocking continuation is created only when the current memory pass is unfinished, while memory_scan_is_finished_ becomes true only when that pass reaches the CC-map end and immediately calls SetFinish. Therefore the state used by the earlier reproducer — finished memory plus a live continuation pin — is not produced by the real execution path.

The actual bug is simpler: on a later client batch, if memory was already finished but any KV bucket was still unfinished, ShardIsDrained() returned false and Execute entered the CC map again. That redundant memory scan could materialize the same CCE and add another ReadIntent.

The minimal fix is now:

  • start unfinished KV fetches as before;
  • if this is a first-entry path and the core memory source is already finished, call SetFinish before constructing the CC-map iterator;
  • make the same change in local and remote paths;
  • retain existing transaction-owned semantic intents until commit/abort;
  • keep exact DecrReadIntent cleanup for genuine in-batch continuations;
  • add no generation tracking and do not reset completed memory progress.

This is safe because HashParitionCcScanner::Merge already resets memory_scan_is_finished_ to false whenever trimming memory tuples leaves work behind the new pause key. If the flag remains true, there are no unconsumed memory tuples for this snapshot-like scan.

TDD: the real TestNode regression was RED on the prior code because the finished memory cache contained 1 tuple instead of 0. It is GREEN after the fix: memory cache 0, existing semantic intent count remains 1, and commit releases it to 0. The focused test passes 121/121 assertions and the full CTest suite passes 51/51.

@liunyl
liunyl force-pushed the codex/issue-508-defer-read-release branch from d8b1493 to a78965a Compare July 18, 2026 07:47
@liunyl
liunyl merged commit 87ed815 into main Jul 18, 2026
10 checks passed
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.

Use-after-free: PostReadCc dereferences a recycled NonBlockingLock when a read-intent is released before its post-read (under CleanDataForTest kickout)

3 participants