Current section
Files
Jump to
Current section
Files
README.md
# capstan
**capstan is MySQL CDC on the BEAM — it streams committed MySQL row changes from the binary
log to a sink, fail-closed and at-least-once (effect-once is the sink-owned atomic path,
deferred in C1 — see below).** It is [replicant](https://github.com/baselabs/replicant)'s
MySQL sibling: replicant consumes Postgres logical replication (pgoutput) over a replication
slot; capstan consumes MySQL row-based binary-log replication over a registered replica
connection.
> **Status: C1 (streaming spine) implemented.** capstan connects to MySQL as a replica, tails
> the row-based binlog from a GTID position, assembles committed transactions, delivers them to a
> sink, and durably advances a processed-GTID checkpoint — fail-closed on every silent-loss
> condition. C1 ships **lib-owned checkpoint mode** and resume-from-durable-checkpoint
> (`start_position: :checkpoint`) only; sink-owned checkpoint mode and explicit start positions are
> deferred and refused fail-closed today (see
> [ADR-0004](docs/adr/0004-c1-scope-lib-owned-checkpoint-only.md)). The protocol viability probe
> that preceded implementation — a ~400-line zero-dependency Elixir client that spoke the full
> MySQL replication protocol against live MySQL 8.0.46 and decoded both a replayed and a
> live-tailed row byte-correct — remains committed under `probe/` (see `probe/FINDINGS.md`).
## Why it exists
The Elixir ecosystem has no MySQL CDC library. Every available option is an external daemon
(Debezium, Maxwell) bolted on through a Port and a JSON wire contract — a second runtime to
operate, and none of the delivery guarantees a BEAM-native library can offer by owning the
sink callback.
capstan is a **library in your supervision tree**, not a daemon.
## Installation
Add `capstan` to your dependencies:
```elixir
def deps do
[
{:capstan, "~> 0.1.0"}
]
end
```
## Quickstart
A sink receives each committed transaction as a `Capstan.Transaction` — `gtid`, `position`,
`commit_ts`, and `changes`, an enumerable of `Capstan.Change` (`op`, `schema`, `table`,
`record`, `old_record`):
```elixir
defmodule MyApp.OrdersSink do
@behaviour Capstan.Sink
@impl true
def handle_transaction(%Capstan.Transaction{changes: changes} = txn) do
# `changes` is a single-pass enumerable — enumerate exactly once, never length/1.
Enum.each(changes, fn %Capstan.Change{} = change ->
# change.op — :insert | :update | :delete
# change.record — the row AFTER the change (nil on :delete)
# change.old_record — the row BEFORE the change (nil on :insert)
MyApp.Projections.apply(change)
end)
{:ok, txn.position}
end
@impl true
def handle_schema_change(%Capstan.SchemaChange{}, _position), do: :ok
end
```
Then embed a pipeline in your supervision tree:
```elixir
children = [
{Capstan,
connection: [
host: "replica.internal",
port: 3306,
username: "capstan",
password: System.fetch_env!("CAPSTAN_MYSQL_PASSWORD"),
database: "orders",
ssl: true,
ssl_opts: [cacertfile: "/etc/ssl/mysql-ca.pem", server_name_indication: :disable]
],
server_id: 1001, # replica identity — MUST be unique in the topology
sink: MyApp.OrdersSink,
checkpoint_store: [module: Capstan.CheckpointStore.InMemory],
tables: [{"orders", "line_items"}] # or :all (default)
}
]
```
Two things to change before production:
1. **The checkpoint store.** `Capstan.CheckpointStore.InMemory` loses the checkpoint on
restart. Implement the two-callback `Capstan.CheckpointStore` behaviour over storage you
already trust (your database, a file) to get resume-across-restart — see
[usage-rules.md](usage-rules.md).
2. **The TLS choice.** `ssl:` defaults to `true` and start-up fails closed until you make an
explicit peer-verification choice — `cacertfile:`/`cacerts:`, `verify: :verify_none`, or
`ssl: false`. The options above verify the chain (`VERIFY_CA`).
One behavior to know before the first start: an **empty checkpoint store requests the
server's full retained binlog history**, and a server that has already purged its earliest
logs refuses that dump (the pipeline halts fail-closed with `:data_gap`). To start "from
now", seed the checkpoint store with the server's current `@@global.gtid_executed` before
first start — the recipe is in [usage-rules.md](usage-rules.md). An initial table snapshot
(backfill of pre-existing rows) is not shipped yet; see `docs/ROADMAP.md`.
[usage-rules.md](usage-rules.md) is the full consumer contract — delivery guarantees, dedup
rules, checkpoint semantics, telemetry, and the fail-closed error surface.
## The contract, shared with replicant
capstan deliberately aligns with replicant's consumer-facing contracts so that porting a sink
between them is a small, *explicit* change rather than a rewrite:
- the same `Sink` behaviour **shape** — `handle_transaction/1`, plus `checkpoint/0` and
`handle_schema_change/2` (batch and snapshot callbacks are later rows, not shipped yet),
- the same lib-owned `CheckpointStore` alternative for non-transactional sinks,
- the same delivery-guarantee taxonomy — effect-once on the sink-owned atomic path,
at-least-once with a bounded duplicate window in lib-checkpoint mode (C1 ships the
lib-checkpoint path; the sink-owned atomic path is deferred — [ADR-0004](docs/adr/0004-c1-scope-lib-owned-checkpoint-only.md)),
- the same value-free error and telemetry conventions (row values never reach a log line).
**The contracts are aligned, not identical, and a sink is not source-blind by default.** Two
differences are forced by MySQL and are deliberate:
1. **Position type.** replicant's position is a scalar `commit_lsn`; capstan's is a GTID set.
A GTID set is legitimately non-contiguous (failover, purged ranges, multi-source), so it
cannot be reduced to a comparable integer without lying about gaps.
2. **Dedup idiom.** replicant dedups with `commit_lsn <= checkpoint`; capstan dedups by set
membership (`Capstan.Gtid.member?/2`). A `<=` comparison on GTIDs is *wrong*, not merely
different — it would silently skip or re-apply transactions.
Consumers such as [beamline](https://github.com/baselabs/beamline) stay source-agnostic through
a thin per-source adapter package, which is that project's existing pattern — not by the two
libraries presenting one identical type.
## Substrate requirements
MySQL 8.0+ configured for row-based replication with GTIDs:
```
binlog_format = ROW
binlog_row_image = FULL
binlog_row_metadata = FULL
binlog_row_value_options = '' # PARTIAL_JSON emits JSON diffs, not values
gtid_mode = ON
enforce_gtid_consistency = ON # implied by gtid_mode=ON; not separately checked
```
capstan verifies the first five at connect and refuses to start if any is wrong. Multi-source
replication (a server aggregating several upstreams) is supported — a GTID set expresses multiple
source UUIDs natively.
To assess a prospective source before any of that — preconditions, GTID posture, retention, XA
usage, and a schema census of the types capstan halts on — have a DBA run the read-only
`scripts/capstan-preflight.sql` and send back the report.
`binlog_row_metadata = FULL` is **required**, not advisory: it is what puts column names and
types in-band on every `TABLE_MAP` event, giving capstan near-parity with pgoutput's `Relation`
messages. The default (`MINIMAL`) does not, and capstan fails closed at connect rather than
decoding rows into positional tuples of unknown provenance.
## Repository layout
| Directory | Contents |
| --- | --- |
| `lib/` | The Elixir library (hex package `capstan`) — the C1 streaming spine |
| `probe/` | The executed protocol viability probe + committed evidence |
| `docs/adr/` | Architecture decision records ([0001](docs/adr/0001-position-and-dedup-model.md)–[0004](docs/adr/0004-c1-scope-lib-owned-checkpoint-only.md)) |
| `usage-rules.md` | Consumer-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](https://github.com/baselabs/capstan/blob/main/LICENSE).