capstan

Hex.pmDocsLicenseRun in Livebook

capstan is MySQL CDC on the BEAM: it connects to MySQL as a replica, tails the row-based binary log from a GTID position, and delivers committed transactions to your sink — durably checkpointed, at-least-once, and fail-closed on every condition that could otherwise lose or corrupt data silently. It is replicant's MySQL sibling.

It is a library in your supervision tree, not a daemon: no JVM, no ports, no second runtime. The MySQL replication-protocol client is in-library and pure Elixir (:gen_tcp/:ssl/:crypto only).

When to use it

You wantcapstan
Row-level change streams out of MySQL 8+ into Elixir✅ this is exactly it
An initial backfill of existing rows, then a gap-free handoff to the stream✅ built in (:snapshot)
Delivery guarantees without operating Debezium/Maxwell✅ one dependency, your tree
Schema-change notifications without DDL text leaking into logs✅ DDL redacted by design
A source with binlog_transaction_compression=ON✅ consumed — the in-library zstd decoder inflates each compressed transaction (byte-exact conformance vs the reference zstd)
XA (two-phase) transactions✅ tracked or refused, never half-delivered (xa: policy)

Install

def deps do
[
{:capstan, "~> 1.2"}
]
end

MySQL requirements, checked at every connect (one query, distinct refusal per violation): binlog_format=ROW, binlog_row_image=FULL, binlog_row_metadata=FULL, binlog_row_value_options='' (full JSON), gtid_mode=ON. Run scripts/capstan-preflight.sql against a prospective source for a read-only readiness report.

A 30-second pipeline

defmodule MyApp.OrderSink do
@behaviour Capstan.Sink
@impl true
def handle_transaction(txn) do
Enum.each(txn.changes, fn change ->
# change.op ∈ {:insert, :update, :delete}; change.record is column => value
MyApp.Projector.apply!(change)
end)
{:ok, txn.position} # persist this atomically with your own write for effect-once
end
end
defmodule MyApp.CDC do
def start_link(opts) do
Capstan.start_link(
connection: [
host: "db.internal",
username: "capstan",
password: opts[:password]
],
server_id: 4242,
sink: MyApp.OrderSink,
checkpoint_store: [module: MyApp.CheckpointStore],
tables: [{"shop", "orders"}]
)
end
end

That is the whole core: connect, stream, deliver, checkpoint. Kill it, restart it — it resumes from the durable checkpoint with no transaction lost (the sink sees replays at worst; make it idempotent — that is the contract).

Try it live (kill/restart resume included) in the getting-started Livebook, or run the minimal printable consumer in examples/. For the complete production shape as one runnable stack — receipts, idempotent mirror, durable checkpoint, all in docker — see examples/replication_pipeline.

The three ways to own your position

1. Lib-owned checkpointing (default). You implement a two-callback Capstan.CheckpointStore (read/write a GTID-set string — a table row, a file, Redis, anything durable). capstan delivers a transaction, then checkpoints. Crash between the two → the transaction is re-delivered on restart: at-least-once.

2. Sink-owned checkpointing (effect-once). Your sink implements checkpoint/0 and persists the position atomically with its own write — the delivery and the position advance become ONE transaction. capstan never checkpoints past a write the sink has not confirmed.

3. Batched (batch: [max_transactions:, flush_ms:, mode:]). The checkpoint write covers N transactions: :lib_owned batches the checkpoint writes; :sink_owned hands your handle_batch/2 the whole list + final position for one atomic write. Wider crash-replay window, fewer round trips.

Backfill existing rows first

Add a :snapshot block and capstan backfills pre-existing rows before (and concurrently with) the stream, gap-free — a per-chunk brief read lock pins each chunk's exact GTID position, and a cursor gate suppresses stream changes a chunk already covers. Kill it mid-backfill and it resumes per-table from durable cursors.

snapshot: [
tables: [{"shop", "orders"}, {"shop", "customers"}], # or :all (scoped base tables)
store: [module: MyApp.SnapshotStore],
chunk_size: 4096
]

Every table gets exactly one completion signal — a final_chunk?: true beat to handle_snapshot/2 (empty tables get exactly one empty final chunk). Primary keys may be integers, BINARY/VARBINARY, CHAR/VARCHAR (any charset and collation — the gate compares the server's own collation sort keys, not string bytes), or composites of those; the TEXT family and ENUM/SET keys are refused with a named halt rather than backfilled in a wrong order.

Fail-closed, everywhere

Any condition that could silently lose or corrupt data halts the pipeline ({:shutdown, {:halt, reason}}; every child is restart: :temporary, so the halt is yours to handle): a purged binlog range, a server identity mismatch, a malformed event, an unknown transaction shape, an unconsumable compression type, a sink error, a schema change mid-backfill. Halts surface as [:capstan, :connection, :halt] / [:capstan, :assembler, :halt] / [:capstan, :snapshot, :halt] telemetry with a value-free reason. No row value, no DDL text, no password ever appears in a halt reason, log line, or telemetry payload — that boundary is enforced at runtime, not by convention.

Where to go next

Repository layout

DirectoryContents
lib/The Elixir library (Hex package capstan)
notebooks/The getting-started Livebook
examples/Runnable minimal consumers (examples/README.md)
docker-compose.yml, scripts/The local MySQL test substrate + the read-only preflight report
probe/The executed protocol viability probe + committed evidence
docs/ADRs, telemetry reference, recipes
usage-rules.mdConsumer-facing usage contract

Support & maintenance

capstan is maintained as the MySQL side of the baselabs CDC stack (replicant's sibling). Issues and pull requests are welcome; there is no support SLA.

License

MIT — see LICENSE.