Skip to content

fix: a nullable UNIQUE key stalls the entire replication stream - #1396

Merged
minguyen9988 merged 2 commits into
2.10.0from
omniwatcher/fix-nullable-unique-key-stalls-replication
Aug 24, 2026
Merged

fix: a nullable UNIQUE key stalls the entire replication stream#1396
minguyen9988 merged 2 commits into
2.10.0from
omniwatcher/fix-nullable-unique-key-stalls-replication

Conversation

@minguyen9988

@minguyen9988 minguyen9988 commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

A MySQL table with a UNIQUE key but no PRIMARY KEY takes the UNIQUE key as
its ClickHouse sorting key (#1394). MySQL permits any number of NULLs in a
UNIQUE index, so that key may name nullable columns -- unlike a PRIMARY KEY,
whose columns MySQL forces to NOT NULL.

ClickHouse rejects a nullable sorting key with Code: 44 ILLEGAL_COLUMN
unless allow_nullable_key is enabled. #1394 emitted that setting only for the
keyless all-columns fallback (isKeylessTable), not for the UNIQUE-key path,
so such a table produced:

  CREATE TABLE ... ORDER BY (a)        <- no SETTINGS allow_nullable_key=1

and the CREATE TABLE failed. Because DDL is retried indefinitely, the failure
did not merely skip that table -- it stalled the WHOLE replication stream.

Measured on the published 2.10.0 image
(1304-7da7cc526d2f6f396f3ea7bbd9d018e52a5f16dc-lt), MySQL 8.0.36
ROW/FULL/GTID -> ClickHouse 24.8.14.10547:

  table          mysql   ch   note
  n_uk               5    1   nullable UNIQUE, no PK -- CREATE failed
  u_notnull          5    0   created before the stall, never populated
  u_nullable         5    -   table never created
  u_composite        3    -   table never created
  u_pk_control       3    -   table never created  <- unrelated, PK table

30 retries later the stream had made no further progress. Three of those
tables have no nullable key at all; they were lost purely because they were
behind the failing DDL in the stream.

The fix keys the setting on whether the sorting key was DERIVED by the parser
(UNIQUE-key path or all-columns fallback) rather than on isKeylessTable
alone. A PRIMARY KEY sorting key still does not emit it -- MySQL guarantees
those columns are NOT NULL, and emitting it unconditionally would silently
permit nullable keys ClickHouse is right to reject.

After the fix, same scenarios on a fresh stack:

  table          mysql   ch   sorting_key    result
  f_uk               4    4   a              PASS  (nullable UNIQUE, no PK)
  f_uk_notnull       5    5   a              PASS
  f_uk_comp          3    3   a, b           PASS  (composite, nullable)
  f_pk               2    2   a              PASS  (no allow_nullable_key)
  f_keyless          2    2   a, b           PASS
  f_alembic          1    1   version_num    PASS
  f_behind           2    2   id             PASS  (stream not stalled)

  Code: 44 errors: 0

Content verified too, not just counts: f_uk survives an UPDATE and a DELETE
as 1:a,2:bX,3:c,4:d, matching MySQL exactly.

Why the existing tests missed it: every UNIQUE-key case in
CreateTableUniqueKeySortKeyTest declares its column NOT NULL, and the DDL is
only rejected once a nullable column reaches the key. Three tests are added
-- nullable single-column UNIQUE, partially-nullable composite UNIQUE, and a
guard that the PRIMARY KEY path does NOT emit the setting. The first two fail
on the unmodified parent (8 run / 2 failures, precisely the new nullable
cases) and pass with the fix.

Test: CreateTableUniqueKeySortKeyTest 8 run / 0 failures,
CreateTableNoKeySortKeyTest 7 run / 0 failures.
MySqlDDLParserListenerImplTest#testAutoCreateTable fails identically on the
unmodified parent commit 33ff707 -- pre-existing, unrelated to this change.
@minguyen9988

Copy link
Copy Markdown
Collaborator Author

Summary

A MySQL table with a UNIQUE key but no PRIMARY KEY whose key column is nullable produces a CREATE TABLE that ClickHouse rejects with Code: 44 ILLEGAL_COLUMN. Because DDL is retried indefinitely, that failure stalls the entire replication stream — not just the offending table.

Root cause

#1394 added allow_nullable_key=1 gated on isKeylessTable — the all-columns fallback. But #1389/#1394 also derive a sorting key from the UNIQUE key when no PRIMARY KEY exists, and MySQL permits any number of NULLs in a UNIQUE index. That path emitted ORDER BY (a) with no SETTINGS allow_nullable_key=1.

A PRIMARY KEY never needs the setting (MySQL forces those columns NOT NULL), so the fix keys on whether the sorting key was derived by the parser rather than on isKeylessTable.

Measured — before

MySQL 8.0.36 ROW/FULL/GTID -> ClickHouse 24.8.14.10547:

table          mysql   ch   note
n_uk               5    1   nullable UNIQUE, no PK -- CREATE failed
u_notnull          5    0   created before the stall, never populated
u_nullable         5    -   table never created
u_composite        3    -   table never created
u_pk_control       3    -   table never created  <- ordinary PK table

30 retries later the stream had made no further progress. The last three tables have no nullable key at all — they were lost purely because they sat behind the failing DDL.

Measured — after

table          mysql   ch   sorting_key    result
f_uk               4    4   a              PASS  (nullable UNIQUE, no PK)
f_uk_notnull       5    5   a              PASS
f_uk_comp          3    3   a, b           PASS  (composite, nullable member)
f_pk               2    2   a              PASS  (no allow_nullable_key emitted)
f_keyless          2    2   a, b           PASS
f_alembic          1    1   version_num    PASS
f_behind           2    2   id             PASS  (stream not stalled)

Code: 44 errors: 0

Content verified, not just counts: f_uk survives an UPDATE and a DELETE as 1:a,2:bX,3:c,4:d, matching MySQL exactly.

Why existing tests missed it

Every UNIQUE-key case in CreateTableUniqueKeySortKeyTest declares its column NOT NULL. The DDL is only rejected once a nullable column reaches the key. Three tests added: testNullableUniqueKeyEmitsAllowNullableKey, testPartiallyNullableCompositeUniqueKeyEmitsAllowNullableKey, and testPrimaryKeyDoesNotEmitAllowNullableKey (guards the other direction).

Failing-first proven: on the unmodified parent 33ff7077, the suite runs 8 / 2 failures — precisely the two new nullable cases. With the fix, 8 / 0.

Test results

  • CreateTableUniqueKeySortKeyTest — 8 run / 0 failures
  • CreateTableNoKeySortKeyTest — 7 run / 0 failures
  • MySqlDDLParserListenerImplTest#testAutoCreateTable fails identically on the unmodified parent 33ff7077 — pre-existing, unrelated.

Rest of the image validated clean

Everything else in the published image passed end-to-end: BIT b8/b16/b24/b64 exact vs MySQL HEX(), Decimal(30,15) exact, bulk multi-row UPDATE/DELETE (#1391), pre-ALTER ADD COLUMN NULL-fill (#1389), the rename drain (#1395 — 90 rows, 0 corruption, INT->BIGINT preserving 2^63-1).

Addresses the judge-panel blocker on the previous commit. That commit fixed
the Code: 44 stall by emitting allow_nullable_key for a nullable UNIQUE-key
sorting key -- which made the CREATE TABLE succeed, but traded a loud stall
for silent data loss.

MySQL does not treat NULLs as equal for uniqueness, so a nullable UNIQUE
index permits any number of rows whose key is NULL. It is not a row identity.
ClickHouse DOES compare NULLs as equal in a sorting key, so adopting such a
key makes ReplacingMergeTree collapse those distinct source rows into one.

Measured on MySQL 8.0.36 ROW/FULL/GTID -> ClickHouse 24.8.14.10547 with the
previous commit's jar, UNIQUE KEY(a) over a nullable `a`:

  table            mysql   ch   contents
  z_nulluk             4    2   ch has keyed,third -- 'first','second' LOST
  z_nulluk_comp        3    2   partially-nullable composite, same loss

So a UNIQUE key is now adopted as the sorting key ONLY when every one of its
columns is NOT NULL. Otherwise the table has no usable declared identity and
falls through to the existing all-columns fallback, which reproduces MySQL's
own semantics for such a table: rows are distinguished by value. A warning
names the table and the reason.

The nullability of each column is already known while parsing the column
definitions, so it is recorded there (notNullColumnNames, cleared per CREATE
TABLE so a reused listener cannot leak state) and the UNIQUE key's columns
are checked against it. Column-level (`uk INT UNIQUE`) and table-level
(`UNIQUE KEY (a,b)`) declarations both route through the same column parse,
so both are covered; index prefix lengths (`a(10)`) are stripped since they
narrow the index, not the column.

The allow_nullable_key gate is now keyed on nullableSortingKey rather than
"was the key derived": only the all-columns fallback can name nullable
columns. A PRIMARY KEY is NOT NULL by MySQL's rule and a UNIQUE key is now
adopted only when NOT NULL, so neither needs the setting -- and emitting it
where unnecessary would silently permit nullable keys ClickHouse is right to
reject. This is what testNotNullCompositeUniqueKeyRemainsSortKey caught.

End-to-end on a fresh stack with the fixed jar:

  table            mysql   ch   sorting_key    result
  q_nulluk             3    3   a, v           PASS  (nullable UNIQUE)
  q_nulluk_comp        3    3   a, b, v        PASS  (partially nullable)
  q_nnuk               4    4   a              PASS  (NOT NULL UNIQUE kept)
  q_nnuk_comp          3    3   a, b           PASS
  q_pk                 2    2   a              PASS
  q_keyless            2    2   a, v           PASS
  q_alembic            1    1   version_num    PASS
  q_behind             2    2   id             PASS  (stream not stalled)

  Code: 44 errors: 0

Content verified, not just counts: q_nulluk holds first,keyed,second_upd on
both sides after an UPDATE and a DELETE that each targeted a NULL-keyed row.
The three NULL-keyed rows are preserved and individually addressable, which
is the property the previous commit lost.

Tests: CreateTableUniqueKeySortKeyTest 9 run / 0 failures,
CreateTableNoKeySortKeyTest 7 run / 0 failures.
testNullableUniqueKeyFallsBackToAllColumns and
testPartiallyNullableCompositeUniqueKeyFallsBackToAllColumns cover the
collapse; testNotNullCompositeUniqueKeyRemainsSortKey guards the safe case
from over-correction.
@minguyen9988

Copy link
Copy Markdown
Collaborator Author

Correction — second commit added (afbef19c)

My first commit fixed the Code: 44 stall by emitting allow_nullable_key for a nullable UNIQUE-key sorting key. That made the CREATE TABLE succeed but traded a loud stall for silent data loss, and an adversarial review caught it. Verified live before acting on it:

table            mysql   ch   contents
z_nulluk             4    2   ch has keyed,third -- 'first','second' LOST
z_nulluk_comp        3    2   partially-nullable composite, same loss

MySQL does not treat NULLs as equal for uniqueness, so a nullable UNIQUE index permits any number of NULL-keyed rows — it is not a row identity. ClickHouse does compare NULLs as equal in a sorting key, so adopting such a key collapses those distinct source rows.

So a UNIQUE key is now adopted only when every one of its columns is NOT NULL. Otherwise the table has no usable declared identity and falls through to the all-columns fallback, which matches MySQL's own semantics: rows are distinguished by value. A warning names the table and the reason.

The allow_nullable_key gate is now keyed on whether the emitted key can actually be nullable — only the all-columns fallback can. A PRIMARY KEY is NOT NULL by MySQL's rule, and a UNIQUE key is now adopted only when NOT NULL, so neither gets the setting. testNotNullCompositeUniqueKeyRemainsSortKey guards that, and it caught the over-correction.

Final end-to-end, fresh stack, fixed jar

table            mysql   ch   sorting_key    result
q_nulluk             3    3   a, v           PASS  (nullable UNIQUE)
q_nulluk_comp        3    3   a, b, v        PASS  (partially nullable)
q_nnuk               4    4   a              PASS  (NOT NULL UNIQUE kept)
q_nnuk_comp          3    3   a, b           PASS
q_pk                 2    2   a              PASS
q_keyless            2    2   a, v           PASS
q_alembic            1    1   version_num    PASS
q_behind             2    2   id             PASS  (stream not stalled)

Code: 44 errors: 0

Content verified, not just counts: q_nulluk holds first,keyed,second_upd on both sides after an UPDATE and a DELETE that each targeted a NULL-keyed row. The three NULL-keyed rows are preserved and individually addressable — the property the first commit lost.

Tests: CreateTableUniqueKeySortKeyTest 9 run / 0 failures, CreateTableNoKeySortKeyTest 7 run / 0 failures.

@minguyen9988 minguyen9988 changed the title WIP: DO NOT MERGE - fix: a nullable UNIQUE key stalls the entire replication stream fix: a nullable UNIQUE key stalls the entire replication stream Aug 24, 2026
@minguyen9988
minguyen9988 merged commit 78b868c into 2.10.0 Aug 24, 2026
1 check passed
@minguyen9988
minguyen9988 deleted the omniwatcher/fix-nullable-unique-key-stalls-replication branch August 24, 2026 02:35
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