Derive the join order randomization seed from the initial query id - #112660
Derive the join order randomization seed from the initial query id#112660groeneai wants to merge 10 commits into
Conversation
`query_plan_optimize_join_order_randomize = 1` means "derive a random seed", but the substitution happened in the `QueryPlanOptimizationSettings` constructor while the session value stayed `1`. That constructor runs once per query plan construction, and one query builds several plans: one per scalar subquery, one per `Merge` child, and, under parallel replicas, one per replica, because `serialize_query_plan` is off by default so every replica re-plans the query text it receives. Each of them rolled its own seed. The seed feeds `getRandomizedStats`, which replaces the relation statistics, so a different seed picks a different leftmost relation. `findReadingStep` descends only `children.front()`, so a different table then receives `requestReadingInOrder` and announces a different `CoordinationMode` for the same stream. Since `stream_to_coordinator` is keyed by the table name alone, the second announcement throws `Coordination mode mismatch for stream` in `ParallelReplicasReadingCoordinator::getOrCreateCoordinator`. This has been firing across many unrelated PRs; the abort dump on the run reported in ClickHouse#107567 contains both preconditions (`query_plan_optimize_join_order_randomize = 1` and `parallel_replicas_local_plan = false`). Derive the seed from `initial_query_id` instead. It is stable for one query and is propagated to remote replicas in `ClientInfo`, so every plan of one query derives the same seed while different queries still explore different orderings. The derived value is forced above 1 so it can never read back as the sentinel (1) or as disabled (0). An empty initial query id keeps `randomSeed()`: an internal or background plan has no query to be consistent with and no parallel-replica fan-out to diverge from. `0` (the default) and explicit seeds above 1 are untouched, so any query that does not opt into randomization is bit-identical. As a side effect a `= 1` run becomes reproducible, which is what the setting's own doc string asks for. Measured against a three-replica cluster, counting seeds across all replica query ids of one initial query id: 3 distinct seeds before, 1 after, deterministic over repeated runs. This removes one carrier of the failure class, not all of them. The process-global hash-table statistics cache and per-replica real statistics can still diverge join order for a query that does not set this setting; both were inactive for the reported abort, and they are followed up separately. Related: ClickHouse#106039
Review round 1 on the join-order-randomize seed fix. The regression test asserted that one query gets one seed, but not that the seed is the value derived from the initial query id. A compile-time constant seed satisfies uniqueness within a query and across replicas, so it would have passed every assertion while silently removing the cross-query randomization the setting exists for. Each of the two affected cells now also compares the logged seed against greatest(sipHash64(<initial query id>), 2), reusing that cell's existing query_id subquery so the two rows cannot drift apart. SQL sipHash64 over a String hashes the same bytes as the C++ overload, with no terminator, and greatest(..., 2) is UInt64, so the comparison reproduces the clamp exactly; this was measured before being asserted. A mutation replacing the hash with a constant now reddens exactly those two rows and nothing else. The setting description claimed the change "keeps a run reproducible". That is false as a user-facing statement: rerunning the same query text mints a new initial query id and therefore a new seed, so reproducibility requires an explicit query id. The description is published through system.settings, so it now states only what holds, and also covers the empty initial query id case that falls back to a random seed. The comment at the fix site kept the incident narrative that belongs in the commit message and the pull request description, so it is reduced to the invariant and the reason for the clamp. The code lines are unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… guarantee The description claimed that rerunning the same query text "still explores a different ordering". The change only guarantees a new seed: the chosen order is a hash of (seed, relation index, table name) over a finite set of orderings, so two distinct seeds can legitimately select the same order. The regression test added by this branch records a measured instance of exactly that, naming seeds 12345 and 54321 as colliding for its table names, so the published text contradicted the diff's own test comment. The text now promises a new seed and says the ordering may differ, and points at an explicit query id as the way to keep the seed stable. The other four claims are unchanged: derivation from the initial query id, sharing with remote replicas, the empty-id fallback to a random seed, and the verbatim use of a value above 1. The description reaches users through system.settings.description and the generated settings page, which is why the wording is corrected rather than left to the PR body. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…sertion The guard read `uniqExact(query_id) > 1` under the label "cell C fanned out to several replicas". The predicate is correct for what it can observe, but the label promised more than it delivers: the initiator's own row is always one of the counted ones, so the check cannot distinguish "initiator plus one remote" from "initiator plus two remotes". Measured directly, it stays green with `max_parallel_replicas = 2`, so it does not notice a degraded fan-out. The predicate is kept verbatim and relabelled to the property it does pin, the same wording cell A already uses for the same fact: the query really did construct more than one plan. That is what protects the two substantive cell C rows from passing vacuously on a single matching row. The fan-out itself is now asserted separately from `ProfileEvents['ParallelReplicasAvailableCount']` on the initiator's own `query_log` row. That counter is incremented once per replica that actually joined the query, and it is accumulated in the initiator's own process, so it does not depend on when the replicas' `query_log` rows become visible. An earlier attempt to count the remote rows themselves was 35% flaky for exactly that reason: `SYSTEM FLUSH LOGS` flushes the local queues only and has no cross-replica barrier, so at the moment the initiator flushes some replica rows may not be enqueued yet. The similarly named `ParallelReplicasUsedCount` counts per replica that requested ranges and reads 1 here, so it is not the right event. The observing statement pins `enable_parallel_replicas = 0` because cell C sets it at session level, matching what `02950_parallel_replicas_used_count` does for its own observers.
…re-planning comment Round 4 of review. Two test-only changes, no behaviour change. The cell C fan-out row read `ProfileEvents['ParallelReplicasAvailableCount'] = 3`. That counter is incremented in the connection-establishment loop of RemoteQueryExecutor, before `sendQuery`, so it counts compatible connections rather than plan constructions that agreed on the seed. Replace it with a row that counts distinct plan constructions carrying this query's derived seed. The scope is the seed value computed from the initiator's own `query_log` row, which is always locally visible; joining the replicas' rows instead races their `query_log` flush, because `SYSTEM FLUSH LOGS` has no cross-replica barrier. The header comment claimed a replica re-plans because `serialize_query_plan` is off by default. That reason does not hold on the `distributed plan` functional jobs, which enable the setting in the default profile. The mechanism that holds on both paths is that `JoinStepLogical::serialize` does not encode the `optimized` flag, so the join-order rewrite runs again on the replica whether it receives the query text or a serialized plan. State that instead; the setting is deliberately left unpinned so the serialized-plan path keeps its coverage. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e query_log lookups Three test-only changes to the 04653 regression test, all requested by review. Split the cell C non-vacuity assertion in two. It previously loaded two independent properties onto one inequality: that several plan constructions agreed on the derived seed, and that the fixture fanned out to the configured replica count. The population that carries the derived seed is one initiator plan construction plus one per replica, so over a population of max_parallel_replicas + 1 no single bound can express both, and "secondary replica participation" cannot be expressed at all. Each property now has its own row with a bound that means what its label says: a >= 2 seed-agreement bound, and an exact = 4 fan-out bound deliberately coupled to max_parallel_replicas = 3. Reducing the replica count to 2 reddens only the fan-out row, which is what the split is for. Pin the join swap setting on both EXPLAIN statements of the cell B reorder oracle. That oracle compares the read orderings produced by two different explicit seeds, and a forced swap changes the read order independently of the seed, so the row failed for every forced value of the setting. Both the functional runner and the stress runner randomize it, and the existing no-random-settings tag does not protect against the stress runner, which injects through a client option that wins over the randomized value. Statement-level scope is used so cells C and B2 keep exercising the default. Order the four expected-seed lookups over system.query_log by event time, descending. That table is server-global and append-only, and a stress thread runs with a fixed database, so an unordered single-row pick could hash a previous run's query id. The ordered form is deterministic and selects the newest run's initiator.
The runner re-runs a failed test in the supplied database without cleaning it (tests/clickhouse-test wraps every execution in a retry loop, and TestCase._cleanup is gated on `not args.database`), and a stress thread supplies a fixed --database. One database can therefore hold several attempts of this test. The observed side of six assertions joined `system.text_log` through every initiator of a `log_comment` in the database, so it saw one derived seed per attempt: the `uniqExact(...) = 1` rows measured the attempt count and the `groupUniqArray` comparisons compared a multi-element array against a single-element one. Both reddened permanently on the second attempt, reporting a correct fix as broken. Reuse the ordered newest-initiator lookup that the expected side already used, as the `initial_query_id` filter rather than as a comparison operand. This keeps every assertion non-circular and changes no row's value, only its scope. The two cell B sites are left unscoped deliberately: an explicit constant seed cannot gain a distinct element from accumulated history, and a `count() = 0` population over `randomize = 0` stays empty however many attempts run.
Cell C sets `parallel_replicas_local_plan = 0`, and on that branch the replicas receive query text rather than a serialized plan: `createRemotePlanForParallelReplicas` is reached only inside the `canUseLocalPlanForParallelReplicas` branch and only under `serialize_query_plan`, and the `else` branch passes no plan at all, so `ReadFromRemote` selects the query-text stage. The test therefore covered only the re-plan-from-text path, on every job. Add a cell that ships a serialized plan. The receiver re-optimizes it, so the join-order rewrite runs there too and must derive the same seed. `serialize_query_plan` is pinned explicitly so the cell exercises the path on every job rather than only on one whose profile enables it, and the settings are statement-level so the existing cell C rows keep measuring the query-text path.
A population filtered by the seed value cannot observe a plan construction that derived a different seed, because such rows are filtered out by definition. Cell C2 was the only cell whose sole assertion was scoped that way, so it could not fail on the seed disagreement this change is about. Scope it by initial_query_id and compare the whole observed set against the single derived value, mirroring the shape cell C already uses. One row then asserts both halves: the constructions agreed, and they agreed on the derived value. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Cell C2 asserted that every plan construction of a serialized-plan query derives the same seed, but that held on the query-text fallback too: a replica that re-plans the query text derives the SAME seed from the same propagated initial_query_id, so removing the serialize_query_plan pin left the cell green and the path it exists to cover was unwitnessed. The discriminator is the number of plan constructions. A receiver that deserializes a plan constructs QueryPlanOptimizationSettings twice (executeQuery.cpp:1980 and :1987 - the second passes do_optimize = false, but the object is built at the call site, so both log the seed), while a replica re-planning the query text constructs it once (InterpreterSelectQueryAnalyzer.cpp:403). The initiator is excluded from the population because it constructs twice on both paths whenever parallel_replicas_local_plan is set, which cell C2 must pin; the guard precedes the construction (InterpreterSelectQueryAnalyzer.cpp:184 returns early for a secondary query, the construction is at :192), so the initiator is exactly the one participant that can double without deserializing. Both subqueries read the initiator's own always-visible query_log row, the same row the seed subquery already reads, so the new row needs no join against the replicas' query_log, whose flush this test cannot force. Removing the cell C2 serialize_query_plan pin now reddens this row and only this row. Measured 20 OK / 0 FAIL over 20 randomized runs and 3 OK / 0 FAIL in a reused database; the population is 4 constructions across 2 replica ids on the serialized-plan path and 2 across 2 on the query-text path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Internal second-model review: adjudication log (click to expand)Pre-publication review by an independent model (engine: codex; 16 findings across 8 review The findings below are the substantive ones. Every AGREE was fixed and re-verified; every
Severity: ❌ blocker / Session id: cron:clickhouse-review-slot-51:20260730-200100 |
Pre-PR validation gate (click to expand)
Session id: cron:clickhouse-impl-slot-7:20260729-180650 |
|
cc @antaljanosbenjamin @devcrafter, could you review this? |
|
Workflow [PR], commit [ca9bfd2] Summary: ❌
AI ReviewSummaryThis PR fixes Final Verdict
|
LLVM Coverage Report
Changed lines: Changed C/C++ lines covered: 17/19 (89.47%) · Uncovered code |
CI finish ledger - ca9bfd2Every failure below has an owner: a fixing PR (ours or external), or a full-effort fix task
Session id: cron:our-pr-ci-monitor:20260731-160000 |
Changelog category (leave one):
Changelog entry (a user-readable short description of the changes that goes into CHANGELOG.md):
Fixes
LOGICAL_ERROR: Coordination mode mismatch for streamwhen a multi-way join runs under parallel replicas withquery_plan_optimize_join_order_randomize = 1. The seed is now derived from the initial query id, so every query plan of one query, including the plans built by remote replicas, picks the same join order.Description
Reported on #107567 as STID
6117-68e9, tracked by #106039: CI report.query_plan_optimize_join_order_randomize = 1means "derive a random seed", but the substitution happened in theQueryPlanOptimizationSettingsconstructor while the session value stayed1. That constructor runs once per plan construction, and one query builds several plans: one per scalar subquery and, under parallel replicas, one per replica. A replica re-plans the query text it receives, and also re-optimizes a serialized plan, sinceJoinStepLogical::serializedoes not encode theoptimizedflag. Each plan rolled its own seed.The seed feeds
getRandomizedStats, which replaces the relation statistics, so a different seed picks a different leftmost relation.findReadingStepdescends onlychildren.front(), so a different table receivesrequestReadingInOrderand announces a differentCoordinationModefor one stream;stream_to_coordinatoris keyed by table name alone, so the second announcement throws. The abort dump contains both preconditions.The fix derives the seed from
initial_query_id, which is stable for one query and reaches remote replicas inClientInfo, so every plan of one query agrees on the seed.0(the default) and explicit seeds above1are untouched; an emptyinitial_query_id, which an internal or background plan carries, keeps the old random seed. The trade is deliberate: a query building several plans now explores one ordering instead of one per plan. Coverage across queries is retained because each gets a fresh id and so a fresh seed.On a three-replica cluster one initial query id yields 3 distinct seeds before the fix and 1 after. The new test carries 13 assertions, of which 8 fail on unmodified master.
Validation matrix and remaining carriers
This removes one carrier of the class, not all: the process-global hash-table statistics cache and per-replica real statistics can also diverge join order without this setting. Both were inactive for the reported abort. Followed up separately.
The pre-fix binary is a pristine master build whose copy of the modified file is byte-identical to
origin/master.count()result, 3-way join under parallel replicasclickhouse-testMutation tests: reverting the derivation to
randomSeedreddens 8 assertions; replacing the hash with a constant reddens 6 of those 8, leaving green only the two that assert a single seed rather than the derived one, so no assertion is satisfiable by an arbitrary constant. Lowering the fixture's replica count reddens the fan-out assertion alone; removing the join-swap pin reddens the reorder control alone; dropping the plan-serialization pin reddens the serialized-plan assertion alone. Removing the clamp that forces the derived value above 1 reddens nothing, as expected: it fires only when the hash is 0 or 1 (probability 2/2^64).Must-not-change controls stay green in both arms: an explicit seed is still used verbatim, two distinct explicit seeds still produce different join orders, and
= 0derives no seed.The neighbouring parallel-replicas coordination tests, the carrier
04305_dpsub_outer_join, and the join-order-reordering tests all pass. Every other failure in a wider local sweep reproduces byte-identically on the pristine binary and is a sandbox gap.