MDEV-40803 Recursive CTE loses a row when its increment table converts - #5563
Open
arcivanov wants to merge 3 commits into
Open
MDEV-40803 Recursive CTE loses a row when its increment table converts#5563arcivanov wants to merge 3 commits into
arcivanov wants to merge 3 commits into
Conversation
`select_union_recursive::send_data()` builds each new row in the record buffer of the union result table and then writes it into the increment table. When that write overflows the in-memory table it asks for the increment table to be converted to an on-disk one. `create_internal_tmp_table_from_heap()` copies every stored row into the new table and then appends the row that did not fit, taking it from the `record[0]` of the table it was given, that is from `incr_table->record[0]`. Nothing ever fills that buffer: `TABLE::insert_all_rows_into_tmp_table()` reads into the destination's `record[0]` and the recursive scan reads the recursive result table. So the overflowing row was dropped and the empty record the table was opened with was appended in its place. The increment table is created with no grouping and no distinct flag, so it carries no key and the substituted row is not rejected. On the next iteration that row's columns are NULL, the recursion predicate evaluates to NULL, and it produces no descendants. That row and every row the recursion would have derived from it are missing from the answer, with no error and no warning. Copy the row into the increment table's own record buffer before asking for the conversion, so that this caller satisfies the same precondition that every other caller of `create_internal_tmp_table_from_heap()` already satisfies. Only `incr_table->s->reclength` bytes are copied: when the union result table uses a unique constraint its record carries a trailing `MARIA_UNIQUE_HASH_LENGTH` hash field, which is appended after the real columns and is not part of the increment table's record. Each shape in the test is run twice, once with the increment table small enough to stay in memory and once with it forced to convert, and the two runs are required to agree. Two properties are needed to expose the defect and neither is obvious: 1. `tmp_memory_table_size=0` cannot be used to force the conversion. It makes `Create_tmp_table::choose_engine()` build the table on disk from the start, so the conversion never happens. This is why the existing tests that set it never caught this. 2. The lost row has to be at a level of the recursion that still has descendants. The in-memory table is only checked for fullness at block boundaries, so a row width that pushes the conversion into the last level hides the defect entirely.
`create_internal_tmp_table_from_heap()` carries the disabled state of
the in-memory table's index over to the table it creates:
```
if (table->file->indexes_are_disabled())
new_table.file->ha_disable_indexes(key_map(0), false);
```
That branch was never executed by the test suite. Measured with gcov
over every test in the `main` suite that manipulates the temporary table
size limits, it is the only reachable branch of that function that no
test takes.
A union reaches it: `st_select_lex_unit::exec()` drops the index of the
result table once the last duplicate-eliminating part has been read, so
a trailing `UNION ALL` part fills a table that has no index, and that
table can then outgrow the in-memory limit.
The test creates `v1` and never drops it, so it ends with a view still in the `test` database. MTR's check of the testcase then reports ``` tables_in_test +v1 ``` and fails the check both for this test and for whichever test happens to run next on the same worker, which is why the name it reports varies between runs.
gkodinov
requested changes
Aug 18, 2026
gkodinov
left a comment
Member
There was a problem hiding this comment.
Thank you for your contribution. This is a preliminary review.
According to https://mariadb.org/get-involved/getting-started-for-developers/#choosing-the-correct-branch a bug fix needs to target the lowest affected version.
Can you please re-target the fix to 10.11?
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.
https://jira.mariadb.org/browse/MDEV-40803
A recursive CTE whose increment table outgrows the in-memory temporary table
returns a wrong result. The row that overflows the increment table is replaced
by an all-NULL record, so that row and every row the recursion would have
derived from it are missing from the answer. No error and no warning is raised.
The defect
select_union_recursive::send_data()builds each new row in the record bufferof the union result table and writes it into the increment table. When
that write overflows, it asks for the increment table to be converted:
create_internal_tmp_table_from_heap()copies every stored row into the newtable and then appends the row that did not fit, taking it from the
record[0]of the table it was given:
There,
tableis the parameter, so this readsincr_table->record[0]. Nothingever fills that buffer:
TABLE::insert_all_rows_into_tmp_table()reads into thedestination's
record[0], and the recursive scan reads the recursive resulttable. It still holds the empty record the table was opened with.
The increment table is created with no grouping and no distinct flag, so it
carries no key and the substituted row is not rejected. On the next iteration
that row's columns are NULL, the recursion predicate evaluates to NULL, and it
produces no descendants.
The fix
Copy the row into the increment table's own record buffer before asking for the
conversion, so this caller satisfies the same precondition every other caller of
create_internal_tmp_table_from_heap()already satisfies. All 13 other callsites were audited; each writes the pending row from the
record[0]of the verytable it converts, which is why the defect is confined to this one.
Only
incr_table->s->reclengthbytes are copied. When the union result tableuses a unique constraint, its record carries a trailing
MARIA_UNIQUE_HASH_LENGTHhash field, appended after the real columns, that theincrement table's record does not have. This is the case the
DBUG_ASSERTabovethe call already describes, and it was observed live at a conversion with
incr_reclength=2409 result_reclength=2413.Testing
Seven shapes in
main.cte_recursive, each run twice, once with the incrementtable small enough to stay in memory and once forced to convert, with the two
runs required to agree: overflow during the anchor, overflow during a recursive
step,
UNIONdistinct,UNIONdistinct with a key too long for an on-disktable, a prepared statement re-executed across a conversion, mutually recursive
CTEs, and
INSERT ... SELECT.Every shape was verified to fail before the fix and pass after it, with the
.resultfile unchanged between the two runs.Two properties are needed to expose the defect, and neither is obvious:
tmp_memory_table_size=0cannot be used to force the conversion. It makesCreate_tmp_table::choose_engine()build the table on disk from the start,so the conversion never happens. This is why the existing tests in
cte_recursive.testthat set it never caught this.descendants. The in-memory table is only checked for fullness at block
boundaries, so a row width that pushes the conversion into the last level
hides the defect entirely.
main.cte_recursiveandmain.unionre-recorded purely additively, no existingresult changed. Full
mainandheapsuites pass, 1434/1434.The other two commits
They are independent of the fix and can be dropped without affecting it.
Cover converting a result table whose index has been disabledadds the onlyreachable branch of
create_internal_tmp_table_from_heap()that no test in themainsuite took, measured with gcov over every test that manipulates thetemporary table size limits.
Drop the view left behind by cte_update_delete.testremoves a view the testcreates and never drops, which makes MTR's check of the testcase fail both for
that test and for whichever test runs next on the same worker.
Versions
Reproduced on
main. No lower bound was established; older release series werenot tested, so the version fields should be set from a check against them.