1
0
mirror of https://github.com/MariaDB/server.git synced 2026-10-23 18:49:29 +03:00

MDEV-40692 GROUP_CONCAT replays a group when an OFFSET skips every row

Nothing says how many times a statement asks for the result of a group,
and the answer must not depend on it. A `HAVING` clause on the alias is
the shortest statement that asks twice, and it returns a different value
than the same aggregate asked once:

    SELECT GROUP_CONCAT(a ORDER BY a LIMIT 2 OFFSET 4) v FROM t1;
    -> (empty)
    SELECT GROUP_CONCAT(a ORDER BY a LIMIT 2 OFFSET 4) v FROM t1
      HAVING v LIKE '%';
    -> a,b

`val_str()` walks only while `result_finalized` is false, and
`dump_leaf_key()` raises that flag for the first row it writes. A row
that falls inside the offset is skipped by an earlier return, which
decrements the offset counter and leaves the flag alone. The row-limit
arm immediately above it does raise the flag before its own early
return, so two adjacent early returns behave differently.

A walk in which every row was skipped therefore writes nothing and
records nothing. The next caller walks again with the offset already
spent, and the rows skipped the first time are appended to a result
buffer that was handed over once already.

Once the duplicate filter has spilled to disk the second walk is worse
than wrong. `Unique::reset()` documents the contract:

    Clear the tree and the file.
    You must call reset() if you want to reuse Unique after walk().

The first walk flushed the tree and emptied it, so the second flushes an
empty tree, appending a chunk that holds no rows. `merge_walk()` reads
nothing back from it and fails `DBUG_ASSERT(bytes_read)`. A build
without assertions goes on to take keys from that chunk.

Set `result_finalized` where the walk block ends, so that it records
every path that has consumed the filter rather than only the paths that
wrote a row.

On the release branches only the form without `ORDER BY` reaches the
duplicate filter. Since MDEV-21879 the `DISTINCT ... ORDER BY`
combination builds its result the same way, so both forms can reach the
assertion here.
This commit is contained in:
Arcadiy Ivanov
2026-09-02 17:11:36 -04:00
committed by Monty
parent ffc817f040
commit c82597af99
3 changed files with 224 additions and 0 deletions

View File

@@ -0,0 +1,123 @@
#
# A plain aggregate, asked once and then asked twice. A HAVING
# clause on the alias is the shortest statement that asks twice.
#
CREATE TABLE t1 (g INT, a VARCHAR(10));
INSERT INTO t1 VALUES (1,'a'),(1,'b'),(1,'c'),(1,'d'),(2,'e'),(2,'f');
SELECT GROUP_CONCAT(a ORDER BY a LIMIT 2 OFFSET 4) AS v FROM t1 WHERE g = 1;
v
SELECT GROUP_CONCAT(a ORDER BY a LIMIT 2 OFFSET 4) AS v FROM t1 WHERE g = 1
HAVING v LIKE '%';
v
#
# Two conditions ask for it a third time.
#
SELECT GROUP_CONCAT(a ORDER BY a LIMIT 2 OFFSET 4) AS v FROM t1 WHERE g = 1
HAVING v LIKE '%' AND v NOT LIKE 'zz%';
v
#
# DISTINCT reaches the walk by the other route.
#
SELECT GROUP_CONCAT(DISTINCT a LIMIT 2 OFFSET 4) AS v FROM t1 WHERE g = 1
HAVING v LIKE '%';
v
#
# One group spends the offset exactly and the other does not, so
# both answers show up in the same statement.
#
SELECT g, GROUP_CONCAT(a ORDER BY a LIMIT 2 OFFSET 4) AS v FROM t1
GROUP BY g HAVING v LIKE '%' ORDER BY g;
g v
1
2
#
# An offset that stops inside the group is not affected, the walk
# having written a row, and neither is a group with no LIMIT.
#
SELECT GROUP_CONCAT(a ORDER BY a LIMIT 2 OFFSET 1) AS v FROM t1 WHERE g = 1
HAVING v LIKE '%';
v
b,c
SELECT GROUP_CONCAT(a ORDER BY a) AS v FROM t1 WHERE g = 1
HAVING v LIKE '%';
v
a,b,c,d
DROP TABLE t1;
#
# The same replay against a duplicate filter that has spilled to
# disk, where the second walk is not merely wrong but unsupported.
#
CREATE TABLE t2 (g INT, a VARCHAR(100));
INSERT INTO t2 SELECT seq MOD 4, LPAD(seq, 6, '0') FROM seq_1_to_2000;
SELECT COUNT(*) AS rows_in_table, COUNT(DISTINCT a) AS distinct_values FROM t2;
rows_in_table distinct_values
2000 2000
SELECT g, COUNT(*) AS rows_in_group FROM t2 GROUP BY g ORDER BY g;
g rows_in_group
0 500
1 500
2 500
3 500
SET @@tmp_memory_table_size=0;
#
# What an offset does while it stops inside the group: the same
# query at two offsets returns two different windows of the same
# ordered values, and each group returns values of its own.
#
SELECT g, GROUP_CONCAT(DISTINCT a ORDER BY a LIMIT 3) AS gc
FROM t2 GROUP BY g ORDER BY g;
g gc
0 000004,000008,000012
1 000001,000005,000009
2 000002,000006,000010
3 000003,000007,000011
SELECT g, GROUP_CONCAT(DISTINCT a ORDER BY a LIMIT 3 OFFSET 2) AS gc
FROM t2 GROUP BY g ORDER BY g;
g gc
0 000012,000016,000020
1 000009,000013,000017
2 000010,000014,000018
3 000011,000015,000019
#
# An offset past the end of every group. Each group holds 500
# values, so there is nothing left to return, and the HAVING asks
# each group for its answer a second time.
#
SELECT g, GROUP_CONCAT(DISTINCT a LIMIT 5 OFFSET 1000) AS gc
FROM t2 GROUP BY g HAVING gc <> 'x';
g gc
0
1
2
3
SELECT g, GROUP_CONCAT(DISTINCT a ORDER BY a LIMIT 5 OFFSET 1000) AS gc
FROM t2 GROUP BY g HAVING gc <> 'x';
g gc
0
1
2
3
#
# An offset that skips only part of the group is not affected, and
# the answer must not depend on whether the filter spilled.
#
SELECT g, GROUP_CONCAT(DISTINCT a ORDER BY a LIMIT 2 OFFSET 3) AS gc
FROM t2 GROUP BY g HAVING gc <> 'x' ORDER BY g;
g gc
0 000016,000020
1 000013,000017
2 000014,000018
3 000015,000019
SET @@tmp_memory_table_size=DEFAULT;
SELECT g, GROUP_CONCAT(DISTINCT a ORDER BY a LIMIT 2 OFFSET 3) AS gc
FROM t2 GROUP BY g HAVING gc <> 'x' ORDER BY g;
g gc
0 000016,000020
1 000013,000017
2 000014,000018
3 000015,000019
DROP TABLE t2;

View File

@@ -0,0 +1,92 @@
#
# Nothing says how many times a statement asks for the result of a group,
# and the answer must not depend on it. When an OFFSET skips every row of
# the group, the walk writes nothing, and the aggregate must still record
# that it has run. Otherwise the next caller walks again with the offset
# already spent, which replays the group into a buffer that was handed
# over once already, and, once the duplicate filter has spilled to disk,
# walks a Unique that can only be walked once.
#
--source include/have_sequence.inc
--echo #
--echo # A plain aggregate, asked once and then asked twice. A HAVING
--echo # clause on the alias is the shortest statement that asks twice.
--echo #
CREATE TABLE t1 (g INT, a VARCHAR(10));
INSERT INTO t1 VALUES (1,'a'),(1,'b'),(1,'c'),(1,'d'),(2,'e'),(2,'f');
SELECT GROUP_CONCAT(a ORDER BY a LIMIT 2 OFFSET 4) AS v FROM t1 WHERE g = 1;
SELECT GROUP_CONCAT(a ORDER BY a LIMIT 2 OFFSET 4) AS v FROM t1 WHERE g = 1
HAVING v LIKE '%';
--echo #
--echo # Two conditions ask for it a third time.
--echo #
SELECT GROUP_CONCAT(a ORDER BY a LIMIT 2 OFFSET 4) AS v FROM t1 WHERE g = 1
HAVING v LIKE '%' AND v NOT LIKE 'zz%';
--echo #
--echo # DISTINCT reaches the walk by the other route.
--echo #
SELECT GROUP_CONCAT(DISTINCT a LIMIT 2 OFFSET 4) AS v FROM t1 WHERE g = 1
HAVING v LIKE '%';
--echo #
--echo # One group spends the offset exactly and the other does not, so
--echo # both answers show up in the same statement.
--echo #
SELECT g, GROUP_CONCAT(a ORDER BY a LIMIT 2 OFFSET 4) AS v FROM t1
GROUP BY g HAVING v LIKE '%' ORDER BY g;
--echo #
--echo # An offset that stops inside the group is not affected, the walk
--echo # having written a row, and neither is a group with no LIMIT.
--echo #
SELECT GROUP_CONCAT(a ORDER BY a LIMIT 2 OFFSET 1) AS v FROM t1 WHERE g = 1
HAVING v LIKE '%';
SELECT GROUP_CONCAT(a ORDER BY a) AS v FROM t1 WHERE g = 1
HAVING v LIKE '%';
DROP TABLE t1;
--echo #
--echo # The same replay against a duplicate filter that has spilled to
--echo # disk, where the second walk is not merely wrong but unsupported.
--echo #
CREATE TABLE t2 (g INT, a VARCHAR(100));
INSERT INTO t2 SELECT seq MOD 4, LPAD(seq, 6, '0') FROM seq_1_to_2000;
SELECT COUNT(*) AS rows_in_table, COUNT(DISTINCT a) AS distinct_values FROM t2;
SELECT g, COUNT(*) AS rows_in_group FROM t2 GROUP BY g ORDER BY g;
SET @@tmp_memory_table_size=0;
--echo #
--echo # What an offset does while it stops inside the group: the same
--echo # query at two offsets returns two different windows of the same
--echo # ordered values, and each group returns values of its own.
--echo #
SELECT g, GROUP_CONCAT(DISTINCT a ORDER BY a LIMIT 3) AS gc
FROM t2 GROUP BY g ORDER BY g;
SELECT g, GROUP_CONCAT(DISTINCT a ORDER BY a LIMIT 3 OFFSET 2) AS gc
FROM t2 GROUP BY g ORDER BY g;
--echo #
--echo # An offset past the end of every group. Each group holds 500
--echo # values, so there is nothing left to return, and the HAVING asks
--echo # each group for its answer a second time.
--echo #
SELECT g, GROUP_CONCAT(DISTINCT a LIMIT 5 OFFSET 1000) AS gc
FROM t2 GROUP BY g HAVING gc <> 'x';
SELECT g, GROUP_CONCAT(DISTINCT a ORDER BY a LIMIT 5 OFFSET 1000) AS gc
FROM t2 GROUP BY g HAVING gc <> 'x';
--echo #
--echo # An offset that skips only part of the group is not affected, and
--echo # the answer must not depend on whether the filter spilled.
--echo #
SELECT g, GROUP_CONCAT(DISTINCT a ORDER BY a LIMIT 2 OFFSET 3) AS gc
FROM t2 GROUP BY g HAVING gc <> 'x' ORDER BY g;
SET @@tmp_memory_table_size=DEFAULT;
SELECT g, GROUP_CONCAT(DISTINCT a ORDER BY a LIMIT 2 OFFSET 3) AS gc
FROM t2 GROUP BY g HAVING gc <> 'x' ORDER BY g;
DROP TABLE t2;

View File

@@ -4928,6 +4928,15 @@ String* Item_func_group_concat::val_str(String* str)
return &result;
else
DBUG_ASSERT(false); // Can't happen
/*
dump_leaf_key() sets this when it writes a row, but a group can end
without one: an OFFSET can eat every row, and a walk that failed
hands over nothing. val_str() can be called again for the same
group, and unique_filter must not be walked twice - Unique::walk()
leaves the object undefined and the second walk asserts in
merge_walk().
*/
result_finalized= true;
}
/*