Skip to content

Make the MySQL schema hash deterministic, and let it cover the column order - #803

Open
ArtemGoutsoul wants to merge 3 commits into
staabm:mainfrom
ArtemGoutsoul:fix/deterministic-mysql-schema-hash
Open

Make the MySQL schema hash deterministic, and let it cover the column order#803
ArtemGoutsoul wants to merge 3 commits into
staabm:mainfrom
ArtemGoutsoul:fix/deterministic-mysql-schema-hash

Conversation

@ArtemGoutsoul

Copy link
Copy Markdown
Contributor

Make the MySQL schema hash deterministic, and let it cover the column order

Issue

SchemaHasherMysql::hashDb() returns a different value for the same schema on different servers, so DbSchemaResultCacheMetaExtension reports a different value and PHPStan discards its whole result cache:

Result cache not used because the metadata do not match: metaExtensions

For us that meant CI restored a result cache and immediately threw it away on most runs — a full re-analysis instead of a warm one, about a minute per run.

Why

The columns are sorted in a derived table and aggregated outside it:

SELECT MD5(GROUP_CONCAT(InnerSelect.columns)) ..., 1 AS grouper
FROM ( SELECT CONCAT(...) as columns FROM information_schema.columns
       WHERE table_schema = DATABASE() ORDER BY table_name, column_name ) as InnerSelect
GROUP BY grouper

Whether that ORDER BY survives is up to the optimizer. When the derived table is merged into the outer query the sort is dropped — EXPLAIN FORMAT=TREE then shows no sort node, just Group aggregate: group_concat(...) over the data dictionary lookups — and GROUP_CONCAT consumes the rows in dictionary scan order (by ordinal_position) instead.

Hashing t (zebra varchar(10) NOT NULL, apple date NULL) shows both orders, on the same query:

server order the rows were concatenated in
MySQL 5.7.44 ordinal_position
MySQL 8.0.32 column_name
MySQL 8.4.11 ordinal_position
MariaDB 12.3.2 ordinal_position

It is not only the server version. The same MySQL 8.0.32 that sorts that two-column schema drops the sort for a 369-table one — 819813065b5deb6c55991fa0a4f92f68 in dictionary order where sorting gives ef6b72fe18377aec70b07772190d7575. So the hash depends on the plan, and the plan depends on the server and on how much there is to scan.

Fix

Two changes.

1. Sort inside the aggregate. GROUP_CONCAT takes its own ORDER BY, which is guaranteed where a merged derived table's is not. The derived table and its GROUP BY grouper are then no longer needed:

SELECT MD5(GROUP_CONCAT(
    CONCAT(COALESCE(COLUMN_NAME, ''), COALESCE(EXTRA, ''), COLUMN_TYPE, IS_NULLABLE, ORDINAL_POSITION)
    ORDER BY TABLE_NAME, COLUMN_NAME
)) AS dbsignature
FROM information_schema.columns
WHERE table_schema = DATABASE()

2. Add ORDINAL_POSITION to the signature. The order a table's columns are declared in decides the shape of a SELECT * row, so reordering them has to invalidate the result cache. On the servers that concatenated in dictionary order that happened by accident; the signature itself never described the order, and sorting by COLUMN_NAME would have dropped that side effect. One extra column in the CONCAT makes it explicit.

Everything else is untouched: the group_concat_max_len session setting, the transaction handling, the PDO and mysqli branches, the empty-hash guard. The hash value changes once for everyone, which invalidates result caches on the first run after upgrading and then stops moving.

Test

tests/default/SchemaHasherMysqlTest.php hashes a database of its own, created and dropped per test, so the schema under test is fully controlled and the other tests' schema is left alone.

  • testSchemaHashIsTheColumnSignatureSortedByName() asserts the whole signature of a two-column table, which pins the order the columns are concatenated in. It fails on main on every server in the table above.
  • testSchemaHashChangesWithTheSchema() covers, as a data provider, the changes that have to be visible: renamed column, changed type, changed nullability, reordered columns, added column, added table. Only the reordering case is new behavior — it fails on main wherever the rows happened to arrive sorted.

A connection of its own per hash, because MySQL hides another connection's DDL inside the transaction the hasher runs in. It skips unless the reflector is a MySQL one and the mode records, so the replay and PostgreSQL jobs skip it.

Compatibility

Ran tests/default green in all of these; no new failures in the rest of the suite.

mysqli pdo-mysql
MySQL 5.7.44
MySQL 8.0.32
MySQL 8.4.11
MariaDB 12.3.2

PHP 8.1, 8.3 and 8.4 run the tests green; 7.4 passes php -l (nothing newer than a typed property is used — my local dev dependencies resolve to 8.0+, so I could not run the suite itself on 7.4). ECS and PHPStan are clean on both files.

One thing left, happy to add it

hashDb() raises group_concat_max_len to 1000000 and does not check for truncation. A schema that concatenates past that limit hashes to a prefix and silently collides with any schema sharing it — the comment above the setting puts that at roughly 3000 columns, and adding ORDINAL_POSITION moves the limit a little closer. A @@warning_count or LENGTH() check would catch it.

SchemaHasherMysql sorted the columns in a derived table and aggregated
outside it. MySQL 8 merges that derived table and drops its ORDER BY --
EXPLAIN FORMAT=TREE shows no sort node -- so GROUP_CONCAT consumes the
rows in whatever order the data dictionary scan produces. Whether the
ORDER BY survives depends on the plan, so the same schema hashes
differently on different servers: on MySQL 8.0.32 a 369-table schema
hashed to 819813065b5deb6c55991fa0a4f92f68 (dictionary order) where
sorting gives ef6b72fe18377aec70b07772190d7575.

DbSchemaResultCacheMetaExtension reports that value to PHPStan, which
discards its whole result cache when the metadata differs from the
cached run's. A cache written on one machine and restored on another is
therefore thrown away, and everything is re-analysed.

GROUP_CONCAT takes its own ORDER BY, which is guaranteed where a merged
derived table's is not, so the sort moves inside the aggregate and the
derived table and its GROUP BY are no longer needed.
The order a table's columns are declared in decides the shape of a
SELECT * row, so a reordering has to invalidate the result cache. Before
sorting inside GROUP_CONCAT that happened by accident, on servers where
the rows arrived in data dictionary order; the signature itself never
described the order. Add ORDINAL_POSITION to the concatenated column
description, so it does.

The test now asserts the whole signature of a two-column table instead of
comparing two schema states, which pins the order the columns are
concatenated in, and covers the schema changes that have to be visible in
a data provider.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant