Skip to content

MDEV-40448 HEAP unique hash duplicate rejection re-hashes the full key value#5409

Open
arcivanov wants to merge 1 commit into
MariaDB:preview-13.1-previewfrom
arcivanov:MDEV-40448
Open

MDEV-40448 HEAP unique hash duplicate rejection re-hashes the full key value#5409
arcivanov wants to merge 1 commit into
MariaDB:preview-13.1-previewfrom
arcivanov:MDEV-40448

Conversation

@arcivanov

Copy link
Copy Markdown
Contributor

JIRA: MDEV-40448

Problem

A rejected duplicate insert into a HEAP unique hash index paid the full-value key hash twice: hp_write_key() hashed the key to insert it (the documented contract was "the record was still added and the caller must call hp_delete_key for it"), and hp_delete_key() then hashed the same record again just to locate the bucket to unlink from. For long key values (BLOB keys, MDEV-38975) the second scan dominates duplicate-heavy workloads: SELECT DISTINCT over 20-50KB blob values (~80% duplicates) regressed 25-32% vs Aria tmp tables at low concurrency, with my_uca_hash_sort_utf8mb4 doing 1.83x the hashing work for the identical query.

The hash caching added by de1765fb64d (HASH_INFO::hash_of_key) already made all chain surgery hash-free; the two full-value hashes per rejected row were the entire cost.

Fix

  1. Probe before insert (hp_write_key()): compute the hash once at the top; for HA_NOSAME keys without NULL key parts walk the key's chain under the pre-insert mask comparing cached HASH_INFO::hash_of_key values, comparing full key values only on a hash match. On a duplicate return HA_ERR_FOUND_DUPP_KEY with nothing modified; otherwise proceed with the linear-hash split and insertion reusing the already-computed hash. Records with equal full hashes share a bucket under any mask, so probing the pre-insert chain finds any duplicate. A successful insert costs exactly one full-value hash, as before; a rejected duplicate drops from two full hashes + insert + undo delete to one hash + compare, the same as a lookup.
  2. Error-path contract change: hp_write_key() no longer inserts the key on a duplicate, so heap_write() rolls back only the preceding keys (unconditional keydef--, as for BTREE/ENOMEM), and heap_update()'s duplicate path now re-inserts the old key for the failing keydef for both algorithms (previously BTREE-only) before rolling back earlier keys.
  3. Delete-side hash reuse (hp_delete_key()): when the row being deleted was positioned via the same hash index (flag set and info->current_hash_ptr->ptr_to_rec == recpos), take the hash from the index entry instead of re-scanning the key value; a DBUG_ASSERT cross-checks the cached hash in debug builds. This removes the remaining full-value hash from DELETE/UPDATE of rows located through the index (heap_rkey() already hashed the key).
  4. heap_rfirst()/heap_rlast(): clear info->current_hash_ptr when rejecting a hash index with HA_ERR_WRONG_COMMAND. Both functions retarget info->lastinx before the algorithm check, so a stale current_hash_ptr from a previous search on a different key could otherwise satisfy the new cached-hash guard in hp_delete_key() and send the bucket lookup to the wrong chain (reachable through the heap API only; the SQL layer never issues ordered reads on hash indexes).

Testing

New unit test hp_test_write_dup-t (105 assertions) wraps the key charset's hash_sort collation handler in a counting shim, asserting the exact number of full-value hashes for every operation: 1 per insert attempt (successful or rejected), 1 total for index-read + delete, 1 for an index-positioned re-key. Behavioral coverage: single- and multi-key rollback for INSERT and UPDATE duplicates, NULL key parts, non-unique keys, delete after rejected heap_rfirst()/heap_rlast(), and a 500-row duplicate-heavy stress across linear-hash splits (exactly 500 hashes; was 827 before the fix). All counting assertions were verified to fail before the fix and pass after, with the production code otherwise unchanged.

Full runs green on RelWithDebInfo and Debug: heap unit tests, hp_test1/hp_test2, MTR suite/heap (27/27), and main-suite distinct/group_by/count_distinct/derived/type_blob/temp_table/replace/insert_update/update/show_check/information_schema (14/14). The DBUG_ASSERT cross-check never fired.

Benchmark

16 threads, 120s recorded runs, 8c/16t AMD 7840HS; base = preview-13.1 without MDEV-38975 (Aria tmp tables), new = unfixed, fix = this patch:

test (QPS) base new fix
blob_case_c 4.65 3.36 (-28%) 5.61 (+21% vs base, +67% vs new)
blob_mixed 5.87 4.13 (-30%) 6.92 (+18% vs base, +68% vs new)

hp_delete_key() (38% inclusive before) is absent from the fixed profile; the low-concurrency regression becomes a win on top of the existing 2x+ high-concurrency wins.

…y value

A rejected duplicate insert into a HEAP unique hash index paid the
full-value key hash twice: `hp_write_key()` hashed the key to insert it
(the documented contract was "the record was still added and the caller
must call hp_delete_key for it"), and `hp_delete_key()` then hashed the
same record **again** just to locate the bucket to unlink from.  For
long key values (BLOB keys, MDEV-38975) the second scan dominates
duplicate-heavy workloads: `SELECT DISTINCT` over 20-50KB blob values
(~80% duplicates) regressed 25-32% vs Aria tmp tables at low
concurrency, with `my_uca_hash_sort_utf8mb4` doing 1.83x the hashing
work for the identical query.  The hash caching added by `de1765fb64d`
(`HASH_INFO::hash_of_key`) already made all chain surgery hash-free;
the two full-value hashes per rejected row were the entire cost.

Fix:

1. **Probe before insert** (`hp_write_key()`): compute the hash once at
   the top; for `HA_NOSAME` keys without NULL key parts walk the key's
   chain under the pre-insert mask comparing cached
   `HASH_INFO::hash_of_key` values, comparing full key values only on a
   hash match.  On a duplicate return `HA_ERR_FOUND_DUPP_KEY` with
   nothing modified; otherwise proceed with the linear-hash split and
   insertion reusing the already-computed hash.  Records with equal
   full hashes share a bucket under any mask, so probing the pre-insert
   chain finds any duplicate.  A successful insert costs exactly one
   full-value hash, as before; a rejected duplicate drops from two full
   hashes + insert + undo delete to one hash + compare, the same as a
   lookup.
2. **Error-path contract change**: `hp_write_key()` no longer inserts
   the key on a duplicate, so `heap_write()` rolls back only the
   preceding keys (unconditional `keydef--`, as for BTREE/ENOMEM), and
   `heap_update()`'s duplicate path now re-inserts the old key for the
   failing keydef for both algorithms (previously BTREE-only) before
   rolling back earlier keys.
3. **Delete-side hash reuse** (`hp_delete_key()`): when the row being
   deleted was positioned via the same hash index (`flag` set and
   `info->current_hash_ptr->ptr_to_rec == recpos`), take the hash from
   the index entry instead of re-scanning the key value; a
   `DBUG_ASSERT` cross-checks the cached hash in debug builds.  This
   removes the remaining full-value hash from `DELETE`/`UPDATE` of rows
   located through the index (`heap_rkey()` already hashed the key).
4. **`heap_rfirst()`/`heap_rlast()`**: clear `info->current_hash_ptr`
   when rejecting a hash index with `HA_ERR_WRONG_COMMAND`.  Both
   functions retarget `info->lastinx` before the algorithm check, so a
   stale `current_hash_ptr` from a previous search on a different key
   could otherwise satisfy the new cached-hash guard in
   `hp_delete_key()` and send the bucket lookup to the wrong chain
   (reachable through the heap API only; the SQL layer never issues
   ordered reads on hash indexes).

New unit test `hp_test_write_dup-t` (105 assertions) wraps the key
charset's `hash_sort` collation handler in a counting shim, asserting
the exact number of full-value hashes for every operation: 1 per insert
attempt (successful or rejected), 1 total for index-read + delete, 1
for an index-positioned re-key.  Behavioral coverage: single- and
multi-key rollback for INSERT and UPDATE duplicates, NULL key parts,
non-unique keys, delete after rejected `heap_rfirst()`/`heap_rlast()`,
and a 500-row duplicate-heavy stress across linear-hash splits
(exactly 500 hashes; was 827 before the fix).

Benchmarked (16 threads, 120s runs, 8c/16t AMD 7840HS; `base` =
preview-13.1 without MDEV-38975 = Aria tmp tables, `new` = unfixed,
`fix` = this patch):

| test (QPS)    | base | new          | fix                 |
|---------------|------|--------------|---------------------|
| `blob_case_c` | 4.65 | 3.36 (-28%)  | 5.61 (+21% vs base) |
| `blob_mixed`  | 5.87 | 4.13 (-30%)  | 6.92 (+18% vs base) |

`hp_delete_key()` (38% inclusive before) is absent from the fixed
profile; the low-concurrency regression becomes a win on top of the
existing 2x+ high-concurrency wins.

@gemini-code-assist gemini-code-assist Bot left a comment

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.

Code Review

This pull request optimizes duplicate key handling in the HEAP storage engine by probing for duplicates before modifying the hash index, which avoids expensive redundant hashing (especially for blob keys) and simplifies rollback logic. It also optimizes deletion by reusing cached hashes, clears stale hash pointers on rejected ordered reads, and introduces a comprehensive unit test suite. The review feedback highlights a critical bug in the new duplicate probe logic in hp_write_key where an empty bucket could trigger a segmentation fault when search_pos is 0, and suggests ensuring pos->ptr_to_rec is non-NULL before evaluating the mask.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread storage/heap/hp_write.c
@gkodinov gkodinov added the External Contribution All PRs from entities outside of MariaDB Foundation, Corporation, Codership agreements. label Jul 20, 2026

@gkodinov gkodinov left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thank you for your contribution! This is a preliminary review.

The only remark I have is that, as a new "feature", this should be based on the main branch. Can you please rebase?

Otherwise, I see nothing wrong in the diff.

@gkodinov gkodinov self-assigned this Jul 20, 2026
@gkodinov

Copy link
Copy Markdown
Member

Forgot to ask: are these somehow related to your previous work? If they are and need to go where the original work is pushed, then please confirm and ignore my rebase requests.

@arcivanov

Copy link
Copy Markdown
Contributor Author

These are BLOB in HEAP (MDEV-38975) performance fixes/regressions that were discovered during performance testing. The feature is in preview so the PR has to go into preview.

@gkodinov gkodinov left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM. Please stand by for the final review.

@gkodinov
gkodinov requested a review from montywi July 20, 2026 11:04
@gkodinov gkodinov assigned montywi and unassigned gkodinov Jul 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

External Contribution All PRs from entities outside of MariaDB Foundation, Corporation, Codership agreements.

Development

Successfully merging this pull request may close these issues.

3 participants