faucet-source-mysql
A MySQL query source that runs a SQL query and streams the rows out as serde_json::Value records.
Part of the faucet-stream ecosystem.
Built on sqlx with a pooled, async connection and a true row cursor (Query::fetch) — rows are decoded and handed to the sink as they arrive off the wire, so client-side memory stays bounded by batch_size no matter how large the result set is. Reach for it to pull tables, filtered extracts, or analytics queries out of MySQL / MariaDB into any faucet-stream sink, or to fan a query out per parent record in a matrix pipeline.
Feature highlights
- Streaming row cursor —
Source::stream_pagesdrives a sqlx cursor and yields oneStreamPageperbatch_sizerows; the sink starts writing before the query finishes draining. - Connection pooling — a single
MySqlPoolis built once innew()and reused for every fetch; size it withmax_connections(default10). - Rich type decoding — JSON, integers, floats, booleans,
DATETIME/TIMESTAMP/DATE/TIME,DECIMAL(exact precision), andBLOB/BINARY(base64) all map to sensible JSON. - Parameterised per-record queries — in a parent/child matrix run,
${parent.field}tokens in the query are substituted as safe bind parameters (?placeholders), never string-interpolated. - TLS by default — built with
tls-rustls; encrypted connections need no extra dependency. - Credential-safe — the connection URL is masked in
Debugoutput and stripped from the lineage dataset URI. batch_size: 0sentinel — drain the whole result set into a single page for small lookup tables or load-job-style sinks.
Installation
# As a library:
# In the CLI (opt-in connector feature):
Or via the umbrella crate:
The MySQL source is not in the CLI/umbrella default build — enable the source-mysql feature explicitly.
Quick start
# pipeline.yaml — faucet run pipeline.yaml
version: 1
pipeline:
source:
type: mysql
config:
connection_url: mysql://user:pass@localhost:3306/mydb
query: SELECT id, name, email FROM users WHERE active = 1 ORDER BY id
sink:
type: jsonl
config:
path: ./users.jsonl
Configuration reference
| Field | Type | Default | Description |
|---|---|---|---|
connection_url |
string | — (required) | MySQL connection URL, e.g. mysql://user:pass@host:3306/db. Masked in Debug output and stripped from the lineage URI. |
query |
string | — (required) | The SQL query to execute. May contain ${parent.field} tokens that are bound as parameters in a matrix run (see Per-record queries). |
max_connections |
int | 10 |
Maximum connections in the sqlx pool. |
batch_size |
int | 1000 |
Rows per emitted StreamPage. 0 = no batching (drain the whole result set into one page). Values above MAX_BATCH_SIZE (1,000,000) are rejected at construction by faucet_core::validate_batch_size. |
shard |
object | (unset) | Optional Mode B sharding: { key: <integer column> }. Opts the source into primary-key range splitting under faucet serve --cluster; no effect on a plain faucet run. |
There is no separate auth block — credentials live in connection_url (and can be sourced from env or a secrets manager; see Config loading).
Examples
Filtered extract into Postgres
Reuses cli/examples/mysql_to_postgres.yaml:
version: 1
name: mysql_to_postgres
pipeline:
source:
type: mysql
config:
connection_url: mysql://user:pass@localhost/legacy
query: SELECT id, name, address, created_at FROM customers ORDER BY id
max_connections: 16
sink:
type: postgres
config:
connection_url: postgres://user:pass@localhost/modern
table_name: customers_imported
column_mapping:
type: auto_map
batch_size: 1000
max_connections: 10
Rolling time window into stdout
version: 1
pipeline:
source:
type: mysql
config:
connection_url: ${env:MYSQL_URL}
query: >
SELECT id, type, payload, created_at
FROM events
WHERE created_at >= CURDATE() - INTERVAL 7 DAY
ORDER BY created_at
batch_size: 5000
sink:
type: stdout
config:
format: jsonl
Small lookup table — one large page
version: 1
pipeline:
source:
type: mysql
config:
connection_url: ${env:MYSQL_URL}
query: SELECT code, label FROM country_codes
batch_size: 0 # drain the whole result set into a single page
sink:
type: bigquery
config:
project_id: my-project
dataset_id: ref
table_id: country_codes
Per-record queries (matrix pipelines)
When a row runs once per record emitted by a parent, ${parent.field} tokens in the query are resolved per record as bind parameters — the value is never spliced into the SQL text:
version: 1
name: orders_per_customer
pipeline:
sources:
customers:
type: mysql
config:
connection_url: ${env:MYSQL_URL}
query: SELECT id FROM customers WHERE region = 'EU'
orders:
type: mysql
config:
connection_url: ${env:MYSQL_URL}
query: SELECT id, total FROM orders WHERE customer_id = ${customers.id}
sink:
type: jsonl
config:
path: ./orders.jsonl
matrix:
- id: customers
source:
- id: orders
parent: customers
source:
Streaming & batching
MysqlSource::stream_pages drives a sqlx row cursor (Query::fetch) without buffering the full result. Rows are accumulated into a batch_size buffer and yielded as a StreamPage once the buffer fills; the trailing partial page (if any) is yielded after the cursor drains. This bounds client-side memory at O(batch_size) and lets the sink begin writing as soon as the first batch is parsed off the wire.
batch_size = 0 is the "no batching" sentinel — the cursor is drained completely and the entire result set is emitted in a single StreamPage. Use it for small lookup tables, or for downstream sinks (SQL COPY, BigQuery load jobs, Snowflake stage uploads) that prefer one large request to many small ones.
The trait-level batch_size argument to stream_pages is ignored in favour of the config field — the config is the authoritative, user-facing knob, so a pipeline-supplied hint can never silently override an explicit config value.
Note — MySQL's wire protocol sends rows from a simple
SELECTin a single response (no server-side cursor), so the streaming here bounds memory on the client side rather than asking the server to page. True server-side cursor streaming is tracked separately as a follow-up.
The MySQL query source has no incremental-replication mode, so every emitted page carries bookmark: None (there is no resume or effectively-once support — see Capabilities). For change-data-capture against MySQL binlogs, use faucet-source-mysql-cdc instead.
Column types
Columns are converted to JSON in order of likelihood; an unsupported or NULL column becomes null:
| MySQL type | JSON shape |
|---|---|
json |
native JSON value |
varchar, text, char |
string |
bigint |
number (i64) |
int, mediumint |
number (i32) |
smallint, tinyint |
number (i16) |
double |
number (f64) |
float |
number (f32) |
tinyint(1), boolean |
true/false |
datetime, timestamp |
string (RFC 3339 / ISO-8601) |
date, time |
string (ISO-8601) |
decimal, numeric |
string (exact precision preserved) |
blob, binary, varbinary |
string (base64) |
other / NULL |
null |
Capabilities
| Capability | Supported | Notes |
|---|---|---|
| Native streaming | ✅ | sqlx row cursor; one page per batch_size rows. |
| Connection pooling | ✅ | max_connections, reused across fetches. |
| Resume / bookmark state | ❌ | Stateless query source; every page is bookmark: None. |
| Effectively-once delivery | ❌ | Source does not implement supports_exactly_once. |
| Write modes / upsert | — | Not applicable (this is a source). |
| Compression | ❌ | No compression feature. |
| Per-record (matrix) queries | ✅ | ${parent.field} → SQL bind parameters. |
Config loading
Load a config from a JSON file, environment variables, or a .env file via the helpers in faucet_core::config:
use ;
use MysqlSourceConfig;
#
MYSQL_SOURCE_CONNECTION_URL=mysql://user:password@localhost:3306/mydb
MYSQL_SOURCE_QUERY=SELECT * FROM users
MYSQL_SOURCE_MAX_CONNECTIONS=10
In CLI configs the connection_url can also be drawn from env, files, or a secrets manager via ${env:VAR} / ${file:PATH} / ${vault:...} directives.
Schema introspection
Print the JSON Schema for this connector's config:
Or from Rust:
use Source;
use ;
# async
Library usage
use ;
use Source;
# async
Wire it into a pipeline to stream straight to any sink:
use ;
use Pipeline;
let source = new.await?;
let pipeline = new;
let result = pipeline.run.await?;
How it works
MysqlSource::new validates batch_size, then builds one MySqlPool (MySqlPoolOptions::max_connections) and stores it on the struct — every subsequent fetch reuses the pool rather than reconnecting. stream_pages opens a sqlx cursor with Query::fetch and pulls rows incrementally via TryStreamExt::try_next, flushing a StreamPage each time the buffer reaches batch_size. Each row is turned into a JSON object keyed by column name (row_to_json), and per-column decoding (mysql_value_to_json) probes the likely sqlx types in order, falling back to Null. Connection and query failures surface as FaucetError::Config with the underlying sqlx error attached. Throughput is dominated by row width and network round-trips; benchmark with your own schema and batch_size.
Lineage dataset URI
mysql://<host>:<port>/<db>?query=<sql> (credentials stripped) — e.g. mysql://host:3306/app?query=SELECT id FROM orders.
Feature flags
This crate has no optional Cargo features of its own. Enable it in the CLI / umbrella via the source-mysql feature. TLS (tls-rustls), chrono, bigdecimal, and json sqlx features are always compiled in.
Troubleshooting / FAQ
| Symptom | Likely cause & fix |
|---|---|
FaucetError::Config: MySQL connection failed: ... |
Wrong host/port/credentials, the database is unreachable, or the URL scheme isn't mysql://. Verify the connection_url and that the server accepts the connection. |
FaucetError::Config: batch_size ... at startup |
batch_size exceeds MAX_BATCH_SIZE (1,000,000). Lower it, or use 0 to disable batching. |
FaucetError::Config: MySQL query failed: ... |
SQL syntax error, missing table/column, or insufficient privileges. Run the query directly against MySQL to confirm. |
| TLS handshake / certificate error | The server requires TLS the client can't negotiate. Adjust the server's TLS settings or supply the right host; tls-rustls is built in, so no extra dependency is needed. |
A DECIMAL or DATETIME column arrives as a string |
Intentional — DECIMAL is stringified to preserve exact precision and temporal types use ISO-8601. Cast downstream if you need a number. |
A column comes back as null unexpectedly |
The column type isn't in the decode list, or the value is genuinely NULL. Wrap the column in CAST(... AS CHAR) in the query to force a string. |
| Run holds many connections open | max_connections is high relative to the workload. Lower it; the default of 10 is plenty for a single streaming query. |
${parent.field} shows up literally in the SQL |
The row isn't a matrix child of that parent (no parent: set), so the token was never resolved. Check the matrix wiring. |
| Need change-data-capture, not a snapshot | This source only runs point-in-time queries. Use faucet-source-mysql-cdc for binlog replication. |
See also
- Connector reference · Config grammar · CLI reference
- Related crates: faucet-source-mysql-cdc · faucet-sink-mysql · faucet-source-postgres
Sharded execution (cluster Mode B)
Under faucet serve --cluster,
a top-level shard: { count: N } block splits this source into contiguous
primary-key ranges that different cluster workers process concurrently. Opt in
by naming an integer-typed key column:
shard:
count: 8
pipeline:
source:
type: mysql
config:
connection_url: ${env:MYSQL_URL}
query: "SELECT * FROM events"
shard: # integer column to range-partition on
The coordinator computes MIN(key) / MAX(key) once and splits that range
into half-open slices (`key` in the generated predicate, injection-safe).
The boundary shards stay open-ended so rows inserted outside the captured
range during the run are still read, and exactly one shard additionally
matches key IS NULL so nullable keys are never silently dropped. Each shard
keeps its own state key ({run}::{shard}), so a reassigned shard resumes
where its previous owner left off.
Outside the cluster coordinator the shard config has no effect — a plain
faucet run streams the whole query.
License
Licensed under either of Apache License, Version 2.0 or MIT license at your option.