Skip to content

Commit 139ba39

Browse files
mysql: verify schema compatibility using column names when binlog_full_metadata=true
Adds a `full_metadata: bool` parameter to `MySqlTableDesc::determine_compatibility()`. When true, columns are matched by name (allowing upstream reordering and safe addition of new columns). When false, uses the original positional prefix check. `verify_schemas()` now passes `output.binlog_full_metadata` to drive the choice. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent b1d06b8 commit 139ba39

5 files changed

Lines changed: 232 additions & 19 deletions

File tree

src/mysql-util/src/desc.rs

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,11 @@ impl MySqlTableDesc {
7575
/// exceptions:
7676
/// - `self`'s columns are a prefix of `other`'s columns.
7777
/// - `self`'s keys are all present in `other`
78-
pub fn determine_compatibility(&self, other: &MySqlTableDesc) -> Result<(), anyhow::Error> {
78+
pub fn determine_compatibility(
79+
&self,
80+
other: &MySqlTableDesc,
81+
full_metadata: bool,
82+
) -> Result<(), anyhow::Error> {
7983
if self == other {
8084
return Ok(());
8185
}
@@ -90,12 +94,29 @@ impl MySqlTableDesc {
9094
);
9195
}
9296

93-
// `columns` is ordered by the ordinal_position of each column in the table,
94-
// so as long as `self.columns` is a compatible prefix of `other.columns`, we can
95-
// ignore extra columns from `other.columns`.
97+
// In the case that we don't have full binlog row metadata, `columns` is ordered by the
98+
// ordinal_position of each column in the table, so as long as `self.columns` is a
99+
// compatible prefix of `other.columns`, we can ignore extra columns from `other.columns`.
100+
//
101+
// If we do have full metadata, then we can match columns by name and just check that all
102+
// columns in `self.columns` are present and compatible with columns in `other.columns`.
96103
let mut other_columns = other.columns.iter();
97-
for self_column in &self.columns {
98-
let other_column = other_columns.next().ok_or_else(|| {
104+
for self_column in self.columns.iter() {
105+
let other_column = if full_metadata {
106+
if self_column.column_type.is_none() {
107+
// This is an excluded column and can be ignored, as it may not have a
108+
// corresponding column in `other.columns` if the column was dropped upstream.
109+
continue;
110+
}
111+
other.columns.iter().find(|c| c.name == self_column.name)
112+
} else {
113+
other_columns.next()
114+
};
115+
if self_column.column_type.is_none() {
116+
// This is an excluded column and can be ignored.
117+
continue;
118+
}
119+
let other_column = other_column.ok_or_else(|| {
99120
anyhow::anyhow!(
100121
"column {} no longer present in table {}",
101122
self_column.name,
@@ -110,7 +131,6 @@ impl MySqlTableDesc {
110131
);
111132
}
112133
}
113-
114134
// Our keys are all still present in exactly the same shape.
115135
// TODO: Implement a more relaxed key compatibility check:
116136
// We should check that for all keys that we know about there exists an upstream key whose

src/storage/src/source/mysql/schemas.rs

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -64,13 +64,18 @@ where
6464
)),
6565
);
6666
match new_desc {
67-
Ok(desc) => match output.desc.determine_compatibility(&desc) {
68-
Ok(()) => None,
69-
Err(err) => Some((
70-
output,
71-
DefiniteError::IncompatibleSchema(err.to_string()),
72-
)),
73-
},
67+
Ok(desc) => {
68+
match output
69+
.desc
70+
.determine_compatibility(&desc, output.binlog_full_metadata)
71+
{
72+
Ok(()) => None,
73+
Err(err) => Some((
74+
output,
75+
DefiniteError::IncompatibleSchema(err.to_string()),
76+
)),
77+
}
78+
}
7479
Err(err) => {
7580
Some((output, DefiniteError::IncompatibleSchema(err.to_string())))
7681
}

test/mysql-cdc/35-exclude-columns.td

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,5 +68,6 @@ contains:column "f2" does not exist
6868
$ mysql-execute name=mysql
6969
ALTER TABLE t1 DROP COLUMN f2;
7070

71-
! select * from t1;
72-
contains:incompatible schema change
71+
> select * from t1;
72+
1 "test"
73+
1 "test"

test/mysql-cdc/alter-column-irrelevant.td

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,10 +59,12 @@ INSERT INTO t1 VALUES (2, 2);
5959
# add a new column to t1 at the beginning of
6060
$ mysql-execute name=mysql
6161
ALTER TABLE t1 ADD COLUMN f3 INTEGER FIRST;
62-
INSERT INTO t1 VALUES (3, 3, 3);
62+
INSERT INTO t1 VALUES (0, 3, 3);
6363

64-
! SELECT * FROM t1;
65-
contains:incompatible schema change
64+
> SELECT * FROM t1;
65+
1
66+
2
67+
3
6668

6769
# add a new column to t2
6870
$ mysql-execute name=mysql
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
# Copyright Materialize, Inc. and contributors. All rights reserved.
2+
#
3+
# Use of this software is governed by the Business Source License
4+
# included in the LICENSE file at the root of this repository.
5+
#
6+
# As of the Change Date specified in that file, in accordance with
7+
# the Business Source License, use of this software will be governed
8+
# by the Apache License, Version 2.0.
9+
10+
# Perform various schema updates to the upstream table
11+
12+
> CREATE SECRET mysqlpass AS '${arg.mysql-root-password}'
13+
14+
> CREATE CONNECTION mysql_conn TO MYSQL (
15+
HOST mysql,
16+
USER root,
17+
PASSWORD SECRET mysqlpass
18+
)
19+
20+
$ mysql-connect name=mysql url=mysql://root@mysql password=${arg.mysql-root-password}
21+
22+
$ mysql-execute name=mysql
23+
DROP DATABASE IF EXISTS public;
24+
CREATE DATABASE public;
25+
USE public;
26+
CREATE TABLE foo (name VARCHAR(16), value VARCHAR(32));
27+
INSERT INTO foo VALUES ('a', 'apple'), ('b', 'banana');
28+
29+
> CREATE SOURCE mysql_src FROM MYSQL CONNECTION mysql_conn;
30+
> CREATE TABLE foo1 FROM SOURCE mysql_src (REFERENCE public.foo);
31+
32+
> SELECT * FROM foo1;
33+
a apple
34+
b banana
35+
36+
$ mysql-execute name=mysql
37+
ALTER TABLE foo ADD COLUMN meta_col VARCHAR(32);
38+
INSERT INTO foo VALUES ('c', 'cherry', 'wild');
39+
40+
# Adding a column to the upstream table does not affect foo1 — it continues to
41+
# replicate only the columns it was created with, ignoring the new meta_col.
42+
> SELECT * FROM foo1;
43+
a apple
44+
b banana
45+
c cherry
46+
47+
$ mysql-execute name=mysql
48+
ALTER TABLE foo MODIFY meta_col VARCHAR(64);
49+
INSERT INTO foo VALUES ('d', 'date', 'ajwa');
50+
51+
# Altering the newly added `meta_col` column does not brick `foo1` because `foo1` is
52+
# not following/replicating `meta_col`, so schema updates involving it are inconsequential.
53+
> SELECT * FROM foo1;
54+
a apple
55+
b banana
56+
c cherry
57+
d date
58+
59+
> DROP TABLE foo1;
60+
61+
# Unlike SQL Server CDC, MySQL binlog-based replication does not require a new capture
62+
# instance for the new column. Creating a table from the existing source will include
63+
# meta_col from the snapshot onward.
64+
> CREATE TABLE foo2 FROM SOURCE mysql_src (REFERENCE public.foo);
65+
> SELECT * FROM foo2;
66+
a apple <null>
67+
b banana <null>
68+
c cherry wild
69+
d date ajwa
70+
71+
$ mysql-execute name=mysql
72+
INSERT INTO foo VALUES ('e', 'elderberry', 'montypython');
73+
74+
> SELECT * FROM foo2;
75+
a apple <null>
76+
b banana <null>
77+
c cherry wild
78+
d date ajwa
79+
e elderberry montypython
80+
81+
> DROP TABLE foo2;
82+
83+
# We can also use EXCLUDE COLUMNS to exclude meta_col if desired.
84+
> CREATE TABLE foo3 FROM SOURCE mysql_src (REFERENCE public.foo) WITH (EXCLUDE COLUMNS = (meta_col));
85+
> SELECT * FROM foo3;
86+
a apple
87+
b banana
88+
c cherry
89+
d date
90+
e elderberry
91+
92+
$ mysql-execute name=mysql
93+
INSERT INTO foo VALUES ('f', 'fig', 'sweet');
94+
95+
> SELECT * FROM foo3;
96+
a apple
97+
b banana
98+
c cherry
99+
d date
100+
e elderberry
101+
f fig
102+
103+
# dropping an excluded column should have no effect
104+
$ mysql-execute name=mysql
105+
ALTER TABLE foo DROP COLUMN meta_col;
106+
INSERT INTO foo VALUES ('g', 'grape');
107+
108+
# The INSERT after the DROP COLUMN forces the binlog to advance past the ALTER
109+
# TABLE event, so the SELECT must succeed after the schema change is processed.
110+
> SELECT * FROM foo3;
111+
a apple
112+
b banana
113+
c cherry
114+
d date
115+
e elderberry
116+
f fig
117+
g grape
118+
119+
> DROP TABLE foo3;
120+
121+
# After meta_col has been dropped upstream, foo4 can be created without excluding it.
122+
> CREATE TABLE foo4 FROM SOURCE mysql_src (REFERENCE public.foo);
123+
> SELECT * FROM foo4;
124+
a apple
125+
b banana
126+
c cherry
127+
d date
128+
e elderberry
129+
f fig
130+
g grape
131+
132+
$ mysql-execute name=mysql
133+
INSERT INTO foo VALUES ('h', 'honeydew');
134+
135+
> SELECT * FROM foo4;
136+
a apple
137+
b banana
138+
c cherry
139+
d date
140+
e elderberry
141+
f fig
142+
g grape
143+
144+
# Dropping a non-excluded (tracked) column stalls the source.
145+
$ mysql-execute name=mysql
146+
ALTER TABLE foo DROP COLUMN value;
147+
148+
! SELECT * FROM foo4;
149+
contains:incompatible schema change
150+
151+
> DROP TABLE foo4;
152+
153+
# Test with multiple excluded columns.
154+
$ mysql-execute name=mysql
155+
CREATE TABLE bar (name VARCHAR(16), value VARCHAR(32), age BIGINT, location VARCHAR(32), meta_col VARCHAR(32));
156+
INSERT INTO bar VALUES ('a', 'apple', 5, 'orchard', 'blah'), ('b', 'banana', 7, 'tree', 'blahblah');
157+
158+
> CREATE TABLE bar1 FROM SOURCE mysql_src (REFERENCE public.bar) WITH (EXCLUDE COLUMNS = (meta_col, location));
159+
160+
> SELECT * FROM bar1;
161+
a apple 5
162+
b banana 7
163+
164+
$ mysql-execute name=mysql
165+
ALTER TABLE bar DROP COLUMN meta_col;
166+
INSERT INTO bar VALUES ('c', 'cherry', 9, 'grove');
167+
168+
# The INSERT after the DROP COLUMN forces the binlog to advance past the ALTER
169+
# TABLE event, so the SELECT must succeed after the schema change is processed.
170+
> SELECT * FROM bar1;
171+
a apple 5
172+
b banana 7
173+
c cherry 9
174+
175+
$ mysql-execute name=mysql
176+
ALTER TABLE bar ADD COLUMN lastname VARCHAR(16) AFTER `name`;
177+
INSERT INTO bar VALUES ('d', 'date_lastname', 'date', 10, 'oasis');
178+
179+
> SELECT * FROM bar1;
180+
a apple 5
181+
b banana 7
182+
c cherry 9
183+
d date 10
184+
185+
> DROP SOURCE mysql_src CASCADE;

0 commit comments

Comments
 (0)