One frozen contract.
Custom connectors plug into the same runtime as the built-in postgres and mssql connectors. The SDK handles framing, batching, checkpoints, replay, retries, dead-lettering, and metrics - you implement Read (sources) or Write (sinks) against a small, byte-frozen interface.
- The same content-addressed wire contract as the built-ins - no second-class treatment.
- Schema is a first-class value: a source describes its relations once, the hot path emits schema-free
Kind-tagged records, and column adds flow through as schema-change events. - At-least-once delivery plus idempotent sinks - replay is safe, so exactly-once is effective.
- Run a connector in-process (runtime-embedded) or as an external process / sidecar over mTLS - same code, placement is a deployment choice.
Add the SDK to your project.
Requires Go 1.26+. The SDK is pure value types and interfaces - its only non-stdlib dependency is google.golang.org/protobuf. Import it aliased as zip.
# Add the SDK go get zipline.run/sdk-go # The reference connectors live in the same tree - # copy connectors/postgres or connectors/mssql to start. zipline connector list
Edition 2021, MSRV 1.75. Uses tokio for async; the SDK re-exports async_trait. The Rust SDK is in preview and mirrors the Go contract method-for-method.
# Add the SDK (preview) cargo add zipline-sdk # Package + register a connector with the same CLI as Go: zipline connector list
The Connector interface.
Every connector implements Init and Stop. A source adds a read style (Read); a sink adds Write. Optional capabilities are detected from the interfaces you implement.
package zip // Every connector implements Init + Stop. type Source interface { Init(ctx context.Context, cfg Config) error Stop(ctx context.Context) error } type Sink interface { Init(ctx context.Context, cfg Config) error Write(ctx context.Context, batch []Event) error // all-or-nothing, durable on return Stop(ctx context.Context) error } // A Source implements exactly one read style - PullReader is the common one. type PullReader interface { Start(ctx context.Context, resume Position, p StartParams) error Read(ctx context.Context) (Batch, error) } // Optional capabilities - implement only what you need; the host detects them. type Acker interface { Ack(ctx context.Context, durable Position) error } type Pauser interface { Pause(context.Context) error; Resume(context.Context) error } type SchemaDescriber interface { DescribeSchemas(context.Context) ([]Schema, error) } type MetricsReporter interface { Metrics(context.Context) MetricSnapshot }
The Event type
type Event struct { Stream string // relation name, e.g. "public.orders" Key []byte // entity identity (partition / compaction) Op Op // Insert | Update | Delete | Snapshot | SchemaChange | Truncate Before *Record // nil for inserts After *Record // nil for deletes Position Position // opaque, per-stream monotonic, bytewise-comparable OccurredAt time.Time // … SchemaRef, CapturedAt, Transaction } type Batch struct { Events []Event SafePosition Position // safe-resume low-water at batch end }
use async_trait::async_trait; // Every connector implements init + stop. #[async_trait] pub trait Source: Send + Sync { async fn init(&mut self, cfg: Config) -> Result<()>; async fn stop(&mut self) -> Result<()>; } #[async_trait] pub trait Sink: Send + Sync { async fn init(&mut self, cfg: Config) -> Result<()>; async fn write(&mut self, batch: Vec<Event>) -> Result<()>; // all-or-nothing async fn stop(&mut self) -> Result<()>; } // A Source implements exactly one read style. #[async_trait] pub trait PullReader: Source { async fn start(&mut self, resume: Position, p: StartParams) -> Result<()>; async fn read(&mut self) -> Result<Batch>; } // Optional capabilities - implement only what you need. #[async_trait] pub trait Acker: Source { async fn ack(&mut self, durable: Position) -> Result<()>; }
The Event struct
pub struct Event { pub stream: String, // relation name, e.g. "public.orders" pub key: Bytes, // entity identity pub op: Op, // Insert | Update | Delete | Snapshot | … pub before: Option<Record>, // None for inserts pub after: Option<Record>, // None for deletes pub position: Position, // opaque, per-stream monotonic pub occurred_at: SystemTime, } pub struct Batch { pub events: Vec<Event>, pub safe_position: Position, }
Lifecycle.
The runtime drives a connector through a fixed lifecycle. Every method takes a context.Context - honor cancellation.
| Method | Applies to | Contract |
|---|---|---|
| Init | source · sink | Decode the Config into your struct, open clients, validate. Called once before any data flows. |
| Start | source | Begin reading from the resume Position (or from now if empty). Stream or snapshot via StartParams.Mode. |
| Read | source | Return the next Batch; block until data is available or the context is cancelled. The host batches, checkpoints, and replays. |
| Write | sink | Persist the whole batch all-or-nothing; once Write returns, the batch is durable. |
| Ack | source · opt | The host calls Ack(durable) once a Position is safe downstream - advance your upstream cursor here, never before. |
| Pause / Resume | source · opt | Quiesce and resume the stream without tearing the connector down. |
| Stop | source · sink | Flush in-flight work and close clients. The process may exit after Stop returns. |
Read/Write calls must abort cleanly when the context is cancelled. Connectors that ignore cancellation are killed forcibly and replayed from the last checkpoint.Building a source.
A source returns events as a Batch. The runtime batches further, checkpoints, and replays - you produce the next batch and advance only once the host acks a durable Position.
ReadBatch on an idle poll is fine.PositionsPosition so bytewise order equals event order. A Postgres LSN, a Kafka offset, a Redis stream id - wrap with zip.Uint64Position or zip.BytesPosition.AckPosition - never before. That's what makes replay safe.A Redis Streams source.
A real connectors/redis package: it reads a Redis stream consumer group, returns one Batch per poll, and acks via XACK once the host confirms a durable Position.
package redis import ( "context" "time" "github.com/redis/go-redis/v9" zip "zipline.run/sdk-go" ) type redisConfig struct { Addr string `json:"addr"` Stream string `json:"stream"` Group string `json:"group"` Password string `json:"password" zip:"secret"` } type Source struct { cfg redisConfig rdb *redis.Client } // Compile-time proof we satisfy the contract + the capabilities the host wires. var ( _ zip.Source = (*Source)(nil) _ zip.PullReader = (*Source)(nil) _ zip.Acker = (*Source)(nil) ) func (s *Source) Init(ctx context.Context, cfg zip.Config) error { if err := cfg.Map(&s.cfg); err != nil { return err } s.rdb = redis.NewClient(&redis.Options{Addr: s.cfg.Addr, Password: s.cfg.Password}) return s.rdb.XGroupCreateMkStream(ctx, s.cfg.Stream, s.cfg.Group, "0").Err() } // Start is a no-op here: XReadGroup with ">" resumes from the group cursor. func (s *Source) Start(_ context.Context, _ zip.Position, _ zip.StartParams) error { return nil } func (s *Source) Read(ctx context.Context) (zip.Batch, error) { res, err := s.rdb.XReadGroup(ctx, &redis.XReadGroupArgs{ Group: s.cfg.Group, Consumer: "zipline", Streams: []string{s.cfg.Stream, ">"}, Count: 256, Block: 2 * time.Second, }).Result() if err == redis.Nil { return zip.Batch{}, nil } // idle poll: nothing new if err != nil { return zip.Batch{}, err } var b zip.Batch for _, msg := range res[0].Messages { rec := zip.NewRecord(msg.Values) // map[string]any → *Record pos := zip.BytesPosition([]byte(msg.ID)) // stream ids sort bytewise b.Add(s.cfg.Stream, zip.OpInsert, rec, pos) } return b, nil } func (s *Source) Ack(ctx context.Context, durable zip.Position) error { return s.rdb.XAck(ctx, s.cfg.Stream, s.cfg.Group, string(durable)).Err() } func (s *Source) Stop(_ context.Context) error { return s.rdb.Close() } func init() { zip.Register[redisConfig](zip.ConnectorDescriptor{ Type: "redis", Direction: zip.DirectionSource, Version: "0.1.0", }, zip.Factory{NewSource: func() zip.Source { return &Source{} }}) }
use zipline_sdk::{Acker, Batch, Config, Op, Position, PullReader, Record, Result, Source, StartParams, async_trait, register_source}; use redis::{aio::MultiplexedConnection, streams::StreamReadOptions, AsyncCommands}; #[derive(serde::Deserialize, Default)] struct RedisConfig { addr: String, stream: String, group: String } #[derive(Default)] pub struct RedisSource { cfg: RedisConfig, conn: Option<MultiplexedConnection> } #[async_trait] impl Source for RedisSource { async fn init(&mut self, cfg: Config) -> Result<()> { self.cfg = cfg.parse()?; let client = redis::Client::open(self.cfg.addr.clone())?; self.conn = Some(client.get_multiplexed_async_connection().await?); Ok(()) } async fn stop(&mut self) -> Result<()> { Ok(()) } } #[async_trait] impl PullReader for RedisSource { // XReadGroup with ">" resumes from the group cursor. async fn start(&mut self, _resume: Position, _p: StartParams) -> Result<()> { Ok(()) } async fn read(&mut self) -> Result<Batch> { let conn = self.conn.as_mut().unwrap(); let opts = StreamReadOptions::default() .group(&self.cfg.group, "zipline") .count(256) .block(2000); let reply: redis::streams::StreamReadReply = conn.xread_options(&[&self.cfg.stream], &[">"], &opts).await?; let mut b = Batch::default(); for key in reply.keys { for id in key.ids { let rec = Record::from_redis(&id.map); // fields → Record let pos = Position::from_bytes(id.id.as_bytes()); b.add(&self.cfg.stream, Op::Insert, rec, pos); } } Ok(b) } } #[async_trait] impl Acker for RedisSource { async fn ack(&mut self, durable: Position) -> Result<()> { let conn = self.conn.as_mut().unwrap(); conn.xack(&self.cfg.stream, &self.cfg.group, &[durable.as_str()]).await?; Ok(()) } } register_source!("redis", RedisSource);
Building a sink.
Sinks consume events in batches. The runtime is at-least-once; you provide idempotency. Key your writes on the record's primary key or the event Position.
Write returns, the batch is durable. Partial success means the whole batch replays.ON CONFLICT, or a ReplacingMergeTree keyed on (Stream, Key). Implement IdempotentSink to advertise it.zip.ErrPermanent to dead-letter poison data immediately and keep moving.A ClickHouse sink.
A real connectors/clickhouse package: it writes the whole batch in one INSERT (all-or-nothing) into a change-log table; a ReplacingMergeTree engine collapses any replays.
package clickhouse import ( "context" "encoding/json" "github.com/ClickHouse/clickhouse-go/v2" "github.com/ClickHouse/clickhouse-go/v2/lib/driver" zip "zipline.run/sdk-go" ) type chConfig struct { Addr string `json:"addr"` Database string `json:"database"` Table string `json:"table"` Username string `json:"username"` Password string `json:"password" zip:"secret"` } type Sink struct { cfg chConfig conn driver.Conn } var ( _ zip.Sink = (*Sink)(nil) _ zip.IdempotentSink = (*Sink)(nil) ) func (s *Sink) Init(ctx context.Context, cfg zip.Config) error { if err := cfg.Map(&s.cfg); err != nil { return err } conn, err := clickhouse.Open(&clickhouse.Options{ Addr: []string{s.cfg.Addr}, Auth: clickhouse.Auth{Database: s.cfg.Database, Username: s.cfg.Username, Password: s.cfg.Password}, }) if err != nil { return err } s.conn = conn return conn.Ping(ctx) } // Write applies the whole batch in one INSERT - all-or-nothing, durable on return. func (s *Sink) Write(ctx context.Context, batch []zip.Event) error { bw, err := s.conn.PrepareBatch(ctx, "INSERT INTO "+s.cfg.Table+" (stream, op, key, after, ts)") if err != nil { return err } for _, ev := range batch { after := []byte("{}") if ev.After != nil { after, _ = json.Marshal(ev.After.Map()) // Record → map[string]any } if err := bw.Append(ev.Stream, ev.Op.String(), string(ev.Key), string(after), ev.OccurredAt); err != nil { return err // abort the whole batch → full replay } } return bw.Send() // ReplacingMergeTree collapses replays } func (s *Sink) Idempotent() bool { return true } func (s *Sink) Stop(_ context.Context) error { return s.conn.Close() } func init() { zip.Register[chConfig](zip.ConnectorDescriptor{ Type: "clickhouse", Direction: zip.DirectionSink, Version: "0.1.0", }, zip.Factory{NewSink: func() zip.Sink { return &Sink{} }}) }
use zipline_sdk::{Config, Event, IdempotentSink, Result, Sink, async_trait, register_sink}; use clickhouse::Client; use std::time::UNIX_EPOCH; #[derive(serde::Deserialize, Default)] struct ChConfig { addr: String, database: String, table: String } #[derive(clickhouse::Row, serde::Serialize)] struct ChangeRow { stream: String, op: String, key: Vec<u8>, after: String, ts: u64 } #[derive(Default)] pub struct ClickHouseSink { cfg: ChConfig, client: Option<Client> } #[async_trait] impl Sink for ClickHouseSink { async fn init(&mut self, cfg: Config) -> Result<()> { self.cfg = cfg.parse()?; self.client = Some(Client::default() .with_url(&self.cfg.addr) .with_database(&self.cfg.database)); Ok(()) } // One INSERT for the whole batch - all-or-nothing. async fn write(&mut self, batch: Vec<Event>) -> Result<()> { let client = self.client.as_ref().unwrap(); let mut insert = client.insert(&self.cfg.table)?; for ev in batch { let after = ev.after.map(|r| r.to_json()).unwrap_or_default(); let ts = ev.occurred_at.duration_since(UNIX_EPOCH) .map(|d| d.as_millis() as u64).unwrap_or(0); insert.write(&ChangeRow { stream: ev.stream, op: ev.op.to_string(), key: ev.key.into(), after, ts, }).await?; } insert.end().await?; // ReplacingMergeTree collapses replays Ok(()) } async fn stop(&mut self) -> Result<()> { Ok(()) } } impl IdempotentSink for ClickHouseSink { fn idempotent(&self) -> bool { true } } register_sink!("clickhouse", ClickHouseSink);
Error handling.
Return the right error and the runtime does the right thing - retry, dead-letter, or stop. There's no taxonomy to learn: any error is retryable unless you mark it permanent.
| Return | Runtime behavior |
|---|---|
| error | Retryable - the host retries the same read or batch with backoff and the connector keeps running. The default for transient failures. |
| zip.ErrPermanent | Permanent - wrap it (or implement Permanent() bool) and the batch is dead-lettered immediately. No retry storm on poison data; processing continues. |
| ctx.Err() | Cancellation - return promptly when the context is cancelled; the last checkpoint is preserved and the connector is torn down cleanly. |
import ( "context" "errors" "fmt" zip "zipline.run/sdk-go" ) func (s *Sink) Write(ctx context.Context, batch []zip.Event) error { if err := s.apply(ctx, batch); err != nil { switch { case errors.Is(ctx.Err(), context.Canceled): return ctx.Err() // clean stop - checkpoint preserved case errors.Is(err, errBadSchema): return fmt.Errorf("clickhouse: %v: %w", err, zip.ErrPermanent) // dead-letter default: return err // transient - host retries with backoff } } return nil }
use zipline_sdk::{Event, Result, SdkError}; async fn write(&mut self, batch: Vec<Event>) -> Result<()> { match self.apply(batch).await { Ok(()) => Ok(()), Err(e) if e.is_bad_schema() => Err(SdkError::permanent(e)), // dead-letter Err(e) => Err(SdkError::retryable(e)), // host retries } }
RowAwareSink to return a per-row []RowResult (OK / retryable / permanent) instead of failing the whole batch. It's defined in the frozen SDK but not yet wired in the pilot.Testing.
Two layers: drive your connector's public interface directly in a unit test, then run the SDK conformance suite to prove it behaves like a first-party connector.
func TestSource_ReadsEntries(t *testing.T) { src := &Source{} cfg := zip.ConfigFromJSON([]byte(`{"addr":"localhost:6379","stream":"orders","group":"zip"}`)) if err := src.Init(t.Context(), cfg); err != nil { t.Fatal(err) } defer src.Stop(t.Context()) if err := src.Start(t.Context(), nil, zip.StartParams{Mode: zip.ModeStream}); err != nil { t.Fatal(err) } batch, err := src.Read(t.Context()) if err != nil { t.Fatal(err) } for _, ev := range batch.Events { amount, _ := ev.After.Int("amount") t.Logf("%s %s amount=%d", ev.Op, ev.Stream, amount) } }
#[tokio::test] async fn reads_entries() { let mut src = RedisSource::default(); let cfg = Config::from_json(r#"{"addr":"localhost:6379","stream":"orders","group":"zip"}"#); src.init(cfg).await.unwrap(); src.start(Position::empty(), StartParams::stream()).await.unwrap(); let batch = src.read().await.unwrap(); for ev in batch.events { let amount = ev.after.unwrap().int("amount").unwrap(); println!("{} {} amount={}", ev.op, ev.stream, amount); } }
Conformance suite
The CLI runs the same behavioral suite the built-ins pass - in a subprocess, through your public Source/Sink interface.
# Run the SDK conformance suite (Init → Read/Write → Stop) zipline connector test redis --no-record # Inspect the descriptor + reflected config schema zipline connector describe redis
--no-record and a green run records the Conformance result on the runtime, so the connector catalog surfaces that your type passed - the same gate the built-in postgres and mssql connectors clear.Publish to the catalog.
Package the in-binary descriptor into a portable .zcp file, then add it to the runtime's connector catalog. The config schema is reflected from your Go struct - never hand-written.
# Package the in-binary descriptor into a portable .zcp zipline connector build --type redis --out redis.zcp # Add it to the runtime's connector catalog zipline connector add redis.zcp # Confirm it's registered + conformance-recorded zipline connector list zipline connector inspect redis
The descriptor is the manifest
There is no hand-written manifest. Your zip.Register call is the source of truth - the config schema (JSON Schema 2020-12) is reflected from the config struct, and optional catalog metadata rides along in zcp.Meta.
import ( zip "zipline.run/sdk-go" "zipline.run/sdk-go/zcp" ) // The descriptor - ConfigSchema is DERIVED at Register, never hand-written. func init() { zip.Register[redisConfig](zip.ConnectorDescriptor{ Type: "redis", Direction: zip.DirectionSource, Version: "0.1.0", Capabilities: []string{"runtime-embeddable"}, // Ack/Pause/Metrics detected from the interfaces }, zip.Factory{NewSource: func() zip.Source { return &Source{} }}) } // `connector build` does exactly this - Lookup the descriptor, then package it. // Supply zcp.Meta to customise the catalog card: d, _, _ := zip.Lookup("redis") pkg, _ := zcp.Build(d, zcp.Meta{ Presentation: &zcp.PresentationInput{ DisplayName: "Redis Streams", Description: "Consume entries from a Redis stream consumer group.", License: "Apache-2.0", }, Compat: &zcp.CompatInput{WireProtocol: 1}, }) b, _ := zcp.Encode(pkg) // → bytes → write redis.zcp
zipline-connector run --connector redis --role source.