WIP: MDEV-40032 promote wide VARCHAR to BLOB inside the HEAP engine - #5655
Draft
arcivanov wants to merge 2 commits into
Draft
WIP: MDEV-40032 promote wide VARCHAR to BLOB inside the HEAP engine#5655arcivanov wants to merge 2 commits into
arcivanov wants to merge 2 commits into
Conversation
arcivanov
marked this pull request as draft
September 9, 2026 22:27
arcivanov
force-pushed
the
MDEV-40032-v2
branch
from
September 9, 2026 23:22
6f08f32 to
33adbcb
Compare
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.
arcivanov
force-pushed
the
MDEV-40032-v2
branch
from
September 10, 2026 20:18
33adbcb to
f7c8e95
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_BLOBis 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, andHP_COPY_SPANmaps 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:
max_recordswas fed a row count and tested against a record counter, which breaks for any row with out-of-line columns. Rows are now limited byHP_SHARE::max_rowsinheap_write(), memory bymax_table_sizeas before, andmax_recordssurvives only to size blocks.HP_CLEAR_DARK_MEMSET_MAXabzerobeats the strided loop by 1.3x to 4.7x.Local status:
--suite=heap51/51,--suite=main1395/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.