MySQL / MariaDB connector¶
Connects to a MySQL or MariaDB server as both a source (incremental cursor SELECT or binlog CDC) and a batched INSERT sink, exchanging rows as JSON-object strings.
Overview¶
This connector integrates a MySQL or MariaDB server over the synchronous mariadb-connector-c C API. It provides three factories on the std::string channel: a polling cursor source (mysql_source) that issues incremental SELECT statements, a binlog CDC source (mysql_cdc_source) that streams row-level changes from the master's binary log, and a sink (mysql_sink) that writes records as batched multi-row INSERT statements. Every record on the wire is a JSON-object string keyed by column name; the SQL Row path bridges these via json_string_to_row (source) and row_to_json_string (sink). The cursor source uses the MySQL text protocol, so every cell is delivered as a JSON string and the downstream Row bridge coerces it to the declared column type. The CDC source emits flat JSON change rows carrying __op, __table, __lsn and __xid metadata.
Dependency and version¶
| Component | Provenance | Version |
|---|---|---|
| mariadb-connector-c (libmariadb) | System package via apt (Debian libmariadb-dev) / brew (macOS mariadb-connector-c) |
Not pinned by clink |
TLS support is built into mariadb-connector-c, so no separate TLS dependency is required. This connector does not use Apache Arrow on its data path; records are JSON-object strings.
Enabling it¶
The module is gated by the CMake option CLINK_WITH_MYSQL, which defaults to AUTO:
AUTO(default): the connector is built only if mariadb-connector-c is discovered.ON: build is required; CMake fails with a fatal error if the client library is not found.OFF: the target is not defined.
The client library is discovered three ways, most portable first: a pkg-config imported target (libmariadb, as shipped by apt's libmariadb-dev), the mariadb CONFIG package, then a manual header and library probe (Homebrew mariadb-connector-c or a bare install). On Debian, install the dependency with the connector deps script or directly:
apt-get install -y libmariadb-dev
cmake -S . -B build -DCLINK_WITH_MYSQL=ON
The target compiles only where the client library is present, defining CLINK_HAS_MYSQL.
Factories¶
| Factory name | Direction | Record type |
|---|---|---|
mysql_source |
Source | std::string (JSON object per row) |
mysql_cdc_source |
Source | std::string (flat JSON change row) |
mysql_sink |
Sink | std::string (JSON object per row) |
mysql_upsert_sink |
Sink | Row (changelog upsert/delete by PRIMARY KEY) |
Configuration¶
Connection options (shared by all three factories)¶
| Option | Required | Default | Description |
|---|---|---|---|
host |
No | localhost |
Server host. |
port |
No | 3306 |
Server port. |
user |
No | `` (empty) | Connection user. |
password |
No | `` (empty) | Connection password; an empty value connects without a password. |
database |
No | `` (empty) | Database selected in-band at connect. |
ssl |
No | false |
ssl='true' encrypts the connection (the server is required to support TLS; a plaintext server is refused). |
ssl_ca |
No | `` (empty) | Optional PEM path to the CA used to verify the server certificate. |
ssl_cert |
No | `` (empty) | Optional PEM path to the client certificate (mutual TLS). |
ssl_key |
No | `` (empty) | Optional PEM path to the client key (mutual TLS). |
ssl_verify |
No | true |
ssl_verify='false' skips server-certificate verification (encrypted but not authenticated, for example a self-signed development certificate). |
Source: conn_options_from in impls/mysql/src/register_factories.cpp and the ConnectOptions struct in impls/mysql/include/clink/mysql/mysql_client.hpp.
mysql_sink¶
| Option | Required | Default | Description |
|---|---|---|---|
table |
Yes | (none) | Target table. |
columns |
See note | (none) | Comma-separated projection and column order. On the SQL path it defaults to the declared table schema (schema_columns), so it is only required when the sink is built outside SQL. Each column is looked up by name in the row's JSON object; an absent or null value becomes SQL NULL. |
on_duplicate |
No | `` (empty) | Empty performs a plain INSERT. on_duplicate='update' appends ON DUPLICATE KEY UPDATE for an idempotent insert-or-update by primary key on replay. This is not clink mode='upsert' (a changelog contract with delete tombstones, which this sink does not implement). |
update_columns |
No | `` (empty) | Comma-separated ON DUPLICATE KEY UPDATE SET list; empty means all columns. |
batch_records |
No | 1000 |
Flush threshold by record count; 0 is coerced to 1. |
max_bytes |
No | 0 |
Byte-based flush threshold; 0 disables it. |
linger_ms |
No | 0 |
Linger before flushing a partial batch, in milliseconds; 0 disables it. |
Source: the mysql_sink lambda in impls/mysql/src/register_factories.cpp and MysqlSinkOptions in impls/mysql/include/clink/mysql/mysql_sink.hpp.
mysql_source (incremental cursor)¶
| Option | Required | Default | Description |
|---|---|---|---|
table |
Yes | (none) | Source table. |
cursor_column |
Yes | (none) | Ascending, NOT NULL cursor column. Must be unique unless id_column is set. The cursor comparison is exclusive (>), so the boundary row is never re-emitted. |
id_column |
No | `` (empty) | Optional unique tie-breaker for keyset pagination, so a non-unique cursor_column does not drop rows sharing a boundary value at a page (LIMIT) boundary. |
initial_cursor |
No | `` (empty) | Cold-start cursor value; empty re-scans from the beginning until the first checkpoint. |
batch_size |
No | 1000 |
Rows per poll (LIMIT). |
poll_ms |
No | 1000 |
Poll interval in milliseconds. |
jitter_frac |
No | 0 |
Fractional jitter applied to the poll interval. |
bounded / mode |
No | false |
bounded='true' or mode='snapshot' reads the table once and then finishes, rather than tailing it forever. |
Source: the mysql_source lambda in impls/mysql/src/register_factories.cpp and MysqlPollOptions in impls/mysql/include/clink/mysql/mysql_source.hpp.
mysql_cdc_source (binlog CDC)¶
| Option | Required | Default | Description |
|---|---|---|---|
server_id |
Yes | 0 |
Replica server-id; must be non-zero and unique against the master. A value of 0 is rejected. |
tables |
No | `` (empty) | Comma-separated db.table allowlist; empty means all tables. Must be fully qualified db.table when initial_snapshot is enabled. |
start_file |
No | `` (empty) | Cold-start binlog file; empty starts from the current master head. |
start_pos |
No | 4 |
Cold-start binlog position (4 is just past the magic header). |
initial_snapshot |
No | false |
initial_snapshot='true' bootstraps existing rows of tables as Insert changes before streaming, captured at a consistent binlog point so existing data and ongoing changes form one gap-free stream. |
snapshot_lock |
No | true |
With true, briefly takes FLUSH TABLES WITH READ LOCK (needs the RELOAD privilege) for an exact, overlap-free snapshot point. snapshot_lock='false' skips the lock, making the snapshot-to-stream boundary at-least-once. |
heartbeat_ms |
No | 1000 |
Master heartbeat period in milliseconds; bounds cancel and checkpoint latency on an idle stream. |
The CDC source requires the master to run binlog_format=ROW with binlog_row_image=FULL, and the connecting user to hold REPLICATION SLAVE and REPLICATION CLIENT. Column names and signedness are resolved from information_schema. A binlog stream is single-reader: only subtask 0 streams, and other subtasks are dormant.
Source: the mysql_cdc_source lambda in impls/mysql/src/register_factories.cpp and MysqlCdcOptions in impls/mysql/include/clink/mysql/mysql_cdc_source.hpp.
SQL usage¶
The connector is mapped under connector='mysql' in src/sql/physical_plan.cpp. The source factory is selected by the mode property: mode='cdc' selects mysql_cdc_source, otherwise mysql_source. The sink maps to mysql_sink.
Source table (incremental cursor):
CREATE TABLE orders (
id BIGINT,
customer VARCHAR,
amount DOUBLE
) WITH (
connector = 'mysql',
host = 'db.internal',
port = '3306',
user = 'reader',
password = 'secret',
database = 'shop',
table = 'orders',
cursor_column = 'id',
batch_size = '500',
poll_ms = '1000'
);
Source table (binlog CDC):
CREATE TABLE orders_cdc (
id BIGINT,
customer VARCHAR,
amount DOUBLE
) WITH (
connector = 'mysql',
mode = 'cdc',
host = 'db.internal',
user = 'repl',
password = 'secret',
server_id = '42',
tables = 'shop.orders'
);
Sink table:
CREATE TABLE orders_out (
id BIGINT,
customer VARCHAR,
amount DOUBLE
) WITH (
connector = 'mysql',
host = 'db.internal',
user = 'writer',
password = 'secret',
database = 'shop',
table = 'orders_out',
on_duplicate = 'update'
);
On the SQL path, columns defaults to the declared table schema, so it does not need to be repeated. mode='upsert' with a PRIMARY KEY selects the changelog-aware upsert sink (mysql_upsert_sink, see Delivery semantics), which lets a retracting query maintain the table; on_duplicate='update' is the lighter append-stream idempotent-by-key option on the default sink. Exactly-once (XA 2PC) delivery is not supported.
Example¶
The sink and cursor source can be constructed directly from their typed Options, as the live test does in impls/mysql/tests/test_mysql_live.cpp:
#include "clink/mysql/mysql_sink.hpp"
#include "clink/mysql/mysql_source.hpp"
using namespace clink::mysql;
ConnectOptions conn;
conn.host = "db.internal";
conn.user = "writer";
conn.password = "secret";
conn.database = "shop";
// Sink: batched INSERT of JSON-object rows.
MysqlSinkOptions sink_opts;
sink_opts.conn = conn;
sink_opts.table = "orders_out";
sink_opts.columns = {"id", "val"};
sink_opts.upsert = true; // INSERT ... ON DUPLICATE KEY UPDATE
MysqlSink sink(std::move(sink_opts));
sink.open();
// sink.on_data(batch); ... flushed on count/byte/linger thresholds and barriers.
// Source: incremental cursor SELECT.
MysqlPollOptions src_opts;
src_opts.conn = conn;
src_opts.table = "orders";
src_opts.cursor_column = "id";
src_opts.batch_size = 500;
auto source = make_mysql_poll_source(src_opts);
The factories are also reachable through the registry on the string channel as mysql_sink, mysql_source and mysql_cdc_source once clink::mysql::install(registry) has run.
Delivery semantics¶
mysql_sink: at-least-once. A replay after a failed checkpoint re-runs theINSERT. A plain insert appends duplicates on replay; withon_duplicate='update'(and a PRIMARY KEY or UNIQUE index) the insert-or-update is idempotent and so effectively-once by key. Buffered rows are flushed on the count, byte and linger thresholds and on every checkpoint barrier. A flush failure drops the connection, clears the pending buffer and rethrows so the job replays from the last checkpoint. Exactly-once (XA 2PC) is not supported.mysql_upsert_sink(mode='upsert'): effectively-once on the sink table for a stable PRIMARY KEY and a deterministic defining query. Consumes the changelog by key - insert/update_afterINSERT ... ON DUPLICATE KEY UPDATE, delete/update_beforeDELETE ... WHERE <pk> IN (...)- netted by key within a flush and applied in one transaction, so a replay converges the table to the same state. Lets a retracting query (GROUP BY, TOP-N, outer join) maintain a MySQL table. Not two-phase commit.mysql_source: at-least-once. The cursor is checkpointed as operator state (viaPollingSource) and replayed on restart; the exclusive>comparison avoids re-emitting the boundary row. A NULL cursor or id value in a delivered row is fatal rather than silently stalling the cursor.mysql_cdc_source: at-least-once for the decodable change stream, with the checkpoint cursor being(binlog_file, position). An undecodable row event (schema drift or an unseen table-map) is dropped and counted, so it is at-most-once for those events; it is never emitted half-populated. Withinitial_snapshotenabled andsnapshot_lock=truethe snapshot-to-stream boundary is exact; withsnapshot_lock=falseit is at-least-once. The snapshot is not checkpointed mid-way, so a crash during it re-snapshots from scratch on restart.
Limitations¶
- The cursor source uses the MySQL text protocol, so every cell is delivered as a string; numeric and date typing depends on the downstream Row bridge coercing to declared column types.
- A VARCHAR
cursor_columnorders lexically (so'10' < '9'); choose a numeric or zero-padded key. - A non-unique
cursor_columncan drop rows at a page boundary unlessid_columnis set as a unique tie-breaker. mysql_cdc_sourcerequiresbinlog_format=ROWandbinlog_row_image=FULLon the master, andREPLICATION SLAVEplusREPLICATION CLIENTprivileges; column names are resolved frominformation_schema.- A binlog stream is single-reader: only subtask 0 streams in a parallel CDC job; other subtasks emit nothing.
- CDC
initial_snapshotrequirestablesto be a non-empty, fully-qualifieddb.tablelist, and a consistent snapshot relies on InnoDB MVCC (a non-transactional engine such as MyISAM has no consistent-snapshot view). - The sink does not implement clink
mode='upsert'(changelog delete tombstones); for idempotent insert-or-update useon_duplicate='update'. - Exactly-once delivery is not supported by the sink.
- The channel is
std::stringonly (JSON-object rows); there is no typed int64 channel and no Arrow or Parquet encoding on this connector's data path.
Testing¶
In-process tests cover factory registration, sink and source SQL building, CDC decode and snapshot logic without a server (impls/mysql/tests/test_factory_registration.cpp, test_mysql_sink_logic.cpp, test_mysql_source_logic.cpp, test_mysql_cdc_decode.cpp, test_mysql_snapshot.cpp).
The live integration tests (test_mysql_live.cpp and test_mysql_cdc_live.cpp) are skipped unless the environment variable CLINK_MYSQL_TEST_DSN is set; each is a space-separated key=value DSN, for example:
export CLINK_MYSQL_TEST_DSN="host=localhost port=3306 user=root password=mysql database=test"
The fixture server is the mysql:8.0 image defined in docker/integration-services.yml, started with --server-id=1 --log-bin=mysql-bin --binlog-format=ROW --binlog-row-image=FULL so the binlog CDC source can stream changes. Run the live tests with:
ctest --test-dir build -L mysql -R Live --output-on-failure