Make the MySQL schema hash deterministic, and let it cover the column order - #803
Open
ArtemGoutsoul wants to merge 3 commits into
Open
Make the MySQL schema hash deterministic, and let it cover the column order#803ArtemGoutsoul wants to merge 3 commits into
ArtemGoutsoul wants to merge 3 commits into
Conversation
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.
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.
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, soDbSchemaResultCacheMetaExtensionreports a different value and PHPStan discards its whole result cache: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:
Whether that
ORDER BYsurvives is up to the optimizer. When the derived table is merged into the outer query the sort is dropped —EXPLAIN FORMAT=TREEthen shows no sort node, justGroup aggregate: group_concat(...)over the data dictionary lookups — andGROUP_CONCATconsumes the rows in dictionary scan order (byordinal_position) instead.Hashing
t (zebra varchar(10) NOT NULL, apple date NULL)shows both orders, on the same query:ordinal_positioncolumn_nameordinal_positionordinal_positionIt 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 —
819813065b5deb6c55991fa0a4f92f68in dictionary order where sorting givesef6b72fe18377aec70b07772190d7575. 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_CONCATtakes its ownORDER BY, which is guaranteed where a merged derived table's is not. The derived table and itsGROUP BY grouperare then no longer needed:2. Add
ORDINAL_POSITIONto the signature. The order a table's columns are declared in decides the shape of aSELECT *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 byCOLUMN_NAMEwould have dropped that side effect. One extra column in theCONCATmakes it explicit.Everything else is untouched: the
group_concat_max_lensession 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.phphashes 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 onmainon 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 onmainwherever 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/defaultgreen in all of these; no new failures in the rest of the suite.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()raisesgroup_concat_max_lento 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 addingORDINAL_POSITIONmoves the limit a little closer. A@@warning_countorLENGTH()check would catch it.