Skip to content

Commit 47a81d6

Browse files
MySQL Source Versioning V2 - reorganized boogaloo (#36333)
This PR effectively re-implements source versioning for mysql after we reverted the initial implementation last week due to decoding problems with exclude columns resulting in an incident. In doing so, it fixes a number of issues that existed with the first implementation, including: MaterializeInc/database-issues#11312 MaterializeInc/database-issues#11313 MaterializeInc/database-issues#11315 And provides a safer mechanism for handling schema changes and changes to the `binlog_row_metadata` MySQL system variable. The first commit is roughly the changes in #36253 which should be merged first, and is required for the other changes. Second commit updates the decoding logic based on the binlog metadata setting at source creation Third commit updates the logic to verify mysql schemas with the schemas in the upstream, allowing for certain types of schema changes when binlog_row_metadata is FULL. Fourth commit contains docs for how to make schema changes to your mysql source without downtime in materialize. --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 27c3b32 commit 47a81d6

16 files changed

Lines changed: 630 additions & 89 deletions

File tree

misc/python/materialize/mzcompose/services/mysql.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,16 @@
1414
)
1515

1616

17-
def create_mysql_server_args(server_id: str, is_master: bool) -> list[str]:
17+
def create_mysql_server_args(
18+
server_id: str, is_master: bool, binlog_row_metadata: str = "full"
19+
) -> list[str]:
1820
args = [
1921
"--log-bin=mysql-bin",
2022
"--gtid_mode=ON",
2123
"--enforce_gtid_consistency=ON",
2224
"--binlog-format=row",
2325
"--binlog-row-image=full",
24-
"--binlog-row-metadata=full",
26+
f"--binlog-row-metadata={binlog_row_metadata}",
2527
f"--server-id={server_id}",
2628
"--max-connections=500",
2729
]

src/mysql-util/src/decoding.rs

Lines changed: 146 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@
77
// the Business Source License, use of this software will be governed
88
// by the Apache License, Version 2.0.
99

10+
use std::fmt::Write;
1011
use std::str::FromStr;
1112

12-
use itertools::{EitherOrBoth, Itertools};
1313
use mysql_common::value::convert::from_value_opt;
1414
use mysql_common::{Row as MySqlRow, Value};
1515

@@ -28,46 +28,164 @@ pub fn pack_mysql_row(
2828
row_container: &mut Row,
2929
row: MySqlRow,
3030
table_desc: &MySqlTableDesc,
31+
gtid_set: Option<&str>,
32+
binlog_full_metadata: bool,
3133
) -> Result<Row, MySqlError> {
3234
let mut packer = row_container.packer();
33-
let row_values = row.unwrap();
3435

35-
for values in table_desc.columns.iter().zip_longest(row_values) {
36-
let (col_desc, value) = match values {
37-
EitherOrBoth::Both(col_desc, value) => (col_desc, value),
38-
EitherOrBoth::Left(col_desc) => {
39-
tracing::error!(
40-
"mysql: extra column description {col_desc:?} for table {}",
41-
table_desc.name
42-
);
43-
Err(MySqlError::ValueDecodeError {
44-
column_name: col_desc.name.clone(),
45-
qualified_table_name: format!("{}.{}", table_desc.schema_name, table_desc.name),
46-
error: "extra column description".to_string(),
47-
})?
48-
}
49-
EitherOrBoth::Right(_) => {
50-
// If there are extra columns on the upstream table we can safely ignore them
51-
break;
52-
}
53-
};
36+
// For each column in `table_desc` (in descriptor order), resolve its wire
37+
// index. With binlog_full_metadata=true, columns are matched by name so a reordered upstream
38+
// still decodes correctly; without binlog_full_metadata, rows have no column names and must be
39+
// matched positionally. A `None` here means the upstream row is missing this column and is
40+
// only tolerated for ignored columns, and for binlog_full_metadata = false, is only tolerated
41+
// for ignored columns at the end of the table.
42+
for (i, col_desc) in table_desc.columns.iter().enumerate() {
5443
if col_desc.column_type.is_none() {
5544
// This column is ignored, so don't decode it.
5645
continue;
5746
}
58-
match pack_val_as_datum(value, col_desc, &mut packer) {
59-
Err(err) => Err(MySqlError::ValueDecodeError {
60-
column_name: col_desc.name.clone(),
61-
qualified_table_name: format!("{}.{}", table_desc.schema_name, table_desc.name),
62-
error: err.to_string(),
63-
})?,
64-
Ok(()) => (),
47+
let wire_idx = if !binlog_full_metadata {
48+
// No column name metadata, so we match by index.
49+
(i < row.len()).then_some(i)
50+
} else {
51+
// This means the row from the binlog has column name included in the metadata,
52+
// so we can match on that instead of position.
53+
row.columns_ref()
54+
.iter()
55+
.position(|wc| wc.name_str() == col_desc.name.as_str())
56+
};
57+
58+
let wire_idx = match wire_idx {
59+
Some(idx) => idx,
60+
None => {
61+
// We could not find a column in the incoming row that matches this descriptor column.
62+
// This is an error as the column is not ignored (ignored columns have already been skipped).
63+
return Err(decode_error(
64+
"extra column description",
65+
col_desc,
66+
table_desc,
67+
gtid_set,
68+
&row,
69+
));
70+
}
6571
};
72+
let value = row
73+
.as_ref(wire_idx)
74+
.expect("wire_idx resolved from row")
75+
.clone();
76+
if let Err(err) = pack_val_as_datum(value, col_desc, &mut packer) {
77+
return Err(decode_error(
78+
&err.to_string(),
79+
col_desc,
80+
table_desc,
81+
gtid_set,
82+
&row,
83+
));
84+
}
6685
}
6786

6887
Ok(row_container.clone())
6988
}
7089

90+
/// Build a `ValueDecodeError`, logging the schema, table, column, source
91+
/// gtid_set (if any), and a shape description of `row` at the same time.
92+
/// The shape string is only built here — pack_mysql_row's happy path does no
93+
/// per-row allocation beyond what decoding requires.
94+
fn decode_error(
95+
err_msg: &str,
96+
col_desc: &MySqlColumnDesc,
97+
table_desc: &MySqlTableDesc,
98+
gtid_set: Option<&str>,
99+
row: &MySqlRow,
100+
) -> MySqlError {
101+
let row_shape = describe_row_shape(row, table_desc);
102+
tracing::warn!(
103+
"mysql decode error for `{}`.`{}` column `{}`: {}; gtid_set={:?}; row_shape={}",
104+
table_desc.schema_name,
105+
table_desc.name,
106+
col_desc.name,
107+
err_msg,
108+
gtid_set,
109+
row_shape,
110+
);
111+
MySqlError::ValueDecodeError {
112+
column_name: col_desc.name.clone(),
113+
qualified_table_name: format!("{}.{}", table_desc.schema_name, table_desc.name),
114+
error: err_msg.to_string(),
115+
}
116+
}
117+
118+
/// Describes the structural shape of a row without revealing any data values.
119+
/// Iterates every wire column. For each, emits the wire name, the binlog
120+
/// wire type, the character-set id (or `binary`), a classification relative
121+
/// to `table_desc` (`expected=<scalar>` for active columns, `ignored` for
122+
/// columns excluded from the source, `extra` for upstream columns with no
123+
/// descriptor entry), and a value disposition (`null` or `bytes(len=N)` /
124+
/// primitive kind). Intended for diagnostic logging on decode errors: MySQL
125+
/// serializes CHAR, VARCHAR, TEXT, JSON, BLOB, etc. all as `Value::Bytes`,
126+
/// so the wire type tag and the expected scalar type are what distinguish
127+
/// them.
128+
fn describe_row_shape(row: &MySqlRow, table_desc: &MySqlTableDesc) -> String {
129+
// Binlogs without full row metadata use positional "@N" names, so we
130+
// have to match by wire position rather than by name.
131+
let fallback_names = row
132+
.columns_ref()
133+
.first()
134+
.is_some_and(|col| col.name_ref().starts_with(b"@"));
135+
136+
let mut out = String::new();
137+
out.push('[');
138+
for (i, wire_col) in row.columns_ref().iter().enumerate() {
139+
if i > 0 {
140+
out.push_str(", ");
141+
}
142+
let wire_name = wire_col.name_str();
143+
let cs = wire_col.character_set();
144+
// 63 = binary collation (binary/blob columns).
145+
let cs_str = if cs == 63 {
146+
"binary".to_string()
147+
} else {
148+
format!("charset={cs}")
149+
};
150+
let wire_type = format!("{:?}", wire_col.column_type());
151+
152+
let matched_col = if fallback_names {
153+
table_desc.columns.get(i)
154+
} else {
155+
table_desc
156+
.columns
157+
.iter()
158+
.find(|c| c.name.as_str() == wire_name)
159+
};
160+
let match_info = match matched_col {
161+
Some(col) => match &col.column_type {
162+
Some(ct) => format!("expected={:?}", ct.scalar_type),
163+
None => "ignored".to_string(),
164+
},
165+
None => "extra".to_string(),
166+
};
167+
168+
let val_desc = match row.as_ref(i) {
169+
None => "absent".to_string(),
170+
Some(Value::NULL) => "null".to_string(),
171+
Some(Value::Bytes(b)) => format!("bytes(len={})", b.len()),
172+
Some(Value::Int(_)) => "int".to_string(),
173+
Some(Value::UInt(_)) => "uint".to_string(),
174+
Some(Value::Float(_)) => "float".to_string(),
175+
Some(Value::Double(_)) => "double".to_string(),
176+
Some(Value::Date(..)) => "date".to_string(),
177+
Some(Value::Time(..)) => "time".to_string(),
178+
};
179+
180+
let _ = write!(
181+
out,
182+
"{{name={wire_name}, wire={wire_type}, {cs_str}, {match_info}, val={val_desc}}}"
183+
);
184+
}
185+
out.push(']');
186+
out
187+
}
188+
71189
// TODO(guswynn|roshan): This function has various `.to_string()` and `format!` calls that should
72190
// use a shared allocation if possible.
73191
fn pack_val_as_datum(

src/mysql-util/src/desc.rs

Lines changed: 41 additions & 8 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+
binlog_full_metadata: bool,
82+
) -> Result<(), anyhow::Error> {
7983
if self == other {
8084
return Ok(());
8185
}
@@ -90,12 +94,42 @@ 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`.
96-
let mut other_columns = other.columns.iter();
97-
for self_column in &self.columns {
98-
let other_column = other_columns.next().ok_or_else(|| {
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`.
103+
for (i, self_column) in self.columns.iter().enumerate() {
104+
if self_column.column_type.is_none() {
105+
// This is an excluded column and can be ignored.
106+
continue;
107+
}
108+
let wire_idx = if !binlog_full_metadata {
109+
// No column name metadata, so we match by index.
110+
(i < other.columns.len()).then_some(i)
111+
} else {
112+
// This means the row from the binlog has column name included in the metadata,
113+
// so we can match on that instead of position.
114+
other
115+
.columns
116+
.iter()
117+
.position(|oc| oc.name.as_str() == self_column.name.as_str())
118+
};
119+
120+
let wire_idx = match wire_idx {
121+
Some(idx) => idx,
122+
None => {
123+
// We could not find a column in the incoming row that matches this descriptor column.
124+
// This is an error as the column is not ignored (ignored columns have already been skipped).
125+
return Err(anyhow::anyhow!(
126+
"column {} no longer present in table {}",
127+
self_column.name,
128+
self.name
129+
));
130+
}
131+
};
132+
let other_column = other.columns.get(wire_idx).ok_or_else(|| {
99133
anyhow::anyhow!(
100134
"column {} no longer present in table {}",
101135
self_column.name,
@@ -110,7 +144,6 @@ impl MySqlTableDesc {
110144
);
111145
}
112146
}
113-
114147
// Our keys are all still present in exactly the same shape.
115148
// TODO: Implement a more relaxed key compatibility check:
116149
// We should check that for all keys that we know about there exists an upstream key whose

src/storage/src/source/mysql.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ impl SourceRender for MySqlSourceConnection {
151151
initial_gtid_set: gtid_set_frontier(&initial_gtid_set).expect("invalid gtid set"),
152152
resume_upper,
153153
export_id: id.clone(),
154+
binlog_full_metadata: details.binlog_full_metadata,
154155
});
155156
}
156157

@@ -257,6 +258,7 @@ struct SourceOutputInfo {
257258
initial_gtid_set: Antichain<GtidPartition>,
258259
resume_upper: Antichain<GtidPartition>,
259260
export_id: GlobalId,
261+
binlog_full_metadata: bool,
260262
}
261263

262264
#[derive(Clone, Debug, thiserror::Error)]

src/storage/src/source/mysql/replication.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -394,7 +394,7 @@ pub(crate) fn render<'scope>(
394394

395395
events::handle_rows_event(
396396
data,
397-
&repl_context,
397+
&mut repl_context,
398398
&cur_gtid,
399399
&mut row_event_buffer,
400400
)

src/storage/src/source/mysql/replication/events.rs

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
// by the Apache License, Version 2.0.
99

1010
use maplit::btreemap;
11+
use mysql_async::binlog::events::OptionalMetaExtractor;
1112
use mysql_common::binlog::events::{QueryEvent, RowsEventData};
1213
use mz_mysql_util::{MySqlError, pack_mysql_row};
1314
use mz_ore::iter::IteratorExt;
@@ -221,7 +222,7 @@ pub(super) async fn handle_query_event(
221222
/// frontier with which to advance the dataflow's progress.
222223
pub(super) async fn handle_rows_event(
223224
event: RowsEventData<'_>,
224-
ctx: &ReplContext<'_>,
225+
ctx: &mut ReplContext<'_>,
225226
new_gtid: &GtidPartition,
226227
event_buffer: &mut Vec<(
227228
(usize, Result<SourceMessage, DataflowError>),
@@ -262,6 +263,11 @@ pub(super) async fn handle_rows_event(
262263
// Capability for this event.
263264
let gtid_cap = ctx.data_cap_set.delayed(new_gtid);
264265

266+
// We can check here if the binlog has full row metadata by looking at the column name optional
267+
// metadata, which is only present if full metadata is enabled.
268+
let optional_metadata = OptionalMetaExtractor::new(table_map_event.iter_optional_meta())?;
269+
let has_full_metadata = optional_metadata.iter_column_name().next().is_some();
270+
265271
// Iterate over the rows in this RowsEvent. Each row is a pair of 'before_row', 'after_row',
266272
// to accomodate for updates and deletes (which include a before_row),
267273
// and updates and inserts (which inclued an after row).
@@ -289,20 +295,37 @@ pub(super) async fn handle_rows_event(
289295
before_row.map(|r| (r, Diff::MINUS_ONE)),
290296
after_row.map(|r| (r, Diff::ONE)),
291297
];
298+
let gtid_str = format!("{new_gtid:?}");
292299
for (binlog_row, diff) in updates.into_iter().flatten() {
293300
let row = mysql_async::Row::try_from(binlog_row)?;
294301
for (output, row_val) in outputs.iter().repeat_clone(row) {
295-
let event = match pack_mysql_row(&mut final_row, row_val, &output.desc) {
296-
Ok(row) => Ok(SourceMessage {
297-
key: Row::default(),
298-
value: row,
299-
metadata: Row::default(),
300-
}),
301-
// Produce a DefiniteError in the stream for any rows that fail to decode
302-
Err(err @ MySqlError::ValueDecodeError { .. }) => Err(DataflowError::from(
303-
DefiniteError::ValueDecodeError(err.to_string()),
304-
)),
305-
Err(err) => Err(err)?,
302+
let event = if !has_full_metadata && output.binlog_full_metadata {
303+
ctx.errored_outputs.insert(output.output_index);
304+
Err(DataflowError::from(DefiniteError::ValueDecodeError(
305+
format!(
306+
"Table {0} was created with binlog_row_metadata=FULL but binlog_row_metadata has since been set to a different value, meaning we cannot reliably decode the columns",
307+
output.table_name
308+
),
309+
)))
310+
} else {
311+
match pack_mysql_row(
312+
&mut final_row,
313+
row_val,
314+
&output.desc,
315+
Some(&gtid_str),
316+
output.binlog_full_metadata,
317+
) {
318+
Ok(row) => Ok(SourceMessage {
319+
key: Row::default(),
320+
value: row,
321+
metadata: Row::default(),
322+
}),
323+
// Produce a DefiniteError in the stream for any rows that fail to decode
324+
Err(err @ MySqlError::ValueDecodeError { .. }) => Err(DataflowError::from(
325+
DefiniteError::ValueDecodeError(err.to_string()),
326+
)),
327+
Err(err) => Err(err)?,
328+
}
306329
};
307330

308331
let data = (output.output_index, event);

0 commit comments

Comments
 (0)