rivet/source/mysql/mod.rs
1//! MySQL `Source` implementation.
2//!
3//! Module layout (mirrors `postgres/`):
4//!
5//! - `mod.rs` (this file) — `MysqlSource` struct + connect/TLS path, the
6//! extraction-pressure sampler, the `lean_pool_opts` / `connect_pool` /
7//! `build_mysql_ssl_opts` helpers, `introspect_mysql_table_for_chunking`
8//! together with the InnoDB `AVG_ROW_LENGTH` correction, the cursor-bound
9//! `exec_iter` export loop (`mysql_run_export`), and the `Source` trait impl.
10//! - [`arrow_convert`] — the entire row → Arrow `RecordBatch` pipeline:
11//! `mysql_type_to_rivet` + `mysql_native_type_name`,
12//! `mysql_schema_and_arrow_types`, BIT / TIME / DECIMAL decoders, and the
13//! array builders. Kept in a sibling because it is the largest
14//! single-purpose cluster in this driver (~510 LoC) and has zero reverse
15//! dependency back into the connection / pool / cursor layer.
16//! - [`proxy`] — `MysqlProxyKind` enum, the pure `classify_mysql_proxy`
17//! classifier, the I/O wrapper `detect_mysql_proxy_kind`, and
18//! `warn_proxy_kind`. Detection runs once at connect time; the classifier
19//! is exhaustively unit-tested in isolation (no live MySQL needed).
20
21mod arrow_convert;
22pub(crate) mod cdc;
23mod proxy;
24
25use std::sync::Arc;
26
27use arrow::datatypes::Schema;
28use mysql::prelude::*;
29use mysql::{Opts, OptsBuilder, Pool, PoolConstraints, PoolOpts, SslOpts};
30
31use crate::config::{SourceType, TlsConfig, TlsMode};
32use crate::error::Result;
33use crate::source::batch_controller::{
34 AdaptiveBatchController, DEFAULT_BATCH_TARGET_MB, PROBE_BATCH_SIZE,
35};
36use crate::source::query::build_export_query;
37use crate::tuning::SourceTuning;
38use crate::types::ColumnOverrides;
39
40use arrow_convert::{
41 mysql_native_type_name, mysql_schema_and_arrow_types, mysql_type_to_rivet,
42 rows_to_record_batch_typed,
43};
44// `bit_bytes_to_u64` is only referenced by the `tests` module below — gate the
45// re-import on `cfg(test)` so non-test builds don't see an unused-import warning.
46#[cfg(test)]
47use arrow_convert::bit_bytes_to_u64;
48use proxy::{detect_mysql_proxy_kind, warn_proxy_kind};
49
50// Re-exported so external code (`tests/live_pool_safety.rs`) can still write
51// `use rivet::source::mysql::MysqlProxyKind` after the proxy block moved to
52// the `proxy` submodule.
53pub use proxy::MysqlProxyKind;
54
55pub struct MysqlSource {
56 pool: Pool,
57 proxy_kind: MysqlProxyKind,
58}
59
60/// Pool options that prevent eager pre-connection. The default mysql::Pool
61/// opens `min=10` connections immediately, which overflows MySQL's
62/// max_connections when many parallel exports run simultaneously.
63fn lean_pool_opts() -> PoolOpts {
64 PoolOpts::default()
65 .with_constraints(PoolConstraints::new(1, 100).expect("valid pool constraints"))
66}
67
68/// Sample an **extraction-pressure** proxy (Epic 18 C1) — the MySQL analogue of
69/// PG's `temp_bytes`. Sums two monotonic global counters:
70///
71/// - `Created_tmp_disk_tables` — a query spilled an internal temp table to disk
72/// (a `GROUP BY` / `DISTINCT` / `ORDER BY` that exceeded `tmp_table_size`).
73/// - `Innodb_buffer_pool_wait_free` — InnoDB had to wait for a free buffer-pool
74/// page, i.e. the read is evicting pages under memory pressure.
75///
76/// Either moving means "my extraction is stressing the source"; their sum is
77/// monotonic, so the governor's `cur > prev` comparison works unchanged. The
78/// sum is robust to MySQL 8.0's `TempTable` engine, where a spill may not bump
79/// `Created_tmp_disk_tables` — `Innodb_buffer_pool_wait_free` carries the signal
80/// then (and `Created_tmp_disk_tables` adds it on 5.7 / MariaDB). This replaces
81/// the old `Innodb_log_waits`, which is redo-**write** pressure and barely moves
82/// during a read-only export.
83fn mysql_sample_extraction_pressure(pool: &Pool) -> Option<u64> {
84 let mut conn = pool.get_conn().ok()?;
85 let rows: Vec<(String, u64)> = conn
86 .query(
87 "SHOW GLOBAL STATUS WHERE Variable_name IN \
88 ('Created_tmp_disk_tables', 'Innodb_buffer_pool_wait_free')",
89 )
90 .ok()?;
91 if rows.is_empty() {
92 return None;
93 }
94 Some(rows.iter().map(|(_, v)| *v).sum())
95}
96
97/// Snapshot the broader source-harm counters from `SHOW GLOBAL STATUS` — a
98/// superset of the governor's [`mysql_sample_extraction_pressure`]. Returns
99/// `(metric, cumulative_value)` pairs the pipeline deltas around the export and
100/// stores in `export_harm`. `SHOW GLOBAL STATUS` needs **no special privilege**.
101/// These are global counters, so concurrent load inflates the delta (accurate on
102/// a quiet pilot box). `Innodb_rows_read` is the read-amplification signal the
103/// 0.12 harm A/B keyed on. `None` on connect/query failure — never blocks the
104/// export.
105pub(crate) fn sample_harm_counters(
106 url: &str,
107 tls: Option<&TlsConfig>,
108) -> Option<Vec<(String, i64)>> {
109 let pool = connect_pool(url, tls).ok()?;
110 let mut conn = pool.get_conn().ok()?;
111 let rows: Vec<(String, i64)> = conn
112 .query(
113 "SHOW GLOBAL STATUS WHERE Variable_name IN \
114 ('Innodb_rows_read', 'Innodb_buffer_pool_reads', 'Created_tmp_disk_tables', \
115 'Handler_read_rnd_next', 'Innodb_row_lock_waits', 'Innodb_row_lock_time')",
116 )
117 .ok()?;
118 if rows.is_empty() {
119 return None;
120 }
121 Some(
122 rows.into_iter()
123 .map(|(k, v)| (format!("mysql_{}", k.to_lowercase()), v))
124 .collect(),
125 )
126}
127
128impl MysqlSource {
129 /// Build a source from an existing pool: the single place that detects the
130 /// proxy kind, warns once, and wraps the pool. The `connect*` entry points
131 /// all funnel through here (also handy in tests that share the pool for
132 /// post-export state inspection).
133 pub fn from_pool(pool: Pool) -> Self {
134 let proxy_kind = detect_mysql_proxy_kind(&pool);
135 warn_proxy_kind(proxy_kind);
136 Self { pool, proxy_kind }
137 }
138
139 /// Connect with no transport security (legacy path).
140 pub fn connect(url: &str) -> Result<Self> {
141 let opts =
142 Opts::from(OptsBuilder::from_opts(Opts::from_url(url)?).pool_opts(lean_pool_opts()));
143 Ok(Self::from_pool(Pool::new(opts)?))
144 }
145
146 /// Connect honoring the user's [`TlsConfig`].
147 pub fn connect_with_tls(url: &str, tls: Option<&TlsConfig>) -> Result<Self> {
148 // Refuse remote plaintext (no `tls:` block) before any dial (CWE-319).
149 crate::source::require_tls_or_loopback(url, tls)?;
150 match tls {
151 Some(cfg) if cfg.mode.is_enforced() => {
152 let base = Opts::from_url(url)?;
153 let ssl = build_mysql_ssl_opts(cfg);
154 let opts = Opts::from(
155 OptsBuilder::from_opts(base)
156 .ssl_opts(Some(ssl))
157 .pool_opts(lean_pool_opts()),
158 );
159 Ok(Self::from_pool(Pool::new(opts)?))
160 }
161 _ => Self::connect(url),
162 }
163 }
164
165 /// Expose the proxy classification for diagnostic tools (preflight,
166 /// integration tests). Not part of the public Source trait — same
167 /// internal-may-change contract as the rest of `rivet::source::mysql::*`.
168 ///
169 /// `#[allow(dead_code)]` covers the binary compilation unit; the lib +
170 /// integration tests reference this through the `rivet::source::mysql`
171 /// public surface.
172 #[allow(dead_code)]
173 pub fn proxy_kind(&self) -> MysqlProxyKind {
174 self.proxy_kind
175 }
176}
177
178/// Build a MySQL connection pool honoring the configured TLS policy.
179///
180/// Shared by preflight, doctor, init, and anywhere else we need a pool outside
181/// the `Source` trait. `tls = None` falls back to plaintext (legacy behavior).
182pub(crate) fn connect_pool(url: &str, tls: Option<&TlsConfig>) -> Result<Pool> {
183 // Refuse remote plaintext (no `tls:` block) before any dial (CWE-319).
184 crate::source::require_tls_or_loopback(url, tls)?;
185 match tls {
186 Some(cfg) if cfg.mode.is_enforced() => {
187 let base = Opts::from_url(url)?;
188 let ssl = build_mysql_ssl_opts(cfg);
189 let opts = Opts::from(
190 OptsBuilder::from_opts(base)
191 .ssl_opts(Some(ssl))
192 .pool_opts(lean_pool_opts()),
193 );
194 Ok(Pool::new(opts)?)
195 }
196 _ => {
197 let opts = Opts::from(
198 OptsBuilder::from_opts(Opts::from_url(url)?).pool_opts(lean_pool_opts()),
199 );
200 Ok(Pool::new(opts)?)
201 }
202 }
203}
204
205/// Threshold above which `AVG_ROW_LENGTH` is treated as inflated by InnoDB BLOB
206/// overflow pages and divided down. Rows under 8 KB fit inline (no overflow),
207/// so the raw figure is accurate; above it the divisor compensates.
208const INNODB_BLOB_OVERFLOW_THRESHOLD_BYTES: i64 = 8 * 1024;
209
210/// Empirical divisor for InnoDB BLOB-page inflation. A wide-text row that
211/// allocates eight 16 KB overflow pages reports ~128 KB in `AVG_ROW_LENGTH`
212/// while the actual wire content is ~40 KB → factor of ~3.
213const INNODB_BLOB_OVERFLOW_DIVISOR: i64 = 3;
214
215/// Apply the InnoDB BLOB-overflow correction to a raw `AVG_ROW_LENGTH` value.
216/// Pure function for unit testability — the live introspection helper calls
217/// this on the figure returned by `information_schema.TABLES`.
218///
219/// - Below the 8 KB threshold: raw value is accurate (no overflow).
220/// - Above: divide by 3, floored at threshold/2 so we never undershoot too far.
221fn correct_innodb_avg_row_length(raw_bytes: i64) -> i64 {
222 if raw_bytes > INNODB_BLOB_OVERFLOW_THRESHOLD_BYTES {
223 (raw_bytes / INNODB_BLOB_OVERFLOW_DIVISOR).max(INNODB_BLOB_OVERFLOW_THRESHOLD_BYTES / 2)
224 } else {
225 raw_bytes
226 }
227}
228
229/// Probe `information_schema` for stats chunked-mode planning needs.
230///
231/// MySQL analogue of [`crate::source::postgres::introspect_pg_table_for_chunking`]:
232/// returns the same source-neutral [`crate::source::TableIntrospection`] so
233/// `plan/build.rs` can dispatch on `source_type` and reuse the same downstream
234/// logic for chunk-column / chunk_size derivation.
235///
236/// Two queries per call, both against `information_schema` (no extra grants
237/// required for a normal app user):
238/// - `TABLES.AVG_ROW_LENGTH` + `TABLE_ROWS` for the row-size and row-count estimate.
239/// These come from `mysql.innodb_table_stats` and are only as fresh as the
240/// last `ANALYZE TABLE` / autostat run. Empty / unanalysed → zero.
241/// - `STATISTICS` filtered to `INDEX_NAME='PRIMARY'` with `SEQ_IN_INDEX=1` and a
242/// second probe ensuring no `SEQ_IN_INDEX=2` row exists — single-column PK only.
243///
244/// `qualified_table` is `<schema>.<table>` or bare `<table>` (resolved under the
245/// current database for the connection). Same strict ident rules as the YAML
246/// `table:` shortcut so the SQL stays trivially safe.
247pub(crate) fn introspect_mysql_table_for_chunking(
248 url: &str,
249 tls: Option<&TlsConfig>,
250 qualified_table: &str,
251) -> Result<crate::source::TableIntrospection> {
252 let pool = connect_pool(url, tls)?;
253 let mut conn = pool.get_conn()?;
254 let default_db: Option<String> = conn.query_first("SELECT DATABASE()")?;
255 let default_db = default_db.unwrap_or_default();
256
257 let (schema, table) = match qualified_table.split_once('.') {
258 Some((s, t)) => (s.to_string(), t.to_string()),
259 None => (default_db, qualified_table.to_string()),
260 };
261
262 // (1) Row count + avg row bytes. AVG_ROW_LENGTH already accounts for
263 // overflow pages on InnoDB, so we use it directly rather than dividing
264 // DATA_LENGTH by TABLE_ROWS (which under-counts for tables with TOAST-like
265 // overflow). Fall back to division when AVG_ROW_LENGTH is 0.
266 let row_stats: Option<(i64, i64, i64)> = conn.exec_first(
267 "SELECT CAST(IFNULL(TABLE_ROWS, 0) AS SIGNED), \
268 CAST(IFNULL(AVG_ROW_LENGTH, 0) AS SIGNED), \
269 CAST(IFNULL(DATA_LENGTH, 0) AS SIGNED) \
270 FROM information_schema.TABLES \
271 WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?",
272 (&schema, &table),
273 )?;
274 let (row_estimate, avg_row_bytes) = match row_stats {
275 Some((rows, avg, data_len)) => {
276 let row_count = rows.max(0);
277 let raw_per_row = if avg > 0 {
278 Some(avg)
279 } else if row_count > 0 {
280 Some(data_len / row_count)
281 } else {
282 None
283 };
284 // InnoDB stores TEXT/BLOB > ~768 B off-page in 16 KB BLOB pages,
285 // and `AVG_ROW_LENGTH` counts the allocated page bytes — not the
286 // actual content. On wide-text workloads (CMS bodies, JSON logs,
287 // audit trails) this inflates the per-row estimate 3-5× compared
288 // to what the client driver actually buffers over the wire.
289 //
290 // We empirically divide by 3 above an 8 KB threshold. Below 8 KB
291 // a row fits inline with no overflow, so the raw figure is
292 // accurate. Above it, dividing by 3 brings content_items' 41 KB
293 // estimate down to ~14 KB — still conservative vs the ~10 KB the
294 // PG side reports for the same payload via `pg_total_relation_size`.
295 //
296 // Pilots who want exact control can set `chunk_size:` explicitly
297 // (it always wins over the budget-derived size).
298 let per_row = raw_per_row.map(correct_innodb_avg_row_length);
299 (row_count, per_row.filter(|b| *b > 0))
300 }
301 None => (0, None),
302 };
303
304 // (2) Single-column int PK probe. STATISTICS has one row per (column,
305 // index) so we filter to PRIMARY + SEQ_IN_INDEX=1 and then check that
306 // the PRIMARY index has no SEQ_IN_INDEX=2 row (composite).
307 let pk_first: Option<(String,)> = conn.exec_first(
308 "SELECT COLUMN_NAME \
309 FROM information_schema.STATISTICS \
310 WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND INDEX_NAME = 'PRIMARY' AND SEQ_IN_INDEX = 1",
311 (&schema, &table),
312 )?;
313 let single_int_pk = if let Some((col,)) = pk_first {
314 let composite: Option<(String,)> = conn.exec_first(
315 "SELECT COLUMN_NAME FROM information_schema.STATISTICS \
316 WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND INDEX_NAME = 'PRIMARY' AND SEQ_IN_INDEX = 2 \
317 LIMIT 1",
318 (&schema, &table),
319 )?;
320 if composite.is_some() {
321 log::debug!(
322 "introspect_mysql_table: composite PK on {schema}.{table} — skipping auto-resolve"
323 );
324 None
325 } else {
326 // Column type must be integer-family for safe range chunking.
327 let type_row: Option<(String,)> = conn.exec_first(
328 "SELECT DATA_TYPE FROM information_schema.COLUMNS \
329 WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND COLUMN_NAME = ?",
330 (&schema, &table, &col),
331 )?;
332 match type_row.map(|(t,)| t.to_ascii_lowercase()) {
333 Some(t)
334 if matches!(
335 t.as_str(),
336 "tinyint" | "smallint" | "mediumint" | "int" | "bigint"
337 ) =>
338 {
339 Some(col)
340 }
341 Some(t) => {
342 log::debug!(
343 "introspect_mysql_table: PK '{col}' on {schema}.{table} has non-int type '{t}' — skipping auto-resolve"
344 );
345 None
346 }
347 None => None,
348 }
349 }
350 } else {
351 None
352 };
353
354 // (3) Keyset keys (OPT-4): single-column, NOT NULL, UNIQUE index columns —
355 // usable as a seek-pagination key. NON_UNIQUE=0 filters to unique indexes
356 // (PRIMARY included); SEQ_IN_INDEX=1 with no SEQ_IN_INDEX=2 row keeps only
357 // single-column indexes; IS_NULLABLE='NO' guarantees `> last` never has to
358 // reason about NULL ordering. Index-backed by definition, so keyset's
359 // `ORDER BY key LIMIT n` is a range scan, not a filesort.
360 let keyset_rows: Vec<(String, String, String)> = conn.exec(
361 "SELECT s.COLUMN_NAME, s.INDEX_NAME, c.IS_NULLABLE \
362 FROM information_schema.STATISTICS s \
363 JOIN information_schema.COLUMNS c \
364 ON c.TABLE_SCHEMA = s.TABLE_SCHEMA AND c.TABLE_NAME = s.TABLE_NAME \
365 AND c.COLUMN_NAME = s.COLUMN_NAME \
366 WHERE s.TABLE_SCHEMA = ? AND s.TABLE_NAME = ? AND s.NON_UNIQUE = 0 \
367 AND s.SEQ_IN_INDEX = 1 \
368 AND c.DATA_TYPE NOT IN ('decimal', 'numeric') \
369 AND NOT EXISTS ( \
370 SELECT 1 FROM information_schema.STATISTICS s2 \
371 WHERE s2.TABLE_SCHEMA = s.TABLE_SCHEMA AND s2.TABLE_NAME = s.TABLE_NAME \
372 AND s2.INDEX_NAME = s.INDEX_NAME AND s2.SEQ_IN_INDEX = 2)",
373 (&schema, &table),
374 )?;
375 let mut keyset_keys: Vec<String> = Vec::new();
376 // PRIMARY first (most efficient — clustered), then other unique indexes.
377 for primary in [true, false] {
378 for (col, index_name, is_nullable) in &keyset_rows {
379 let is_primary = index_name == "PRIMARY";
380 if is_primary == primary
381 && is_nullable.eq_ignore_ascii_case("NO")
382 && !keyset_keys.contains(col)
383 {
384 keyset_keys.push(col.clone());
385 }
386 }
387 }
388
389 // Integer-family columns — the safety set for an explicit `chunk_column`
390 // (see TableIntrospection::int_columns). `DATA_TYPE` is unsigned-agnostic
391 // (`int unsigned` still has DATA_TYPE `int`).
392 let int_columns: Vec<String> = conn
393 .exec::<(String,), _, _>(
394 "SELECT COLUMN_NAME FROM information_schema.COLUMNS \
395 WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? \
396 AND DATA_TYPE IN ('tinyint', 'smallint', 'mediumint', 'int', 'bigint')",
397 (&schema, &table),
398 )?
399 .into_iter()
400 .map(|(c,)| c)
401 .collect();
402
403 Ok(crate::source::TableIntrospection {
404 single_int_pk,
405 keyset_keys,
406 row_estimate,
407 avg_row_bytes,
408 int_columns,
409 })
410}
411
412fn build_mysql_ssl_opts(cfg: &TlsConfig) -> SslOpts {
413 let mut ssl = SslOpts::default();
414 if let Some(path) = &cfg.ca_file {
415 ssl = ssl.with_root_cert_path(Some(std::path::PathBuf::from(path)));
416 }
417 match cfg.mode {
418 TlsMode::Require => {
419 ssl = ssl
420 .with_danger_accept_invalid_certs(true)
421 .with_danger_skip_domain_validation(true);
422 }
423 TlsMode::VerifyCa => {
424 ssl = ssl.with_danger_skip_domain_validation(true);
425 }
426 TlsMode::VerifyFull => {
427 // Strict: verify chain + hostname.
428 }
429 TlsMode::Disable => {
430 // Never invoked: gated in connect_with_tls.
431 }
432 }
433 if cfg.accept_invalid_certs {
434 ssl = ssl.with_danger_accept_invalid_certs(true);
435 }
436 if cfg.accept_invalid_hostnames {
437 ssl = ssl.with_danger_skip_domain_validation(true);
438 }
439 ssl
440}
441
442/// RAII reset of the per-connection session state the export mutates
443/// (`time_zone`, optionally `max_execution_time`) — the MySQL analogue of
444/// `postgres::PgTxnGuard` (Epic 18 B1).
445///
446/// MySQL hands connections back to the `mysql` crate's pool on drop, and may sit
447/// behind ProxySQL / MaxScale that reuse a physical backend across logical
448/// connections. The previous end-of-`export()` reset covered success and the
449/// `Err` return (no `?`), but **not** a panic mid-export, nor an early `?` on
450/// the `SET max_execution_time` itself (MariaDB spells it `max_statement_time`,
451/// so that SET errors — and `time_zone`, already set, would leak). Arming the
452/// reset on `Drop` closes both: whatever exit path the export takes, the
453/// connection is clean before it returns to the pool.
454struct MysqlSessionGuard<'a> {
455 conn: &'a mut mysql::PooledConn,
456 reset_max_exec: bool,
457}
458
459impl<'a> MysqlSessionGuard<'a> {
460 /// Apply the session SETs and arm the reset. `time_zone` is always set (UTC
461 /// normalisation so Parquet writes `isAdjustedToUTC=true`); the guard is
462 /// constructed *immediately* after it, so if the later `max_execution_time`
463 /// SET fails (or anything panics), `Drop` still resets `time_zone`.
464 fn apply(conn: &'a mut mysql::PooledConn, max_exec_ms: Option<u64>) -> Result<Self> {
465 conn.query_drop("SET time_zone = '+00:00'")?;
466 let mut guard = Self {
467 conn,
468 reset_max_exec: false,
469 };
470 // Pin sql_mode so NO_BACKSLASH_ESCAPES is OFF: the parallel-keyset inline
471 // boundary literals (escape_mysql_literal) use backslash escaping, which is
472 // ONLY correct when backslash is an escape char. Under a server default of
473 // NO_BACKSLASH_ESCAPES a boundary like 'C:\data' would parse as a DIFFERENT
474 // value than the `?`-bound next-page cursor, silently dropping/dup'ing the
475 // row on that boundary (the encode-leg class CLAUDE.md pins for PG). Removing
476 // just that flag keeps the server's other modes (strictness etc.) intact.
477 guard
478 .conn
479 .query_drop("SET SESSION sql_mode = REPLACE(@@sql_mode, 'NO_BACKSLASH_ESCAPES', '')")?;
480 if let Some(ms) = max_exec_ms {
481 guard
482 .conn
483 .query_drop(format!("SET SESSION max_execution_time = {ms}"))?;
484 guard.reset_max_exec = true;
485 }
486 Ok(guard)
487 }
488
489 fn conn(&mut self) -> &mut mysql::PooledConn {
490 self.conn
491 }
492}
493
494impl Drop for MysqlSessionGuard<'_> {
495 fn drop(&mut self) {
496 // Best-effort; the connection is about to return to the pool either way.
497 let _ = self.conn.query_drop("SET time_zone = @@global.time_zone");
498 let _ = self
499 .conn
500 .query_drop("SET SESSION sql_mode = @@global.sql_mode");
501 if self.reset_max_exec {
502 let _ = self.conn.query_drop("SET SESSION max_execution_time = 0");
503 }
504 }
505}
506
507/// Execute the MySQL query and stream results to sink.
508///
509/// Session-state cleanup (`time_zone`, `max_execution_time`) is handled by the
510/// caller's [`MysqlSessionGuard`], which resets it on `Drop` regardless of how
511/// this function exits (success, `Err`, or panic).
512///
513/// `sample_pool`: when `tuning.adaptive` is true, a clone of the source pool used
514/// to obtain a second connection for extraction-pressure sampling without interfering
515/// with the streaming result set on `conn`.
516/// Whether a MySQL driver error is the server-side `max_execution_time` /
517/// ER_QUERY_TIMEOUT (3024) statement-duration timeout — so the export path can
518/// re-wrap it as an actionable `StatementDurationTimeout` instead of leaking the
519/// terse driver prose. Matched by code AND message so it survives a MariaDB
520/// spelling (`max_statement_time` / 1969) or a reworded server string.
521fn is_statement_timeout(e: &mysql::Error) -> bool {
522 if let mysql::Error::MySqlError(db) = e
523 && (db.code == 3024 || db.code == 1969)
524 {
525 return true;
526 }
527 let msg = e.to_string();
528 msg.contains("max_execution_time")
529 || msg.contains("max_statement_time")
530 || msg.contains("maximum statement execution time exceeded")
531}
532
533fn mysql_run_export(
534 conn: &mut mysql::PooledConn,
535 sample_pool: Option<Pool>,
536 sql: &str,
537 cursor_param: Option<&str>,
538 tuning: &SourceTuning,
539 column_overrides: &ColumnOverrides,
540 sink: &mut dyn super::BatchSink,
541) -> Result<usize> {
542 // Re-wrap a server-side `max_execution_time` timeout (ERROR 3024) as an
543 // actionable StatementDurationTimeout (the classifier downcasts the TYPE →
544 // permanent; the Display carries the fix). The raw driver error is terse and
545 // gave the field's `*_version` timeouts no guidance (#field-3024).
546 let wrap_timeout = |e: mysql::Error| -> anyhow::Error {
547 if tuning.statement_timeout_s > 0 && is_statement_timeout(&e) {
548 super::StatementDurationTimeout::mysql(tuning.statement_timeout_s).into()
549 } else {
550 anyhow::Error::new(e)
551 }
552 };
553
554 // SecOps: cursor value is bound via exec_iter rather than string-interpolated.
555 // Using exec_iter uniformly (even with empty params) keeps match arms
556 // type-compatible — query_iter returns a Text-protocol result, exec_iter Binary.
557 let mut result = match cursor_param {
558 Some(val) => conn.exec_iter(sql, (val,)).map_err(&wrap_timeout)?,
559 None => conn.exec_iter(sql, ()).map_err(&wrap_timeout)?,
560 };
561 let columns = result.columns().as_ref().to_vec();
562
563 // Compute TypeMappings once; derive both the Arrow schema and the
564 // per-column DataType vec from the same source so they can never diverge.
565 let (schema, arrow_types) = mysql_schema_and_arrow_types(&columns, column_overrides)?;
566 let schema = Arc::new(schema);
567
568 sink.on_schema(schema.clone())?;
569
570 // PG path uses `work_mem × 0.7 / row_bytes` for FETCH N — the analogous
571 // bottleneck on MySQL is *our* `row_buf` accumulator. The mysql crate
572 // streams rows from the wire one-at-a-time, but we pile up `effective_bs`
573 // of them in a `Vec<Row>` before flushing to Arrow → for `batch_size: 50000`
574 // (fast profile) on content_items that's ~650 MB just for the row_buf,
575 // plus another ~650 MB for the Arrow batch it feeds — RSS scales with
576 // `batch_size`, not chunk size.
577 //
578 // Fix: start with a small probe (`PROBE_BATCH_SIZE`), measure the actual
579 // Arrow bytes per row after the first batch, then cap `effective_bs` so
580 // each flush fits in roughly `MYSQL_BATCH_TARGET_MB` of Arrow memory.
581 // Caller's `batch_size_memory_mb` wins when set; the default is 64 MB —
582 // chosen to keep peak RSS well under 200 MB on wide-row tables while
583 // keeping batches large enough to be efficient for the parquet writer.
584 let configured_batch_size = tuning.effective_batch_size(Some(&schema));
585 // Shared batch-size state machine (probe → memory-cap → adaptive → throttle);
586 // MySQL provides only the row source + the target-MB cap formula below.
587 let mut ctl = AdaptiveBatchController::new(tuning, configured_batch_size);
588 ctl.seed_pressure(if tuning.adaptive {
589 sample_pool
590 .as_ref()
591 .and_then(mysql_sample_extraction_pressure)
592 } else {
593 None
594 });
595 let row_set = result
596 .iter()
597 .ok_or_else(|| anyhow::anyhow!("no result set"))?;
598 let mut row_buf: Vec<mysql::Row> = Vec::with_capacity(ctl.target());
599 let mut total_rows: usize = 0;
600 let mut memory_cap_applied = false;
601 // Per-value ceiling (MB→bytes; `0`/None disables), enforced pre-allocation
602 // inside the batch builder so an oversized cell bails before Arrow reserves
603 // the buffer. Same source of truth as the sink's backstop guard.
604 let max_value_bytes = tuning.max_value_bytes();
605
606 for row_result in row_set {
607 // The timeout usually fires mid-stream (the query runs while rows are
608 // pulled), so wrap here too, not only at exec_iter above.
609 let row = row_result.map_err(&wrap_timeout)?;
610 row_buf.push(row);
611
612 if row_buf.len() >= ctl.target() {
613 total_rows += row_buf.len();
614 let batch =
615 rows_to_record_batch_typed(&schema, &arrow_types, &row_buf, max_value_bytes)?;
616 let batch_rows = row_buf.len();
617 row_buf.clear();
618
619 // After the first (probe-sized) batch we know how many bytes per
620 // row Arrow actually uses. Cap subsequent flushes to a memory
621 // target. The controller clamps it to the configured `batch_size`.
622 if !memory_cap_applied && batch_rows > 0 {
623 let arrow_bytes = crate::tuning::SourceTuning::batch_memory_bytes(&batch);
624 let arrow_per_row = (arrow_bytes / batch_rows).max(64);
625 let target_mb = tuning
626 .batch_size_memory_mb
627 .unwrap_or(DEFAULT_BATCH_TARGET_MB);
628 let safe = ((target_mb * 1024 * 1024) / arrow_per_row).max(PROBE_BATCH_SIZE);
629 if let Some(new) = ctl.apply_memory_cap(safe) {
630 log::info!(
631 "MySQL row_buf cap: arrow≈{} B/row, target={} MB → batch_size → {} (configured={})",
632 arrow_per_row,
633 target_mb,
634 new,
635 configured_batch_size
636 );
637 row_buf.reserve(new.saturating_sub(row_buf.capacity()));
638 }
639 memory_cap_applied = true;
640 }
641
642 sink.on_batch(&batch)?;
643
644 if let Some((new, under_pressure)) = ctl.after_batch(|| {
645 sample_pool
646 .as_ref()
647 .and_then(mysql_sample_extraction_pressure)
648 }) {
649 log::info!(
650 "adaptive batch size → {} ({})",
651 new,
652 if under_pressure {
653 "pressure"
654 } else {
655 "recovery"
656 }
657 );
658 }
659
660 log::info!("fetched {} rows so far...", total_rows);
661 ctl.throttle(batch.num_rows());
662 }
663 }
664
665 if !row_buf.is_empty() {
666 total_rows += row_buf.len();
667 let batch = rows_to_record_batch_typed(&schema, &arrow_types, &row_buf, max_value_bytes)?;
668 sink.on_batch(&batch)?;
669 }
670
671 drop(result);
672 Ok(total_rows)
673}
674
675impl super::Source for MysqlSource {
676 fn export(
677 &mut self,
678 request: &super::ExportRequest<'_>,
679 sink: &mut dyn super::BatchSink,
680 ) -> Result<()> {
681 let built = build_export_query(request, SourceType::Mysql);
682 log::debug!(
683 "executing query (connection={}): {}",
684 self.proxy_kind.log_label(),
685 built.sql
686 );
687
688 let mut conn = self.pool.get_conn()?;
689
690 // Per-connection session state, reset on `Drop` (Epic 18 B1) so a pooled
691 // connection — returned to the mysql-crate pool or reused behind
692 // ProxySQL/MaxScale — never carries our settings into the next checkout,
693 // even on a panic or an early return. `time_zone` normalises TIMESTAMP to
694 // UTC (Parquet `isAdjustedToUTC=true`); `max_execution_time` bounds the
695 // statement when a timeout is configured.
696 let max_exec_ms = (request.tuning.statement_timeout_s > 0)
697 .then(|| request.tuning.statement_timeout_s * 1000);
698 let mut guard = MysqlSessionGuard::apply(&mut conn, max_exec_ms)?;
699
700 let sample_pool = if request.tuning.adaptive {
701 Some(self.pool.clone())
702 } else {
703 None
704 };
705 let result = mysql_run_export(
706 guard.conn(),
707 sample_pool,
708 &built.sql,
709 built.cursor_param.as_deref(),
710 request.tuning,
711 request.column_overrides,
712 sink,
713 );
714 // Reset now (success or `Err`); the `Drop` impl is the backstop for a
715 // panic or early return inside `mysql_run_export`.
716 drop(guard);
717
718 // The empty-result fallback to `Schema::empty()` lives here for
719 // parity with the PG implementation, even though `exec_iter` always
720 // returns the column metadata before yielding any rows so
721 // mysql_run_export's `on_schema` already fired.
722 let total_rows = result?;
723 if total_rows == 0 {
724 sink.on_schema(Arc::new(Schema::empty()))?;
725 }
726 log::info!("total: {} rows", total_rows);
727 Ok(())
728 }
729
730 fn query_scalar(&mut self, sql: &str) -> Result<Option<String>> {
731 let mut conn = self.pool.get_conn()?;
732 // Pin the SAME session state the export/worker connections get (sql_mode with
733 // NO_BACKSLASH_ESCAPES stripped + UTC time_zone) via the shared guard.
734 // sample_key_boundaries runs INLINE escaped boundary literals through here; a
735 // sampler whose sql_mode disagrees with the workers' mis-parses a backslash-
736 // bearing key and drifts the percentile split points (the M1 hole this closes).
737 let mut guard = MysqlSessionGuard::apply(&mut conn, None)?;
738 let row: Option<mysql::Row> = guard.conn().query_first(sql)?;
739 match row {
740 Some(r) => {
741 let val: Option<mysql::Value> = r.get(0);
742 match val {
743 Some(mysql::Value::Bytes(b)) => {
744 Ok(Some(String::from_utf8_lossy(&b).into_owned()))
745 }
746 Some(mysql::Value::Int(v)) => Ok(Some(v.to_string())),
747 Some(mysql::Value::UInt(v)) => Ok(Some(v.to_string())),
748 Some(mysql::Value::Float(v)) => Ok(Some(v.to_string())),
749 Some(mysql::Value::Double(v)) => Ok(Some(v.to_string())),
750 _ => Ok(None),
751 }
752 }
753 None => Ok(None),
754 }
755 }
756
757 fn type_mappings(
758 &mut self,
759 query: &str,
760 column_overrides: &ColumnOverrides,
761 ) -> Result<Vec<crate::types::TypeMapping>> {
762 let wrapped = format!("SELECT * FROM ({}) AS _rivet_type_probe LIMIT 0", query);
763 let mut conn = self.pool.get_conn()?;
764 let result = conn.exec_iter(&wrapped, ())?;
765 let columns = result.columns().as_ref().to_vec();
766 drop(result);
767 let mappings = columns
768 .iter()
769 .map(|col| {
770 let rivet =
771 crate::types::resolve_or(column_overrides, col.name_str().as_ref(), || {
772 mysql_type_to_rivet(col)
773 });
774 let source = crate::types::SourceColumn::simple(
775 col.name_str().as_ref(),
776 mysql_native_type_name(col),
777 true,
778 );
779 crate::types::TypeMapping::from_source(&source, rivet)
780 })
781 .collect();
782 Ok(mappings)
783 }
784
785 /// Governor pressure proxy (Epic 18 C1): the same monotonic
786 /// extraction-pressure sum the adaptive batch loop samples
787 /// (`Created_tmp_disk_tables` + `Innodb_buffer_pool_wait_free`). Rising
788 /// between samples means the extraction is spilling a temp table to disk or
789 /// stalling on buffer-pool memory — the MySQL analogue of PG `temp_bytes`.
790 fn sample_pressure(&mut self) -> Option<u64> {
791 mysql_sample_extraction_pressure(&self.pool)
792 }
793
794 fn server_context(&mut self) -> Option<String> {
795 // Best-effort per var (a proxy or older server may omit one).
796 // `@@max_execution_time` (ms) is the query-level limit that surfaces as
797 // ERROR 3024 — the single field that makes a timeout post-mortem possible.
798 let version = self.query_scalar("SELECT @@version").ok().flatten();
799 let max_exec = self
800 .query_scalar("SELECT @@max_execution_time")
801 .ok()
802 .flatten();
803 // GLOBAL (server truth), not the SESSION value — query_scalar now runs through
804 // MysqlSessionGuard, which pins the session `time_zone`/`sql_mode` (bug #2). A
805 // session read would report the guard's normalized values (+00:00, NO_BACKSLASH_
806 // ESCAPES stripped) and HIDE the exact server settings this forensic snapshot
807 // exists to surface (a tz shift / a backslash-escape data issue).
808 let sql_mode = self.query_scalar("SELECT @@global.sql_mode").ok().flatten();
809 let time_zone = self
810 .query_scalar("SELECT @@global.time_zone")
811 .ok()
812 .flatten();
813 let wait_timeout = self.query_scalar("SELECT @@wait_timeout").ok().flatten();
814 let max_conns = self.query_scalar("SELECT @@max_connections").ok().flatten();
815 Some(
816 serde_json::json!({
817 "engine": "mysql",
818 "version": version,
819 "max_execution_time_ms": max_exec,
820 "sql_mode": sql_mode,
821 "time_zone": time_zone,
822 "wait_timeout_s": wait_timeout,
823 "max_connections": max_conns,
824 })
825 .to_string(),
826 )
827 }
828}
829
830#[cfg(test)]
831mod tests {
832 use super::{bit_bytes_to_u64, correct_innodb_avg_row_length};
833
834 // Proxy classifier tests live in `proxy.rs` alongside the classifier.
835
836 // ── bit_bytes_to_u64 (lives in arrow_convert.rs, exported pub(super)) ──
837
838 #[test]
839 fn bit_bytes_single_byte() {
840 assert_eq!(bit_bytes_to_u64(&[0x00]), 0);
841 assert_eq!(bit_bytes_to_u64(&[0x01]), 1);
842 assert_eq!(bit_bytes_to_u64(&[0xFF]), 255);
843 }
844
845 #[test]
846 fn bit_bytes_multi_byte() {
847 assert_eq!(bit_bytes_to_u64(&[0x01, 0x02]), 258);
848 assert_eq!(bit_bytes_to_u64(&[0xFF; 8]), u64::MAX);
849 }
850
851 #[test]
852 fn bit_bytes_empty() {
853 assert_eq!(bit_bytes_to_u64(&[]), 0);
854 }
855
856 #[test]
857 fn bit_bytes_ascii_digit_bytes_are_bits_not_text() {
858 // Regression (mysql-bit): BIT bytes that happen to be ASCII digits are
859 // still big-endian bits — never decimal text.
860 assert_eq!(bit_bytes_to_u64(&[0x39]), 57); // "9" as text, BIT(8) 57
861 assert_eq!(bit_bytes_to_u64(&[0x31, 0x32]), 0x3132); // b"12" → 12594
862 assert_eq!(bit_bytes_to_u64(&[0x31, 0xFF]), 12799); // digit head, non-digit tail
863 }
864
865 // ── InnoDB AVG_ROW_LENGTH correction ────────────────────────────────
866
867 #[test]
868 fn innodb_correction_below_threshold_is_identity() {
869 assert_eq!(correct_innodb_avg_row_length(82), 82);
870 assert_eq!(correct_innodb_avg_row_length(314), 314);
871 assert_eq!(correct_innodb_avg_row_length(2_048), 2_048);
872 assert_eq!(correct_innodb_avg_row_length(8 * 1024), 8 * 1024);
873 }
874
875 #[test]
876 fn innodb_correction_above_threshold_divides_by_three() {
877 assert_eq!(correct_innodb_avg_row_length(40_978), 40_978 / 3);
878 assert_eq!(correct_innodb_avg_row_length(120_000), 40_000);
879 }
880
881 #[test]
882 fn innodb_correction_does_not_undershoot_floor() {
883 let just_above = 8 * 1024 + 1;
884 let divided = correct_innodb_avg_row_length(just_above);
885 assert!(divided >= 4 * 1024, "got {divided}");
886 }
887}