From e720636e7c82fe83accae6aef327e0819e663ebe Mon Sep 17 00:00:00 2001 From: Arcadiy Ivanov Date: Fri, 17 Jul 2026 14:04:18 -0400 Subject: [PATCH] MDEV-40376 `Protocol::end_statement` assertion when window function fills HEAP tmp table `save_window_function_values()` stores computed window function values back into the sorted tmp table with `ha_update_row()`. When the result column is a blob (expressions wider than 512 characters are promoted to TEXT in tmp tables), each update allocates a blob continuation record in the HEAP tmp table. Once this crosses `max_heap_table_size`, the update fails with `HA_ERR_RECORD_FILE_FULL`, which was returned as a plain `true` without calling `handler::print_error()` and without any overflow-to-Aria handling. The statement then failed with an **empty diagnostics area**: debug builds hit `Assertion '0'` in `Protocol::end_statement()`, release builds send a bare OK packet after the result set metadata, which the client mis-parses (appears as a hang / lost connection). The write path into window tmp tables already converts HEAP to Aria on overflow (`create_internal_tmp_table_from_heap()`); the update path performed during window function computation had no such handling. The fix has two layers: 1. **Error exposure**: `save_window_function_values()` now calls `print_error()` for any `ha_update_row()` failure it does not recover from; the previously ignored `ha_rnd_pos()` return values in `save_window_function_values()` and `compute_window_func()` are checked and reported; `compute_window_func()` distinguishes a read error from EOF (positive `read_record()` return) and stops the row scan as soon as the per-row loop fails instead of continuing to rewrite the remaining rows after an error or `KILL`. 2. **Overflow recovery**: `HA_ERR_RECORD_FILE_FULL` from a HEAP tmp table is propagated up (via a new `out_error` parameter through `compute_window_func()` and `Window_func_runner::exec()`) to `Window_funcs_sort::exec()`, which converts the table to Aria with `create_internal_tmp_table_from_heap()` and re-runs the whole sort step. Converting at the point of failure is not possible: the filesort result and the frame cursors hold raw HEAP row positions that the conversion invalidates, so the filesort result is discarded and rebuilt after the conversion. Re-running the computation is safe because it reads only the source columns (preserved by the conversion) and unconditionally recomputes and overwrites the window function result columns. The re-run also re-evaluates compound expressions containing window functions (`items_to_copy`) for every row, which is only correct for side-effect-free expressions. The retry is therefore refused -- the original "table is full" error is reported instead -- when any such expression is non-deterministic (`RAND_TABLE_BIT`: user variable assignments, non-deterministic stored functions). A captured engine error that does not qualify for the retry is reported via `print_error()` so no path can leave the diagnostics area empty. `create_internal_tmp_table_from_heap()` gains a `copy_pending_row` parameter (defaults to `true`, preserving all existing call sites): the write-overflow paths append the pending `record[0]` that failed to be written, but here the row whose update failed already exists in the table and must not be appended again. Note: a spilling statement reports sort-related aggregate warnings (e.g. `max_sort_length` truncation counts) for both sort passes. This is consistent with the existing statement-wide accumulation semantics of `THD::num_of_strings_sorted_on_truncated_length` -- the sort really does run twice, as it already does in multi-sort statements. Add `heap.blob_window_overflow` test: window function computation over a HEAP tmp table with a blob result column overflows mid-computation and must transparently spill to Aria -- a single window over all rows, a partitioned window, and refusal of the retry for a side-effecting compound window expression. --- .../suite/heap/blob_window_overflow.result | 94 +++++++++++++ .../suite/heap/blob_window_overflow.test | 124 ++++++++++++++++++ sql/sql_select.cc | 35 +++-- sql/sql_select.h | 3 +- sql/sql_window.cc | 119 +++++++++++++++-- sql/sql_window.h | 10 +- 6 files changed, 358 insertions(+), 27 deletions(-) create mode 100644 mysql-test/suite/heap/blob_window_overflow.result create mode 100644 mysql-test/suite/heap/blob_window_overflow.test diff --git a/mysql-test/suite/heap/blob_window_overflow.result b/mysql-test/suite/heap/blob_window_overflow.result new file mode 100644 index 0000000000000..cb8a203b61730 --- /dev/null +++ b/mysql-test/suite/heap/blob_window_overflow.result @@ -0,0 +1,94 @@ +# +# Window function computation must spill HEAP tmp table to Aria +# when storing computed values fills the table +# +# Window function results are written back into the sorted tmp +# table with ha_update_row(). A blob result column (an expression +# wider than 512 characters is promoted to TEXT in the tmp table) +# makes each such update allocate a blob continuation record; once +# this crosses max_heap_table_size the update fails with "table is +# full" and the server must transparently convert the tmp table to +# Aria and re-run the window computation step, just as the write +# path already does. +# +# latin1 is used so that actual data size ~= declared column width: +# the memory arithmetic then holds whether or not the source VARCHAR +# column itself is stored as a blob in the HEAP tmp table. +# +SET @save_max_heap= @@max_heap_table_size; +SET @save_tmp= @@tmp_table_size; +SET max_heap_table_size = 4*1024*1024; +SET tmp_table_size = 4*1024*1024; +# +# Test 1: single window over all rows +# +# 800 rows of ~3KB fill the 4MB HEAP tmp table to ~2.4MB; storing +# the ~3KB computed value adds a blob continuation chain per row +# and overflows mid-computation. +# +CREATE TABLE t1 (a VARCHAR(3000)) CHARACTER SET latin1; +INSERT INTO t1 SELECT CONCAT(LPAD(seq, 8, '0'), REPEAT('x', 2900)) +FROM seq_1_to_800; +FLUSH STATUS; +# Should overflow during computation and convert HEAP->Aria +SELECT COUNT(*) AS cnt, COUNT(DISTINCT p) AS distinct_p, +MIN(LENGTH(p)) AS min_len, LEFT(MAX(p), 12) AS max_prefix +FROM (SELECT PERCENTILE_DISC(1) WITHIN GROUP (ORDER BY a) OVER () AS p +FROM t1) dt; +cnt distinct_p min_len max_prefix +800 1 2908 00000800xxxx +Warnings: +Warning 4202 1600 values were longer than max_sort_length. Sorting used only the first 1024 bytes +# Verify HEAP->Aria conversion happened +SELECT variable_name, variable_value FROM information_schema.session_status +WHERE variable_name = 'Created_tmp_disk_tables'; +variable_name variable_value +CREATED_TMP_DISK_TABLES 1 +DROP TABLE t1; +# +# Test 2: partitioned window +# +# Same overflow, but the re-run must also correctly restart +# partition tracking (values recomputed per partition). +# +CREATE TABLE t2 (g INT, a VARCHAR(3000)) CHARACTER SET latin1; +INSERT INTO t2 SELECT seq % 4, CONCAT(LPAD(seq, 8, '0'), REPEAT('y', 2900)) +FROM seq_1_to_800; +FLUSH STATUS; +SELECT COUNT(*) AS cnt, COUNT(DISTINCT p) AS partitions_seen, +MIN(LENGTH(p)) AS min_len, MAX(LENGTH(p)) AS max_len +FROM (SELECT PERCENTILE_DISC(0.5) WITHIN GROUP (ORDER BY a) +OVER (PARTITION BY g) AS p +FROM t2) dt; +cnt partitions_seen min_len max_len +800 4 2908 2908 +Warnings: +Warning 4202 1600 values were longer than max_sort_length. Sorting used only the first 1024 bytes +SELECT variable_name, variable_value FROM information_schema.session_status +WHERE variable_name = 'Created_tmp_disk_tables'; +variable_name variable_value +CREATED_TMP_DISK_TABLES 1 +DROP TABLE t2; +# +# Test 3: side-effecting window expression must not be re-run +# +# When a compound expression containing a window function has side +# effects (here: a user variable assignment), re-running the +# computation after the conversion would apply the side effects a +# second time for rows already processed, so the statement must +# fail with "table is full" instead. +# +CREATE TABLE t3 (a VARCHAR(3000)) CHARACTER SET latin1; +INSERT INTO t3 SELECT CONCAT(LPAD(seq, 8, '0'), REPEAT('z', 2900)) +FROM seq_1_to_800; +SET @c= 0; +SELECT CONCAT(@c:= @c+1, ':', +PERCENTILE_DISC(1) WITHIN GROUP (ORDER BY a) OVER ()) AS e +FROM t3; +ERROR HY000: The table 'TMP_TABLE' is full +DROP TABLE t3; +# +# Cleanup +# +SET max_heap_table_size = @save_max_heap; +SET tmp_table_size = @save_tmp; diff --git a/mysql-test/suite/heap/blob_window_overflow.test b/mysql-test/suite/heap/blob_window_overflow.test new file mode 100644 index 0000000000000..e534081dda1c2 --- /dev/null +++ b/mysql-test/suite/heap/blob_window_overflow.test @@ -0,0 +1,124 @@ +--source include/have_sequence.inc + +--echo # +--echo # Window function computation must spill HEAP tmp table to Aria +--echo # when storing computed values fills the table +--echo # +--echo # Window function results are written back into the sorted tmp +--echo # table with ha_update_row(). A blob result column (an expression +--echo # wider than 512 characters is promoted to TEXT in the tmp table) +--echo # makes each such update allocate a blob continuation record; once +--echo # this crosses max_heap_table_size the update fails with "table is +--echo # full" and the server must transparently convert the tmp table to +--echo # Aria and re-run the window computation step, just as the write +--echo # path already does. +--echo # +--echo # latin1 is used so that actual data size ~= declared column width: +--echo # the memory arithmetic then holds whether or not the source VARCHAR +--echo # column itself is stored as a blob in the HEAP tmp table. +--echo # + +SET @save_max_heap= @@max_heap_table_size; +SET @save_tmp= @@tmp_table_size; + +SET max_heap_table_size = 4*1024*1024; +SET tmp_table_size = 4*1024*1024; + +--echo # +--echo # Test 1: single window over all rows +--echo # +--echo # 800 rows of ~3KB fill the 4MB HEAP tmp table to ~2.4MB; storing +--echo # the ~3KB computed value adds a blob continuation chain per row +--echo # and overflows mid-computation. +--echo # + +CREATE TABLE t1 (a VARCHAR(3000)) CHARACTER SET latin1; +INSERT INTO t1 SELECT CONCAT(LPAD(seq, 8, '0'), REPEAT('x', 2900)) +FROM seq_1_to_800; + +--disable_ps_protocol +--disable_ps2_protocol +--disable_view_protocol +--disable_cursor_protocol +FLUSH STATUS; + +--echo # Should overflow during computation and convert HEAP->Aria +SELECT COUNT(*) AS cnt, COUNT(DISTINCT p) AS distinct_p, + MIN(LENGTH(p)) AS min_len, LEFT(MAX(p), 12) AS max_prefix +FROM (SELECT PERCENTILE_DISC(1) WITHIN GROUP (ORDER BY a) OVER () AS p + FROM t1) dt; + +--echo # Verify HEAP->Aria conversion happened +--disable_warnings +SELECT variable_name, variable_value FROM information_schema.session_status + WHERE variable_name = 'Created_tmp_disk_tables'; +--enable_warnings +--enable_cursor_protocol +--enable_view_protocol +--enable_ps2_protocol +--enable_ps_protocol + +DROP TABLE t1; + +--echo # +--echo # Test 2: partitioned window +--echo # +--echo # Same overflow, but the re-run must also correctly restart +--echo # partition tracking (values recomputed per partition). +--echo # + +CREATE TABLE t2 (g INT, a VARCHAR(3000)) CHARACTER SET latin1; +INSERT INTO t2 SELECT seq % 4, CONCAT(LPAD(seq, 8, '0'), REPEAT('y', 2900)) +FROM seq_1_to_800; + +--disable_ps_protocol +--disable_ps2_protocol +--disable_view_protocol +--disable_cursor_protocol +FLUSH STATUS; + +SELECT COUNT(*) AS cnt, COUNT(DISTINCT p) AS partitions_seen, + MIN(LENGTH(p)) AS min_len, MAX(LENGTH(p)) AS max_len +FROM (SELECT PERCENTILE_DISC(0.5) WITHIN GROUP (ORDER BY a) + OVER (PARTITION BY g) AS p + FROM t2) dt; + +--disable_warnings +SELECT variable_name, variable_value FROM information_schema.session_status + WHERE variable_name = 'Created_tmp_disk_tables'; +--enable_warnings +--enable_cursor_protocol +--enable_view_protocol +--enable_ps2_protocol +--enable_ps_protocol + +DROP TABLE t2; + +--echo # +--echo # Test 3: side-effecting window expression must not be re-run +--echo # +--echo # When a compound expression containing a window function has side +--echo # effects (here: a user variable assignment), re-running the +--echo # computation after the conversion would apply the side effects a +--echo # second time for rows already processed, so the statement must +--echo # fail with "table is full" instead. +--echo # + +CREATE TABLE t3 (a VARCHAR(3000)) CHARACTER SET latin1; +INSERT INTO t3 SELECT CONCAT(LPAD(seq, 8, '0'), REPEAT('z', 2900)) +FROM seq_1_to_800; + +SET @c= 0; +--replace_regex /table '[^']+' is full/table 'TMP_TABLE' is full/ +--error ER_RECORD_FILE_FULL +SELECT CONCAT(@c:= @c+1, ':', + PERCENTILE_DISC(1) WITHIN GROUP (ORDER BY a) OVER ()) AS e +FROM t3; + +DROP TABLE t3; + +--echo # +--echo # Cleanup +--echo # +SET max_heap_table_size = @save_max_heap; +SET tmp_table_size = @save_tmp; diff --git a/sql/sql_select.cc b/sql/sql_select.cc index 9a1bf2115fea0..e91566f9d6741 100644 --- a/sql/sql_select.cc +++ b/sql/sql_select.cc @@ -24022,6 +24022,11 @@ bool create_internal_tmp_table(TABLE *table, KEY *org_keyinfo, If a HEAP table gets full, create a internal table in MyISAM or Maria and copy all rows to this + copy_pending_row is FALSE when the overflow happened while updating an + existing row: table->record[0] then holds the failed update's new image + of a row that is already in the table, and must not be appended as an + extra row. + In case of error, my_error() or handler::print_error() will be called. Note that in case of error, table->file->ha_rnd_end() may have been called! */ @@ -24033,7 +24038,8 @@ create_internal_tmp_table_from_heap(THD *thd, TABLE *table, TMP_ENGINE_COLUMNDEF **recinfo, int error, bool ignore_last_dupp_key_error, - bool *is_duplicate) + bool *is_duplicate, + bool copy_pending_row) { TABLE new_table; TABLE_SHARE share; @@ -24112,19 +24118,22 @@ create_internal_tmp_table_from_heap(THD *thd, TABLE *table, } if (!new_table.no_rows && (write_err= new_table.file->ha_end_bulk_insert())) goto err; - /* copy row that filled HEAP table */ - if (unlikely((write_err=new_table.file->ha_write_tmp_row(table->record[0])))) - { - if (new_table.file->is_fatal_error(write_err, HA_CHECK_DUP) || - !ignore_last_dupp_key_error) - goto err; - if (is_duplicate) - *is_duplicate= TRUE; - } - else + if (copy_pending_row) { - if (is_duplicate) - *is_duplicate= FALSE; + /* copy row that filled HEAP table */ + if (unlikely((write_err=new_table.file->ha_write_tmp_row(table->record[0])))) + { + if (new_table.file->is_fatal_error(write_err, HA_CHECK_DUP) || + !ignore_last_dupp_key_error) + goto err; + if (is_duplicate) + *is_duplicate= TRUE; + } + else + { + if (is_duplicate) + *is_duplicate= FALSE; + } } /* remove heap table and change to use myisam table */ diff --git a/sql/sql_select.h b/sql/sql_select.h index 2507a871a6005..024246b83e25d 100644 --- a/sql/sql_select.h +++ b/sql/sql_select.h @@ -2682,7 +2682,8 @@ bool create_internal_tmp_table_from_heap(THD *thd, TABLE *table, TMP_ENGINE_COLUMNDEF *start_recinfo, TMP_ENGINE_COLUMNDEF **recinfo, int error, bool ignore_last_dupp_key_error, - bool *is_duplicate); + bool *is_duplicate, + bool copy_pending_row= true); bool create_internal_tmp_table(TABLE *table, KEY *keyinfo, TMP_ENGINE_COLUMNDEF *start_recinfo, TMP_ENGINE_COLUMNDEF **recinfo, diff --git a/sql/sql_window.cc b/sql/sql_window.cc index 526471ee44924..451864c721221 100644 --- a/sql/sql_window.cc +++ b/sql/sql_window.cc @@ -2792,14 +2792,25 @@ bool get_window_functions_required_cursors( /** Helper function that takes a list of window functions and writes their values in the current table record. + + @param[out] out_error Set to the handler error code if the updated row + could not be stored because the in-memory tmp + table got full; the error is then not reported and + the caller is expected to convert the table to a + disk-based one and re-run the computation. */ static bool save_window_function_values(List& window_functions, - TABLE *tbl, uchar *rowid_buf) + TABLE *tbl, uchar *rowid_buf, int *out_error) { + int err; List_iterator_fast iter(window_functions); JOIN_TAB *join_tab= tbl->reginfo.join_tab; - tbl->file->ha_rnd_pos(tbl->record[0], rowid_buf); + if ((err= tbl->file->ha_rnd_pos(tbl->record[0], rowid_buf))) + { + tbl->file->print_error(err, MYF(0)); + return true; + } store_record(tbl, record[1]); while (Item_window_func *item_win= iter++) item_win->save_in_field(item_win->result_field, true); @@ -2830,9 +2841,15 @@ bool save_window_function_values(List& window_functions, func->save_in_result_field(true); } - int err= tbl->file->ha_update_row(tbl->record[1], tbl->record[0]); + err= tbl->file->ha_update_row(tbl->record[1], tbl->record[0]); if (err && err != HA_ERR_RECORD_IS_THE_SAME) + { + if (err == HA_ERR_RECORD_FILE_FULL && tbl->s->db_type() == heap_hton) + *out_error= err; + else + tbl->file->print_error(err, MYF(0)); return true; + } return false; } @@ -2878,12 +2895,13 @@ bool compute_window_func(THD *thd, List& window_functions, List& cursor_managers, TABLE *tbl, - SORT_INFO *filesort_result) + SORT_INFO *filesort_result, + int *out_error) { List_iterator_fast iter_win_funcs(window_functions); List_iterator_fast iter_cursor_managers(cursor_managers); bool ret= false; - uint err; + int err; READ_RECORD info; @@ -2914,7 +2932,13 @@ bool compute_window_func(THD *thd, while (true) { if ((err= info.read_record())) - break; // End of file. + { + /* read_record() returns -1 at end of file; positive values are read + errors, for which the error has already been reported. */ + if (err > 0) + ret= true; + break; + } /* Remember current row so that we can restore it before computing each window function. */ @@ -2952,16 +2976,21 @@ bool compute_window_func(THD *thd, /* Return to current row after notifying cursors for each window function. */ - if (tbl->file->ha_rnd_pos(tbl->record[0], rowid_buf)) + if ((err= tbl->file->ha_rnd_pos(tbl->record[0], rowid_buf))) { + tbl->file->print_error(err, MYF(0)); ret= true; break; } } + if (ret) + break; + /* We now have computed values for each window function. They can now be saved in the current row. */ - if (save_window_function_values(window_functions, tbl, rowid_buf)) + if (save_window_function_values(window_functions, tbl, rowid_buf, + out_error)) { ret= true; break; @@ -3057,7 +3086,8 @@ bool Window_func_runner::add_function_to_run(Item_window_func *win_func) /* Compute the value of window function for all rows. */ -bool Window_func_runner::exec(THD *thd, TABLE *tbl, SORT_INFO *filesort_result) +bool Window_func_runner::exec(THD *thd, TABLE *tbl, SORT_INFO *filesort_result, + int *out_error) { List_iterator_fast it(window_functions); Item_window_func *win_func; @@ -3080,7 +3110,7 @@ bool Window_func_runner::exec(THD *thd, TABLE *tbl, SORT_INFO *filesort_result) bool is_error= compute_window_func(thd, window_functions, cursor_managers, - tbl, filesort_result); + tbl, filesort_result, out_error); while ((win_func= it++)) { win_func->set_phase_to_retrieval(); @@ -3105,7 +3135,74 @@ bool Window_funcs_sort::exec(JOIN *join, bool keep_filesort_result) TABLE *tbl= join_tab->table; SORT_INFO *filesort_result= join_tab->filesort_result; - bool is_error= runner.exec(thd, tbl, filesort_result); + int out_error= 0; + bool is_error= runner.exec(thd, tbl, filesort_result, &out_error); + + if (is_error && out_error == HA_ERR_RECORD_FILE_FULL && + tbl->s->db_type() == heap_hton && + !thd->is_error() && !thd->is_killed()) + { + /* + The in-memory tmp table got full while storing computed window + function values. Convert it to a disk-based table and re-run the + whole sort step: the filesort result holds raw positions of rows + in the old table, so it must be rebuilt after the conversion. + Re-running the computation is safe because it only reads the + source columns, which the conversion preserves, and unconditionally + recomputes and overwrites the window function result columns. + The row whose update failed is already present in the table, so + the pending record[0] must not be appended (copy_pending_row). + + The re-run also re-evaluates the compound expressions containing + window functions (items_to_copy) for every row, including rows + already processed by the failed pass. That is only correct for + expressions without side effects, so do not retry when any of them + is non-deterministic (e.g. assigns a user variable or calls a + non-deterministic stored function) and report the original error + instead. + */ + TMP_TABLE_PARAM *param= join_tab->tmp_table_param; + bool retry_safe= true; + for (Item **item_ptr= param->items_to_copy; *item_ptr; item_ptr++) + { + Item *item= *item_ptr; + if (item->with_window_func() && + item->type() != Item::WINDOW_FUNC_ITEM && + (item->used_tables() & RAND_TABLE_BIT)) + { + retry_safe= false; + break; + } + } + + if (!retry_safe) + tbl->file->print_error(out_error, MYF(0)); + else + { + delete join_tab->filesort_result; + join_tab->filesort_result= NULL; + + if (create_internal_tmp_table_from_heap(thd, tbl, param->start_recinfo, + ¶m->recinfo, out_error, + 0, NULL, + /*copy_pending_row*/ false)) + return true; + + if (create_sort_index(thd, join, join_tab, filesort)) + return true; + filesort_result= join_tab->filesort_result; + is_error= runner.exec(thd, tbl, filesort_result, &out_error); + } + } + else if (is_error && out_error && !thd->is_error() && !thd->is_killed()) + { + /* + A captured engine error that did not qualify for the conversion + retry above: make sure it reaches the diagnostics area instead of + failing the statement with an empty one. + */ + tbl->file->print_error(out_error, MYF(0)); + } if (!keep_filesort_result) { diff --git a/sql/sql_window.h b/sql/sql_window.h index 7009b8895a667..ed457a71f4879 100644 --- a/sql/sql_window.h +++ b/sql/sql_window.h @@ -199,8 +199,14 @@ class Window_func_runner : public Sql_alloc /* Add the function to be computed during the execution pass */ bool add_function_to_run(Item_window_func *win_func); - /* Compute and fill the fields in the table. */ - bool exec(THD *thd, TABLE *tbl, SORT_INFO *filesort_result); + /* + Compute and fill the fields in the table. + On failure, *out_error is set to the handler error code if the values + could not be stored because the tmp table got full and no error has + been reported yet (the caller may then convert the table to a + disk-based one and re-run this step); it is left untouched otherwise. + */ + bool exec(THD *thd, TABLE *tbl, SORT_INFO *filesort_result, int *out_error); private: /* A list of window functions for which this Window_func_runner will compute