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

@arcivanov arcivanov commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Draft, pushed for CI. Not proposed for review yet.

Transparent VARCHAR to BLOB promotion for HEAP tables. A VARCHAR whose declared width exceeds HEAP_CONVERT_IF_BIGGER_TO_BLOB has its payload stored outside the row, while the SQL layer continues to see an unchanged VARCHAR: type(), type_handler(), sql_type(), the metadata sent to the client, and VARCHAR comparison and key semantics are all unaffected. 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, and 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.

Two commits, on bb-blob-main-monty at 5bfa1a2cdbe.

1. MDEV-40032 Promote wide VARCHAR to BLOB inside the HEAP engine

The engine stores the column out of line itself, in the blob continuation records MDEV-38975 already built. 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. 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() 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.

Not every column reporting MYSQL_TYPE_VARCHAR has a width to reclaim. A VECTOR reports it too, because Type_handler_vector derives from Type_handler_varchar without overriding field_type(), and a VECTOR(10) is 40 bytes, already over the threshold. But a VECTOR holds one length and no other -- store() rejects anything that is not exactly the declared width -- and reset() writes that width into the length prefix even of a row that stored nothing, so storing one out of line ships every byte into a continuation run and pays that run's per-record overhead on top of what the column already cost inline. Under a 1MB ceiling a VECTOR(16383) table holds 30 rows inline against 17 out of line. The decision therefore asks Field::can_store_data_out_of_line(), which a Field answers no to unless it says otherwise, rather than asking the type. A compressed VARCHAR says no for its own reason: its header and compressed bytes sit where the value would be, and it addresses them directly.

Two changes independent of promotion are folded into this commit:

  • Limits split by unit. max_records was fed a row count and tested against a record counter, which a row using many continuation records exhausts long before its memory budget, so such a table filled at a fraction of its budget. Rows are now 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(), and max_records survives only as HP_CREATE_INFO's block-sizing estimate. That changes what a table accepts, which heap/blob.result records.
  • Contiguous clear of short dark records. Below 32 bytes a bzero of the whole record beats the strided loop by 1.3x to 4.7x, measured on Zen 4, Broadwell and Denverton; above it the two cross over.

Also here: heap_info() reports the ceiling data_length counts toward rather than the expected record count times reclength, that product being a byte ceiling only while one record holds one row; and 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, since heap_scan() skips a coalesced block in a single step.

2. Make VARCHAR promotion zero-copy

Promoting inside the engine is not zero-copy. 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. Create_tmp_table::add_field() takes the decision, before the record layout is measured, and 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.

Both routes ask the one question the first commit introduced, Field::can_store_data_out_of_line(), which heap_wants_out_of_line() now carries for both. Here it is stricter than a matter of what moving a column costs. This route rewrites the field's record layout, so a field whose accessors address the payload at a fixed offset in the record would be handed a layout it cannot read: a VECTOR row storing no value advertises its declared width in the length prefix while the slot holds only a pointer, and the value is read through it.

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, while where the payload sits is a separate property that a promoted VARCHAR now has too. The sites that meant the latter are switched over -- the blob_field[] array, join buffer sizing, 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, TABLE::has_unbounded_blob_field() answers the question it meant.

Keys are where the two 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 -- it has to, 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.

Reaching an out-of-line payload becomes a set of Field virtuals rather than a cast to Field_blob, and a value stored through a promoted field gets somewhere to live: Field_varstring gains the buffer Field_blob has, with the same rule of one per field, and where many rows are alive at once and sorted afterwards the value goes to the table's Blob_mem_storage instead.

Tests

Nine tests are added. In the first commit: heap.promotion_transparent (which opens by proving promotion is in effect, since the assertions would pass just as well if nothing were promoted), heap.row_limit, heap.max_data_length, and heap.promotion_vector_column, which closes on the capacity that separates a column left inline from one stored out of line, with a VARBINARY of the same declared width beside it as the control that gives that reading its meaning. In the second: heap.promotion_layout_agreement, heap.promotion_keeps_optimizations, heap.group_concat_cut_reporting, heap.promotion_data_free, the SQL layer's half of heap.promotion_vector_column, and the hp_test_rectest-t unit test.

Six existing tests observed the old storage layout and are adjusted: versioning.partition, perfschema.memory_table_io, main.gconcat_distinct_walk_fail, heap.count_distinct_blob_convert, heap.tmp_table_convert_dedup and heap.blob_update_overflow. main.information_schema and main.log_slow_innodb each asked for an order that does not determine one and were stable only by accident; both are given the tie-break they were missing rather than re-recorded.

Status

heap, perfschema, versioning and main: all 1975 tests successful. Heap unit tests 11/11, and the first commit builds and passes its 10 unit tests standalone.

@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()` 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.

Those two are `static inline` in `heapdef.h` rather than functions in
`hp_blob.c`, because every row read and every row write of every HEAP
table goes through one of them, including the tables with nothing
promoted.  There the span list is a single span covering the whole
record and the body is the memcpy the call sites used to do inline, so
out of line it costs a call and two dependent loads to reach its own
size.  Measured at `-O2` on a keyless table with a 128-byte record and
nothing promoted, a scan reads a row in 5.5 ns inline against 6.8 out
of line.

The chain pointer a stored record holds for an out-of-line column is
read, written and cleared through `hp_blob_get_chain()`,
`hp_blob_set_chain()` and `hp_blob_clear_chain()`.  The offset
arithmetic behind them is what moves when a promoted column moves, and
it was written out at seventeen sites across four files.

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 only as `HP_CREATE_INFO`'s block-sizing estimate: it was
being tested against a slot counter, which a row using many
continuation records exhausts long before its memory budget.  With
nothing left reading them, `HP_SHARE::max_records`,
`HEAPINFO::max_records` and `NO_LIMIT_RECORDS` go rather than stay
beside the row limit that replaced them, where two sentinels of the
same value would have differed only in name.  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 the expected record count times `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.

Not every `Field_varstring` can be moved, and the decision cannot be
taken from `type()`.  `Field_vector` reports `MYSQL_TYPE_VARCHAR`,
because `Type_handler_vector` derives from `Type_handler_varchar` and
does not override `field_type()`, while its `reset()` writes the whole
declared width into the length prefix and the copy functions
`get_copy_func()` returns address the value at a fixed offset in the
record.  A `VECTOR(10)` is over the threshold, so a decision taken on
type and width alone moved one: a row storing no value then advertised
its full declared width in the prefix while the slot held only a
pointer, and the engine read the value through it.
`can_store_data_out_of_line()` asks the field instead and every
`Field_varstring` subclass answers for itself, a compressed VARCHAR
saying no for the same reason.  Only the SQL layer asks, because only
it rewrites a field's record layout; the engine route moves the bytes
itself and leaves `record[0]` as the SQL layer laid it out, so a field
that cannot represent the moved layout is not affected by it.

`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 one-byte-prefix VARCHAR key segment used to have `HA_BLOB_PART`
stripped in `heap_create()` unconditionally, a real blob always
entering as VARTEXT4 or VARBINARY4 and the flag therefore only ever
being a stray one.  A promoted VARCHAR sets it deliberately and keeps
its own one or two byte prefix, so the strip is narrowed to the
segments with no descriptor behind them rather than dropped for an
assertion: `key_part_flag` reaches the engine from a `.frm` byte that
nothing masks, and the key code reads the record as a pointer wherever
the flag is set.

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.  It
reaches that data through `hp_materialize_one_blob()`, the call the
key code already makes, which hands back a pointer into the chain
where the run layout has the bytes contiguous and copies only where it
does not.

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.
A third run puts values back into them and asserts only what does not
depend on where the overflow lands: that the conversion happens, and
that deduplication over columns stored out of line still returns each
distinct row once.  Without it the storage layout never meets the
conversion path.  All three runs read the conversion counter through
`heap/count_distinct_converted.inc`, which the suite already had.

`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.

Six 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.

`heap.promotion_vector_column` reads a `VECTOR` column through both
routes, the internal temporary table where the SQL layer decides and a
user `ENGINE=MEMORY` table where the engine does.  The fatal row is
the one storing no value, so each case includes one, and the first
reads `Created_tmp_tables` and `Created_tmp_disk_tables` to show that
the temporary table it needs was built and stayed in memory rather
than passing on a query that materialized nothing.

`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` each asked for an
order that does not determine one, and were stable only by accident.
Keeping their temporary table in `MEMORY` where it previously
converted to `Aria` takes the accident away: `filesort` tie-breaks
equal sort keys on the rowid, and a `MEMORY` table's rowid is the
address the record was allocated at, so tied rows do not come back in
the same order from one server start to the next.  Both tests are
given the tie-break they were missing rather than re-recorded -- a
`GROUP_CONCAT` ordered by the columns it concatenates, which is what
the bug that case covers is about, and a `LIMIT` window ordered by the
grouping column as well as by the count that is equal across every
group.  Neither is what the case checks, so pinning it costs the tests
nothing.
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