Skip to content

[CBRD-27034] Serialize DELETE write-write conflicts on the MVCC stamp under a transient row lock - #7698

Open
hgryoo wants to merge 20 commits into
CUBRID:developfrom
hgryoo:CBRD-27034_pr1
Open

[CBRD-27034] Serialize DELETE write-write conflicts on the MVCC stamp under a transient row lock#7698
hgryoo wants to merge 20 commits into
CUBRID:developfrom
hgryoo:CBRD-27034_pr1

Conversation

@hgryoo

@hgryoo hgryoo commented Aug 13, 2026

Copy link
Copy Markdown
Member

http://jira.cubrid.org/browse/CBRD-27034

Purpose

An MVCC DELETE holds a per-row X-lock until commit: O(rows) lock-table entries, longer bucket chains, and escalation to a table X-lock past lock_escalation_at.

  • AS-IS: the per-row X-lock duplicates the write-owner the record header already carries in mvcc_del_id, and is retained until commit.
  • TO-BE: the delete phase still takes the row X-lock, but only across verdict → publish (DELID stamp + index maintenance), releasing it once the delete is published. Ownership then lives in the header: a late-arriving writer reads the owner MVCCID from the stamp and waits on that transaction's self-lock (the CBRD-26942 primitive), then re-reads — at most one re-verdict; a second conflicting owner fails the statement with ER_CANNOT_GET_LOCK rather than spin.

Uncommitted write-lock footprint becomes O(transactions): 1 granted X entry while holding an uncommitted 1,900-row DELETE (baseline ~1,900).

Implementation

  • src/transaction/log_tran_table.c, log_impl.hlogtb_is_active_other_mvccid (the is-another-writer-active predicate) and logtb_wait_for_tran_end (the wait on an owner's MVCCID self-lock: uncond S_LOCK, release; a no-op grant fails the statement instead of spinning).
  • src/transaction/locator_sr.c — the heap fetch treats an in-progress DELID of another active transaction as "published, row lock released": wait the owner out (our row lock is held, so at most one wait), then re-read and re-classify. With no reevaluation data, a version that changed after the statement snapshot is skipped (skip_unevaluated_version), not modified unevaluated.
  • src/transaction/locator_sr.clocator_lock_and_get_object_with_evaluation establishes the reevaluation verdict for the fetch it performs (V_TRUE on entry). It used to leave filter_result untouched when the last version was visible — that object needs no reevaluation — but the field is initialized once per statement, so the previous object's V_FALSE was read back as this one's: a DELETE of ten matching rows with one of them flipped by a concurrent transaction deleted 0 rows instead of 9. Reachable here because DELETE now reevaluates; a multi-object qexec_execute_selupd_list can reach it on develop as well. Reported by Greptile on [CBRD-27238] Extend the transient row lock to UPDATE, with reevaluation against the latest version #7711.
  • src/storage/btree.c — the unique probes wait on the conflicting INSID/DELID owner and restart from root (a published deleter holds no row lock the probe could suspend on); same for the FK existence check's DELETE_IN_PROGRESS path.
  • src/parser/xasl_generation.c — DELETE plans stop forcing the select-phase row lock and keep mvcc_reev_classes for condition-only reevaluation (GROUP BY / derived-table plans keep the select-phase lock as before).
  • src/query/query_executor.c, src/storage/heap_file.c — the delete loop binds inst_oid per tuple for reevaluation and releases the row lock at publish; heap_delete_logical ensures the MVCCID self-lock is held before the DELID stamp becomes observable, so "an observable in-progress DELID implies a held self-lock" holds by construction.

Remarks

  • Depends on CBRD-27079 (in develop): the prepare record persists the self-lock, so 2PC needs no per-row tracking.
  • Draft [CBRD-27034] Introduces implicit row locking for MVCC DELETE #7436 attacks the same problem by latch-first sealing. Here the transient row lock keeps the publish window atomic across all row replicas (heap + index entries) and keeps waits visible to the deadlock detector, so the heap layer needs no seal/re-dispatch machinery; [CBRD-27034] Introduces implicit row locking for MVCC DELETE #7436 stays open as the reference for that alternative.
  • Under READ COMMITTED the serialization point of two DELETEs of the same row moves from the fetch to the stamp; abort counts are unchanged, only which side wins inside that window can differ.
  • The lockless scan's silent skip of a still-matching changed version is closed by the condition-only reevaluation: 0 skipped / 2,000 racing DELETEs (~0.1% before).
  • SELECT ... FOR UPDATE, non-MVCC operations and DDL are out of scope and unchanged; UPDATE keeps its per-row X-lock in this PR. Follow-ups: UPDATE on the same protocol, then the lockless FK existence check (CBRD-26664).

Test

Debug build, asserts active: write-write endings (second deleter waits, then 0-rows on commit / deletes on rollback), FK child-insert vs parent-delete both endings, deadlock resolves with a single victim, savepoint partial rollback, REC_BIGONE rows, multi-class DELETE; 90s full-mix gate (3 deleters + 2 inserters + 3 key-moving updaters): heap count == index count, checkdb clean; reevaluation gate: 0 skips / 2,000; the reevaluation verdict is not inherited across rows (10 matching rows, 1 flipped mid-statement → 9 deleted, 1 left). Medium suite as CI runs it: Fail 0 / Success 975 / Total 975.

Release build: hot-row del/upd mix 30s/11s vs baseline 34s/38s (a sealed prototype: 156s/179s); Num_object_locks_waits −46% (cross-checked against self-lock accounting); data-page fetches and cold-buffer ioreads at parity; during a single 150k-row DELETE (lock_escalation_at=100k) a concurrent writer completed 293 single-row inserts vs 51 on baseline, the bulk statement itself at parity; 10-cycle soak with checkdb between cycles.

hgryoo and others added 5 commits August 13, 2026 20:43
…val meets an in-progress DELID

A transient-row-lock DELETE releases its per-row lock once the delete is
published, so a later writer that acquires the row lock can find an active
other transaction's DELID in the record header -- a state the X-lock world
never produced. Teach every late arrival to settle against the deleter's
transaction self-lock (CBRD-26942) instead of misreading the row as deleted
or spinning on a lock the deleter no longer holds:

- locator_lock_and_get_object_internal: on an active-other DELID, wait on
  the owner's self-lock while holding the row lock (no third writer can
  re-stamp the row, so at most one wait is needed), then re-read and
  re-classify. A grant while the owner is still active is an invariant
  breach; fail the statement rather than spin.
- btree unique probes (unique / non-unique): extend the insert-MVCCID wait
  to DELETE_RECORD_DELETE_IN_PROGRESS -- the row-lock fallback cannot
  serialize with a deleter that already released its lock.
- FK existence check (btree_fk_object_does_exist): same extension; the
  object-lock suspend would be granted at once and busy-spin through the
  re-check loop until the deleter ends.

logtb_is_active_other_mvccid is ported as the shared "is there anything to
wait on" predicate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lete phase under a statement-snapshot pin

Plan generation stops forcing PT_SELECT_INFO_MVCC_LOCK_NEEDED on DELETE, so
the select phase reads with the snapshot only and the delete phase acquires
the per-row X-lock itself (the existing need_locking mode; GROUP BY and
derived-table plans keep the select-phase lock as before).

Predicate reevaluation stays disabled: its machinery is unfinished -- the
DELETE loop never binds a per-tuple inst_oid, and the reevaluation filters
point into the select scan that is already closed by force time (measured:
V_FALSE regardless of the predicate). Instead, a last version that is not
visible to the statement snapshot was never checked against the predicate,
and mvcc_update_reev_data::skip_unevaluated_version now skips such a row at
the delete phase rather than deleting an unevaluated version. Under RR /
SERIALIZABLE the existing isolation-conflict classification is unchanged;
under READ COMMITTED the skip surfaces as 0 rows for that row (measured at
~0.1% under a predicate-invariant update race, matching the prior baseline
of this delta).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Write-write ownership of a deleted row is the MVCCID stamp: once DELID is
stamped on the record header and the row's index entries, a later writer
settles against the deleter's transaction self-lock, and the per-row lock's
remaining job -- atomicity of the claim across the row's replicas -- is
done. Release it right after the row force instead of holding it to commit;
the uncommitted write-lock footprint becomes O(transactions), not O(rows).

- heap_delete_logical ensures the deleter's self-lock before the stamp can
  become observable (same pattern as the insert and update paths).
- qexec_execute_delete releases via lock_unlock_object_donot_move_to_non2pl
  (the same discipline the reevaluation V_FALSE path already uses); the
  count-based unlock preserves grants taken by earlier statements of the
  same transaction.
- The row is tracked like a lockless insert so 2PC prepare re-locks it
  (CBRD-27079 fallback); if the self-lock or the tracking cannot be
  ensured, the row lock is simply kept (baseline behavior).

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

DELETE joins the condition-only side of the reevaluation machinery that the
UPDATE extension completed: plan generation keeps the condition-reevaluation
flags, and the executor binds the tuple's instance OID per row -- the one
piece the DELETE loop never had. When a row's version changed after the
statement snapshot, the delete phase now re-checks the predicate against the
latest version and deletes it on a match, exactly what the baseline's
select-phase lock-and-wait produced. The silent skip of an updated-but-
still-matching row is gone (measured: 0/2000 under a predicate-invariant
update race, previously ~0.1%); a version that stops matching is skipped as
before. Plans that generate no reevaluation data keep the snapshot-pin skip
as the conservative path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…and drop the superseded 2PC tracking

The early unlock was gated on skip_unevaluated_version as a stand-in for
"the select phase did not lock"; with the condition-only reevaluation
that flag is now false whenever reevaluation data exists, which would
keep every reevaluating DELETE at the O(rows) footprint. Gate on
need_locking directly -- it is true exactly when the transient protocol
ran the delete-phase lock.

logtb_track_lockless_insert is gone since CBRD-27079: the prepare
record persists the MVCCID self-lock, so an in-doubt transaction keeps
serializing late arrivals without per-row tracking.

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

github-actions Bot commented Aug 13, 2026

Copy link
Copy Markdown

❌ TC Merge Gate — Merge Blocked

One or more TC PRs are still open. Please merge or close them before merging this PR.

TC Repositories & Branches:

  • cubrid-testcases: TC PR tc/pr-7698 is open (draft) — must be merged or closed first
  • cubrid-testcases-private-ex: TC PR tc/pr-7698 is open (draft) — must be merged or closed first

Steps to unblock:

  1. Merge or close all TC PRs listed above.
  2. Re-run this check: Actions tab → TC Merge Gate → Re-run failed jobs

@github-actions

Copy link
Copy Markdown

🧪 TC Test Environment Ready

CircleCI Testing:

  • CircleCI will automatically test using the branches below.

TC Repositories & Branches:

Next Steps:

  1. Wait for CircleCI tests to complete
  2. If CircleCI tests failed, please check the test results and fix the issues.
  3. When ready to merge this PR, please merge the TC PR first, then merge this PR.

@greptile-apps

greptile-apps Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Reviews (1): Last reviewed commit: "[CBRD-27034] Gate the delete early unloc..." | Re-trigger Greptile

…end and trim comments to their invariants

The S_LOCK-then-release wait protocol existed three times (btree helper,
locator inline, and the btree key-level wrapper via the helper); since it
now serves deleters as well as inserters, the btree names also lied
(btree_wait_for_inserter_end waiting out a deleter). One logtb function
carries the protocol; btree_key_wait_for_tran_end keeps only the
latch/lock-release choreography, and the duplicate is_active predicate is
gone. Comments now state the invariant or constraint and drop the
narration; two grown stale (they still said DELETE has no reevaluation)
are corrected.

The wait sequence itself is unchanged: uncond S_LOCK on the owner's
MVCCID self-lock, release, then fail the statement if the owner is
somehow still active.

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

hgryoo commented Aug 13, 2026

Copy link
Copy Markdown
Member Author

/run all

hgryoo and others added 2 commits August 13, 2026 23:11
…e DELETE path

The condition-only reevaluation reads MVCC_UPDDEL_REEV_DATA fields that
only the UPDATE path fills (prepare_mvcc_reev_data): curr_assigns,
curr_attrinfo, vd, cons_pred, the extra-assign pair, and the lazily
built mvcc_cond_reev_list. qexec_execute_delete left them as stack
garbage, so on release builds a DELETE with reevaluation data could
take the assignment branch on a garbage pointer and fail the statement
with the generic "Query execution failure" (-495). Debug builds masked
it with zeroed stack. Caught by the medium suite's path-expression
DELETEs (del1/del2/del4/del6/del7); all five pass on debug and release
after the fix.

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

Field-by-field zeroing at the call sites is how the DELETE path came to
read stack garbage in the first place; the empty, condition-only state
now lives on mvcc_update_reev_data itself, so every present and future
stack instance starts safe and a call site only binds what it actually
uses (the executor's value descriptor, and the UPDATE path's assignment
fields via prepare_mvcc_reev_data).

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

hgryoo commented Aug 13, 2026

Copy link
Copy Markdown
Member Author

/run all

Giving mvcc_update_reev_data member defaults made it non-trivial, so
clearing it with memset is rejected (-Werror=class-memaccess). Assign a
value-initialized instance: same empty state, now defined by the type
rather than restated at the call site.

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

hgryoo commented Aug 13, 2026

Copy link
Copy Markdown
Member Author

/run all

@hgryoo hgryoo self-assigned this Aug 13, 2026
@hgryoo

hgryoo commented Aug 13, 2026

Copy link
Copy Markdown
Member Author

/run all

… re-check the predicate

The delete phase resolves each reevaluation class to its access spec by
exact class OID (qexec_upddel_mvcc_set_filters). That resolution only
holds for a spec the statement deletes from that scans a single class:
a class the condition merely references is fetched rather than scanned,
and a hierarchy scan reports subclass OIDs no spec carries. In both
cases the helper finds no spec and fails the statement -- and it
returns ER_FAILED with no error set, so the statement surfaces the
generic "Query execution failure" (-495) instead of a diagnosis.

Enabling DELETE reevaluation made that dormant path reachable: the
medium suite's path-expression and hierarchy deletes (del1, del2, del4,
del6, del7 on joe.shipment_c / joe.inventory_c) failed. Those
statements now keep the select-phase lock, as they did before, so the
delete phase never has to re-check what it cannot evaluate. The
transient row lock still covers a DELETE from a single non-hierarchy
class, which is what the footprint and reevaluation gates measure.

Reproduced by running the medium suite the way CTP does -- one JDBC
connection, _01_fixed then _02_xtests in name order -- where the five
cases fail and, with this change, pass; all 26 del/updat cases match
their answer files, and the write-write, footprint, reevaluation
(skip 0/2000) and full-mix gates stay green.

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

hgryoo commented Aug 13, 2026

Copy link
Copy Markdown
Member Author

/run all

hgryoo and others added 3 commits August 14, 2026 02:43
qexec_upddel_mvcc_set_filters returns ER_FAILED when it cannot match a
reevaluation class to an access spec, without setting an error, so the
statement surfaces the generic "Query execution failure" (-495) and the
cause cannot be told from the message. Set ER_QPROC_INVALID_XASLNODE
there, so this failure names itself.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e cannot re-check the predicate"

This reverts commit 63088c3.
…ass no filters

qexec_upddel_mvcc_set_filters matches a reevaluation class to an access
spec by exact class OID. A DELETE whose predicate walks a path
expression reads an inner query's list instead of scanning the class,
so the plan carries no spec for it -- and the helper returned ER_FAILED
without setting an error, which surfaced as the generic "Query
execution failure" (-495). Enabling DELETE reevaluation made that
dormant path reachable and broke the medium suite's path-expression
deletes (del1, del2, del4, del6, del7).

A plan that does not scan the class has no filters to re-evaluate with,
which is a state to handle rather than an error: the helper now reports
it through has_spec, and the delete phase falls back to skipping a
version the statement never evaluated -- the same contract a plan
carrying no reevaluation data already has. The update phase keeps
treating it as a failure, as before.

Verified by running the medium suite through CTP as CI does (975 cases,
ha_mode on): 5 failures before, 0 after. Write-write, footprint,
reevaluation (skip 0/2000) and full-mix gates stay green.

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

hgryoo commented Aug 13, 2026

Copy link
Copy Markdown
Member Author

/run all

hgryoo and others added 2 commits August 14, 2026 13:37
…EY UPDATE

The duplicate the unique probe returns may carry an in-progress delete
by another transaction, which holds no per-row lock once it has
published. Reading the visible version hides that delete, so the update
overwrote a record its deleter still had to undo: rolling that deleter
back then found no delete stamp on the record and failed the server
(heap_rv_mvcc_undo_delete). Every other write path settles such a row
by waiting on the deleter's MVCCID; this one read around the protocol.

Fetch through locator_lock_and_get_object instead, which waits the
deleter out and re-reads under the row lock. If the deleter committed
in the meantime the key is free, so the row is no longer a duplicate
and the caller inserts, exactly as it would have had the probe found
nothing.

Reproduced with the online-index stress from cbrd_22705 (100 sessions
of update/delete/insert/replace/ODKU with rollbacks, against parallel
CREATE INDEX): the server aborted within 3-5 runs before, and survived
8 consecutive runs after. develop does not abort. medium suite 975/975,
write-write, footprint, reevaluation and full-mix gates green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The comments carried issue numbers as labels and narrated the change
rather than what holds afterwards. Say the invariant instead, so each
one still reads correctly after the next rewrite of the code under it,
and inline the external fact (the prepare record persists the self-lock
across 2PC) rather than pointing at an issue for it.

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

hgryoo commented Aug 14, 2026

Copy link
Copy Markdown
Member Author

/run all

locator_lock_and_get_object_with_evaluation () left mvcc_reev_data->filter_result
untouched when the last version was visible to the statement snapshot: that object
needs no reevaluation, the visible version being the one already evaluated. But the
verdict is initialized once per statement, so the previous object's V_FALSE was still
sitting there, and the callers that read the field right after the call skipped an
object that had failed nothing.

A DELETE of ten matching rows, one of them flipped by a concurrent transaction,
deleted 0 rows instead of 9 -- the flipped row correctly failed reevaluation and the
nine untouched rows inherited its verdict. On develop the two UPDDEL callers could not
reach this, both taking the already-locked-in-select-phase path; a multi-object
qexec_execute_selupd_list could, silently skipping an increment.

The field now describes the fetch that just happened: V_TRUE on entry, and every path
that decides otherwise writes its own result. This is the discipline the scan path
already keeps by calling set_scan_reevaluation () before each fetch.

Reported by Greptile on CUBRID#7711.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@hgryoo

hgryoo commented Aug 15, 2026

Copy link
Copy Markdown
Member Author

/run all

hgryoo and others added 4 commits August 16, 2026 02:49
prepare_mvcc_reev_data () value-initializes the reevaluation struct and then
returns early when the statement carries no reevaluation class, so the value
descriptor was bound only on the path that has one. Bind it before that early
return and drop the two assignments the change makes redundant -- the one in
qexec_execute_delete (), which prepare_mvcc_reev_data () overwrote immediately,
and the later duplicate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…spec

qexec_upddel_mvcc_set_filters () used to return ER_FAILED when the plan holds
no access spec for the class, and both update call sites went to the error
exit. Reporting the case through has_spec left those call sites ignoring it,
so the update phase ran on with a reevaluation entry whose filters init ()
never filled. Act on has_spec there and keep failing the statement; only the
delete phase is meant to treat the case as "no reevaluation possible".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… counter

mvcc_reev_class_cnt is the bound of the loop that consumes the OID - CLASS OID
pairs of the scan's value list, so zeroing it to say "reevaluation is off"
ended that loop early and left the pairs of the remaining reevaluation classes
unconsumed. The trailing LOB loop then read one of them as a LOB: an assert in
a debug build, and in a release build db_get_elo () reading the OID union.

Record the state in its own flag and consult it only where the reevaluation
data is used -- the set-filters call, the scan order setup, and the per class
loop. That loop also indexes mvcc_reev_classes without an upper bound, which
reads past the array once there are fewer reevaluation classes than deleted
classes; bound it while here.

Reproduced by multi_delete_3tables.sql and cbrd_25382_2.sql.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
num_reev_classes was counted over the delete statement's spec list, while
mvcc_reev_classes[] is filled from the generated SELECT's OID column list. A
class the SELECT drops -- an outer joined table none of whose columns the outer
query reads -- is counted but never gets an entry, so the tail of the array
stays uninitialized and the delete phase walks the value list past its end.

Report the number of entries the loop wrote, and bound the loop so it cannot
run past the allocation in the other direction either.

Reproduced by multi_delete_using_bit.sql. Unreachable until this branch, since
reevaluation was disabled for DELETE before the count was taken.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@hgryoo

hgryoo commented Aug 16, 2026

Copy link
Copy Markdown
Member Author

/run all

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