Skip to content

WIP: MDEV-40032 promote wide VARCHAR to BLOB inside the HEAP engine - #5655

Draft
arcivanov wants to merge 2 commits into
MariaDB:bb-blob-main-montyfrom
arcivanov:MDEV-40032-v2
Draft

WIP: MDEV-40032 promote wide VARCHAR to BLOB inside the HEAP engine#5655
arcivanov wants to merge 2 commits into
MariaDB:bb-blob-main-montyfrom
arcivanov:MDEV-40032-v2

Conversation

@arcivanov

Copy link
Copy Markdown
Contributor

WIP, pushed to get a full CI run. Not ready for review.

Transparent VARCHAR to BLOB promotion inside the HEAP engine. A VARCHAR whose declared width exceeds HEAP_CONVERT_IF_BIGGER_TO_BLOB is stored as a blob by the engine, while the SQL layer continues to see an unchanged VARCHAR. The stored record carries a length prefix and a continuation chain pointer where the inline payload used to sit, and HP_COPY_SPAN maps between the record buffer and the stored layout.

Two further changes are folded into the same commit. Both are independent of promotion and will be split out before this is proposed for review:

  • Limits split by unit. max_records was fed a row count and tested against a record counter, which breaks for any row with out-of-line columns. Rows are now limited by HP_SHARE::max_rows in heap_write(), memory by max_table_size as before, and max_records survives only to size blocks.
  • Contiguous clear of short dark records. Below HP_CLEAR_DARK_MEMSET_MAX a bzero beats the strided loop by 1.3x to 4.7x.

Local status: --suite=heap 51/51, --suite=main 1395/1395, heap unit tests 10/10.

Known gap: there are no tests yet asserting that promotion is invisible to the SQL layer. That is being written now.

@arcivanov
arcivanov marked this pull request as draft September 9, 2026 22:27
@gkodinov gkodinov added the External Contribution All PRs from entities outside of MariaDB Foundation, Corporation, Codership agreements. label Sep 10, 2026
A VARCHAR whose declared width exceeds a threshold is stored out of
line by the HEAP engine itself, in the blob continuation records that
MDEV-38975 already built.  The SQL layer is never told: the field
stays a `Field_varstring`, so the declared type, the metadata sent to
the client, and VARCHAR comparison and key semantics are all
unchanged.  This applies to user `ENGINE=MEMORY` tables as well as to
internal temporary tables.

Heap records are fixed width, so an inline `VARCHAR(N)` reserves its
full declared width in every row whether the row uses it or not.  N
counts characters, so that width is between N and 4N bytes depending
on the character set.  Stored out of line the row pays a length
prefix, a chain pointer, and only the bytes actually present.

`HP_BLOB_DESC` carries the stored-side geometry and a `promoted` flag,
`HP_SHARE` gains a `stored_reclength` and the byte ranges that are
identical between the two layouts, and `hp_pack_record()` /
`hp_unpack_record()` in `hp_blob.c` replace the whole-record memcpys.
The three key functions that read a stored record use a parallel
keyseg array.  With nothing promoted every new pointer aliases the old
one and the code path is what it was.

The engine's limits are split three ways, each enforced in its own
unit.  Rows are limited by the new `HP_SHARE::max_rows`, tested in
`heap_write()` where rows are counted; `share->records` had been
incremented and compared against nothing, so HEAP had no row limit at
all.  Bytes are limited by the existing `max_table_size` test in
`hp_alloc_from_tail()`, now the only ceiling there.  `max_records`
survives as the block-sizing estimate and as what `heap_info()`
reports, and is documented as not being a limit: it was being tested
against a slot counter, which a row using many continuation records
exhausts long before its memory budget.  This is also the first time
LIMIT pushdown is applied as the row count it always was.

That changes what a table accepts, which `heap.blob` records.  Under
`max_heap_table_size=65536` a table with a declared BLOB took one
30000-byte row and refused the second; it now takes two and refuses
the third.  The refusal came from the `max_records` test, which
compared a slot counter against an estimate derived from the SQL row
width, and only where a new block was about to be allocated.  A row
whose value spans many continuation records exhausts that count while
using a fraction of the bytes it stands for, so the table filled at
half its budget.  With bytes limited where bytes are counted, it fills
when it has spent them.

`hp_clear_dark_records()` clears a short record whole rather than at a
stride.  Measured on Zen 4, Broadwell and Denverton: at a 16-byte
record the contiguous clear is 2.6 to 4.7 times faster, at 32 it is
1.3 to 2.3 times faster, and the two cross over above that, so the
switch is at 32.

`heap.promotion_transparent` asserts that storing a VARCHAR out of
line changes nothing.  Such a test would pass just as well if the
engine never promoted anything, so it opens by proving promotion is in
effect: an inline `VARCHAR(60000)` holds about 17 rows under a 1MB
ceiling while the promoted form holds 200.  `heap.row_limit` covers
the row limit on a promoted VARCHAR, a declared BLOB and a plain INT.

`heap_info()` now reports the ceiling `data_length` counts toward
rather than `max_records * reclength`.  That product is a record count
times a row width, which is a byte ceiling only while one record holds
one row; once a column is stored out of line it overstates the ceiling
by the promotion ratio, so a `VARCHAR(3000)` table under a 1MB limit
advertised 196MB and read as 0.2% full at the point it refused a row.
The number is `max_table_size`, or `max_rows * reclength` where a row
limit admits fewer bytes than that.  `heap.max_data_length` asserts a
table reaches the ceiling it reports before it refuses a row.

`ha_heap::scan_time()` prices the free records a scan steps over at
one step per free list entry rather than one per free record.
heap_scan() reads a coalesced block's length from its first record and
skips the block in a single step, so a row whose out-of-line data
freed a run of a thousand records costs a later scan what a row that
freed one record costs it; charging per record priced such a table at
its promotion ratio above what it is worth.  `HP_SHARE` counts the
entries alongside the records, maintained in the five functions that
own the free list -- only a block's last record ends an entry, and a
coalescing push extends one rather than adding another.
`heap_check_heap()` counts both while walking the list and rejects a
share whose counters disagree.

Two existing tests observed the old storage layout.

`versioning.partition` filled a `VARCHAR(45000)` MEMORY table with
rows left at their default and relied on each one reserving its full
width anyway.  Stored out of line those rows occupy almost nothing and
the table never fills, so the `ER_RECORD_FILE_FULL` the test is built
around never arrives.  The rows now carry their full width as data,
which restores the fullness the test needs and holds whether the
column is stored inline or not.

`perfschema.memory_table_io` records one more fetch on the scan that
follows a delete.  A row stored out of line leaves a hole behind when
it is updated, and a scan that lands in one reports a deleted record
to the caller, which costs a further `rnd_next`.  A declared BLOB
produces exactly the same event sequence, so this is the existing
behaviour of out-of-line storage rather than anything promotion
introduces.
Promoting inside the engine is not zero-copy.  `record[0]` keeps its
full declared width, so the engine compacts the record on the way in
and expands it again on the way out, and every read of a promoted
column copies the payload back into the record.  Promote the field
instead, and the payload is not copied at all: a read is handed the
address of the engine's own bytes, and a record copied wholesale
copies that address rather than the value behind it.

`Field_varstring` gains a `promoted` flag that changes only where the
payload lives.  The record slot becomes the column's own length prefix
followed by a pointer, which is the shape a blob already has, and
`pack_length()` answers for it.  `type()`, `type_handler()` and
`sql_type()` keep answering VARCHAR, so nothing above the Field can
tell the difference.

`Create_tmp_table::add_field()` takes the decision, before the record
layout is measured.  It may read the column list and nothing else: two
temporary tables built from one column list are written from each
other's record buffer -- a recursive CTE fills its increment table
that way -- and they agree on the layout only while every column
decides the same way in both.  `insert_all_rows_into_tmp_table()`
asserts that the two record lengths match, the same thing
`select_union_recursive::send_data()` already asserts for the other
direction of that copy.  A column that disagrees costs at least the
width that put it over the threshold, so the record lengths cannot
match if any column does.

`Field::data_is_out_of_line()` is the question the rest of the server
means where it asks `flags & BLOB_FLAG` today, and the two are not the
same question.  BLOB_FLAG says the column is declared as a blob, and
`sql_select.cc` asserts that it agrees with `type()`, so it cannot be
set on anything reported as a VARCHAR.  Where the payload sits is a
separate property, and a promoted VARCHAR now has it too.  The sites
that meant the latter are switched over: the `blob_field[]` array,
join buffer sizing and its cache fields, the wholesale copy and free
of out-of-line values, `Item_copy_string` substitution, `Cached_item`
selection, and the key segment setup for both temporary table
engines.  Where a caller instead means "declared as a blob" and was
reading `s->blob_fields`, which conflates the two,
`TABLE::has_unbounded_blob_field()` answers the question it meant:
duplicate removal by hash and the subquery expression cache both need
a bounded width rather than an inline one, and a wide VARCHAR has it.

Reaching an out-of-line payload becomes a set of `Field` virtuals
rather than a cast.  `out_of_line_data()` and `out_of_line_length()`
read the length and pointer pair out of any image of the field's
record slot, and `set_out_of_line_image()` writes such a pair back,
pointing it at the payload where it already lies.  With `copy()` and
`free()` also virtual, the join buffer, the window function row
remapper and the `GROUP_CONCAT` cut check no longer cast to
`Field_blob` to reach a field that is not one.

Keys are where the two questions diverge most.  A declared blob has no
maximum width, so a unique index over one has to be a hash, and its
key is marked `HA_BLOB_PART_KEY`, which stops it being a key at all
once the table converts to Aria.  A promoted VARCHAR is still as wide
as it was declared, so it keeps an ordinary bounded key part: a
VARTEXT of that width, whose `HA_BLOB_PART` tells the engine to follow
the pointer.  It has to keep one, because the optimizer planned a
lookup on it from the declared type.  `Create_tmp_table` therefore
tracks whether any distinct column is a declared blob separately from
the columns that are merely out of line, and only the former reaches
`HA_UNIQUE_HASH`.  Carrying both flags also decides `key_restore()`,
where the bounded question has to be asked first: it is
`HA_VAR_LENGTH_PART` that says how to put the value back, and only a
column declared as a blob may be cast to `Field_blob`.

A value stored through a promoted field needs somewhere to live.
`Field_varstring` gains the buffer `Field_blob` has, with the same
rule of one per field, and `copy()` gives the record a set of bytes it
owns when the value has to outlive the buffer it was read from.
`store()` takes the source somewhere else first when it lies inside
that buffer, the way `Field_blob::store()` does, so that `UPDATE t SET
c = c` does not read a buffer it is writing.  Where many rows are
alive at once and sorted afterwards, as they are for `GROUP_CONCAT`
with `ORDER BY` or `DISTINCT`, the value goes to the table's
`Blob_mem_storage` instead, so each row has bytes of its own.  It is
stored whole there: a blob is cut to `group_concat_max_len` on the way
in because it has no declared width, and a VARCHAR has one that the
copy has already applied.  `unpack()` points the slot at the row being
read, the way `Field_blob::unpack()` does, and
`max_packed_col_length()` answers from the declared width, since its
callers ask with `pack_length()` and that now describes the pointer.

The engine reads a promoted column exactly as it reads a declared
blob, both being a length prefix and a pointer, so
`heap_prepare_hp_create_info()` builds one descriptor for either and
does not promote again what the SQL layer has already moved.  Two of
the heap key functions capped a VARCHAR segment at the segment's
declared width; for an out-of-line column that width describes the
descriptor in the record rather than the value, so the value is used
whole, as the blob segments already are.

Two places in the engine still counted records as though one held a
row.  `heap_info()` reports free space as the free record count times
the record stride rather than times the SQL row width, which is a byte
count only while nothing is stored out of line: a `VARCHAR(3000)`
table reported more free space than it had ever allocated.  And
`hp_rectest()`, which answers whether the record a caller read is
still what the table holds, compares each out-of-line column through
its length and then its data.  A promoted column's payload is not in
the stored record to be compared at all, and the pointer beside a
declared blob's is the continuation chain rather than wherever the
read handed the value out, so comparing the two records byte for byte
both missed a change and reported one that had not happened.

Four tests observed the old storage layout.

`main.gconcat_distinct_walk_fail` starved the duplicate filter with
600 rows of a `VARCHAR(100)`.  Out of line the filter holds a pointer
per value rather than the value itself, so it takes more distinct
values to fill it.  The row count and the number of distinct values go
up, which is what fills the filter either way.

`heap.count_distinct_blob_convert` described its last case as having
no blob argument.  Its `VARCHAR(64)` is now stored out of line and
takes the same path a declared blob does, so that case is relabelled
and a narrow column carrying the same values is added beside it as the
control the old case used to be.

`heap.tmp_table_convert_dedup` needs the write that finds the table
full to be a duplicate of a row the conversion has already copied.
Its two wide columns held values, and a row storing an out-of-line
value costs a record slot and a place for the value, so the
overflowing write was as likely to be the store as the duplicate that
follows it.  The wide columns are left empty, which is all they are
needed for, and a narrow column carries what makes the rows distinct.

`heap.blob_update_overflow` records different counters for the same
overflow.  The aggregate's own temporary tables are unchanged.  What
moved is the `information_schema.session_status` query the test reads
the counters with: `VARIABLE_VALUE` is a `varchar(2048)`, 6144 bytes
per row in utf8mb3, and under the shrunken ceiling the test sets it
used to spill on its own.

Five tests are added, each written against the question it answers
rather than against the fix.

`heap.promotion_layout_agreement` reads one recursive CTE three ways,
one of them alongside a fulltext match, which is what can build the
two tables of a recursion with different options.

`heap.promotion_keeps_optimizations` measures the rows read by
`SELECT DISTINCT` and the subquery cache hits for a narrow VARCHAR, a
wide one and a declared TEXT.  A wide column that lost an optimization
to the BLOB_FLAG question reads the declared column's row count.

`heap.group_concat_cut_reporting` pins which row each of the three
reports as cut, a wide VARCHAR having a declared width to cut against
where a TEXT has none.

`heap.promotion_data_free` asserts that a table never reports more
free space than it has allocated, which holds whatever the rows look
like and does not pin a byte count.

`hp_test_rectest-t` is the only heap unit test that leaves the read
check on, the server turning it off for every table it opens.  It runs
each case at both a single-record and a multi-run chain, because which
one a read hands out decides whether the record buffer's pointer
happens to equal the stored one, and a case run at only one of them
cannot tell a right answer from that coincidence.

`main.information_schema` and `main.log_slow_innodb` are disabled
rather than re-recorded.  Keeping a temporary table in `MEMORY` where
it previously converted to `Aria` exposes MDEV-41068: `filesort`
tie-breaks equal sort keys on the rowid, and a `MEMORY` table's rowid
is the address the record was allocated at, so the order of tied rows
is not the same from one server start to the next.  Both tests assert
an order that is unspecified, so neither is stable here whatever is
recorded for it.  The fix is on `10.11`; both tests are re-enabled
when it upmerges.
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.

2 participants