Sink adapter SDK
Build a custom sink adapter for rustcdc, and verify it against the conformance suite.
On this page
Contract
SinkAdapter is an async trait for adapter development. It uses native async fn in traits
(RPITIT, stabilised in Rust 1.75). No #[async_trait] macro is required in implementations —
just write plain async fn methods.
Required methods:
send(&mut self, event: &Event) -> Result<()>: accept one canonical event.flush(&mut self) -> Result<()>: durably flush buffered sends.close(&mut self) -> Result<()>: graceful shutdown.name(&self) -> &str: stable adapter identifier.
Optional capability methods (all default to no-op / false):
delivery_guarantee() -> SinkDeliveryGuarantee: advertiseAtLeastOnce,AtLeastOnceIdempotent, orEffectivelyOnce.idempotent_delivery_capable() -> bool: whether retrying the same event is idempotent.transactional_checkpoint_barrier_capable() -> bool: whether the adapter implements the full barrier protocol.queue_depth() -> Option<usize>: current in-memory buffer depth for back-pressure observation.flush_tick_interval() -> Option<Duration>: hint for periodic time-based flushing.begin_checkpoint_barrier / commit_checkpoint_barrier / abort_checkpoint_barrier: two-phase-commit-style barrier protocol for effectively-once delivery.preflight_check(): connectivity validation called once at startup.close_with_timeout(ms): pre-built graceful close with timeout (no override needed).exported_events() -> Option<&[Event]>: for in-memory/test adapters; enables conformance assertions.delivery_metrics() -> Option<SinkDeliveryMetrics>: snapshot of delivery counters (events_sent,events_errored,events_retried,last_delivered_offset) for admin endpoints and Prometheus scrapers.SinkDeliveryMetrics::merge()is provided for composed sinks.is_closed() -> bool: closed-state hook used by conformance suite; defaults tofalse.
Behavioral expectations:
- Preserve event ordering per
sendcall sequence. - Return structured errors instead of panicking.
- Treat
flushas a durability boundary. - Reject sends after
closewith a state error.
Implementation Guide
- Implement a concrete sink type that stores connection/client state.
- Implement
SinkAdapteron that type (onlysend,flush,close,nameare required). - Ensure idempotent
closebehavior. - Redact credentials from logs and debug output.
- Surface recoverable versus unrecoverable errors using
rustcdc::Errorvariants. - Override
delivery_guaranteeif your sink can offer stronger-than-default guarantees. - Override
delivery_metricsto expose per-sink counters to admin/observability endpoints. - Use
your_sink.boxed()(orBoxedSink::new(your_sink)) when you need type-erased storage orFanOutSinkAdapter/TableRouter<BoxedSink>composition.
Recommended pattern:
send: enqueue and optionally batch.flush: commit batch to external sink.close: callflush, then release resources.
Conformance Instructions
Use AdapterGoldenFixture, BasicAdapterConformance, and AdapterConformanceSuite from testkit:
single_event: validates basic acceptance path.batch_send: validates batch handling and flush behavior.ordering: validates stable send order.crash_recovery: validates close semantics after sends.
Minimum validation loop:
- Build fixture sequences with
AdapterGoldenFixture::{single_event,batch,ordering,crash_recovery}. - Run
AdapterConformanceSuite::run_all()against your adapter and fixture. - Assert all returned
TestResultvalues arepassed = true. - Add adapter-specific fault tests (network loss, sink timeout, partial flush failure).
Quick smoke harness:
use rustcdc::testkit::{AdapterConformanceSuite, AdapterGoldenFixture};
use rustcdc::{Event, MemorySinkAdapter, Operation, SourceMetadata};
use serde_json::json;
async fn validate_adapter() -> rustcdc::Result<()> {
// Build from `Event::default()` and override what matters. A full struct literal
// breaks every time the envelope gains a field, which is exactly how this sample
// rotted before the Markdown was wired into the doctest run.
let fixture = AdapterGoldenFixture::single_event(
Event::builder("items", Operation::Insert)
.after(json!({"id": 1}))
.source(SourceMetadata::new("test", "1", 1))
.ts(1)
.schema("public")
.primary_key(["id"])
.build(),
);
let suite = AdapterConformanceSuite::new();
let mut adapter = MemorySinkAdapter::default();
let results = suite.run_all(&mut adapter, &fixture).await?;
assert!(results.iter().all(|result| result.passed));
Ok(())
}Dynamic Dispatch, Fan-Out, and Routing
To store sinks heterogeneously or compose multiple sinks:
use rustcdc::sink::{BoxedSink, FanOutSinkAdapter, MemorySinkAdapter, SinkAdapter};
use rustcdc::pipeline::HeterogeneousTableRouter;
// Type-erase any SinkAdapter (two equivalent spellings):
let boxed: BoxedSink = MemorySinkAdapter::new("mem").boxed();
let boxed2: BoxedSink = BoxedSink::new(MemorySinkAdapter::new("mem2"));
// Fan out to multiple sinks concurrently:
// An empty child list is refused: a fan-out with no children accepts every event,
// delivers none, and reports success.
let fan = FanOutSinkAdapter::new(vec![
MemorySinkAdapter::new("a").boxed(),
MemorySinkAdapter::new("b").boxed(),
])
.expect("at least one child");
// fan.send(event) drives all children concurrently; latency = max(children)
// Route events by table name to heterogeneous sinks:
let router = HeterogeneousTableRouter::builder("demo")
.route("public.orders", MemorySinkAdapter::new("orders").boxed())
.route("public.audit", MemorySinkAdapter::new("audit").boxed())
.default(MemorySinkAdapter::new("fallback").boxed())
.build()
.expect("valid patterns");Notes
- The four required methods (
send,flush,close,name) are the minimum viable implementation. - All optional capability methods have no-op defaults; override only what your sink actually supports.
- Retry policies and delivery semantics are the responsibility of the embedding application.
MemorySinkAdapteris exported from the crate root for convenience in tests.
EffectivelyOnce Delivery: Barrier Protocol
SinkDeliveryGuarantee::EffectivelyOnce is the strongest guarantee available. It requires
the sink to participate in a two-phase checkpoint barrier so that the runtime and the sink
advance their durable state atomically — no event is lost on crash, and no event is emitted
twice to the downstream system (assuming the downstream is a transactional target such as a
database or a Kafka topic with transactions enabled).
Responsibility split
Important: The runtime does not call
begin_checkpoint_barrier,commit_checkpoint_barrier, orabort_checkpoint_barrierautomatically. No runtime code drives the barrier. The embedder must call these methods explicitly around the commit path. The barrier methods are pure hooks on theSinkAdaptertrait — they exist so the embedder can drive them in a coordinated sequence withcommit_ack.
The embedder is responsible for:
- Calling
sink.begin_checkpoint_barrier()before the commit window opens. - Calling
sink.commit_checkpoint_barrier()afterruntime.commit_ack(...)succeeds. - Calling
sink.abort_checkpoint_barrier()on any error in the commit window.
The sink is responsible for:
- Opening a transaction (or writing to a transactional staging area) on
begin_checkpoint_barrier. - Making all
send()calls part of that transaction during the window. - Committing the transaction atomically with the checkpoint on
commit_checkpoint_barrier. - Rolling back on
abort_checkpoint_barrier.
Minimal implementation skeleton
use rustcdc::sink::{SinkAdapter, SinkDeliveryGuarantee};
use rustcdc::{Event, Result};
/// A sink that writes to a transactional target and participates in the
/// checkpoint barrier protocol to provide EffectivelyOnce delivery.
pub struct TransactionalSink {
name: String,
pending: Vec<Event>,
in_barrier: bool,
closed: bool,
}
impl TransactionalSink {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
pending: Vec::new(),
in_barrier: false,
closed: false,
}
}
async fn begin_transaction(&mut self) -> Result<()> {
// Open a database transaction, Kafka transaction, etc.
self.in_barrier = true;
Ok(())
}
async fn commit_transaction(&mut self) -> Result<()> {
// Commit all pending events atomically with the checkpoint.
self.pending.clear();
self.in_barrier = false;
Ok(())
}
async fn rollback_transaction(&mut self) -> Result<()> {
self.pending.clear();
self.in_barrier = false;
Ok(())
}
}
impl SinkAdapter for TransactionalSink {
async fn send(&mut self, event: &Event) -> Result<()> {
// Buffer into the open transaction.
self.pending.push(event.clone());
Ok(())
}
async fn flush(&mut self) -> Result<()> {
// Flush pending events within the current transaction (no commit yet).
Ok(())
}
async fn close(&mut self) -> Result<()> {
if self.in_barrier {
self.rollback_transaction().await?;
}
self.closed = true;
Ok(())
}
fn name(&self) -> &str {
&self.name
}
fn delivery_guarantee(&self) -> SinkDeliveryGuarantee {
SinkDeliveryGuarantee::EffectivelyOnce
}
fn transactional_checkpoint_barrier_capable(&self) -> bool {
true
}
fn is_closed(&self) -> bool {
self.closed
}
async fn begin_checkpoint_barrier(&mut self) -> Result<()> {
self.begin_transaction().await
}
async fn commit_checkpoint_barrier(&mut self) -> Result<()> {
self.commit_transaction().await
}
async fn abort_checkpoint_barrier(&mut self) -> Result<()> {
self.rollback_transaction().await
}
}Embedder-side barrier orchestration
# use rustcdc::{CdcRuntime, sink::SinkAdapter};
async fn run(mut runtime: CdcRuntime, mut sink: impl SinkAdapter) -> rustcdc::Result<()> {
loop {
let batch = runtime.poll_event_batch().await?;
if batch.is_empty() {
break;
}
// Send all events in the batch to the sink.
for event in batch.events() {
sink.send(event).await?;
}
// Open the barrier — sink begins its transaction.
sink.begin_checkpoint_barrier().await?;
// Commit the checkpoint durably.
match runtime.commit_ack(batch.ack_mode()).await {
Ok(_) => {
// Checkpoint is durable — commit the sink transaction.
sink.commit_checkpoint_barrier().await?;
}
Err(e) => {
// Checkpoint failed — roll back the sink transaction.
sink.abort_checkpoint_barrier().await?;
return Err(e);
}
}
}
sink.close().await?;
Ok(())
}