Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

DeltaForge

Version Arch License

Introduction

DeltaForge is a versatile, high-performance Change Data Capture (CDC) engine built in Rust. It streams database changes into downstream systems like Kafka, Redis, NATS, HTTP endpoints, and S3 object storage - giving you full control over how events are routed, transformed, and delivered. Supports JSON and Avro encoding (with Confluent Schema Registry), transactional atomic-batch delivery via Kafka transactions (at-least-once across restart; consumers dedup on event id for exactly-once end to end), and built-in schema discovery that automatically infers and tracks the shape of your data as it flows through.

Pipelines are defined declaratively in YAML, making it straightforward to onboard new use cases without custom code.

Built with Sources Processors Sinks Output Formats
Rust
Rust
MySQL PostgreSQL
MySQL · PostgreSQL
JavaScript
JavaScript · Outbox · Flatten · Filter
Kafka Redis NATS
Kafka · Redis · NATS · HTTP · S3
JSON Avro
Native Debezium CloudEvents

Why DeltaForge?

Core Capabilities

  • ⚡ Powered by Rust : Predictable performance, memory safety, and minimal resource footprint.
  • 🔌 Pluggable architecture : Sources, processors, and sinks are modular and independently extensible.
  • 🧩 Declarative pipelines : Define sources, transforms, sinks, and commit policies in version-controlled YAML with environment variable expansion for secrets.
  • 📦 Reliable checkpointing : Per-sink independent checkpoints. All sinks at-least-once; Kafka transactions add atomic-batch delivery (dedup on event id for exactly-once end to end). At-least-once with dedup for NATS and Redis.
  • 🔁 Avro encoding : Confluent wire format with Schema Registry. DDL-derived schemas with exact types and nullability. Safe defaults for unsigned integers, enums, and timestamps.
  • 🪦 Dead letter queue : Poison events routed to DLQ instead of blocking the pipeline. REST API for inspection, filtering, and replay.
  • 🔀 Dynamic routing : Route events to per-table topics, streams, or subjects using templates or JavaScript logic.
  • 📤 Transactional outbox : Publish domain events atomically with database writes. Per-aggregate routing, raw payload delivery, zero polling.
  • 🛠️ Cloud-native ready : Single binary, Docker images, JSON logs, Prometheus metrics, and liveness/readiness probes for Kubernetes.

Schema Intelligence

  • 🔍 Schema sensing : Automatically infer and track schema from event payloads, including deep inspection of nested JSON structures.
  • 🗺️ High-cardinality handling : Detect and normalize dynamic map keys (session IDs, trace IDs) to prevent false schema evolution events.
  • 🏷️ Schema fingerprinting : SHA-256 based change detection with schema-to-checkpoint correlation for reliable replay.
  • 🗃️ Source-owned semantics : Preserves native database types (PostgreSQL arrays, MySQL JSON, etc.) instead of normalizing to a universal type system.

Operational Features

  • 🔄 Graceful failover : Handles source failover with automatic schema revalidation - no manual intervention needed.
  • 🧬 Zero-downtime schema evolution : Detects DDL changes and reloads schemas automatically, no pipeline restart needed.
  • 🎯 Flexible table selection : Wildcard patterns (db.*, schema.prefix%) for easy onboarding.
  • 📀 Transaction boundaries : Optionally keep source transactions intact across batches.
  • ⚙️ Commit policies : Control checkpoint behavior with all, required, or quorum modes across multiple sinks.
  • 🔧 Live pipeline management : Pause, resume, patch, and inspect running pipelines via the REST API.
  • 🗄️ Safe initial snapshot : Consistent parallel backfill of existing tables before streaming begins, with binlog/WAL retention validation, background guards, and crash-resume at table granularity.

Use Cases

DeltaForge is designed for:

  • Real-time data synchronization : Keep caches, search indexes, and analytics systems in sync with your primary database.
  • Event-driven architectures : Stream database changes to Kafka or NATS for downstream microservices.
  • Transactional messaging : Use the outbox pattern to publish domain events atomically with database writes - no distributed transactions needed.
  • Audit trails and compliance : Capture every mutation with full before/after images for SOC2, HIPAA, or GDPR requirements.
  • Lightweight ETL : Transform, filter, and route data in-flight with JavaScript processors - no Spark or Flink cluster needed.

DeltaForge is not a DAG-based stream processor. It is a focused CDC engine meant to replace tools like Debezium when you need a lighter, cloud-native, and more customizable runtime.

Getting Started

GuideDescription
QuickstartGet DeltaForge running in minutes
CDC OverviewUnderstand Change Data Capture concepts
ConfigurationPipeline spec reference
DevelopmentBuild from source, contribute

Quick Example

metadata:
  name: orders-to-kafka
  tenant: acme

spec:
  source:
    type: mysql
    config:
      dsn: ${MYSQL_DSN}
      tables: [shop.orders]

  processors:
    - type: javascript
      inline: |
        function processBatch(events) {
          for (const event of events) {
            event.processed_at = Date.now();
          }
          return events;
        }

  sinks:
    - type: kafka
      config:
        brokers: ${KAFKA_BROKERS}
        topic: order-events

Installation

Docker (recommended):

docker pull ghcr.io/vnvo/deltaforge:latest

From source:

git clone https://github.com/vnvo/deltaforge.git
cd deltaforge
cargo build --release

See the Development Guide for detailed build instructions and available Docker image variants.

License

DeltaForge is dual-licensed under MIT and Apache 2.0.

Change Data Capture (CDC)

Change Data Capture (CDC) is the practice of streaming database mutations as ordered events so downstream systems can stay in sync without periodic full loads. Rather than asking “what does my data look like now?”, CDC answers “what changed, and when?”

DeltaForge is a CDC engine built to make log-based change streams reliable, observable, and operationally simple in modern, containerized environments. It focuses on row-level CDC from MySQL binlog and Postgres logical replication to keep consumers accurate and latency-aware while minimizing impact on source databases.

In short: DeltaForge tails committed transactions from MySQL and Postgres logs, preserves ordering and transaction boundaries, and delivers events to Kafka and Redis with checkpointed delivery, configurable batching, and Prometheus metrics - without requiring a JVM or distributed coordinator.


Why CDC matters

Traditional data integration relies on periodic batch jobs that query source systems, compare snapshots, and push differences downstream. This approach worked for decades, but modern architectures demand something better.

The batch ETL problem: A nightly sync means your analytics are always a day stale. An hourly sync still leaves gaps and hammers your production database with expensive SELECT * queries during business hours. As data volumes grow, these jobs take longer, fail more often, and compete for the same resources your customers need.

CDC flips the model. Instead of pulling data on a schedule, you subscribe to changes as they happen.

AspectBatch ETLCDC
LatencyMinutes to hoursSeconds to milliseconds
Source loadHigh (repeated scans)Minimal (log tailing)
Data freshnessStale between runsNear real-time
Failure recoveryRe-run entire jobResume from checkpoint
Change detectionDiff comparisonNative from source

These benefits compound as systems scale and teams decentralize:

  • Deterministic replay: Ordered events with stable idempotency keys let consumers reconstruct state and dedup replays, so at-least-once delivery becomes effectively exactly-once at the consumer.
  • Polyglot delivery: The same change feed can serve caches, queues, warehouses, and search indexes simultaneously without additional source queries.

How CDC works

All CDC implementations share a common goal: detect changes and emit them as events. The approaches differ in how they detect those changes.

Log-based CDC

Databases maintain transaction logs (MySQL binlog, Postgres WAL) that record every committed change for durability and replication. Log-based CDC reads these logs directly, capturing changes without touching application tables.

┌─────────────┐    commits    ┌─────────────┐    tails     ┌─────────────┐
│ Application │──────────────▶│  Database   │─────────────▶│ CDC Engine  │
└─────────────┘               │   + WAL     │              └──────┬──────┘
                              └─────────────┘                     │
                                                                  ▼
                                                           ┌─────────────┐
                                                           │   Kafka /   │
                                                           │   Redis     │
                                                           └─────────────┘

Advantages:

  • Zero impact on source table performance
  • Captures all changes including those from triggers and stored procedures
  • Preserves transaction boundaries and ordering
  • Can capture deletes without soft-delete columns

Trade-offs:

  • Requires database configuration (replication slots, binlog retention)
  • Schema changes need careful handling
  • Log retention limits how far back you can replay

DeltaForge uses log-based CDC exclusively. This allows DeltaForge to provide stronger ordering guarantees, lower source impact, and simpler operational semantics than hybrid approaches that mix log tailing with polling or triggers.

Trigger-based CDC

Database triggers fire on INSERT, UPDATE, and DELETE operations, writing change records to a shadow table that a separate process polls.

Advantages:

  • Works on databases without accessible transaction logs
  • Can capture application-level context unavailable in logs

Trade-offs:

  • Adds write overhead to every transaction
  • Triggers can be disabled or forgotten during schema migrations
  • Shadow tables require maintenance and can grow unbounded

Polling-based CDC

A process periodically queries tables for rows modified since the last check, typically using an updated_at timestamp or incrementing ID.

Advantages:

  • Simple to implement
  • No special database configuration required

Trade-offs:

  • Cannot reliably detect deletes
  • Requires updated_at columns on every table
  • Polling frequency trades off latency against database load
  • Clock skew and transaction visibility can cause missed or duplicate events

Anatomy of a CDC event

A well-designed CDC event contains everything downstream consumers need to process the change correctly.

{
  "id": "evt_01J7K9X2M3N4P5Q6R7S8T9U0V1",
  "source": {
    "database": "shop",
    "table": "orders",
    "server_id": "mysql-prod-1"
  },
  "operation": "update",
  "timestamp": "2025-01-15T14:32:01.847Z",
  "transaction": {
    "id": "gtid:3E11FA47-71CA-11E1-9E33-C80AA9429562:42",
    "position": 15847293
  },
  "before": {
    "id": 12345,
    "status": "pending",
    "total": 99.99,
    "updated_at": "2025-01-15T14:30:00.000Z"
  },
  "after": {
    "id": 12345,
    "status": "shipped",
    "total": 99.99,
    "updated_at": "2025-01-15T14:32:01.000Z"
  },
  "schema_version": "v3"
}

Key components:

FieldPurpose
operationINSERT, UPDATE, DELETE, or DDL
before / afterRow state before and after the change (enables diff logic)
transactionGroups changes from the same database transaction
timestampWhen the change was committed at the source
schema_versionHelps consumers handle schema evolution

Not all fields are present for every operation-before is omitted for INSERTs, after is omitted for DELETEs - but the event envelope and metadata fields are consistent across MySQL and Postgres sources.


Real-world use cases

Cache invalidation and population

Problem: Your Redis cache serves product catalog data, but cache invalidation logic is scattered across dozens of services. Some forget to invalidate, others invalidate too aggressively, and debugging staleness issues takes hours.

CDC solution: Stream changes from the products table to a message queue. A dedicated consumer reads the stream and updates or deletes cache keys based on the operation type. This centralizes invalidation logic, provides replay capability for cache rebuilds, and removes cache concerns from application code entirely.

┌──────────┐     CDC      ┌─────────────┐   consumer   ┌─────────────┐
│  MySQL   │─────────────▶│   Stream    │─────────────▶│ Cache Keys  │
│ products │              │  (Kafka/    │              │ product:123 │
└──────────┘              │   Redis)    │              └─────────────┘
                          └─────────────┘

Event-driven microservices

Problem: Your order service needs to notify inventory, shipping, billing, and analytics whenever an order changes state. Direct service-to-service calls create tight coupling and cascade failures.

CDC solution: Publish order changes to a durable message queue. Each downstream service subscribes independently, processes at its own pace, and can replay events after failures or during onboarding. The order service doesn’t need to know about its consumers.

┌─────────────┐     CDC      ┌─────────────┐
│   Orders    │─────────────▶│   Message   │
│   Database  │              │   Queue     │
└─────────────┘              └──────┬──────┘
                    ┌───────────────┼───────────────┐
                    ▼               ▼               ▼
             ┌───────────┐   ┌───────────┐   ┌───────────┐
             │ Inventory │   │ Shipping  │   │ Analytics │
             └───────────┘   └───────────┘   └───────────┘

Search index synchronization

Problem: Your Elasticsearch index drifts from the source of truth. Full reindexing takes hours and blocks search updates during the process.

CDC solution: Stream changes continuously to keep the index synchronized. Use the before image to remove old documents and the after image to index new content.

Data warehouse incremental loads

Problem: Nightly full loads into your warehouse take 6 hours and block analysts until noon. Business users complain that dashboards show yesterday’s numbers.

CDC solution: Stream changes to a staging topic, then micro-batch into your warehouse every few minutes. Analysts get near real-time data without impacting source systems.

Audit logging and compliance

Problem: Regulations require you to maintain a complete history of changes to sensitive data. Application-level audit logging is inconsistent and can be bypassed.

CDC solution: The transaction log captures every committed change regardless of how it was made - application code, admin scripts, or direct SQL. Stream these events to immutable storage for compliance.

Cross-region replication

Problem: You need to replicate data to a secondary region for disaster recovery, but built-in replication doesn’t support the transformations you need.

CDC solution: Stream changes through a CDC pipeline that filters, transforms, and routes events to the target region’s databases or message queues.


Architecture patterns

The outbox pattern

When you need to update a database and record the intent to publish an event atomically, the outbox pattern ties the event to the same transaction as the business write - the event is captured if and only if the transaction commits, with no dual-write gap and no distributed transaction. Delivery of that captured event downstream is still at-least-once; consumers dedup on the event id.

┌─────────────────────────────────────────┐
│              Single Transaction         │
│  ┌─────────────┐    ┌─────────────────┐ │
│  │ UPDATE      │    │ INSERT INTO     │ │
│  │ orders SET  │ +  │ outbox (event)  │ │
│  │ status=...  │    │                 │ │
│  └─────────────┘    └─────────────────┘ │
└─────────────────────────────────────────┘
                      │
                      ▼ CDC
               ┌─────────────┐
               │   Kafka     │
               └─────────────┘
  1. The application writes business data and an event record in the same transaction.
  2. CDC tails the outbox table and publishes events to Kafka.
  3. A cleanup process (or CDC itself) removes processed outbox rows.

This guarantees that events are published if and only if the transaction commits.

When to skip the outbox: If your only requirement is to react to committed database state (not application intent), direct CDC from business tables is often simpler than introducing an outbox. The outbox pattern adds value when you need custom event payloads, explicit event versioning, or when the event schema differs significantly from table structure.

Event sourcing integration

CDC complements event sourcing by bridging legacy systems that weren’t built event-first. Stream changes from existing tables into event stores, then gradually migrate to native event sourcing.

CQRS (Command Query Responsibility Segregation)

CDC naturally supports CQRS by populating read-optimized projections from the write model. Changes flow through the CDC pipeline to update denormalized views, search indexes, or cache layers.


Challenges and solutions

Schema evolution

Databases change. Columns get added, renamed, or removed. Types change. CDC pipelines need to handle this gracefully.

Strategies:

  • Schema registry: Store and version schemas centrally (e.g., Confluent Schema Registry with Avro).
  • Forward compatibility: Add columns as nullable; avoid removing columns that consumers depend on.
  • Consumer tolerance: Design consumers to ignore unknown fields and handle missing optional fields.
  • Processor transforms: Use DeltaForge’s JavaScript processors to normalize schemas before sinks.

Ordering guarantees

Events must arrive in the correct order for consumers to reconstruct state accurately. A DELETE arriving before its corresponding INSERT would be catastrophic.

DeltaForge guarantees:

  • Source-order preservation per table partition (always enabled)
  • Transaction boundary preservation when respect_source_tx: true is configured in batch settings

Kafka sink uses consistent partitioning by primary key to maintain ordering within a partition at the consumer.

Delivery guarantees

Network failures, process crashes, and consumer restarts can cause duplicates. DeltaForge delivers at-least-once and gives every event a stable idempotency key so consumers can collapse duplicates into an effectively exactly-once result.

DeltaForge approach:

  • Checkpoints track the last committed position in the source log.
  • Configurable commit policies (all, required, quorum) control when checkpoints advance.
  • Kafka: transactional atomic-batch delivery via the transactional producer (exactly_once: true); consumers set isolation.level=read_committed to avoid partial batches. Still at-least-once across restart - a replayed batch is a new transaction - so consumers dedup on event id.
  • NATS: at-least-once with server-side dedup via Nats-Msg-Id header within duplicate_window.
  • Redis: at-least-once with consumer-side dedup via idempotency_key field.

Default behavior: DeltaForge provides at-least-once delivery out of the box. Kafka exactly_once: true adds transactional atomic-batch visibility on top; it is not end-to-end exactly-once on its own. See the Guarantees page for the full delivery tier matrix.

High availability

Production CDC pipelines need to handle failures without data loss or extended downtime.

Best practices:

  • Run multiple pipeline instances with leader election.
  • Store checkpoints in durable storage (DeltaForge persists to local files, mountable volumes in containers).
  • Monitor lag between source position and checkpoint position.
  • Set up alerts for pipeline failures and excessive lag.

Expectations: DeltaForge checkpoints ensure no data loss on restart, but does not currently include built-in leader election. For HA deployments, use external coordination (Kubernetes leader election, etcd locks) or run active-passive with health-check-based failover.

Backpressure

When sinks can’t keep up with the change rate, pipelines need to slow down gracefully rather than dropping events or exhausting memory.

DeltaForge handles backpressure through:

  • Configurable batch sizes (max_events, max_bytes, max_ms).
  • In-flight limits (max_inflight) that bound concurrent sink writes.
  • Blocking reads from source when batches queue up.

Performance considerations

Batching trade-offs

SettingLow valueHigh value
max_eventsLower latency, more overheadHigher throughput, more latency
max_msFaster flush, smaller batchesLarger batches, delayed flush
min_flush_interval_msLower latency on a trickle, more batches and checkpoint commitsFewer batches, latency up to this value
max_bytesMemory-safe, frequent commitsEfficient for large rows

Start with DeltaForge defaults and tune based on observed latency and throughput.

Source database impact

Log-based CDC has minimal impact, but consider:

  • Replication slot retention: Paused pipelines cause WAL/binlog accumulation.
  • Connection limits: Each pipeline holds a replication connection.
  • Network bandwidth: High-volume tables generate significant log traffic.

Sink throughput

  • Kafka: Tune batch.size, linger.ms, and compression in client_conf.
  • Redis: Use pipelining and connection pooling for high-volume streams.

Monitoring and observability

CDC pipelines are long-running, stateful processes; without metrics and alerts, failures are silent by default. A healthy pipeline requires visibility into lag, throughput, and errors.

Key metrics to track

MetricDescriptionAlert threshold
cdc_lag_secondsTime between event timestamp and processing> 60s
events_processed_totalThroughput counterSudden drops
checkpoint_lag_eventsEvents since last checkpoint> 10,000
sink_errors_totalFailed sink writesAny sustained errors
batch_size_avgEvents per batchOutside expected range

DeltaForge exposes Prometheus metrics on the configurable metrics endpoint (default :9000).

Health checks

  • GET /health: Liveness probe - is the process running?
  • GET /ready: Readiness probe - are pipelines connected and processing?
  • GET /pipelines: Detailed status of each pipeline including configuration.

Choosing a CDC solution

When evaluating CDC tools, consider:

FactorQuestions to ask
Source supportDoes it support your databases? MySQL binlog? Postgres logical replication?
Sink flexibilityCan it write to your target systems? Kafka, Redis, HTTP, custom?
TransformationCan you filter, enrich, or reshape events in-flight?
Operational overheadHow much infrastructure does it require? JVM? Distributed coordinator?
Resource efficiencyWhat’s the memory/CPU footprint per pipeline?
Cloud-nativeDoes it containerize cleanly? Support health checks? Emit metrics?

Where DeltaForge fits

DeltaForge intentionally avoids the operational complexity of JVM-based CDC stacks (Kafka Connect-style deployments with Zookeeper, Connect workers, and converter configurations) while remaining compatible with Kafka-centric architectures.

DeltaForge is designed for teams that want:

  • Lightweight runtime: Single binary, minimal memory footprint, no JVM warmup.
  • Config-driven pipelines: YAML specs instead of code for common patterns.
  • Inline transformation: JavaScript processors for custom logic without recompilation.
  • Container-native operations: Built for Kubernetes with health endpoints and Prometheus metrics.

DeltaForge is not designed for:

  • Complex DAG-based stream processing with windowed aggregations
  • Stateful joins across multiple streams
  • Sources beyond MySQL and Postgres (currently)
  • Built-in schema registry integration (use external registries)

If you need those capabilities, consider dedicated stream processors or the broader Kafka ecosystem. DeltaForge excels at getting data out of databases and into your event infrastructure reliably and efficiently.


Getting started

Ready to try CDC with DeltaForge? Head to the Quickstart guide to run your first pipeline in minutes.

For production deployments, review the Development guide for container builds and operational best practices.

Quickstart

Get DeltaForge running in minutes.

1. Prepare a pipeline spec

Create a YAML file that defines your CDC pipeline. Environment variables are expanded at runtime, so secrets stay out of version control.

metadata:
  name: orders-mysql-to-kafka
  tenant: acme

spec:
  source:
    type: mysql
    config:
      id: orders-mysql
      dsn: ${MYSQL_DSN}
      tables:
        - shop.orders

  processors:
    - type: javascript
      id: transform
      inline: |
        function processBatch(events) {
          for (const event of events) {
            event.tags = ["processed"];
          }
          return events;
        }

  sinks:
    - type: kafka
      config:
        id: orders-kafka
        brokers: ${KAFKA_BROKERS}
        topic: orders
        required: true

  batch:
    max_events: 500
    max_bytes: 1048576
    max_ms: 1000

2. Start DeltaForge

Using Docker (recommended):

docker run --rm \
  -p 8080:8080 -p 9000:9000 \
  -e MYSQL_DSN="mysql://user:pass@host:3306/db" \
  -e KAFKA_BROKERS="kafka:9092" \
  -v $(pwd)/pipeline.yaml:/etc/deltaforge/pipeline.yaml:ro \
  -v deltaforge-checkpoints:/app/data \
  ghcr.io/vnvo/deltaforge:latest \
  --config /etc/deltaforge/pipeline.yaml

From source:

cargo run -p runner -- --config ./pipeline.yaml

Runner options

FlagDefaultDescription
--config(required)Path to pipeline spec file or directory
--api-addr0.0.0.0:8080REST API address
--metrics-addr0.0.0.0:9095Address shown in the startup banner. Note: the Prometheus /metrics listener is currently bound to :9000.

3. Verify it’s running

Check health and pipeline status:

# Liveness probe
curl http://localhost:8080/health

# Readiness with pipeline status
curl http://localhost:8080/ready

# List all pipelines
curl http://localhost:8080/pipelines

4. Manage pipelines

Control pipelines via the REST API:

# Pause a pipeline
curl -X POST http://localhost:8080/pipelines/orders-mysql-to-kafka/pause

# Resume a pipeline
curl -X POST http://localhost:8080/pipelines/orders-mysql-to-kafka/resume

# Stop a pipeline
curl -X POST http://localhost:8080/pipelines/orders-mysql-to-kafka/stop

Next steps

Production Quick Start

A minimal, production-shaped deployment on Kubernetes using the bundled Helm chart. It uses the supported single-instance topology, persistent storage, referenced secrets, readiness/liveness probes, explicit resource limits, and the preflight command as a deployment gate.

This is a starting point, not a configuration reference - see Configuration for every option and the Capacity & Resource Envelope for sizing.

Prerequisites

  • A Kubernetes cluster with a default StorageClass (for the checkpoint volume).
  • A reachable source database, already provisioned per Supported Deployment Envelope → Required privileges. For PostgreSQL, create the publication (DeltaForge creates and owns the slot).
  • Source (and sink) credentials in a Kubernetes Secret - referenced, never inlined in the config.

1. Create the credentials Secret

Keep secrets out of the pipeline config; the config references them with ${VAR}.

kubectl create secret generic mysql-creds \
  --from-literal=MYSQL_USER=cdc_user \
  --from-literal=MYSQL_PASSWORD=s3cret

2. Write a values file

# values.prod.yaml
replicaCount: 1                     # single instance (required)

pipeline:
  config: |
    apiVersion: deltaforge/v1
    kind: Pipeline
    metadata:
      name: orders
      tenant: default
    spec:
      source:
        type: mysql
        config:
          id: orders-src
          dsn: "mysql://mysql-primary:3306/orders"
          credentials:                # typed secret references (protected resolution)
            username:
              provider: env
              location: MYSQL_USER
            password:
              provider: env
              location: MYSQL_PASSWORD
          tables: ["orders.*"]
      sinks:
        - type: kafka
          config:
            id: orders-kafka
            brokers: kafka:9092
            topic: cdc.orders
            exactly_once: true
      commit_policy:
        mode: required
      journal:
        enabled: true               # DLQ on, so a poison event never blocks

secrets:
  existingSecrets:                  # referenced, not created from values
    - name: mysql-creds

persistence:
  enabled: true                    # durable checkpoints + DLQ (SQLite on a PVC)
  size: 5Gi

preflight:
  enabled: true                    # gate the deploy on preflight (default)

resources:
  requests: { cpu: 250m, memory: 512Mi }
  limits:   { cpu: "2",  memory: 2Gi }   # no aggregate memory cap exists; size per Capacity Envelope

Probes (/health liveness, /ready readiness) and the single-instance StatefulSet come from the chart defaults; you do not need to set them.

The deployment is gated on preflight automatically: with preflight.enabled: true the chart runs deltaforge preflight as an initContainer - with the same config, secrets, and --storage-* settings as the pipeline container - before the pipeline starts. A failing check fails the pod, so a misconfigured deployment never begins streaming, and it re-runs on every rollout.

3. Install

helm install creates the ConfigMap and Secret wiring and runs the preflight initContainer as the authoritative deployment gate:

helm install orders ./deploy/helm/deltaforge -f values.prod.yaml

4. Verify

# The preflight initContainer must complete before the app container starts.
kubectl get pods -l app.kubernetes.io/instance=orders
kubectl logs orders-0 -c preflight        # the preflight report (the deploy gate)

# Readiness gates traffic; liveness restarts a wedged process:
kubectl get pod orders-0 -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}'

# Health/metrics:
kubectl port-forward svc/orders 8080:8080 &
curl -fsS localhost:8080/ready

If preflight fails, the pod stays in Init and kubectl logs orders-0 -c preflight shows the hard errors to fix before the pipeline can start.

Notes

  • Single instance is required. The guarantee that only one process holds the state store comes from replicaCount: 1, the StatefulSet’s one-pod-at-a-time behavior, and the documented prohibition against a second installation against the same store - not from ReadWriteOnce (RWO restricts volume attachment to one node, but multiple pods on that node could still mount it). Do not scale the replica count or point a second install at the same volume - see the single-owner rule.
  • Persistent storage. SQLite checkpoints and the DLQ live on the PVC. Treat the state volume as durable production data. Back it up using the documented SQLite-safe procedure. If it is lost, stop the pipeline and recover the state or explicitly reinitialize/re-snapshot; do not assume automatic checkpoint recovery (a lost volume loses the durable checkpoint, and exact recovery may be impossible once source logs expire). For a shared store, use the postgres storage backend (still single-instance).
  • Upgrades. A single-replica StatefulSet terminates the old pod before starting the new one; the preflight initContainer re-gates every rollout.

Supported Deployment Envelope

This page defines the bounded support envelope for the current DeltaForge release: the configurations DeltaForge is validated for, what operators must provide, and what is explicitly out of scope. Anything not listed here as supported should be treated as unvalidated - test it in a disposable environment first, and talk to us before relying on it.

The delivery and correctness guarantees referenced below are defined in Guarantees & Correctness. Read that page together with this one.

Build and freeze

  • Pin your deployment to a specific release commit or tag rather than tracking main, so your environment does not drift.
  • Toolchain: built with the Rust stable toolchain, edition 2024. Single static binary; no JVM, no runtime GC.
  • Report issues against the exact commit/tag so we can reproduce them.

Supported versions

ComponentValidated versionNotes
PostgreSQL (source)17Logical replication (wal_level = logical). There is no hard server-version gate in code; other recent majors likely work but are not part of the validated envelope. Validate before relying on a different major.
MySQL (source)8.4Native binlog CDC. MariaDB is not supported.
Kafka (sink)Tested on Confluent Platform 7.5 and 7.7 (cp-kafka)The transactional producer (exactly_once: true) needs a broker with transaction support (the Kafka 2.5+ protocol floor). Other broker versions and distributions are untested - validate during onboarding.

Other sinks (Redis, NATS, HTTP, S3-compatible object storage, ClickHouse, Elasticsearch) are supported as documented on their per-sink pages; run your target versions through a disposable pipeline during onboarding.

Topology: single owner per source

Run one DeltaForge instance per source (per replication slot / binlog reader). DeltaForge does not yet provide a cluster-wide lock or lease that prevents two instances from being started against the same source; single ownership is enforced at the slot and producer level, not globally.

Single-instance requirement. For the supported single-instance deployment, run exactly one DeltaForge process against a given checkpoint/state store. Within one process, lifecycle operations (start, stop, delete, patch, resume) are serialized and a durable per-source-id claim rejects a second pipeline that reuses an active source id, so two pipelines can never share a source id and corrupt each other’s checkpoints (checkpoints are keyed by source id). Across processes, the store gate makes a second server against the same state store refuse to start. Deploy DeltaForge as a single instance (a single replica; if orchestrated, replicas: 1 with strategy: Recreate, not rolling), and do not point a second process at the same state store.

  • PostgreSQL: the replication slot has durable, DeltaForge-recorded ownership. DeltaForge only drops or recreates a slot it can prove it owns, whose lineage matches, and that is inactive; a foreign, ambiguously owned, or active slot fails closed with remediation rather than being taken over. A slot created outside DeltaForge (no ownership record) fails closed on re-snapshot - drop it and let DeltaForge recreate it, or run snapshot.mode = never.
  • Kafka: with exactly_once: true, a second producer using the same transactional.id fences the first. Fencing is a fatal error that stops the pipeline. This is a safety net, not a substitute for running a single instance.
  • MySQL: each instance derives its replication server_id from the source id; give each source a unique id to avoid server_id collisions on the same MySQL server.
  • Failover: for planned HA, use GTID (MySQL) and a slot-aware HA tool (for example Patroni permanent_slots) so the replication position survives a primary change. See Failover Handling.

Deployment preflight

Before deploying a pipeline, validate its config against the live source:

deltaforge preflight <config-file-or-dir>      # human-readable report
deltaforge preflight <config-file-or-dir> --json   # machine-readable report

Preflight resolves the config’s secrets and source DSN exactly as startup does, connects to the source, and runs the source checks plus local config validation. It exits non-zero (fail closed) if any check fails, so it is safe to gate a deploy on it in CI/CD.

  • PostgreSQL: wal_level = logical; the publication exists and has tables; WAL-retention capacity vs estimated snapshot size. Slot validation is mode-aware: DeltaForge creates and owns the replication slot in every supported mode, so an absent slot on a fresh deployment is reported as informational, not a failure. An existing slot is checked under the same ownership rules as startup - a slot that is invalidated, WAL-lost, currently active (another consumer), or foreign (no matching ownership record for this server/database) fails closed.
  • MySQL: log_bin on, binlog_format = ROW, gtid_mode = ON, the RELOAD privilege (when a snapshot will run), captured tables on InnoDB, binlog retention window.
  • Both: connectivity; source and sink credential references are resolved (a missing/invalid secret or an inline+reference conflict fails here); commit-policy validity vs sink count; at least one sink configured; and source ids are unique across the supplied config(s).

Wildcard table patterns are reported as not validated per-table (server-level checks still run). Preflight validates credentials and configuration, not endpoints: sink endpoint reachability is not probed (a documented follow-up), and the slot-ownership check requires pointing preflight at the deployment’s storage backend (the same --storage-* flags the server uses).

Store gate

Breaking change on upgrade. Earlier releases let several processes share one PostgreSQL-backed state store. That topology now refuses to start; give each process its own store. Upgrade by stopping the old version cleanly before starting the new one; if the old process crashed instead, release its gate with deltaforge store-gate break as described below.

The state store carries one durable store gate. A DeltaForge server acquires it at startup, before it reads any schema history, and holds it until it shuts down cleanly; deltaforge schema-migrate --apply holds it while it writes. Only one holder can exist, so a second server, or a migration apply while a server runs, refuses to start with an error that names the holder (role, owner id, host, pid, since) and the command to release it.

  • Clean shutdown (SIGTERM/SIGINT) stops the API, stops every pipeline and waits for its tasks, then releases the gate. This also holds for a signal that arrives while the server is still starting: the signal handlers are installed before the gate is acquired, and startup stops at the next step. Give the process enough time to do this (the Helm chart’s terminationGracePeriodSeconds is 30).

  • A crash keeps the gate held. Nothing expires. After an OOM kill, a SIGKILL (including one sent after the grace period), or a host loss, the next start fails until an operator releases the gate. In Kubernetes this shows as the restarted container exiting with the store-gate error (CrashLoopBackOff). This is deliberate: DeltaForge will not guess that the previous holder is gone.

  • Recovery:

    deltaforge store-gate status            # who holds it (add --json for scripts)
    deltaforge store-gate break --owner <owner-id>
    

    Before breaking, confirm that the recorded process (host and pid) is no longer running. break releases the gate only if that owner still holds it, so a stale or mistyped owner id changes nothing. Pass the same --storage-backend/--storage-path/--storage-dsn flags the server uses.

  • A migration’s gate cannot be broken. A schema migration that failed or crashed may have written part of its plan, and a server must never read partially migrated history, so store-gate break refuses it. Finish the migration instead (see below); the error message prints the exact command.

Migrating pre-upgrade schema history

Schema history written by earlier releases is kept but not used automatically: a table’s history is only trusted once it is tied to the source’s verified lineage (PostgreSQL system identifier and database OID; MySQL server UUID). deltaforge schema-migrate adopts it into the lineage-scoped history for tables you list explicitly. It never deletes the old records.

  1. Start the pipeline once on the new release so it records the source lineage, then stop the server. deltaforge schema-migrate needs the lineage hash of each source; the dry run prints the recorded one.

  2. Write a mapping file that lists each table (no wildcards):

    mappings:
      - tenant: acme
        source_id: orders-pg
        lineage_hash: <recorded lineage hash>
        tables:
          - { db: public, table: orders }
          - { db: public, table: order_items }
    
  3. Dry run (the default; read-only, safe while the server runs):

    deltaforge schema-migrate --mapping mapping.yaml [--tenant T] [--source S] [--json]
    

    It classifies every table as migrate, already migrated, ambiguous or rejected (with the reason: lineage missing or different from the asserted hash, empty or corrupt history, a version that conflicts with existing history, or a marker from a different migration), and prints a proof digest over everything it would adopt.

  4. With the server stopped, apply exactly that plan:

    deltaforge schema-migrate --mapping mapping.yaml --apply --expect-proof <proof>
    

    The apply recomputes the plan under the store gate and refuses if the proof differs, so anything that changed since the review stops it (nothing is written and the gate is released). Tables already migrated by a different reviewed proof are reported as rejected, with that migration’s details, rather than counted as done.

  5. If the apply fails or is interrupted, the store gate stays held (role migration, recording the proof) and the server cannot start, because part of the plan may already be written. After confirming the failed process is no longer running and fixing the cause, finish the same plan:

    deltaforge schema-migrate --mapping mapping.yaml --apply --expect-proof <proof> --resume-owner <owner-id>
    

    The owner id is in the error message and in deltaforge store-gate status. The resume takes the gate over in one step (it is never unlocked in between), accepts only the recorded owner and the same proof, treats the work already done as progress, and releases the gate once the migration completes.

Required privileges

PostgreSQL

  • A role with LOGIN REPLICATION.
  • GRANT CONNECT ON DATABASE <db>.
  • GRANT USAGE ON SCHEMA <schema> and GRANT SELECT on the tables to be captured (snapshot reads and schema introspection).
  • pg_hba.conf must permit both regular and replication connections for the role/host.
  • The publication is not auto-created - create it before starting the pipeline. The replication slot is created by DeltaForge.
  • REPLICA IDENTITY FULL is recommended on captured tables for complete before-images.

MySQL

  • REPLICATION SLAVE, REPLICATION CLIENT ON *.* (always).
  • BACKUP_ADMIN ON *.* and SELECT ON performance_schema.log_status for a first start without a checkpoint and without a snapshot: the capture boundary (binlog position and GTID set) is read there in one consistent query. Without them that start fails closed. Validated on MySQL 8.4; managed services are not yet qualified for these grants.
  • SELECT, SHOW VIEW on the captured database(s) for reads and introspection.
  • RELOAD ON *.* only when an initial snapshot runs (needed for the brief FLUSH TABLES WITH READ LOCK that anchors the snapshot). A CDC-only pipeline (snapshot.mode = never) does not need RELOAD.
  • Server settings enforced fail-closed at snapshot preflight: gtid_mode = ON, enforce_gtid_consistency = ON, binlog_format = ROW, and captured tables on InnoDB. binlog_row_image = FULL is recommended.
  • On managed MySQL (for example RDS/Aurora) where RELOAD / FLUSH TABLES WITH READ LOCK is unavailable, the snapshot fails closed with a typed error - there is no silent unsafe fallback. Grant RELOAD, or run snapshot.mode = never and perform the initial load out of band.

Sinks

Sink credentials and auth modes are documented per sink (see Sinks). Where a sink is configured to auto-create its target object (for example ClickHouse tables or Elasticsearch indices/templates), the sink user needs the corresponding create privilege on the target system; otherwise disable auto-create and provision the target yourself.

Supported credential modes

DeltaForge resolves credentials from typed secret references and never stores resolved secrets in serialized config. See Secrets & Credentials.

  • Secret providers: env, file, and vault (Vault KV v2). Kubernetes is not a separate provider - inject secrets as env (secretKeyRef) or as files (projected volume) and reference them with the env or file provider.
  • Vault: static KV references are supported (the runner must be built with the vault feature; a Vault reference fails closed at startup otherwise). Dynamic Vault database credentials (lease-driven reconnect) are not live in the current release - the lease lifecycle exists but does not yet drive a DSN swap.
  • Rotation: treat credential rotation as restart-required. Change the secret, then restart the affected pipeline or the runner for it to take effect. An opt-in live-reconnect path for file-backed source database credentials exists in the code (MySQL requires GTID mode), but it is outside the supported deployment envelope - validate it in a disposable environment before considering it. Environment-variable credentials are process-immutable and never rotate live; sink credentials are restart-required.

Network and TLS

Plan the network on the basis that DeltaForge’s source database connections and its own HTTP endpoints are not encrypted or authenticated in this release. Deploy accordingly.

  • Source DB connections are not encrypted. Neither the PostgreSQL nor the MySQL source establishes TLS to the database in this build. Run DeltaForge on a trusted/private network segment with the source database, or place an encrypted tunnel (for example a service mesh sidecar, stunnel, or a cloud private link) between DeltaForge and the database. Do not run source traffic across an untrusted network.
  • REST API is unauthenticated and binds all interfaces by default. The default --api-addr is 0.0.0.0:8080 (every interface), so an unrestricted deployment exposes the unauthenticated API on the network. Bind it to loopback (--api-addr 127.0.0.1:8080) where the client is local, or confine it with a firewall / Kubernetes NetworkPolicy; do not expose it publicly.
  • Metrics endpoint is unauthenticated and defaults to 0.0.0.0:9000 (all interfaces). Bind it to loopback (--metrics-addr 127.0.0.1:9000) where the scraper is local, or confine it with a firewall/NetworkPolicy. See Observability.
  • Sink TLS/auth is supported where the sink provides it: Kafka (SASL + SASL_SSL via client_conf), Elasticsearch (https:// with tls.ca_file, basic/API-key auth), HTTP (https:// with header-based auth), NATS (TLS + credentials/token). Confirm Redis and ClickHouse TLS in your environment before relying on it.

State and backup requirements

DeltaForge keeps all runtime state (checkpoints, schema registry, snapshot progress, DLQ/journal) in one storage backend. See Storage and Checkpoints.

  • Backends: sqlite (default; single-instance production) and postgres (a shared storage backend, beta - not yet given the same crash/recovery validation as SQLite). One state store serves exactly one DeltaForge server at a time (enforced by the store gate); the PostgreSQL backend does not make source processing highly available. Use with caution. memory is for testing only and is lost on restart.

  • Back up the state store regularly. For SQLite the store is the deltaforge.db file (default under ./data/), which holds both checkpoints and schema history; losing it means losing resume position and schema lineage. The store runs in WAL mode, so do not copy deltaforge.db on its own while DeltaForge is running - committed data may still be in the -wal file, and a bare file copy can be inconsistent. Use one of:

    • SQLite’s online-backup API or VACUUM INTO 'backup.db' against the live database;
    • stop DeltaForge cleanly, checkpoint the WAL (PRAGMA wal_checkpoint(TRUNCATE)), then copy the file;
    • a storage-level consistent snapshot that captures the database together with its -wal and -shm files.

    For the PostgreSQL backend, back up that database with your normal PostgreSQL backup tooling (pg_dump or a consistent base backup).

  • Durability: the SQLite store runs in WAL mode with synchronous = NORMAL and survives SIGKILL without a graceful shutdown; a checkpoint is persisted only after the sink acknowledges delivery and the commit policy is satisfied (the basis of at-least-once).

  • Restoring from a backup rewinds to that backup’s position; expect at-least-once re-delivery (duplicates) of everything after the backup point, which consumers dedup on the event id.

Known limitations

These are documented in full on Guarantees & Correctness; the summary:

  • Delivery is at-least-once. No sink is end-to-end exactly-once. Kafka exactly_once: true adds transactional atomic-batch delivery (a read_committed consumer never sees a partial batch) but is still at-least-once across a restart. Consumers must dedup on the event id for exactly-once end to end.
  • Snapshot to CDC has a bounded at-least-once overlap - rows committed between the anchor and the snapshot export are delivered by both the snapshot and CDC. Current-state sinks converge; append-only sinks see duplicates.
  • No cross-table global ordering. Per-primary-key ordering within a table is guaranteed; different tables may interleave.
  • No stateful stream processing (no joins/aggregations/windowing). Do that downstream (Flink, ksqlDB, Kafka Streams).
  • Optional sinks (required: false) have no in-session retry. A failed optional sink is re-delivered only on the next restart via source replay; a chronic optional-sink outage accumulates a growing replay gap.
  • S3 sink is at-least-once at file granularity and requires an AbortIncompleteMultipartUpload bucket lifecycle policy in production to reclaim orphaned multiparts.
  • DLQ must be enabled (journal.enabled: true) to isolate poison events; without it a single unprocessable event blocks the pipeline.
  • PostgreSQL start_position is not implemented - a newly created slot always starts at its consistent point (the current WAL position when it is created).
  • Changing a pipeline’s sink set via PATCH is disabled. Adding or removing a sink changes the per-sink checkpoint keys, which is not crash-safe; a PATCH that alters the sink set is rejected. Patches that leave the sink set unchanged are allowed. To change sinks, delete and recreate the pipeline.
  • Deleting a pipeline is fail-closed. Delete stops the source, cleans up its checkpoints, and only then releases the source-id claim; if checkpoint cleanup fails, the delete fails and the source id stays locked (safe: it blocks reuse rather than exposing stale checkpoints). Retry the delete once the store is reachable.
  • Cross-primary position safety at failover depends on GTID (MySQL) and slot-aware HA (PostgreSQL); see Failover Handling.
  • MySQL replays binlog rows across a DDL only when the schema in effect can be proven. Rows are decoded with the version proven for their position. When none can be proven (retained rows older than a DDL the source never observed, a statement that cannot be attributed to tables such as a versioned-comment DDL, or two DDLs of one table before the source reads the first), the source stops fail-closed (no event, no checkpoint advance). The immediate remediation is a re-snapshot (restart once with snapshot mode always); after an unattributable statement, the table’s next rows (once its definition is captured and proven unchanged since them) or a later proven DDL of the table also restore proof. Moving the source position past the DDL intentionally abandons all retained changes in between, for every captured table, and requires an operator assessment of that loss. Set binlog_row_metadata=FULL to remove most of these stops. See Guarantees.

Out of scope

Not part of the supported deployment envelope (do not rely on these):

  • Multi-instance HA for a single source (no cluster-wide ownership lock/lease yet).
  • TLS directly to the source database (use a trusted network or an external tunnel).
  • Authenticated REST API or metrics endpoint (use network controls).
  • Live/dynamic credential rotation as a supported contract (treat rotation as restart-required; dynamic Vault DB credentials are not live).
  • End-to-end exactly-once delivery, deterministic snapshot+replay rebuild, and any feature marked planned on the Roadmap.

Capacity & Resource Envelope

This page states DeltaForge’s resource behaviour so operators can size a deployment conservatively. It is a description of how the engine behaves today, not a set of guaranteed maximums.

Every bound below is tagged with how we know it:

  • [measured] - observed in a test/benchmark run (environment-dependent; not a guarantee).
  • [code-derived] - a constant or algorithm in the source; exact, but a mechanism, not a tested ceiling.
  • [external] - enforced by the source/sink database, not by DeltaForge.
  • [operator] - a configuration default the operator can change.
  • [unknown] - not yet benchmarked; treat with caution.

These are not product ceilings. Where a number is [code-derived] or [operator] it describes a default or a mechanism, not a validated limit. Do not quote them as maximums. Run the preflight command and a disposable soak in your environment before committing to any number.

At-a-glance conservative starting guidance

DimensionConservative starting guidanceBasis
Pipelines (source units) per instanceUnvalidated starting point: a small number (single digits); single instance only. Not a supported ceiling either way.[unknown] scale; single-owner is [code-derived]
Tables per sourceTens to low hundreds; watch snapshot cost. A CDC start neither enumerates the catalog nor loads schemas up front, the schema registry’s cache is bounded, and each source’s resident schemas are capped (a compact per-table record still grows with the tables used; see Schema registry at scale); an initial snapshot’s work still grows with the catalog, linearly (see Initial Snapshots)registry [measured] to 1M tables; CDC restart [measured] flat from 20 to 1,000 matched tables; initial snapshot [measured] near-linear from 1,000 to 10,000 tables, beyond that [unknown]
MemoryProvision for channel-depth × event-size + in-flight batch bytes + the schema cache budget (default 64 MiB); no aggregate cap exists[code-derived] / [unknown]
ThroughputBenchmark per environment; do not assume a headline number[measured] dev-only / [unknown]

Everything else is detailed below.

Memory and backpressure

DeltaForge has no aggregate (pipeline-wide or process-wide) memory or byte budget [code-derived - confirmed absent]. Resident memory is bounded only indirectly by the levers below, so you must provision headroom rather than rely on a cap.

  • Source→coordinator channel: 32,768 items, item-count bound only, no byte cap [code-derived]. A burst of large change events can hold up to 32,768 SourceItems resident with no byte ceiling. This is the primary backpressure lever and is not operator-configurable. Worst-case channel memory ≈ 32768 × (largest event size); size RAM for your widest rows/transactions accordingly.
  • In-flight batch bytes: bounded per batch by batch.max_bytes (default 16 MiB [operator]) and per source transaction by batch.max_tx_bytes (default 512 MiB [operator], only when respect_source_tx = true). If an operator sets these to unset/None, the effective cap becomes unbounded (usize::MAX) [code-derived] - do not disable them.
  • Schema cache: only the latest version of tables in use is cached, process-wide, bounded by --schema-cache-max-bytes (default 64 MiB, a conservative estimate of resident bytes) and --schema-cache-max-entries (default 50,000) [operator]. Older versions and unused tables stay in the state store and are read on demand. See Schema registry at scale.
  • Guidance: budget ≈ (channel depth × typical event size) + max_tx_bytes per active pipeline + the schema cache budget, with generous headroom. There is no backstop if you under-provision. Actual RSS under load is [unknown] pending a soak in your environment.

Transaction and batch size

BatchConfig defaults [operator], all overridable:

SettingDefaultMeaning
max_events2000events per batch
max_bytes16 MiBserialized bytes per batch
max_ms50 msbatch flush interval
min_flush_interval_ms40 msminimum time between flushes of a drained input
respect_source_txtruealign batches to source transactions
max_inflight1batch depth (see Concurrency)
max_tx_events1,000,000events in one source transaction
max_tx_bytes512 MiBserialized bytes in one source transaction
oversized_txFailbehaviour when a transaction exceeds the caps

A single source transaction larger than max_tx_events/max_tx_bytes is failed closed by default (oversized_tx = Fail). Very large transactions on the source are the main way to blow past the byte budget - keep the defaults.

Concurrency and in-flight

  • max_inflight default 1 [operator]. It bounds pipeline batch depth, not concurrent sink delivery: delivery is processed FIFO by a single delivery task to preserve checkpoint ordering, so raising it does not parallelize sinks [code-derived]. With respect_source_tx = true (the default), max_inflight must be 1 (startup fails otherwise) [code-derived]. So the effective default is single-in-flight.
  • Snapshot parallelism [operator]: discovery_page_size 1,000 (catalog rows per discovery query), max_parallel_tables 8, chunk_size 10,000 rows, intra_table_parallel false, max_parallel_chunks 4 (only when intra-table parallel), lock_timeout_secs 10. Effective table concurrency is min(max_parallel_tables, table_count).

Queue and DLQ bytes

  • DLQ (when journal.enabled = true, default off): max_entries 10,000 [operator], max_age_secs 7 days [operator], overflow_policy DropOldest [operator] (Reject/Block also available). The Block policy waits at most 60 s before failing closed [code-derived]. Journal max_event_bytes default 256 KB [operator].
  • Replay stream (default off): max_entries/max_bytes default 0 = unbounded [operator]; max_envelope_bytes 8 MiB [operator]; retention_secs 24 h.
  • Secret material cap: 1 MiB per secret [code-derived].

Source-log retention

Retention windows are [external] (enforced by the source DB); preflight only estimates risk.

  • PostgreSQL: one replication slot retains WAL. max_slot_wal_keep_size governs the cap. Preflight estimates snapshot WAL as ≈ 2× data bytes and warns at ≥50% / flags HIGH risk at ≥80% of max_slot_wal_keep_size [code-derived heuristic]. wal_status = lost/unreserved is reported. Set max_slot_wal_keep_size generously or the slot can be invalidated mid-snapshot.
  • MySQL: binlog_expire_logs_seconds (fallback expire_logs_days) governs binlog retention. Preflight estimates snapshot duration and warns at ≥50% / HIGH at ≥80% of the retention window [code-derived heuristic]; post-snapshot it fails closed if the captured binlog file was purged during the snapshot.
  • Snapshot throughput estimate used by both: 20 MB/s per worker [code-derived heuristic, not measured].
  • During a snapshot, retention the anchor needs (the PostgreSQL slot, the MySQL anchor binlog file) and the anchor’s age (snapshot.max_anchor_age_secs, default 24 h) are checked continuously: a warning incident at 80%, and at the limit the generation stops and blocks before anything is lost (snapshot_anchor_unavailable) [code-derived, tested]. See Bounds and blocking.

Database connections

Per pipeline [code-derived; scales with snapshot config]:

  • Snapshot connection cap [operator]: every snapshot connection, across all pipelines of the process, is taken from one pool of --max-snapshot-connections (default 64); each source’s share is snapshot.max_snapshot_connections (default max_parallel_tables x max_parallel_chunks + 2). A snapshot that cannot get its 3 base connections queues holding nothing (deltaforge_snapshot_queued). See Connection limits.
  • PostgreSQL - snapshot: 1 anchor + min(max_parallel_tables, tables) workers (up to 8) + up to ×max_parallel_chunks per worker if intra_table_parallel (worst case with defaults + intra-table ≈ ~41), within the source’s share. Steady CDC: 1 replication connection (= 1 walsender) + occasional short-lived control connections. No source-side connection pool - each is an individual connection.
  • MySQL - snapshot: 1 lock connection + min(max_parallel_tables, tables) workers + 1 binlog-position guard, within the source’s share. Steady CDC: 1 binlog stream (+ one per proof scan in flight) + the source’s control-plane pool of at most proofs.control_connections (default 4, at most one kept idle), within the process budget --max-mysql-control-connections (default 32, 4 reserved for cancellations) [code-derived, tested]. See Control-plane connections.
  • Storage backend (PostgreSQL): one connection pool per backend instance (shared across pipelines), max_size = deadpool default ≈ CPU × 4 [library-default], plus one background TTL-sweep task.

Size max_connections on the source and on the PostgreSQL storage DB for the snapshot peak, not steady state: at most --max-snapshot-connections snapshot connections across the process.

PostgreSQL slots and walsenders

  • One replication slot and one walsender per PostgreSQL source [code-derived]. Provision max_replication_slots and max_wal_senders ≥ number of PG source pipelines on that server, with headroom for other consumers.

Checkpoint-store and schema-registry load

  • Checkpoint writes: one put_raw per checkpointed sink per committed batch, with no coalescing [code-derived]:

    checkpoint writes/sec = committed batches/sec × checkpointed sinks
    

    This is workload-dependent and has no configured QPS ceiling. max_ms = 50 is a time-based flush ceiling, not a rate cap: batches also flush on max_events, max_bytes, and when the input drains (at most once per min_flush_interval_ms while nothing is in flight), so committed-batches/sec can be much higher than 1000/max_ms under load. Size the checkpoint/storage backend for the actual committed-batch rate × checkpointed sinks across all pipelines. Reads are change-driven (the source is notified on commit), not polled.

  • Checkpoint reads are scoped to the source [code-derived, measured]: a source reads its per-sink checkpoints by key prefix, answered by the store as a bounded key range (PostgreSQL: a partial index on the checkpoint namespace). Cost does not grow with the number of other sources or pipelines sharing the store.

  • Schema registry startup reads one record [measured]: no namespace scan and no history replay; each table’s latest schema is read on first use and cached within the budget above. See below.

Schema registry at scale

Measured with the dense-catalog harness (crates/scale-harness, registry-scale; see its README to reproduce) at commit 664838a, on a developer machine, single runs, SQLite state store. The numbers are indicative, not guarantees; the shape (what does and does not grow) is the point.

Synthetic registries: N tables per source × 2 sources with the same table names (colliding across sources), 2-3 versions each, generated through the normal registration path. 100 active tables are looked up.

100K tables × 3 versions1M tables × 2 versions
Registry startup1 key read, 0.04 ms1 key read, 0.04 ms
First lookup of a table (cold)1 read; p50 31 µs, p99 230 µs1 read; p50 21 µs, p99 35 µs
Repeat lookup (cached)no store access; p50 3 µsno store access; p50 2 µs
Working set 4× the cache budgetstays within budget, evictsstays within budget, evicts
64 concurrent first lookups of one table1 store read1 store read
Reads of another source’s records00
Time to first CDC event after a restart (PostgreSQL / MySQL)34 ms / 21 ms45 ms / 21 ms
  • PostgreSQL state store (100K tables × 2 versions × 2 sources, local container): the same operation counts as SQLite - startup 1 key read (1.4 ms), a cold lookup 1 read (p50 205 µs, p99 816 µs, network round trips), cached lookups no store access, 1 read for 64 concurrent first lookups, 0 reads of other sources. Migration of 10K tables: ~56-60 s at 1 version and ~185 s at 5 versions per table (vs ~4 s / ~14 s on SQLite); memory the same ~2.4-2.5 KB per mapped table. Registrations ran at ~250/s against ~3,600/s on SQLite.
  • Time to first CDC event: the source’s own registry holds N synthetic tables (they are not in the source database); a row committed while the pipeline was stopped is timed from source start until the sink receives it. The storage calls in that window are identical at 100K and 1M tables (PostgreSQL: 12 key reads, 8 prefix-scoped checkpoint listings, 1 write, 1 schema read; MySQL: 5, 1, 1, 1) with no registry scan. This isolates the registry.
  • CDC restart against a large live catalog [measured]: the source’s table pattern matches 20 or 1,000 real tables in the database (only one of them changes). A CDC-only restart does the same work at both sizes, measured up to the arrival of the first event: the same storage operations, calls and returned and written bytes per primitive (PostgreSQL 11 key reads, 3 latest-version reads; MySQL 9 key reads; the timer-driven rereads of the sink checkpoints for WAL feedback are not counted), no registry enumeration, and the same number of statements on the server (PostgreSQL 9, MySQL 27); no catalog enumeration and no schema load for tables without changes. Both runs create the same 1,000 tables; only how many the pattern matches differs. Reproduce with cargo test -p scale-harness --test live_ttfce -- --include-ignored or registry-scale --live postgres,mysql --live-catalog-tables N.
  • Per-source schema cache [code-derived]: the resident (heavyweight) table schemas are capped at 4,096 tables or about 64 MiB of serialized schema per source, whichever is reached first, with least-recently-used eviction. The limit is fixed (not an operator setting). An evicted table is rebuilt from durable history as exactly the version it resolved to, so the source keeps a compact record per table resolved in the current lineage generation (table key, version, sequence, fingerprint). That record is not bounded: it grows with the distinct tables used since the source started (or last changed lineage), and the 64 MiB does not include it or the cache’s key and map overhead. Bounding or paging these records is a later capacity improvement.
  • MySQL schema selection caches [code-derived, measured]: each MySQL source caches at most 4,096 tables’ validated timelines and 256 MiB of timelines, shared barrier streams (once each), row-schema selections and read-ahead versions, evicted least recently used first (deltaforge_mysql_selection_cache_evictions_total{pipeline,source}); the estimate and the timelines held are exported as deltaforge_mysql_selection_cache_bytes and deltaforge_mysql_selection_cache_timelines, under the same labels. The bytes cover resident memory (resident measured at 0.66 to 0.75 of the estimate). A load in flight adds at most one timeline’s limit (64 MiB, 100,000 records) and one record. An evicted entry is rebuilt from the durable registry and activation records, never from the live catalog, in batches of up to 256 tables. The bounds are fixed (not operator settings). See Activation histories.
  • Pre-upgrade schema history migration (deltaforge schema-migrate) [measured]: memory grows with the number of mapped tables, not with history length: about 2.4-2.8 KB per mapped table (10K tables: ~39 MiB peak; 100K tables: ~243 MiB peak), unchanged between 1 and 5 versions per table and between page sizes. Time: 100K tables took ~36 s at 1 version and ~135 s at 5 versions per table. For very large catalogs, split the migration with --tenant / --source or several mapping files.

Metric cardinality

  • Default: independent of the table count [measured]. No metric carries a table label unless the pipeline enables metrics.per_table; a live test records identical series sets for pipelines over 10 and 1,000 tables (apart from batch flush causes, a closed set that varies with timing). See Observability.
  • Opt-in per-table detail [code-derived, measured]: at most max_tables + 1 table values (default 100, up to 10,000) per metric and remaining label set; the per-table lag holds at most max_tables + 1 series per pipeline and drops a table’s series after lag_idle_secs without events. The histogram (deltaforge_schema_sensing_seconds) multiplies this by its buckets. Budget Prometheus accordingly before raising max_tables.

Table and source-unit counts

  • No coded limit on tables per source or pipelines per instance [confirmed absent]. Practical limits come from: initial snapshot time (linear in the tables and rows copied; the durable plan is bounded by snapshot.max_plan_bytes / max_plan_items), and connection/slot math above. A CDC start does no per-table work (see below). The schema registry is no longer one of them ([measured] to 1M tables per source).
  • Single-instance requirement: run exactly one DeltaForge process against a given state store (see Supported Deployment Envelope) [code-derived containment].
  • Conservative starting configuration (unvalidated, not a supported limit): a small number of pipelines (single digits) and tens-to-low-hundreds of tables per source is a reasonable place to start, and grow only after a soak in your environment. We have not measured enough to claim either that larger configurations are unsupported or that this range is universally safe - both directions are [unknown] pending benchmark.

Throughput

Throughput is environment-dependent and is [measured] only on a developer machine (single runs, heavy desktop contention observed), so no headline number is published as a guarantee. The figures in Performance Tuning are such development-machine observations, useful for the relative effect of settings only; comparative benchmarks are defined in the benchmark design and run after rc.1. Treat sustained throughput as [unknown] for your environment until you run a soak. Backpressure bounds queued item count and batch/transaction payloads where their byte limits are enabled, but it does not provide a process-wide memory bound.

Known scaling caveats (pending benchmark)

These are [unknown] and should be validated before scaling up:

  • Aggregate/RSS memory under sustained load (no coded cap).
  • Initial snapshot time for sources capturing many tables: discovery reads the catalog in keyset pages (discovery_page_size, one query per page) and each table’s schema is resolved once, from the schema registry when known or the source catalog otherwise (a CDC restart does neither; see above).
  • Initial snapshots of large catalogs [measured to 10,000 tables]: snapshot state is a durable, paged plan with constant-size boundaries and no progress rewrites; resident queue state is bounded by discovery_page_size and max_parallel_tables. From 1,000 to 10,000 tables, boundary bytes grew 10x and wall time 10.5x (PostgreSQL) and 12.2x (MySQL). Total process memory still grows with the tables captured, through the schema caches (MySQL peak RSS growth 17.9 MiB at 1K, 48.4 MiB at 10K). Figures, environment and scope in Initial Snapshots. Qualification beyond 10,000 tables per source (up to 400,000 per source, and fleets of 50 to 120 million tables) is a milestone after rc.1, not a current support claim.
  • MySQL mysql_async control-pool sizing under many concurrent pipelines.

Configuration

Pipelines are defined as YAML documents that map directly to the PipelineSpec type. Environment variables are expanded before parsing using ${VAR} syntax, so secrets and connection strings can be injected at runtime. Unknown variables (e.g. ${source.table}) pass through for use as routing templates.

For credentials, prefer references (env, file, Kubernetes projected volume, or Vault KV) over inline values and ${ENV} expansion. See Secrets & Credentials.

Document structure

apiVersion: deltaforge/v1
kind: Pipeline
metadata:
  name: <pipeline-name>
  tenant: <tenant-id>
spec:
  source: { ... }
  processors: [ ... ]
  sinks: [ ... ]
  batch: { ... }
  commit_policy: { ... }
  schema_sensing: { ... }

Metadata

FieldTypeRequiredDescription
namestringYesUnique pipeline identifier. Used in API routes and metrics.
tenantstringYesBusiness-oriented tenant label for multi-tenancy.
labelsmapNoArbitrary key/value labels. GET /pipelines?label=k=v filters on these.
annotationsmapNoArbitrary key/value annotations (non-identifying metadata).

Spec fields

FieldTypeRequiredDescription
sourceobjectYesDatabase source configuration. See Sources.
processorsarrayNoOrdered list of processors. See Processors.
sinksarrayYes (at least one)One or more sinks that receive each batch. See Sinks.
shardingobjectNoOptional hint for downstream distribution.
connection_policyobjectNoHow the runtime establishes upstream connections.
batchobjectNoCommit unit thresholds. See Batching.
commit_policyobjectNoHow sink acknowledgements gate checkpoints. See Commit policy.
sink_batch_deadline_secsintNoOuter per-batch delivery deadline across all sinks (unset = no outer deadline).
schema_sensingobjectNoAutomatic schema inference from event payloads. See Schema sensing.
journalobjectNoEvent journal (DLQ). See Dead Letter Queue.
metricsobjectNoPer-table metric detail (off by default). See Metrics.

Sources

MySQL

Captures row-level changes via binlog replication. See MySQL source documentation for prerequisites and detailed configuration.

source:
  type: mysql
  config:
    id: orders-mysql
    dsn: ${MYSQL_DSN}
    tables:
      - shop.orders
      - shop.order_items
    outbox:
      tables: ["shop.outbox"]
    snapshot:
      mode: initial
    on_schema_drift: adapt
FieldTypeDescription
idstringUnique identifier for checkpoints and metrics
dsnstringMySQL connection string with replication privileges
tablesarrayTable patterns to capture; omit for all tables
outbox.tablesarrayTable patterns to tag as outbox events. Must also appear in tables. Supports globs: shop.outbox, *.outbox, shop.outbox_%.
snapshot.modestringnever (default), initial - run once if no checkpoint exists, always - re-snapshot on every restart
snapshot.max_parallel_tablesintTables snapshotted concurrently (default: 8)
snapshot.discovery_page_sizeintTables read per catalog query while discovering the tables a snapshot copies (default: 1000, allowed 1-10000)
snapshot.chunk_sizeintRows per range chunk for integer-PK tables (default: 10000)
snapshot.max_snapshot_connectionsintThis source’s share of the process-wide snapshot connection cap (default: max_parallel_tables x max_parallel_chunks + 2; the process cap is --max-snapshot-connections, default 64)
snapshot.max_anchor_age_secsintHow long a snapshot generation may hold its anchor before it blocks (default: 86400)
snapshot.max_plan_bytes / snapshot.max_plan_itemsintBounds of a generation’s durable table plan; discovery blocks before sealing at either (default: 256 MiB / 1000000). See Initial Snapshots
on_schema_driftstringadapt (default) - reload schema and continue after failover drift; halt — stop and require operator intervention. See Failover Handling.
type_mappingstringv2 (default) or the deprecated v1, stamped on every event. See Data Types.

Table patterns (the initial snapshot and CDC select exactly the same tables):

  • db.table - exact match
  • db.prefix% or db.prefix* - tables matching the prefix (a wildcard counts only as the last character; _ is literal)
  • db.% or db.* - all tables in database
  • table (no qualifier) - that table in every database/schema

PostgreSQL

Captures row-level changes via logical replication. See PostgreSQL source documentation for prerequisites and detailed configuration.

source:
  type: postgres
  config:
    id: users-postgres
    dsn: ${POSTGRES_DSN}
    slot: deltaforge_users
    publication: users_pub
    tables:
      - public.users
      - public.sessions
    start_position: earliest
    outbox:
      prefixes: ["outbox", "order_outbox_%"]
    snapshot:
      mode: initial
    on_schema_drift: adapt
FieldTypeDescription
idstringUnique identifier
dsnstringPostgreSQL connection string
slotstringReplication slot name
publicationstringPublication name
tablesarrayTable patterns to capture
start_positionstringearliest, latest, or lsn
outbox.prefixesarraypg_logical_emit_message prefixes to tag as outbox events. Supports globs: outbox, outbox_%, *.
snapshot.modestringnever (default), initial - run once if no checkpoint exists, always - re-snapshot on every restart
snapshot.max_parallel_tablesintTables snapshotted concurrently (default: 8)
snapshot.discovery_page_sizeintTables read per catalog query while discovering the tables a snapshot copies (default: 1000, allowed 1-10000)
snapshot.chunk_sizeintRows per range chunk (default: 10000)
snapshot.max_snapshot_connectionsintThis source’s share of the process-wide snapshot connection cap (default: max_parallel_tables x max_parallel_chunks + 2; the process cap is --max-snapshot-connections, default 64)
snapshot.max_anchor_age_secsintHow long a snapshot generation may hold its anchor before it blocks (default: 86400)
snapshot.max_plan_bytes / snapshot.max_plan_itemsintBounds of a generation’s durable table plan; discovery blocks before sealing at either (default: 256 MiB / 1000000). See Initial Snapshots
on_schema_driftstringadapt (default) — reload schema and continue after failover drift; halt — stop and require operator intervention. See Failover Handling.
type_mappingstringv2 only (the default); v1 is refused. See Data Types.

Note: The source-level outbox field only tags matching events with the __outbox sentinel. Routing and transformation are handled by the outbox processor.


Processors

Processors transform events between source and sinks. They run in order and can filter, enrich, or modify events.

JavaScript

processors:
  - type: javascript
    id: transform
    inline: |
      function processBatch(events) {
        return events.map(e => {
          e.tags = ["processed"];
          return e;
        });
      }
    limits:
      cpu_ms: 50
      mem_mb: 128
      timeout_ms: 500
FieldTypeDescription
idstringProcessor identifier
inlinestringJavaScript code
limits.cpu_msintCPU time limit
limits.mem_mbintMemory limit
limits.timeout_msintExecution timeout

Flatten

processors:
  - type: flatten
    id: flat
    separator: "__"
    max_depth: 3
    on_collision: last
    empty_object: preserve
    lists: preserve
    empty_list: drop
FieldTypeDefaultDescription
idstring"flatten"Processor identifier
separatorstring"__"Separator between path segments
max_depthintunlimitedStop recursing at this depth; objects at the boundary kept as-is
on_collisionstringlastKey collision policy: last, first, or error
empty_objectstringpreserveEmpty object policy: preserve, drop, or null
listsstringpreserveArray policy: preserve or index
empty_liststringpreserveEmpty array policy: preserve, drop, or null

Filter

processors:
  - type: filter
    id: only-active-orders
    ops: [create, update]
    tables:
      include: ["shop.orders"]
      exclude: ["*.tmp"]
    fields:
      - path: status
        op: eq
        value: "active"
      - path: total
        op: gte
        value: 100
    match: all
FieldTypeDefaultDescription
idstring"filter"Processor identifier
opslist[]Op types to keep. Empty = all. create, update, delete, read, truncate
tables.includelist[]Table glob patterns to include. Empty = all
tables.excludelist[]Table glob patterns to exclude. Takes priority over include
fieldslist[]Field predicates against event.after. See Filter operators
matchstringallall - every predicate must match; any - at least one

Outbox

Transforms raw outbox events into routed, sink-ready events. Requires the source to have outbox configured so events are tagged before reaching this processor. See Outbox pattern documentation for full details.

processors:
  - type: outbox
    id: outbox
    topic: "${aggregate_type}.${event_type}"
    default_topic: "events.unrouted"
    raw_payload: true
    columns:
      payload: data
    additional_headers:
      x-trace-id: trace_id
FieldTypeDefaultDescription
idstring"outbox"Processor identifier
tablesarray[]Filter: only process outbox events matching these patterns. Empty = all outbox events.
topicstring—Topic template resolved against the raw payload using ${field} placeholders
default_topicstring—Fallback topic when template resolution fails and no topic column exists
keystring—Key template resolved against raw payload. Default: aggregate_id value.
raw_payloadboolfalseDeliver the extracted payload as-is to sinks, bypassing envelope wrapping
strictboolfalseFail the batch if required fields are missing rather than silently falling back
columns.payloadstringpayloadColumn containing the event payload
columns.aggregate_typestringaggregate_typeColumn for aggregate type
columns.aggregate_idstringaggregate_idColumn for aggregate ID
columns.event_typestringevent_typeColumn for event type
columns.topicstringtopicColumn for pre-computed topic override
columns.event_idstringidColumn extracted as df-event-id header
additional_headersmap{}Forward extra payload fields as routing headers: header-name: column-name

Sinks

Sinks deliver events to downstream systems. Each sink supports configurable envelope formats and wire encodings to match consumer expectations. See the Sinks documentation for detailed information on multi-sink patterns, commit policies, and failure handling.

Envelope and encoding

The Kafka, Redis, NATS, and HTTP sinks support these serialization options. (The S3 sink uses format/compression instead — see S3.)

FieldTypeDefaultDescription
envelopeobjectnativeOutput structure format. See Envelopes.
encodingstringjsonWire encoding format
json_wide_integersstringstringNative JSON only: 64-bit integers as exact decimal strings (string) or JSON numbers (number). Under number, JavaScript, jq and every parser that reads numbers as doubles silently round values above 2^53, and an unsigned value above 2^63-1 is a conversion failure. See Data Types.

Envelope types:

  • native - Direct Debezium payload structure (default, most efficient)
  • debezium - Full {"payload": ...} wrapper
  • cloudevents - CloudEvents 1.0 specification (requires type_prefix)
# Native (default)
envelope:
  type: native

# Debezium wrapper
envelope:
  type: debezium

# CloudEvents
envelope:
  type: cloudevents
  type_prefix: "com.example.cdc"

Kafka

See Kafka sink documentation for detailed configuration options and best practices.

sinks:
  - type: kafka
    config:
      id: orders-kafka
      brokers: ${KAFKA_BROKERS}
      topic: orders
      envelope:
        type: debezium
      encoding: json
      required: true
      exactly_once: false
      send_timeout_secs: 30
      client_conf:
        security.protocol: SASL_SSL
FieldTypeDefaultDescription
idstring-Sink identifier
brokersstring-Kafka broker addresses
topicstring-Target topic or template
keystring-Message key template
envelopeobjectnativeOutput format
encodingstringjsonWire encoding
requiredbooltrueGates checkpoints
exactly_onceboolfalseTransactional mode
send_timeout_secsint30Send timeout
client_confmap-librdkafka overrides

CloudEvents example:

sinks:
  - type: kafka
    config:
      id: events-kafka
      brokers: ${KAFKA_BROKERS}
      topic: events
      envelope:
        type: cloudevents
        type_prefix: "com.acme.cdc"
      encoding: json

Redis

See Redis sink documentation for detailed configuration options and best practices.

sinks:
  - type: redis
    config:
      id: orders-redis
      uri: ${REDIS_URI}
      stream: orders
      envelope:
        type: native
      encoding: json
      required: true
FieldTypeDefaultDescription
idstring-Sink identifier
uristring-Redis connection URI
streamstring-Redis stream key or template
keystring-Entry key template
envelopeobjectnativeOutput format
encodingstringjsonWire encoding
requiredbooltrueGates checkpoints
send_timeout_secsint5XADD timeout
batch_timeout_secsint30Pipeline timeout
connect_timeout_secsint10Connection timeout

NATS

See NATS sink documentation for detailed configuration options and best practices.

sinks:
  - type: nats
    config:
      id: orders-nats
      url: ${NATS_URL}
      subject: orders.events
      stream: ORDERS
      envelope:
        type: native
      encoding: json
      required: true
      send_timeout_secs: 5
      batch_timeout_secs: 30
FieldTypeDefaultDescription
idstring-Sink identifier
urlstring-NATS server URL
subjectstring-Subject or template
keystring-Message key template
streamstring-JetStream stream name
envelopeobjectnativeOutput format
encodingstringjsonWire encoding
requiredbooltrueGates checkpoints
send_timeout_secsint5Publish timeout
batch_timeout_secsint30Batch timeout
connect_timeout_secsint10Connection timeout
credentials_filestring-NATS credentials file
usernamestring-Auth username
passwordstring-Auth password
tokenstring-Auth token

HTTP / Webhook

See HTTP sink documentation for full configuration. POST/PUT events to any URL with custom headers, URL templates, and batch mode.

sinks:
  - type: http
    config:
      id: orders-webhook
      url: "https://example.com/hooks/{source.table}"
      method: POST

S3 / Object storage

See S3 sink documentation for full configuration. Writes Parquet or JSON Lines to any S3-compatible store, Hive-partitioned by table + date. Uses format/compression (not envelope/encoding).

sinks:
  - type: s3
    config:
      id: lake
      bucket: my-lake
      prefix: cdc
      endpoint: "http://localhost:9000"
      region: us-east-1
      format: parquet
      compression: snappy
      required: true

Batching

batch:
  max_events: 500
  max_bytes: 1048576
  max_ms: 1000
  respect_source_tx: true
  max_inflight: 2
FieldTypeDefaultDescription
max_eventsint2000Flush after this many events
max_bytesint16777216Flush after size reaches limit (16 MiB)
max_msint50Flush after time (ms)
respect_source_txbooltrueNever split source transactions
max_inflightint1Max concurrent batches

Commit policy

commit_policy:
  mode: required

# For quorum mode:
commit_policy:
  mode: quorum
  quorum: 2
ModeDescription
allEvery sink must acknowledge before checkpoint
requiredOnly required: true sinks must acknowledge (default)
quorumCheckpoint after quorum sinks acknowledge

Schema sensing

Schema sensing automatically infers and tracks schema from event payloads. See the Schema Sensing documentation for detailed information on how it works, drift detection, and API endpoints.

Performance tip: Schema sensing can be CPU-intensive, especially with deep JSON inspection. Consider your throughput requirements when configuring:

  • Set enabled: false if you don’t need runtime schema inference
  • Limit deep_inspect.max_depth to avoid traversing deeply nested structures
  • Increase sampling.sample_rate to analyze fewer events (e.g., 1 in 100 instead of 1 in 10)
  • Reduce sampling.warmup_events if you’re confident in schema stability
schema_sensing:
  enabled: true
  deep_inspect:
    enabled: true
    max_depth: 3
    max_sample_size: 500
  sampling:
    warmup_events: 50
    sample_rate: 5
    structure_cache: true
    structure_cache_size: 50
  high_cardinality:
    enabled: true
    min_events: 100
    stable_threshold: 0.5
    min_dynamic_fields: 5
FieldTypeDefaultDescription
enabledboolfalseEnable schema sensing
deep_inspect.enabledbooltrueInspect nested JSON
deep_inspect.max_depthint10Max nesting depth
deep_inspect.max_sample_sizeint1000Max events for deep analysis
sampling.warmup_eventsint1000Events to fully analyze first
sampling.sample_rateint10After warmup, analyze 1 in N
sampling.structure_cachebooltrueCache structure fingerprints
sampling.structure_cache_sizeint100Max cached structures
high_cardinality.enabledbooltrueDetect dynamic map keys
high_cardinality.min_eventsint100Events before classification
high_cardinality.stable_thresholdfloat0.5Frequency for stable fields
high_cardinality.min_dynamic_fieldsint5Min unique fields for map

Metrics

By default no metric carries a table label, so a pipeline’s series count does not depend on how many tables it captures. Per-table detail is opt-in:

metrics:
  per_table:
    enabled: true
    max_tables: 100      # tables with their own series (1-10,000)
    lag_idle_secs: 300   # per-table lag disappears after this long without events (10-86,400)
FieldTypeDefaultDescription
per_table.enabledboolfalseAdd table and table_scope labels to the per-table metrics (see Observability).
per_table.max_tablesint100The first max_tables distinct tables keep their own series for the life of the DeltaForge process (across restarts and delete/recreate of the pipeline); every other table shares one overflow series. Fixed once the pipeline has started with per-table detail: changing it is refused (before anything stops) until DeltaForge restarts.
per_table.lag_idle_secsint300A table’s deltaforge_source_table_lag_seconds series is removed after this many seconds without an event for it.

Out-of-range values are rejected when the pipeline is created and by preflight.

Complete examples

MySQL to Kafka with Debezium envelope

apiVersion: deltaforge/v1
kind: Pipeline
metadata:
  name: orders-mysql-to-kafka
  tenant: acme

spec:
  source:
    type: mysql
    config:
      id: orders-mysql
      dsn: ${MYSQL_DSN}
      tables:
        - shop.orders

  processors:
    - type: javascript
      id: transform
      inline: |
        function processBatch(events) {
          return events.map(event => {
            event.tags = (event.tags || []).concat(["normalized"]);
            return event;
          });
        }
      limits:
        cpu_ms: 50
        mem_mb: 128
        timeout_ms: 500

  sinks:
    - type: kafka
      config:
        id: orders-kafka
        brokers: ${KAFKA_BROKERS}
        topic: orders
        envelope:
          type: debezium
        encoding: json
        required: true
        exactly_once: false
        client_conf:
          message.timeout.ms: "5000"

  batch:
    max_events: 500
    max_bytes: 1048576
    max_ms: 1000
    respect_source_tx: true
    max_inflight: 2

  commit_policy:
    mode: required

PostgreSQL to Kafka with CloudEvents

apiVersion: deltaforge/v1
kind: Pipeline
metadata:
  name: users-postgres-to-kafka
  tenant: acme

spec:
  source:
    type: postgres
    config:
      id: users-postgres
      dsn: ${POSTGRES_DSN}
      slot: deltaforge_users
      publication: users_pub
      tables:
        - public.users
        - public.user_sessions
      start_position: earliest

  sinks:
    - type: kafka
      config:
        id: users-kafka
        brokers: ${KAFKA_BROKERS}
        topic: user-events
        envelope:
          type: cloudevents
          type_prefix: "com.acme.users"
        encoding: json
        required: true

  batch:
    max_events: 500
    max_ms: 1000
    respect_source_tx: true

  commit_policy:
    mode: required

Multi-sink with different formats

apiVersion: deltaforge/v1
kind: Pipeline
metadata:
  name: orders-multi-sink
  tenant: acme

spec:
  source:
    type: mysql
    config:
      id: orders-mysql
      dsn: ${MYSQL_DSN}
      tables:
        - shop.orders

  sinks:
    # Kafka Connect expects Debezium format
    - type: kafka
      config:
        id: connect-sink
        brokers: ${KAFKA_BROKERS}
        topic: connect-events
        envelope:
          type: debezium
        required: true

    # Lambda expects CloudEvents
    - type: kafka
      config:
        id: lambda-sink
        brokers: ${KAFKA_BROKERS}
        topic: lambda-events
        envelope:
          type: cloudevents
          type_prefix: "com.acme.cdc"
        required: false

    # Redis cache uses native format
    - type: redis
      config:
        id: cache-redis
        uri: ${REDIS_URI}
        stream: orders-cache
        envelope:
          type: native
        required: false

  batch:
    max_events: 500
    max_ms: 1000
    respect_source_tx: true

  commit_policy:
    mode: required

MySQL to NATS

apiVersion: deltaforge/v1
kind: Pipeline
metadata:
  name: orders-mysql-to-nats
  tenant: acme

spec:
  source:
    type: mysql
    config:
      id: orders-mysql
      dsn: ${MYSQL_DSN}
      tables:
        - shop.orders
        - shop.order_items

  sinks:
    - type: nats
      config:
        id: orders-nats
        url: ${NATS_URL}
        subject: orders.events
        stream: ORDERS
        envelope:
          type: native
        encoding: json
        required: true

  batch:
    max_events: 500
    max_ms: 1000
    respect_source_tx: true

  commit_policy:
    mode: required

Secrets & Credentials

DeltaForge resolves every credential in a deployment - source database, sinks, schema registry, and the storage backend - from references rather than inline values. A reference names where a secret lives (an environment variable, a file, or a Vault KV path); the runtime reads it at startup, holds it in a zeroized buffer, and hands it straight to the client that needs it. The secret never enters the parsed config, so it cannot leak through the status API, logs, or Debug output.

Inline secrets and ${ENV} expansion still work for backward compatibility, but they are deprecated. Prefer references for anything sensitive.

Reference shape

A reference is a small object:

{ provider: file, location: /run/secrets/db/password }
FieldRequiredDescription
providerYesenv, file, or vault.
locationYesEnv var name, absolute file path, or <mount>/<path> for Vault.
selectorVault onlyField within a structured record (e.g. the KV key).
versionNoProvider-specific immutable version pin (Vault KV version).
purposeNoNon-secret label for diagnostics (e.g. source-password).

Providers

Environment variables (env)

{ provider: env, location: DF_PG_PASSWORD }

Covers Kubernetes env.valueFrom.secretKeyRef, which projects a Secret key into an environment variable.

Files (file)

{ provider: file, location: /run/secrets/db/password }

The path must be absolute. By default the file must be a regular file (not a symlink), which suits operator-mounted secrets.

Kubernetes projected volumes

A projected Secret volume mounts each key as a symlink into a versioned directory. To follow those symlinks safely, set a trusted root so the resolved target must stay within the mounted volume:

  • Sources and sinks: set projected_file_root under spec.secrets (see Pipeline-level secret providers). A source file rotation trigger’s trusted_root is honored as a fallback.
  • Storage backend: set secret_trusted_root on the storage config.
storage:
  backend: postgres
  dsn: postgres://pg.internal:5432/deltaforge      # password-less base
  secret_trusted_root: /var/run/secrets
  credentials:
    username: { provider: file, location: /var/run/secrets/store/username }
    password: { provider: file, location: /var/run/secrets/store/password }

HashiCorp Vault KV (vault)

A Vault reference points at a KV v2 record; location is <mount>/<path> (the KV v2 data/ segment is added automatically, so do not include it) and selector picks the field:

{ provider: vault, location: secret/deltaforge/pg, selector: password }

Vault KV references require a Vault connection. Configure it in the scope that owns the reference: the pipeline (spec.secrets) for source and sink references, and the storage config for the storage backend. The two are independent.

storage:
  backend: postgres
  dsn: postgres://pg.internal:5432/deltaforge
  vault:
    address: https://vault.internal:8200
    auth:
      method: kubernetes
      role: deltaforge-storage
      jwt_path: /var/run/secrets/kubernetes.io/serviceaccount/token
  dsn_secret: { provider: vault, location: secret/deltaforge/store, selector: dsn }

The runner must be built with the vault feature; otherwise a Vault-referenced credential fails closed at startup with a clear error.

Pipeline-level secret providers

spec.secrets assembles the single resolver shared by both source and sink credential resolution. Configure it here whenever any source or sink reference needs Vault or a Kubernetes projected volume - the source does not need to use credential rotation for a sink’s Vault or projected-file reference to resolve.

spec:
  secrets:
    # Enables `vault` references on the source and any sink.
    vault:
      address: https://vault.internal:8200
      auth:
        method: kubernetes
        role: deltaforge-pipeline
        jwt_path: /var/run/secrets/kubernetes.io/serviceaccount/token
    # Enables projected-volume (symlink) `file` references for the source and any sink.
    projected_file_root: /var/run/secrets

When spec.secrets is absent, the resolver falls back to the source’s rotation configuration (backward compatible): a source file rotation trigger supplies the projected-file root, and a source vault trigger supplies the Vault connection.

Where references apply

Source (PostgreSQL / MySQL)

Three mutually exclusive forms:

source:
  type: postgres
  config:
    dsn_secret: { provider: vault, location: secret/pg, selector: dsn }   # whole DSN

or a password-less base DSN plus credential references:

    dsn: postgres://pg.internal:5432/orders
    credentials:
      username: { provider: env,  location: DF_PG_USER }
      password: { provider: file, location: /run/secrets/pg/password }

Storage backend

dsn_secret (whole DSN) or a password-less dsn plus credentials (username/password). Resolved before the backend is opened.

Sinks

SinkReference fields
Kafkasecret_refs map (any client-conf key, e.g. sasl.password)
Redisuri_secret (whole URI) or credentials (username/password) on a password-less uri
NATSusername_ref, password_ref, token_ref
HTTPsecret_refs map (any header name)
S3access_key_id_ref, secret_access_key_ref, session_token_ref
ClickHouseuser_ref, password_ref
Elasticsearchbasic: username_ref, password_ref; apikey: api_key_ref

For Kafka and HTTP, a key set both inline and in secret_refs is rejected - never silently overridden.

Schema registry (Avro)

Any sink using encoding: { type: avro } resolves the registry’s basic-auth password from a reference:

encoding:
  type: avro
  schema_registry_url: https://schema-registry.internal:8081
  username: sr-user
  password_ref: { provider: vault, location: secret/sr, selector: password }

S3 and ambient AWS identity

Omitting all S3 keys is a first-class, no-static-secret mode: the sink uses the ambient AWS credential chain (IAM instance/role, environment, or profile). Provide access_key_id_ref and secret_access_key_ref together or not at all; a partial pair is rejected.

Rules the runtime enforces

All of these fail closed at startup, before any network connection:

  • A reference that resolves to nothing, an empty value, or invalid UTF-8 is an error.
  • Setting both an inline value and its reference for the same field is rejected.
  • Partial credential sets (a username without a password, one S3 key without the other) are rejected.
  • A base DSN that already embeds a password cannot also take credential references.
  • Storage credential resolution happens before the backend is opened; source and sink resolution happens before any client is constructed.

Redaction

Resolved secrets are held in zeroized buffers and never written back into the serializable config. Inline secrets that remain in config for compatibility are redacted everywhere they could surface - the status API, the pipeline Debug representation, and logs all show ***REDACTED*** in place of secret values.

Rotation is restart-required

This baseline does not hot-swap credentials in a live client. Rotating any referenced credential (updating the Secret, file, or Vault KV value) takes effect when the affected pipeline or the runner restarts. Plan rotations as a rolling restart.

Zero-downtime rotation of dynamic Vault database credentials for PostgreSQL and MySQL sources is designed as a separate capability and is not part of this baseline.

Complete example: no inline secrets

apiVersion: deltaforge/v1
kind: Pipeline
metadata:
  name: orders-cdc
  tenant: retail
spec:
  source:
    type: postgres
    config:
      id: pg
      dsn: postgres://pg.internal:5432/orders
      publication: df_pub
      slot: df_slot
      tables: [public.orders]
      credentials:
        username: { provider: env,  location: DF_PG_USER }
        password: { provider: file, location: /run/secrets/pg/password }
  sinks:
    - type: kafka
      config:
        id: kafka
        brokers: broker.internal:9092
        topic: orders.events
        secret_refs:
          sasl.password: { provider: file, location: /run/secrets/kafka/sasl_password }
        encoding:
          type: avro
          schema_registry_url: https://schema-registry.internal:8081
          username: sr-user
          password_ref: { provider: file, location: /run/secrets/sr/password }
    - type: s3
      config:
        id: archive
        bucket: cdc-archive
        prefix: orders/
        # no keys: uses the pod's IAM role (ambient AWS identity)

REST API Reference

DeltaForge exposes a REST API for health checks, pipeline management, schema inspection, and drift detection. All endpoints return JSON.

Base URL

Default: http://localhost:8080

Configure with --api-addr:

deltaforge --config pipelines.yaml --api-addr 0.0.0.0:9090

Health Endpoints

Liveness Probe

GET /health

Liveness only: returns 200 while the process and its API are running, and reports how many pipelines have failed. A failed pipeline is a readiness concern (see below), not a reason to restart the process - a pipeline that stopped fail-closed would fail again after every restart.

Response: 200 OK

{"status": "healthy", "pipelines": 3, "failed_pipelines": 1}

Readiness Probe

GET /ready

Use for Kubernetes readiness probes. Returns 503 while any pipeline has failed, carries a blocking incident that is open or only acknowledged (acknowledging an incident does not clear it), has a pending recovery operation, or is starting, and names them (starting_pipelines lists the starting ones). Paused and stopped pipelines are operator intent and do not hold readiness back. A pipeline whose initial snapshot runs is starting until its snapshot generation completes, so /ready stays 503 for that whole time: in Kubernetes the pod is out of the Service’s endpoints while it snapshots. Liveness (/health) stays 200, so the pod is not restarted.

Response: 503 Service Unavailable

{
  "status": "not_ready",
  "failed_pipelines": ["orders-cdc"],
  "starting_pipelines": [],
  "blocked_pipelines": [
    {
      "name": "orders-cdc",
      "status": "failed",
      "primary_incident": "3f9c...",
      "durability_pending": false,
      "store_unavailable": false,
      "blocking_incidents": [
        {"incident_id": "3f9c...", "reason_code": "unclassified_failure", "status": {"state": "acknowledged"}, "durable": true}
      ],
      "overflow_blocking": 0
    }
  ]
}

Response: 200 OK

{
  "status": "ready",
  "pipelines": [
    {
      "name": "orders-cdc",
      "status": "running",
      "spec": { ... }
    }
  ]
}

Pipeline Management

List Pipelines

GET /pipelines
GET /pipelines?label=env:prod
GET /pipelines?label=env:prod&label=team:platform

Returns all pipelines with current status. Filter by labels with AND logic. Key-only filter (?label=env) matches any value.

Response: 200 OK

[
  {
    "name": "orders-cdc",
    "status": "running",
    "spec": {
      "metadata": { "name": "orders-cdc", "tenant": "acme" },
      "spec": { ... }
    }
  }
]

Get Pipeline

GET /pipelines/{name}

Returns a single pipeline by name with operational status.

status is starting, running, paused, stopped, deleting or failed. A started pipeline is starting until its current run’s capture boundary is fixed and durable, then running, and capture_start names that boundary: changes to the configured tables committed after it are captured. It says nothing about delivery latency, tables excluded by configuration, or delivery while paused. Its origin is first_start (no checkpoint, no snapshot: the server’s position when it was read), resume (the committed resume position), snapshot (a snapshot generation’s anchor, once the generation is durably completed) or maintenance (a PostgreSQL resume moved by a recorded publication maintenance decision). A restart reports starting again until its run publishes its boundary. See design.

"capture_start": {"source": "mysql", "origin": "first_start", "lineage": "…",
                  "server_uuid": "…", "file": "mysql-bin.000007", "pos": 685,
                  "gtid_set": "…:1-721031"}
"capture_start": {"source": "postgres", "origin": "resume", "lineage": "…",
                  "system_identifier": 7412…, "slot": "df_slot", "lsn": "0/16B3780"}

Response: 200 OK

{
  "name": "orders-cdc",
  "status": "running",
  "spec": { ... },
  "ops": {
    "uptime_seconds": 3600.5,
    "dlq_entries": 0,
    "sink_errors": {},
    "checkpoints": [
      {"sink_id": "kafka-primary", "position": {"file": "mysql-bin.000005", "pos": 12345}, "age_seconds": 0.3}
    ]
  }
}

Errors:

  • 404 Not Found - Pipeline doesn’t exist

Create Pipeline

POST /pipelines
Content-Type: application/json

Creates a new pipeline from a full spec.

Request:

{
  "metadata": {
    "name": "orders-cdc",
    "tenant": "acme"
  },
  "spec": {
    "source": {
      "type": "mysql",
      "config": {
        "id": "mysql-1",
        "dsn": "mysql://user:pass@host/db",
        "tables": ["shop.orders"]
      }
    },
    "processors": [],
    "sinks": [
      {
        "type": "kafka",
        "config": {
          "id": "kafka-1",
          "brokers": "localhost:9092",
          "topic": "orders"
        }
      }
    ]
  }
}

Response: 200 OK

{
  "name": "orders-cdc",
  "status": "running",
  "spec": { ... }
}

Errors:

  • 409 Conflict - Pipeline already exists

Update Pipeline

PATCH /pipelines/{name}
Content-Type: application/json

Applies a partial update to an existing pipeline. The spec is merged, the pipeline is restarted from its last saved checkpoint, and the new config takes effect immediately. Only the fields present in the request body are changed — omitted fields retain their current values.

If the pipeline is currently stopped, PATCH applies the new config and restarts it from the saved checkpoint. This is the recommended way to tune throughput settings before resuming after a planned stop.

Request:

{
  "spec": {
    "batch": {
      "max_events": 1000,
      "max_ms": 500
    }
  }
}

Response: 200 OK

{
  "name": "orders-cdc",
  "status": "running",
  "spec": { ... }
}

Errors:

  • 404 Not Found - Pipeline doesn’t exist
  • 400 Bad Request - Invalid field value or name mismatch in patch

Pause Pipeline

POST /pipelines/{name}/pause

Suspends event processing while keeping the source connection alive. No new events are consumed from the binlog/WAL. Resume restarts processing from exactly where it paused — no events are missed.

Response: 200 OK

{
  "name": "orders-cdc",
  "status": "paused",
  "spec": { ... }
}

Resume Pipeline

POST /pipelines/{name}/resume

Resumes a paused or stopped pipeline.

  • From paused — restarts event processing immediately; source connection was kept alive.
  • From stopped — reconnects to the source and replays from the last saved checkpoint; any events written to the binlog/WAL while stopped are replayed in order.
  • From failed: restarts it like a stopped pipeline. A resume never acknowledges or resolves its incidents: an unclassified_failure is resolved (pipeline_recovered) only once the restarted pipeline has reached verified running (the source finished its startup checks and opened its stream while the coordinator runs and nothing has failed).

Response: 200 OK

{
  "name": "orders-cdc",
  "status": "running",
  "spec": { ... }
}

Stop Pipeline

POST /pipelines/{name}/stop

Gracefully stops a pipeline: flushes in-flight events, saves the binlog/WAL checkpoint, and disconnects from the source. The pipeline remains in the registry and can be resumed with POST /pipelines/{name}/resume or by issuing a PATCH with updated config.

Use stop (rather than delete) when you intend to restart the pipeline later — for example, before a planned maintenance window or when tuning config for a backlog drain.

Response: 200 OK

{
  "name": "orders-cdc",
  "status": "stopped",
  "spec": { ... }
}

Delete Pipeline

DELETE /pipelines/{name}

Permanently removes a pipeline from the runtime. The checkpoint is not preserved. Use stop first if you may want to restart the pipeline later.

Response: 204 No Content

Errors:

  • 404 Not Found - Pipeline doesn’t exist

Schema Management

List Database Schemas

GET /pipelines/{name}/schemas

Returns all tracked database schemas for a pipeline. These are the schemas loaded directly from the source database.

Response: 200 OK

[
  {
    "database": "shop",
    "table": "orders",
    "column_count": 5,
    "primary_key": ["id"],
    "fingerprint": "sha256:a1b2c3d4e5f6...",
    "registry_version": 2
  },
  {
    "database": "shop",
    "table": "customers",
    "column_count": 8,
    "primary_key": ["id"],
    "fingerprint": "sha256:f6e5d4c3b2a1...",
    "registry_version": 1
  }
]

Get Schema Details

GET /pipelines/{name}/schemas/{db}/{table}

Returns detailed schema information including all columns.

Response: 200 OK

{
  "database": "shop",
  "table": "orders",
  "columns": [
    {
      "name": "id",
      "column_type": "bigint(20) unsigned",
      "data_type": "bigint",
      "nullable": false,
      "ordinal_position": 1,
      "default_value": null,
      "extra": "auto_increment",
      "is_primary_key": true
    },
    {
      "name": "customer_id",
      "column_type": "bigint(20)",
      "data_type": "bigint",
      "nullable": false,
      "ordinal_position": 2,
      "default_value": null,
      "extra": null,
      "is_primary_key": false
    }
  ],
  "primary_key": ["id"],
  "fingerprint": "sha256:a1b2c3d4..."
}

Schema Sensing

Schema sensing automatically infers schema structure from JSON event payloads. This is useful for sources that don’t provide schema metadata or for detecting schema evolution in JSON columns.

List Inferred Schemas

GET /pipelines/{name}/sensing/schemas

Returns all schemas inferred via sensing for a pipeline.

Response: 200 OK

[
  {
    "table": "orders",
    "fingerprint": "sha256:abc123...",
    "sequence": 3,
    "event_count": 1500,
    "stabilized": true,
    "first_seen": "2025-01-15T10:30:00Z",
    "last_seen": "2025-01-15T14:22:00Z"
  }
]
FieldDescription
tableTable name (or table:column for JSON column sensing)
fingerprintSHA-256 content hash of current schema
sequenceMonotonic version number (increments on evolution)
event_countTotal events observed
stabilizedWhether schema has stopped sampling (structure stable)
first_seenFirst observation timestamp
last_seenMost recent observation timestamp

Get Inferred Schema Details

GET /pipelines/{name}/sensing/schemas/{table}

Returns detailed inferred schema including all fields.

Response: 200 OK

{
  "table": "orders",
  "fingerprint": "sha256:abc123...",
  "sequence": 3,
  "event_count": 1500,
  "stabilized": true,
  "fields": [
    {
      "name": "id",
      "types": ["integer"],
      "nullable": false,
      "optional": false
    },
    {
      "name": "metadata",
      "types": ["object"],
      "nullable": true,
      "optional": false,
      "nested_field_count": 5
    },
    {
      "name": "tags",
      "types": ["array"],
      "nullable": false,
      "optional": true,
      "array_element_types": ["string"]
    }
  ],
  "first_seen": "2025-01-15T10:30:00Z",
  "last_seen": "2025-01-15T14:22:00Z"
}

Export JSON Schema

GET /pipelines/{name}/sensing/schemas/{table}/json-schema

Exports the inferred schema as a standard JSON Schema document.

Response: 200 OK

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "title": "orders",
  "type": "object",
  "properties": {
    "id": { "type": "integer" },
    "metadata": { "type": ["object", "null"] },
    "tags": {
      "type": "array",
      "items": { "type": "string" }
    }
  },
  "required": ["id", "metadata"]
}

Get Sensing Cache Statistics

GET /pipelines/{name}/sensing/stats

Returns cache performance statistics for schema sensing.

Response: 200 OK

{
  "tables": [
    {
      "table": "orders",
      "cached_structures": 3,
      "max_cache_size": 100,
      "cache_hits": 1450,
      "cache_misses": 50
    }
  ],
  "total_cache_hits": 1450,
  "total_cache_misses": 50,
  "hit_rate": 0.9667
}

Drift Detection

Drift detection compares expected database schema against observed data patterns to detect mismatches, unexpected nulls, and type drift.

Get Drift Results

GET /pipelines/{name}/drift

Returns drift detection results for all tables in a pipeline.

Response: 200 OK

[
  {
    "table": "orders",
    "has_drift": true,
    "columns": [
      {
        "column": "amount",
        "expected_type": "decimal(10,2)",
        "observed_types": ["string"],
        "mismatch_count": 42,
        "examples": ["\"99.99\""]
      }
    ],
    "events_analyzed": 1500,
    "events_with_drift": 42
  }
]

Get Table Drift

GET /pipelines/{name}/drift/{table}

Returns drift detection results for a specific table.

Response: 200 OK

{
  "table": "orders",
  "has_drift": false,
  "columns": [],
  "events_analyzed": 1000,
  "events_with_drift": 0
}

Errors:

  • 404 Not Found - Table not found or no drift data available

Dead Letter Queue

See the DLQ page for full documentation.

Peek DLQ Entries

GET /pipelines/{name}/journal/dlq?limit=50&sink_id=kafka-primary&error_kind=serialization

Returns DLQ entries (oldest first). All query params are optional.

DLQ Count

GET /pipelines/{name}/journal/dlq/count

Response: 200 OK

{"count": 42}

Acknowledge DLQ Entries

POST /pipelines/{name}/journal/dlq/ack
Content-Type: application/json

{"up_to_seq": 42}

Permanently removes entries from the head up to the given sequence number.

Response: 200 OK

{"acked": 12}

Purge DLQ

DELETE /pipelines/{name}/journal/dlq

Response: 200 OK

{"purged": 42}

Checkpoint Inspection

Get Checkpoints

GET /pipelines/{name}/checkpoints

Returns per-sink checkpoint positions and ages.

Response: 200 OK

[
  {"sink_id": "kafka-primary", "position": {"file": "mysql-bin.000005", "pos": 12345}, "age_seconds": 0.3},
  {"sink_id": "redis-cache", "position": {"file": "mysql-bin.000005", "pos": 11000}, "age_seconds": 2.1}
]

Incidents

A pipeline that stops (or cannot do something safely) records an incident: a structured description with a stable reason_code, retryability, safety_state (halted_safe, halted_uncertain, running_degraded), allow-listed evidence, recommended_actions and an explanation generated from them. Incidents never contain error text, DSNs, SQL or row values. The same condition is the same incident across retries and restarts (its occurrences grow); a later independent occurrence is a new one.

Lifecycle: open -> acknowledged -> resolved. Acknowledging means an operator has seen it; the incident stays open, keeps blocking the pipeline and readiness, and is resolved only by a verified check (or a future recovery operation). Every transition is recorded in an audit log, including reclassified: the same condition raised again with another retryability or safety state (an automatic retry that exhausted its budget now needs an operator) updates the incident rather than opening a new one. A pipeline keeps at most 63 open incidents individually; beyond that one incident_overflow incident counts the rest by reason code (blocking ones first).

Reason codes:

reason_codeRaised whenSafetyResolved by
pg_different_clusterA PostgreSQL source reaches another cluster (or a replaced database); refused before anything is recorded or streamedhalted_safelineage_verified: a later start verifies the source’s own server
pg_continuity_unprovenA PostgreSQL resume position cannot be shown to continue on the server, checked before every START_REPLICATION on the session that then streams (see promotion continuity). Classes: slot_missing, slot_invalidated, wal_lost, slot_beyond_checkpoint, slot_not_persistent_logical, unknown_slot_position, timeline_not_descended, switch_before_checkpoint, switch_before_read_position, checkpoint_chain_mismatch (the checkpoint belongs to another continuity chain, to a transition the record never proved, carries a partial stamp, or predates a recorded transition without being the position it was proven at; evidence carries both chain ids and transitions; recommended: inspect, re-snapshot), slot_not_synced, failover_unsupported_version, timeline_unrecorded, server_in_recovery (retried for up to 2 min as auto_retry, then operator_action; route the source to the writable primary), wal_behind_checkpoint, or unknown (unknown_unreachable: retried for up to 2 min, during which the incident is open as auto_retry, then the same incident becomes operator_action when the source stops, or is resolved as operation_cancelled when the pipeline is stopped on purpose; unknown_query_failed: a server error answer, operator_action at once). Evidence includes the recorded and live timelines, the switch point, the slot positions, the WAL flush and read positions; replication never openshalted_safeposition_verified: a later start verifies the position
pg_failover_slot_unavailableA PostgreSQL 17+ replication slot is not a failover slot: the source keeps streaming on the current primary, but a promotion would stop it (slot_not_synced). Recommended action enable_failover_slotrunning_degradedfailover_slot_verified: a later stream proves the slot is a failover slot
pg_catalog_visibility_unprovenA PostgreSQL catalog read could not be proven to reach the node and live walsender of the stream (the slot held by the stream’s own session, its nonce, its database) at or past the commit position of the transaction being decoded, within 30 seconds; classes include server_in_recovery, slot_not_held_by_stream, walsender_nonce_mismatch, behind_target, catalog_unreachable. Nothing read from that session is usedhalted_safea later start whose catalog reads are proven
pg_annotation_unavailableA capture-time catalog annotation of a PostgreSQL table could not be recorded. Rows and event schemas are unaffectedrunning_degradedannotation_recorded: a later annotation of that table is recorded
pg_table_unsupportedA tracked PostgreSQL table uses an unsupported feature, for example a stored generated column (stored_generated_column), or a pipeline whose sinks need complete rows captures a table with TOAST-capable columns without REPLICA IDENTITY FULL (toast_without_full_identity, checked before streaming and again at each Relation message; evidence: the first table and blocking_count, the number of tables; the source log names each table with its ALTER TABLE ... REPLICA IDENTITY FULL statement); the source stops before that table’s next rowhalted_safea later start after the table is excluded or changed
pg_publication_changedThe source’s publication is not registered, changed since the registration the source accepted (changed), was re-registered without a maintenance decision (reregistered), awaits a re-snapshot (resnapshot_pending), or the stream would start before the registration (retained_wal); the source stops before streaminghalted_safethe publication maintenance procedure (pg-publication-maintenance)
pg_message_too_largeA PostgreSQL replication message is larger than the source’s max_message_bytes. The session is abandoned without a reconnect and the checkpoint stays before the message’s transaction. Evidence: message_bytes, message_limit, source_id (never the payload)halted_safepipeline_recovered: restart with a raised max_message_bytes
pg_publication_enforcementThe database’s publication enforcement is missing or was tampered with; changes between the tampering and this check are outside the guaranteehalted_saferestore enforcement through the maintenance procedure
mysql_gtid_position_unavailableThe MySQL resume position is not available: purged, not_executed, binlog_missing, or unknown (as above)halted_safeposition_verified
mysql_capture_contentionA MySQL activation window could not lock the metadata of its tables for their schema capture within proofs.capture_lock_wait_secs, on three attempts (a schema change holds a lock on one of them). Classes: lock_wait_timeout, deadlock. Evidence: the first table, blocking_count (the number of tables), attempts, checkpoint_position. Nothing of the window was emitted and the checkpoint stays before it; retried automatically by a restarthalted_safepipeline_recovered: the restarted pipeline reaches verified running
state_item_too_largeA new state item (a registry entry or an activation record) is larger than the store’s 16 MiB item limit; the store refused it before writing anything of its batch. Evidence: table, state_bytes, state_limit, key_digest (a SHA-256 of the item’s namespace and key), checkpoint_position. Nothing of the window was emitted and the checkpoint stays before it; an operator must acthalted_safepipeline_recovered: the restarted pipeline reaches verified running
mysql_table_state_too_largeA MySQL table’s state from capture through commit (its schema, history and writes) is larger than the 64 MiB per-table limit, so no activation window can admit it. Evidence: table, state_bytes, state_limit, checkpoint_position. Raised when the table is reached as a window’s table; nothing of the window was emitted and the checkpoint stays before it; an operator must acthalted_safepipeline_recovered: the restarted pipeline reaches verified running
mysql_control_plane_saturatedA MySQL schema capture (an activation window’s, a failover drift check’s, or a DDL’s forward proof) got no control-plane connection within its wait: the process budget (--max-mysql-control-connections) or the source’s pool (proofs.control_connections) stayed full. Class: the bound that stayed full, runner or source. Evidence: the first table, blocking_count (the number of tables), checkpoint_position. Nothing that needed the capture was emitted or recorded and the checkpoint stays before it; retried automatically by a restarthalted_safepipeline_recovered: the restarted pipeline reaches verified running
mysql_timeline_too_largeA MySQL table’s activation timeline (its records and the barriers that apply to it) holds more than 100,000 records or 64 MiB, so it is never loaded whole and the table’s rows cannot be decoded. Evidence: table, key_digest (of the table’s activation stream key), state_limit, and read_items/state_bytes when the timeline was read whole. Nothing of the table’s rows was emitted and the checkpoint stays before them; an operator must acthalted_safepipeline_recovered: the restarted pipeline reaches verified running
mysql_forward_proof_unavailableA MySQL DDL’s forward proof (the capture of a tracked table’s new shape and the scan since the DDL) could not be completed. While a transient failure is retried in place (lock wait, deadlock, busy capture slots, connection failure; at most 4 attempts within 120 s) the incident is open as auto_retry; when the retries are exhausted, or at once for an authentication or authorization failure, the source stops on it as operator action. Class: the last failure (lock_wait_timeout, deadlock, capture_slots_busy, transport, read, not_reached, deadline, authentication, authorization). Evidence: the first table, blocking_count (tables not proven), attempts, checkpoint_position. Nothing after the DDL was emitted and the checkpoint stays before it; a restart replays the DDL and proves it again. A retry that ends otherwise is withdrawn as operation_cancelled, its class naming why (ended_deferred, ended_cancelled, ended_by_lineage, ended_by_mysql_control_plane_saturated, ended_by_store)halted_safeforward_proof_bound when a retry proves every table; otherwise pipeline_recovered: the restarted pipeline reaches verified running
mysql_table_registry_too_largeA MySQL table’s registration needs more reads than one plan allows (16,384; for example thousands of lineages), so no activation window can admit it. Evidence: table, read_items, state_limit, checkpoint_position. Nothing of the window was emitted and the checkpoint stays before it; an operator must acthalted_safepipeline_recovered: the restarted pipeline reaches verified running
schema_drift_blockedon_schema_drift = halt stopped at a schema change of a tablehalted_safeschema_accepted: that table is accepted at its first use in a later run (unchanged schema, or an adapt reload)
sink_ack_uncertainAn S3 durable publish whose HEAD update response was lost and HEAD could not be reread (a Kafka transaction commit whose outcome stays unknown stops the pipeline as unclassified_failure until an operator recovery path exists)halted_uncertainOnly a read of exactly that boundary, never a later batch: at the start of a later run the sink rereads the HEAD object the incident names (object_key, expected_generation = epoch, seq and entry the update was conditioned on, content_identity) and resolves it as sink_boundary_committed (HEAD’s chain holds the proposed entry at the proposed seq) or sink_boundary_absent (the complete, integrity-checked chain back to the conditioned generation holds another entry there, or HEAD is still at that generation). A truncated, compacted, corrupt or unreadable history leaves it open
source_decode_failedA source value cannot be decoded exactly by its column’s declared type (undecodable bytes for the column’s character set, a MySQL ENUM index 0 or one beyond the definition, a SET bit outside the definition, a PostgreSQL value that does not parse under the pinned session, an unchanged TOAST value outside an update). Evidence: table, column, source_type, never the value. The source stops before the transaction: with transaction-aligned batching (the default) none of its events is delivered, and the checkpoint stays before ithalted_safepipeline_recovered: the restarted pipeline reaches verified running
sink_conversion_failedA sink cannot represent an event’s value exactly in its encoding (for example an Avro field), or the event has a column its schema version does not define. Evidence: column, source_type, target_type, never the value. With a DLQ that durably acknowledges the event, it is dead-lettered and the checkpoint advances past it; otherwise the pipeline stops before ithalted_safepipeline_recovered: the restarted pipeline reaches verified running
sink_schema_rejectedA sink’s Schema Registry rejected a schema as incompatible with the subject’s registered versions (HTTP 409). Evidence: subject. Never dead-lettered; the checkpoint does not pass the event. (A registry outage is retried, not an incident of its own)halted_safepipeline_recovered: the restarted pipeline reaches verified running
sink_destination_conflictA ClickHouse table or Elasticsearch index the sink is about to write is not provably its own, or this writer no longer holds its fence. Classes: marker_missing, marker_malformed, marker_unsupported_version, owned_by_other, bound_to_another_relation, destination_identity_mismatch, encoding_mismatch, fenced (a server-enforced rejection: another claimer rotated the fence), fence_malformed, alias_not_supported, destination_missing, unrecorded_destination, server_changed, unsupported_topology, fencing_unenforced. Evidence: sink_id, destination, relation, reason_class, bound_relation and an owner digest where relevant; never row data or credentials. Never dead-lettered; the checkpoint does not advancehalted_safepipeline_recovered: the restarted pipeline reaches verified running
unclassified_failureAny other failure of a source, sink or the coordinator (cause code only)halted_uncertainpipeline_recovered: the restarted pipeline reaches verified running
incident_overflowMore than 63 open incidents; counts the rest by reason

Conditions resolved by a verified start (unclassified_failure, pg_different_cluster, the two position reasons) are bound to a recovery generation: the same failure before the restarted pipeline reaches verified running updates the same incident; after a genuine recovery it is a new one.

Status (GET /pipelines/{name}, GET /pipelines, /ready) carries an incidents summary: primary (the blocking incident of a failed pipeline), primary_final, blocking, overflow_blocking, durability_pending (an incident is known but its durable write is still being retried - it is not yet auditable) and store_unavailable.

List Incidents

GET /pipelines/{name}/incidents

Every incident of the pipeline (open, acknowledged, recently resolved, and any not yet durable), with the primary incident.

Get Incident

GET /pipelines/{name}/incidents/{id}

Response: 200 OK

{
  "incident_id": "3f9c...",
  "reason_code": "unclassified_failure",
  "component": {"kind": "source", "id": "mysql"},
  "retryability": "auto_retry",
  "safety_state": "halted_uncertain",
  "cause_code": "source_connect",
  "explanation": "source mysql failed (source_connect). The pipeline stopped; this failure is not classified yet, see the logs for details.",
  "evidence": {},
  "recommended_actions": ["inspect_logs"],
  "status": {"state": "open"},
  "blocking": true,
  "occurrences": 2,
  "durable": true,
  "audit_pending": false
}

Acknowledge Incident

POST /pipelines/{name}/incidents/{id}/acknowledge
Content-Type: application/json

{"asserted_actor": "alice", "reason": "investigating the source outage"}

Moves an open incident to acknowledged and records the actor, the request’s peer address (origin) and the reason in the audit log. The API has no authenticated identity yet: asserted_actor is recorded as caller-supplied (actor_verified: false). It does not change the pipeline, its source, sinks or checkpoints, and does not resolve the incident.

Responses: 200 OK with the incident; 400 when a field is empty; 404 for an unknown incident; 409 when it is already resolved, or not durable yet (retry once it is).


System Endpoints

Log Level

GET /log-level

Returns the current RUST_LOG value.

Response: 200 OK

{"level": "deltaforge=info,sources=info,sinks=info,warn"}

Validate Config

POST /validate
Content-Type: application/json

Dry-run validation of a pipeline config without creating it.

Response: 200 OK — config is valid

{"valid": true, "pipeline": "orders-cdc", "source_type": "mysql", "sink_count": 2}

Response: 400 Bad Request — config has errors

{"valid": false, "error": "spec: missing field `processors` at line 7 column 3"}

Error Responses

All error responses return structured JSON:

{
  "code": "PIPELINE_NOT_FOUND",
  "message": "pipeline orders-cdc not found"
}
Status CodeCodeMeaning
400 Bad RequestPIPELINE_NAME_MISMATCHInvalid request body or name mismatch
404 Not FoundPIPELINE_NOT_FOUNDResource doesn’t exist
409 ConflictPIPELINE_ALREADY_EXISTSResource already exists
500 Internal Server ErrorINTERNAL_ERRORUnexpected server error

Pipelines

Each pipeline is created from a single PipelineSpec. The runtime spawns the source, processors, and sinks defined in the spec and coordinates them with batching and checkpointing.

  • 🔄 Live control: pause, resume, or stop pipelines through the REST API without redeploying.
  • 📦 Coordinated delivery: batching and commit policy keep sinks consistent even when multiple outputs are configured.

Lifecycle controls

The REST API addresses pipelines by metadata.name and returns PipeInfo records containing the live spec and status.

  • GET /health - liveness probe.
  • GET /ready - readiness with pipeline states.
  • GET /pipelines - list pipelines.
  • POST /pipelines - create from a full spec.
  • PATCH /pipelines/{name} - merge a partial spec (for example, adjust batch thresholds) and restart the pipeline.
  • POST /pipelines/{name}/pause - pause ingestion and coordination.
  • POST /pipelines/{name}/resume - resume a paused pipeline.
  • POST /pipelines/{name}/stop - stop a running pipeline.

Pausing halts both source ingestion and the coordinator. Resuming re-enables both ends so buffered events can drain cleanly.

Processors

Processors run in the declared order for each batch. The built-in processor type is JavaScript, powered by deno_core.

  • type: javascript
    • id: processor label.
    • inline: JS source. Export a processBatch(events) function that returns the transformed batch.
    • limits (optional): resource guardrails (cpu_ms, mem_mb, timeout_ms).

Batching

The coordinator builds batches using soft thresholds:

  • max_events: flush after this many events.
  • max_bytes: flush after the serialized size reaches this limit.
  • max_ms: flush after this much time has elapsed since the batch started.
  • min_flush_interval_ms (default 40): when the input drains and no batch is being delivered, a batch of whole transactions is delivered at once, but no sooner than this long after the previous such delivery. A quiet source’s transaction goes immediately; a steady trickle is batched over this window; under load, batches fill to the limits above.
  • respect_source_tx: when true, never split a single source transaction across batches.
  • max_inflight: cap the number of batches being processed concurrently.

Commit policy

When multiple sinks are configured, checkpoints can wait for different acknowledgement rules:

  • all: every sink must acknowledge.
  • required (default): only sinks marked required: true must acknowledge; others are best-effort.
  • quorum: checkpoint after at least quorum sinks acknowledge.

Sources

DeltaForge captures database changes through pluggable source connectors. Each source is configured under spec.source with a type field and a config object. Environment variables are expanded before parsing using ${VAR} syntax.

Supported Sources

SourceStatusDescription
mysql✅ ProductionMySQL binlog CDC with GTID support
postgres✅ ProductionPostgreSQL logical replication via pgoutput

Common Behavior

All sources share these characteristics:

  • Checkpointing: Progress is automatically saved and resumed on restart
  • Schema tracking: Table schemas are loaded and fingerprinted for change detection
  • At-least-once delivery: Events may be redelivered after failures; sinks should be idempotent
  • Batching: Events are batched according to the pipeline’s batch configuration
  • Transaction boundaries: respect_source_tx: true (default) keeps source transactions intact

Source Interface

Sources implement a common trait that provides:

#![allow(unused)]
fn main() {
trait Source {
    fn checkpoint_key(&self) -> &str;
    async fn run(&self, tx: Sender<Event>, checkpoint_store: Arc<dyn CheckpointStore>) -> SourceHandle;
}
}

The returned SourceHandle supports pause/resume and graceful cancellation.

Adding Custom Sources

The source interface is pluggable. To add a new source:

  1. Implement the Source trait
  2. Add configuration parsing in deltaforge-config
  3. Register the source type in the pipeline builder

See existing sources for implementation patterns.

MySQL

MySQL source

DeltaForge tails the MySQL binlog to capture row-level changes with automatic checkpointing and schema tracking.

Prerequisites

MySQL Server Configuration

Ensure your MySQL server has binary logging enabled with row-based format:

-- Required server settings (my.cnf or SET GLOBAL)
log_bin = ON
binlog_format = ROW
gtid_mode = ON                    -- required when the initial snapshot runs
enforce_gtid_consistency = ON     -- required with gtid_mode = ON
log_replica_updates = ON          -- required (the default); see below
binlog_row_image = FULL  -- Recommended for complete before-images
binlog_row_metadata = FULL  -- Recommended: decodes rows across unobserved schema changes

log_replica_updates = ON is required on every source, primary or replica (log_slave_updates before MySQL 8.0.26; the default since 8.0). With it OFF, transactions the server applies through replication - from a channel configured now or at any later time - change its tables and advance its executed GTID set without reaching the binlog DeltaForge reads, so changes would be missed and a schema change could go unseen. DeltaForge refuses such a server before it writes anything durable, reads any table or runs a snapshot, and checks again each time a binlog stream opens (startup, every reconnect, credential rotation) before reading from it, and before a failover candidate is reconciled. The setting is read-only: changing it requires a server restart, which ends the stream, so the reconnect afterwards is checked. Changing it on a running server is otherwise not possible and not supported. The check fails closed when the setting cannot be read.

If binlog_row_image is not FULL, DeltaForge will warn at startup and before-images on UPDATE/DELETE events may be incomplete. For binlog_row_metadata, see Schema Tracking.

Initial-snapshot requirements. When a snapshot runs (snapshot.mode = initial or always), DeltaForge fails closed at preflight unless: gtid_mode = ON, binlog_format = ROW, every snapshotted table uses the InnoDB storage engine, and the user holds the global RELOAD privilege (see Snapshot). These are checked only when a snapshot actually runs; a CDC-only pipeline (snapshot.mode = never) is never rejected for them.

User Privileges

Create a dedicated replication user with the required grants:

-- Create the user with the server's default authentication plugin
-- (caching_sha2_password on MySQL 8.x)
CREATE USER 'deltaforge'@'%' IDENTIFIED BY 'your_password';

-- Replication privileges (required)
GRANT REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'deltaforge'@'%';

-- The capture boundary of a first start without a checkpoint and without a
-- snapshot: the binlog position and GTID set, read together
GRANT BACKUP_ADMIN ON *.* TO 'deltaforge'@'%';
GRANT SELECT ON performance_schema.log_status TO 'deltaforge'@'%';

-- Global RELOAD (required only when the initial snapshot runs; used for the
-- brief FLUSH TABLES WITH READ LOCK that brackets the consistent anchor)
GRANT RELOAD ON *.* TO 'deltaforge'@'%';

-- Schema introspection (required for table discovery)
GRANT SELECT, SHOW VIEW ON your_database.* TO 'deltaforge'@'%';

FLUSH PRIVILEGES;

For capturing all databases, grant SELECT on *.* instead. RELOAD is only required if the pipeline will run an initial snapshot; a CDC-only pipeline (snapshot.mode = never) does not need it.

Configuration

Set spec.source.type to mysql and provide a config object:

FieldTypeRequiredDescription
idstringYesUnique identifier used for checkpoints, server_id derivation, and metrics
dsnstringYesMySQL connection string with replication privileges
tablesarrayYesTable patterns to capture. The key is required, but an empty list [] captures all user tables.
proofsobjectNoBounds of the schema proofs: see Schema proof bounds.

Table Patterns

The tables field takes table patterns; the initial snapshot and CDC select exactly the same tables:

tables:
  - shop.orders          # exact match: database "shop", table "orders"
  - shop.order_%         # prefix: tables starting with "order_" in "shop" (same as shop.order_*)
  - analytics.*          # wildcard: all tables in "analytics" database
  - %.audit_log          # cross-database: "audit_log" table in any database
  # use an empty list [] to capture all user tables (excludes system schemas)

A wildcard (* or %) only counts as the last character of a part (a prefix match); _ and any other character are literal. A pattern without a qualifier matches that table name in every database/schema. The initial snapshot copies exactly the tables these patterns capture during CDC.

System schemas (mysql, information_schema, performance_schema, sys) are always excluded.

Example

source:
  type: mysql
  config:
    id: orders-mysql
    dsn: ${MYSQL_DSN}
    tables:
      - shop.orders
      - shop.order_items

Schema proof bounds

proofs bounds the work that proves the schema version of a table’s first rows (see Rows are decoded with the definition in effect when they were written). Every field is optional. A value outside its range is refused when the configuration is loaded:

FieldDefaultRangeDescription
activation_window_ms501 to 1,000How long after its first event was received an activation window reads ahead to batch the first rows of newly active tables. The window holds the stream’s dispatch meanwhile.
activation_window_events100001 to 100,000At most this many events buffered by a window.
activation_window_bytes1677721665,536 to 268,435,456At most this many bytes of events buffered by a window.
capture_lock_wait_secs301 to 300How long a window’s capture waits for each table’s metadata lock, on each of its three attempts.
max_seek_ms10000100 to 600,000A proof scan with no known start offset fails closed when the server sends no transaction within this bound.
control_connections42 to 16The source’s control-plane pool: at most this many SQL connections at once for schema captures, catalog reads, position queries and health checks.
source:
  type: mysql
  config:
    id: orders-mysql
    dsn: ${MYSQL_DSN}
    tables: ["shop.*"]
    proofs:
      activation_window_ms: 50
      max_seek_ms: 10000

Activation histories

A table’s schema version at its rows is chosen from its activation timeline: the records of its own stream and the barriers that apply to it (its lineage’s and its database’s), kept in the state store.

  • Cached and bounded: a source caches at most 4,096 tables’ timelines and 256 MiB of them (records, barrier streams once each, and the schemas it read), whichever comes first. The bytes cover resident memory. Admission evicts the least recently used entries first; an evicted table is read again exactly from the store.

  • Read in batches: a table the cache does not hold is read with the other uncached tables the stream is about to reach (the events an activation window read ahead or holds, or a short read-ahead within the window’s bounds), at most 256 tables per statement, each barrier stream once. Known tables returning after eviction cost a few store calls per batch, not per table.

  • Barriers: a barrier invalidates only the cached timelines and selections of the tables it applies to: a database barrier, that database’s; a lineage barrier, every table’s.

  • The per-timeline limit: a timeline is never loaded whole beyond 100,000 records or 64 MiB (stored bytes), its barrier streams included. A table over the limit stops the source with a mysql_timeline_too_large incident before any of its rows is dispatched; the checkpoint stays before them. It needs an operator: the timeline does not shrink by itself. Before upgrading, this read-only query on the state store (PostgreSQL or SQLite) lists the activation and barrier streams that are large, as records and approximate bytes:

    SELECT ns, key, COUNT(*) AS records,
           SUM(LENGTH(val) + COALESCE(LENGTH(capture_id), 0)
               + COALESCE(LENGTH(content_hash), 0) + 32) AS bytes
    FROM df_log
    WHERE ns IN ('schemas.v1.activation', 'schemas.v1.activation.barrier')
    GROUP BY ns, key
    HAVING COUNT(*) > 10000 OR SUM(LENGTH(val)) > 8388608
    ORDER BY bytes DESC;
    

    A table’s timeline is its activation stream (key is the table’s key) plus its lineage’s and its database’s barrier streams (keys that prefix it).

Control-plane connections

Every SQL connection a MySQL source opens besides its binlog stream and its snapshot copy (schema captures, catalog reads, position and identity queries, health checks) comes from the source’s pool of proofs.control_connections, within one budget for the whole process, --max-mysql-control-connections (default 32, of which 4 are reserved for cancelling captures):

  • A connection holds its permits from before it opens until it has closed.
  • A returned connection is kept as the source’s one idle connection for at most 10 s; any other closes. When the process budget is full, every idle connection closes at once to make room.
  • Waits are first come, first served. A capture waits at most its capture_lock_wait_secs; a position query or a health check at most 5 s, one health check at a time per source; a catalog read at most 30 s.
  • An activation window’s, a failover drift check’s or a DDL’s forward proof capture that gets no connection within its wait stops the source with a mysql_control_plane_saturated incident and is retried by the restart: without it, rows could not be decoded, drift decided, or the rows after the DDL proven. A forward proof stops before its DDL is emitted or checkpointed; the restart replays the DDL and proves it.
  • A snapshot baseline that gets no connection keeps its existing safe deferral, as for any failed capture: it only proves early what the table’s lazy proof proves at its next rows, so the table is proven there before any of those rows is delivered. Each deferral is logged and counted in deltaforge_mysql_control_saturation_deferrals_total{kind} (kind="baseline"); it never raises an incident.
  • Cancelling a capture never waits for a pool: it draws on the reserve.
  • After a credential rotation or a failover, connections opened before it are never reused: the idle one closes at once, the others when returned.

deltaforge_mysql_connections{source, kind} reports a source’s open connections by kind (replication, control, cancellation, snapshot_copy). deltaforge_mysql_control_permit_wait_seconds{class}, deltaforge_mysql_control_permit_timeouts_total{class, scope} and deltaforge_mysql_control_idle_evictions_total report the waits.

A DDL’s forward proof

When the stream reads a DDL on a tracked table, the source proves the table’s new shape (a capture of it and a scan of the binlog since the DDL) before the DDL event is emitted. A proof that fails for a transient reason is retried in place, with the stream paused at the DDL:

  • Retried: a metadata-lock wait timeout or deadlock (another session holds or queues the table’s lock), busy capture slots, and connection failures (refused, reset, timed out, closed, server shutting down).
  • Bounds: at most 4 attempts within 120 s from the first, with 1, 4 and 16 s between them. A capture starts only with at least 1 s left, and waits for the lock at most 30 s or the time left. Each attempt captures and scans afresh; a table proven in one attempt is committed then and not captured again.
  • Exhausted: the source stops before the DDL is emitted or checkpointed, with a mysql_forward_proof_unavailable incident. A restart replays the DDL and proves it again; tables proven earlier are recognized by their recorded binding. While the proof retries, the same incident is open as auto_retry; it is resolved when the proof succeeds.
  • Not retried: an authentication or authorization failure stops the source the same way at once (operator action). A connection to another server stops it as a lineage error, and a saturated control plane as mysql_control_plane_saturated, as before. A refusal on the evidence (a DDL on the table in the scanned interval, a purged binlog, a scan bound) leaves the DDL pending as before; the table’s next rows are then proven by its lazy proof, which can still succeed.
  • Deadline: no capture, scan, store batch or repair of the proof starts after its deadline. Store work already in progress is not cut: planning reads, a repair, a batch (including the resolution of an unacknowledged commit), and binding or freshness reads, so the proof can end after the deadline by as long as those take. A capture or scan cut at the deadline is ended on the server. A stop during the last batch keeps what it committed and still stops the source before the DDL.

deltaforge_mysql_forward_proof_retries_total{class} counts retried failures (lock_wait_timeout, deadlock, capture_slots_busy, transport, read, not_reached, deadline), and deltaforge_mysql_forward_proof_outcomes_total{outcome} the ends of proofs that retried (recovered, ended_deferred, exhausted, cancelled, ended_by_terminal).

Resume Behavior

DeltaForge automatically checkpoints progress and resumes from the last position on restart. The resume strategy follows this priority:

  1. GTID: Preferred if the MySQL server has GTID enabled. Provides the most reliable resume across binlog rotations and failovers.
  2. File:position: Used when GTID is not available. Resumes from the exact binlog file and byte offset.
  3. Capture boundary: On first run with no checkpoint and no snapshot, the source reads the binlog position and executed GTID set together in one performance_schema.log_status query (MySQL documents them as consistent with each other) and persists it before the stream opens. The pipeline reports running only after that, with the position as capture_start: changes committed after it are captured, earlier ones are not (no historical replay). Every retry and restart resumes from it, never from a newer tail. Reading it needs BACKUP_ADMIN and SELECT on performance_schema.log_status (both, only for this first start; a start with a checkpoint or a snapshot does not read it); without them the start fails closed with a permission error naming both grants, with no fallback:
GRANT BACKUP_ADMIN ON *.* TO 'deltaforge'@'%';
GRANT SELECT ON performance_schema.log_status TO 'deltaforge'@'%';

performance_schema.log_status exists from MySQL 8.0.14. The requirement is validated on MySQL 8.4 only; other versions and managed services (where BACKUP_ADMIN may not be grantable) are not yet qualified.

Checkpoints are stored using the id field as the key.

GTID sets are exact

A GTID checkpoint holds exactly the transactions DeltaForge has seen or resumed past, gaps included. Gaps in a server’s executed set are normal:

  • Group Replication and InnoDB Cluster members assign GTIDs of the group’s UUID from their own blocks (group_replication_gtid_assignment_block_size, 1,000,000 by default). A group whose members both write has an executed set such as group:1-3:1000001, and the gap fills as the first member commits more.
  • An explicitly assigned GTID (SET gtid_next) leaves the numbers below it free for later transactions.

A checkpoint that claimed such a gap would make the server skip the transactions that fill it on resume. Earlier versions stored group:1-3:1000001 as group:1-1000001; on a group, every row the first member committed after a restart was then lost. Such checkpoints are refused (see MySQL GTID state from earlier versions).

Snapshot (Initial Load)

DeltaForge can perform a consistent initial snapshot of existing table data before starting binlog streaming. This is the recommended approach for migrating existing tables without manual backfills.

How it works

DeltaForge establishes a single consistent anchor under a brief global read lock. It holds FLUSH TABLES WITH READ LOCK on a dedicated connection only while it opens a bounded pool of REPEATABLE READ START TRANSACTION WITH CONSISTENT SNAPSHOT workers and captures the binlog position + GTID set, then releases the lock. Because no transaction can commit while the lock is held, every worker shares one consistent view that matches the captured position exactly - so a row committed during snapshot setup is never lost from both the snapshot and the CDC stream. Workers then read in parallel (bounded by max_parallel_tables), each reading its tables under its single snapshot and committing once.

The lock window is bounded by snapshot.lock_timeout_secs (default 10s); if the lock cannot be acquired in time, the snapshot fails closed rather than stalling writes. The lock is always released - the dedicated lock connection is dropped on any error, panic, or timeout, which releases it server-side.

Correctness note. This closes a real initial-snapshot data-loss window in earlier versions, where per-worker snapshots were opened independently and the position was captured afterward, so a change committed during snapshot setup could be absent from both the snapshot and the CDC stream.

Requirements (fail closed at preflight when a snapshot runs): gtid_mode = ON, binlog_format = ROW, all snapshotted tables InnoDB, and the global RELOAD privilege.

Managed MySQL (RDS/Aurora and similar). Where FLUSH TABLES WITH READ LOCK is unavailable (RELOAD restricted), the snapshot fails closed with a typed permission error - DeltaForge does not silently fall back to an unsafe anchor. Remediation:

  • grant the global RELOAD privilege if your provider allows it; or
  • run without an initial load by setting snapshot.mode = never to stream only changes from the current position (this is not a complete initial load); or
  • perform the initial load out of band (e.g. a provider snapshot/dump) and start DeltaForge in never mode.

Additional privileges

-- Required for snapshot: SELECT (introspection + reads) and global RELOAD.
GRANT SELECT ON your_database.* TO 'deltaforge'@'%';
GRANT RELOAD ON *.* TO 'deltaforge'@'%';

Configuration

source:
  type: mysql
  config:
    id: orders-mysql
    dsn: ${MYSQL_DSN}
    tables:
      - shop.orders
    snapshot:
      mode: initial           # initial | always | never (default: never)
      max_parallel_tables: 8  # tables snapshotted concurrently (also caps workers under the lock)
      chunk_size: 10000       # rows per chunk for integer-PK tables
      lock_timeout_secs: 10   # bound on the brief FLUSH TABLES WITH READ LOCK setup window
FieldDefaultDescription
modeneverinitial: run once if no checkpoint exists; always: re-snapshot on every restart; never: skip
max_parallel_tables8Tables snapshotted concurrently; also bounds the worker connections opened under the read lock
lock_timeout_secs10Upper bound on acquiring the consistent-anchor lock; the snapshot fails closed if exceeded
chunk_size10000Rows per range chunk (integer single-column PK tables only; others do a full scan)
discovery_page_size1000Tables read per catalog query during discovery; also the most plan entries held in memory
max_snapshot_connectionsmax_parallel_tables x max_parallel_chunks + 2This source’s share of the process-wide snapshot connection cap (--max-snapshot-connections, default 64)
max_anchor_age_secs86400How long a generation may hold its anchor; blocks at the limit
max_plan_bytes / max_plan_items256 MiB / 1000000Bounds of the durable plan; blocks before sealing at either limit

Snapshot events

Snapshot rows are emitted as Op::Read events (Debezium op: "r"), distinguishable from live CDC Op::Create events. The binlog position captured at snapshot time becomes the CDC resume point, so no rows are missed. A snapshot copied again after a restart delivers its rows again (at-least-once).

Resume after interruption

An interrupted snapshot is copied again in full on the next restart, as a new snapshot generation with a new anchor; it is never resumed table by table. Losing any worker’s consistent-snapshot connection loses the generation’s read view and replaces it the same way.

A snapshot counts as complete only once its terminal barrier is committed by the sinks the commit policy requires; a restart after that completes it without copying. Completion, lagging sinks, bounds and blocking are described in Initial Snapshots.

Binlog retention safety

DeltaForge validates binlog retention before starting a snapshot and monitors it throughout. This prevents the silent failure mode where a long snapshot completes successfully but CDC startup then fails because the captured binlog position was purged.

Preflight checks (before any rows are read):

  • Fails hard if log_bin=0 or binlog_format != ROW
  • Estimates snapshot duration from table sizes and max_parallel_tables
  • Warns at ≥50% of binlog_expire_logs_seconds usage; HIGH RISK at ≥80%

During snapshot:

  • Background task polls SHOW BINARY LOGS every 30s
  • Cancels the snapshot immediately if the captured file is purged

After all tables complete:

  • Synchronous final check before the generation records its rows as produced: a snapshot whose anchor binlog file was purged never completes, and blocks with snapshot_anchor_unavailable (see Bounds and blocking)

If you see retention risk warnings, the recommended actions are:

  1. Increase binlog_expire_logs_seconds to cover the estimated snapshot duration
  2. Use a read replica as the snapshot source to avoid load on the primary

Server ID Handling

MySQL replication requires each replica to have a unique server_id. DeltaForge derives this automatically from the source id using a CRC32 hash:

server_id = 1 + (CRC32(id) % 4,000,000,000)

When running multiple DeltaForge instances against the same MySQL server, ensure each has a unique id to avoid server_id conflicts.

Schema Tracking

DeltaForge has a built-in schema registry to track table schemas, per source. For MySQL source:

  • Schemas are loaded when a table is first used, not at startup: startup work does not grow with the number of matched tables (only an initial snapshot reads the tables it copies)
  • Each schema is fingerprinted using SHA-256 for change detection
  • Events carry schema_version (the fingerprint of the version the row was decoded with) and schema_sequence (that version’s registry sequence)
  • Schema-to-checkpoint correlation enables reliable replay

A DDL forgets the cached shapes of the tables it affects; their new versions are registered only by a positional proof (below), never reloaded from the live catalog. See Schema Registry: DDL Handling.

DDL events

Each DDL statement emits one event (source.table = "_ddl", operation r). Its payload (format 2) identifies what the statement acts on:

{
  "ddl_format": 2,
  "scope": "tables",
  "objects": [
    {"kind": "table", "database": "shop", "name": "orders"},
    {"kind": "table", "database": "archive", "name": "orders"}
  ],
  "database": null,
  "session_database": "shop",
  "sql": "RENAME TABLE shop.orders TO archive.orders"
}
  • scope: what the statement does to base-table shapes:
    • tables: it may change the listed tables;
    • same_shape: TRUNCATE;
    • database: a database barrier;
    • lineage: unattributable;
    • none: no base table changes (views, account statements).
  • objects: {kind, database, name} for each table, view or barrier database, in statement order. A rename lists source then target. The list is empty when the statement cannot be attributed. Under lower_case_table_names = 2 table statements are applied as lineage barriers but keep their objects.
  • database and source.db: name the one database every object belongs to. A cross-database statement or one without objects has database: null and source.db: "".
  • session_database: the connection’s active database when the statement ran (null when none). It is context only, never the attribution.
  • Account statements (CREATE USER, ALTER USER, SET PASSWORD) are scope: none with no objects. Their SQL is never emitted (operation, redacted: true instead).
  • No DDL event, as before: plain GRANT and REVOKE.

Rows are decoded with the definition in effect when they were written

Binlog row events carry values by column position, not by name. DeltaForge decodes each rows event with the schema version proven for its position in the binlog, never simply with the table’s current definition, so replaying retained binlog across a DDL (after a restart from an older checkpoint, or while catching up) puts every value under the right column. A version is proven by:

  • the table’s definition captured when its first rows without other proof arrive, and proven unchanged between those rows and the capture (no DDL of the table and no unattributable statement in between);
  • a DDL’s resulting definition, captured right after the source reads the DDL and proven unchanged since;
  • with binlog_row_metadata = FULL and neither of the above: the single recorded definition that exactly matches the row’s binlog metadata (column names, types, signedness, charsets, primary key), recorded durably before the row is emitted.

Capturing a definition never requires the server to be idle. In one read-only transaction the source takes the table’s shared metadata lock (the lock any SELECT takes: inserts, updates and deletes proceed, DDL of that table or its database waits), then reads the binlog position, the table’s definition (several INFORMATION_SCHEMA queries) and the position again, then releases the lock. A DDL already running on the table finishes before the lock is granted, so the definition read is never older than the position; a DDL issued meanwhile waits a few milliseconds and lands after the capture. The source then proves from the binlog that nothing between the two positions could have changed that table, such as an unattributable statement. Ordinary writes, to any table, and DDL of other tables do not matter, so a busy server proves tables as readily as a quiet one. Only a relevant statement inside that short interval causes a retry; a binlog that cannot be read (purged, unknown events, scan limits) or a lock not granted within 30 seconds stops the proof instead. The capture needs SELECT on the table. Table and database names are compared as lower_case_table_names defines: exactly with 0, case-insensitively with 1, and with 2 (names stored as written, compared case-insensitively) every DDL counts as relevant.

When no version can be proven, the source stops with a schema error naming the table, emits nothing for those rows and does not advance its checkpoint. Under the default binlog_row_metadata = MINIMAL this happens when:

  • retained rows predate a DDL the source never observed (for example, a checkpoint restored from before a schema change made by an earlier deployment);
  • a statement DeltaForge cannot attribute to specific tables was executed (versioned comments such as /*!50100 ALTER TABLE ... */, several statements in one event, ALTER DATABASE or DROP DATABASE of a tracked database, among others): every table it may affect stays unproven until later proof for that table exists - usually its next rows, once the source captures the table’s definition and proves nothing changed it since them, or a later DDL of the table that the source proves - or a re-snapshot;
  • a table’s first rows (or its first rows after an unattributable statement) are followed, before the source reads them, by a DDL of that table or another unattributable statement: the definition those rows were written under can no longer be captured (this needs the source to lag behind the database);
  • two DDLs of one table both happen before the source reads the first, with rows written between them (the definition between the two was never captured; with binlog_row_metadata = FULL those rows proceed only if that definition was already recorded and is the single exact match).

Re-snapshot is the immediate remediation that always works.

Many newly active tables at once. When rows of a table without proof arrive, the source first reads ahead (an activation window, at most proofs.activation_window_ms, without advancing its position or emitting anything) and collects every other table whose first rows follow. A statement that can change a schema, or an idle stream, closes the window early. One read-only transaction then captures all of these tables: it locks their metadata in sorted order, which avoids deadlocking against a multi-table DDL, and reads every definition between the same two binlog positions. Meanwhile the source keeps reading the stream, holding what arrives for the next window without emitting it, so tables first touched during an admission are not held for a second full window. The next window’s capture and proof scan also start while the current window is still being admitted. It is decided and written only after the current window commits, in stream order: tables it captured that turn out to be proven already are dropped, and any table it should have captured but did not stops the source (an internal error, never repaired by guessing). At most two windows exist at a time. One binlog scan proves them all, and each table is admitted or refused exactly as it would be on its own. The admitted tables’ schema versions and proofs are then written in one state-store transaction: all of them or none. A failover drift check runs for every table of the window first, so a drift incident stops the whole window with nothing of it written. If the store cannot commit the window (after a few retries of the whole transaction), the source stops; it never falls back to admitting the tables one by one. If the locks are not granted within proofs.capture_lock_wait_secs on three attempts, the source stops with a mysql_capture_contention incident: nothing of the window is emitted, the checkpoint stays before it, and a restart retries once the schema change holding the lock is done. There is no fallback to one capture per table.

Proof scans start where their interval starts. A proof scan normally opens the binlog at a position the source observed in its own stream, either the transaction of the rows or the end of the last scan, and checks the first events the server sends before trusting it. Without such a position, for example a snapshot baseline, the server seeks from the start of the binlog file. That seek is bounded by proofs.max_seek_ms: a seek that exceeds it stops the proof rather than wait, and the position the scan then observes positions the next proof at the same boundary. GTID membership still decides what a proof contains, so the start position never changes a proof, only how long it takes.

Recommended: set binlog_row_metadata = FULL on the server. It removes the first two cases whenever the row’s definition is still recorded, at a small cost in binlog size.

Schema versions record per-column detail (character octet length, collation, fractional-seconds precision, primary-key prefix). After upgrading from a release that did not, the first capture of each table may register one new version even without a DDL; events then carry that version’s schema_version and schema_sequence.

Timeouts and Heartbeats

BehaviorValueDescription
Heartbeat interval15sServer sends heartbeat if no events
Read timeout90sMaximum wait for next binlog event
Inactivity timeout60sTriggers reconnect if no data received
Connect timeout30sMaximum time to establish connection

These values are currently fixed. Reconnection uses exponential backoff with jitter.

Event Format

Each captured row change produces an event with:

  • op: insert, update, or delete
  • before: Previous row state (updates and deletes only, requires binlog_row_image = FULL)
  • after: New row state (inserts and updates only)
  • table: Fully qualified table name (database.table)
  • tx_id: GTID if available
  • checkpoint: Binlog position for resume
  • schema_version: Schema fingerprint
  • schema_sequence: Monotonic sequence for schema correlation

Troubleshooting

Connection Issues

DeltaForge authenticates with the server’s default plugin (caching_sha2_password on MySQL 8.x); no particular plugin is required. MySQL 8.4 disables mysql_native_password by default, so a user created IDENTIFIED WITH mysql_native_password fails to log in with an error mentioning that plugin. Move the user to the server’s default plugin:

ALTER USER 'deltaforge'@'%' IDENTIFIED BY 'password';

Missing Before-Images

If UPDATE/DELETE events have incomplete before data:

SET GLOBAL binlog_row_image = 'FULL';

Binlog Not Enabled

Check binary logging status:

SHOW VARIABLES LIKE 'log_bin';
SHOW VARIABLES LIKE 'binlog_format';
SHOW BINARY LOG STATUS;  -- or SHOW MASTER STATUS on older versions

Privilege Issues

Verify grants for your user:

SHOW GRANTS FOR 'deltaforge'@'%';

Required grants include REPLICATION SLAVE and REPLICATION CLIENT, plus BACKUP_ADMIN and SELECT on performance_schema.log_status for a first start without a checkpoint and without a snapshot.

PostgreSQL

PostgreSQL source

DeltaForge captures row-level changes from PostgreSQL using logical replication with the pgoutput plugin.

Prerequisites

PostgreSQL Server Configuration

Enable logical replication in postgresql.conf:

# Required settings
wal_level = logical
max_replication_slots = 10    # At least 1 per DeltaForge pipeline
max_wal_senders = 10          # At least 1 per DeltaForge pipeline

Restart PostgreSQL after changing these settings.

User Privileges

Create a replication user with the required privileges:

-- Create user with replication capability
CREATE ROLE deltaforge WITH LOGIN REPLICATION PASSWORD 'your_password';

-- Grant connect access
GRANT CONNECT ON DATABASE your_database TO deltaforge;

-- Grant schema usage and table access for schema introspection
GRANT USAGE ON SCHEMA public TO deltaforge;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO deltaforge;

-- The replication SLOT is auto-created on first run using the REPLICATION
-- attribute granted above. The PUBLICATION is NOT auto-created — create it
-- yourself (see "Replication Slot and Publication" below).

The same role reads the catalog for capture-time annotations and checks. Each such read first proves, in its own snapshot, that it reaches the node and the live walsender serving the stream: it reads pg_replication_slots, pg_stat_activity (the walsender’s row; visible to the same role, otherwise grant pg_read_all_stats) and pg_control_system(). A capture takes the table’s ACCESS SHARE lock (granted by SELECT on the table) for a few milliseconds: inserts, updates and deletes are not blocked, DDL on that table waits until the capture ends.

Unsupported: a tracked table with a stored generated column (GENERATED ALWAYS AS ... STORED) stops the source before its next row (pg_table_unsupported): pgoutput does not publish generated columns. Exclude the table or drop the generation.

pg_hba.conf

Ensure your pg_hba.conf allows replication connections:

# TYPE  DATABASE        USER            ADDRESS                 METHOD
host    replication     deltaforge      0.0.0.0/0               scram-sha-256
host    your_database   deltaforge      0.0.0.0/0               scram-sha-256

Replication Slot and Publication

DeltaForge creates the replication slot on first run and records durable ownership of it (bound to the server’s system_identifier, the database, the slot name, and the plugin). The publication is never created by DeltaForge: create it, then register it.

CREATE PUBLICATION my_pub FOR TABLE public.orders, public.order_items;

The publication contract

A publication DeltaForge streams from is immutable while it is registered. Registration is a superuser step, done before the source’s first start:

deltaforge pg-publication --dsn-file superuser.dsn register --publication my_pub
# review the database-wide impact printed, then repeat with --confirm

Registering a publication:

  • requires an explicit FOR TABLE list of ordinary tables, with all four publish operations, no row filter, no column list, no publish_via_partition_root and no publish_generated_columns. FOR ALL TABLES, TABLES IN SCHEMA and partitioned tables are refused: their membership changes without publication DDL;
  • transfers the publication to the NOLOGIN role deltaforge_publication_owner and records its canonical digest and the WAL position of the registration;
  • installs enforcement for the whole database. While any publication of the database is registered, every ALTER PUBLICATION and DROP PUBLICATION is refused for every role, superusers included, and any drop that would remove a registered publication’s table (DROP TABLE, DROP SCHEMA ... CASCADE, DROP OWNED, …) is refused inside its transaction. The database enforces this while DeltaForge is stopped.

At startup and on every reconnect, before it snapshots, decodes, emits or advances a checkpoint, the source verifies that enforcement is intact, the publication is registered and its live digest equals the registration it accepted, and that it does not stream from WAL older than the registration. Otherwise it stops with pg_publication_changed or pg_publication_enforcement.

Changing a publication (membership, options, a table drop) is a database-wide maintenance procedure:

  1. stop every DeltaForge PostgreSQL source using the database;
  2. for each, record how it continues: deltaforge recover apply <pipeline> pg-publication-maintenance --arg mode=resnapshot (then the resnapshot operation) or --arg mode=abandon (the backlog before the new registration is skipped and never delivered; the applied plan is the audit record);
  3. deltaforge pg-publication ... unregister --publication <each>;
  4. deltaforge pg-publication ... uninstall (refused while any registration remains, naming them);
  5. apply the change;
  6. register the publications again (new digests);
  7. restart the sources.

Outside the guarantee: a superuser can disable or drop the enforcement triggers. The source detects it at its next startup or reconnect and stops, but changes made between the tampering and that check are not guaranteed to have been captured completely.

Replica Identity

For complete before-images on UPDATE and DELETE operations, set tables to REPLICA IDENTITY FULL:

ALTER TABLE public.orders REPLICA IDENTITY FULL;
ALTER TABLE public.order_items REPLICA IDENTITY FULL;

Without this setting:

  • FULL: Complete row data in before-images
  • DEFAULT (primary key): Only primary key columns in before-images
  • NOTHING: No before-images at all

DeltaForge warns at startup if tables don’t have REPLICA IDENTITY FULL.

Unchanged TOAST values

PostgreSQL stores large values out of line (TOAST). When an UPDATE leaves such a value unchanged, PostgreSQL does not send it in the new row.

  • Under REPLICA IDENTITY FULL PostgreSQL sends the complete old row, including those values (verified on PostgreSQL 14 through 18). DeltaForge fills each unchanged value from the old row, exactly. The event’s after is complete.
  • Otherwise the value is not available. Sinks that accept partial rows (native JSON: Kafka, Redis, NATS, HTTP, S3 JSON Lines) leave it out of after and list it in unchanged_columns.

Sinks that need complete rows (Avro encoding on any sink, S3 Parquet, ClickHouse, Elasticsearch) cannot represent a missing value. A pipeline with at least one of them requires REPLICA IDENTITY FULL on every captured table that has a TOAST-capable column (any variable-length type stored with a strategy other than PLAIN, domains and arrays included, read from the catalog).

  • At start: a bulk check over the publication’s tables, before anything is snapshotted or streamed, refuses the pipeline with pg_table_unsupported (class toast_without_full_identity). The source log names every such table with its TOAST-capable columns and the statement that fixes it, for example ALTER TABLE "public"."docs" REPLICA IDENTITY FULL;. The refusal contains table and column names only, never row values, and the checkpoint does not move.
  • While running: the check is repeated whenever PostgreSQL announces a table’s definition (stream start, after DDL, after reconnect or failover), so changing a table’s identity away from FULL stops the source before that table’s next row.

A pipeline whose sinks all accept partial rows does not require FULL; one sink that needs complete rows is enough to require it. deltaforge_pg_unchanged_toast_total{pipeline,source,outcome} counts values filled from the old row (reconstructed) and values left unavailable (unavailable).

WAL cost of FULL. Under FULL, every UPDATE and DELETE writes the complete old row to the WAL, and the old row includes its out-of-line values even when the UPDATE leaves them unchanged. The cost grows with row width:

Workload (PostgreSQL 17, one 1,000-10,000 row UPDATE, steady state)WAL with DEFAULTWAL with FULL
Narrow rows (int, int, short text), one column changed227 B/row251 B/row (+10%)
Rows with 16 KiB of out-of-line text/jsonb, a small column changed253 B/row16.3 KiB/row (about 66x)

More WAL also means more WAL decoded and sent to the source, and more retained for the slot while the source is behind. For wide, frequently updated tables, weigh this against delivering those tables only to sinks that accept partial rows.

Configuration

Set spec.source.type to postgres and provide a config object:

FieldTypeRequiredDefaultDescription
idstringYes—Unique identifier for checkpoints and metrics
dsnstringYes—PostgreSQL connection string
slotstringYes—Replication slot name
publicationstringYes—Publication name
tablesarrayYes—Table patterns to capture
start_positionstring/objectNo—⚠️ Parsed but not yet implemented — a new slot always starts at its consistent point (the current WAL LSN when it is created)
max_message_bytesintegerNo1 GiBLargest replication message accepted, 1 to 1073741824 bytes (see Message size limit)

Message size limit

PostgreSQL sends each row change as one replication message, so a single wide row (or a large pg_logical_emit_message() payload) must fit under max_message_bytes. A larger message is refused before any of it is buffered, and the source stops fail-closed with the incident pg_message_too_large:

  • the session that received it is abandoned and the source does not reconnect: a reconnect with the same limit would meet the same message again;
  • the checkpoint stays before the message’s transaction, so nothing of it is acknowledged or delivered partially;
  • the evidence is the message’s size (message_bytes), the limit (message_limit) and the source, never the payload.

Raise max_message_bytes above the reported size and restart the pipeline: it resumes from the held checkpoint and delivers the whole transaction. The limit bounds one message, not a transaction or total memory.

DSN Formats

DeltaForge accepts both URL-style and key=value DSN formats:

# URL style
dsn: "postgres://user:pass@localhost:5432/mydb"

# Key=value style
dsn: "host=localhost port=5432 user=deltaforge password=pass dbname=mydb"

Table Patterns

The tables field supports flexible pattern matching:

tables:
  - public.orders          # exact match: schema "public", table "orders"
  - public.order_%         # prefix: tables starting with "order_" (same as public.order_*)
  - myschema.*             # wildcard: all tables in "myschema"
  - %.audit_log            # cross-schema: "audit_log" table in any schema
  - orders                 # table "orders" in every schema

A wildcard (* or %) only counts as the last character of a part (a prefix match); _ and any other character are literal. A pattern without a qualifier matches that table name in every database/schema. The initial snapshot copies exactly the tables these patterns capture during CDC.

System schemas (pg_catalog, information_schema, pg_toast) are always excluded.

Start Position

⚠️ Not yet implemented. start_position is accepted by the config parser but currently ignored — a new slot always starts from its consistent point (the current WAL position when it is created). The forms below are aspirational.

Intended to control where replication begins when no checkpoint exists:

# Start from the earliest available position (slot's restart_lsn)
start_position: earliest

# Start from current WAL position (skip existing data)
start_position: latest

# Start from a specific LSN
start_position:
  lsn: "0/16B6C50"

Example

source:
  type: postgres
  config:
    id: orders-postgres
    dsn: ${POSTGRES_DSN}
    slot: deltaforge_orders
    publication: orders_pub
    tables:
      - public.orders
      - public.order_items

Resume Behavior

DeltaForge checkpoints progress using PostgreSQL’s LSN (Log Sequence Number):

  1. With checkpoint: Resumes from the stored LSN
  2. Without checkpoint: Uses the slot’s confirmed_flush_lsn or restart_lsn
  3. New slot: Starts from the slot’s consistent point (the LSN its creation returns): changes committed after it are captured.

The pipeline reports running only once the slot exists, the start position is persisted for every sink and the stream is open, with that position as capture_start. A retry or restart before then starts from the slot again, never from a newer position.

Checkpoints are stored using the id field as the key.

Snapshot (Initial Load)

DeltaForge performs a consistent initial snapshot using PostgreSQL’s exported snapshot mechanism before starting logical replication.

How it works

DeltaForge anchors CDC at the replication slot’s consistent point C - the LSN pg_create_logical_replication_slot returns when the slot is created - rather than a separately sampled pg_current_wal_lsn(). A coordinator connection then exports an MVCC snapshot for worker mutual consistency; worker connections import it into their own REPEATABLE READ transactions, so all workers see one consistent DB state with no locks held on the source. Tables with a single integer primary key use PK-range chunking; others fall back to ctid page-range chunking.

Anchoring at C (which precedes the snapshot read) closes the snapshot-to-CDC seam: no committed row is lost. The trade-off is a bounded at-least-once overlap - a change committed between C and the snapshot export is present in both the snapshot and the CDC stream from C, so it is delivered more than once. This is at-least-once, not exactly-once (see Snapshot events).

Correctness note. Earlier versions sampled pg_current_wal_lsn() decoupled from the exported snapshot, which admitted a (small) window where a row could be absent from both the snapshot and the CDC stream. The slot-consistent-point anchor removes that window, trading the loss risk for the bounded overlap above.

Configuration

source:
  type: postgres
  config:
    id: orders-postgres
    dsn: ${POSTGRES_DSN}
    slot: deltaforge_orders
    publication: orders_pub
    tables:
      - public.orders
    snapshot:
      mode: initial           # initial | always | never (default: never)
      max_parallel_tables: 8  # tables snapshotted concurrently
      chunk_size: 10000       # rows per chunk for integer-PK tables
FieldDefaultDescription
modeneverinitial: run once if no checkpoint exists; always: re-snapshot on every restart; never: skip
max_parallel_tables8Tables snapshotted concurrently
chunk_size10000Rows per range chunk (integer PK tables only; others use ctid chunking)
discovery_page_size1000Tables read per catalog query during discovery; also the most plan entries held in memory
max_snapshot_connectionsmax_parallel_tables x max_parallel_chunks + 2This source’s share of the process-wide snapshot connection cap (--max-snapshot-connections, default 64)
max_anchor_age_secs86400How long a generation may hold its anchor; blocks at the limit
max_plan_bytes / max_plan_items256 MiB / 1000000Bounds of the durable plan; blocks before sealing at either limit

Snapshot events

Snapshot rows are emitted as Op::Read events (Debezium op: "r"), distinguishable from live CDC Op::Create events. The slot’s consistent point C is the CDC resume position, so no rows are missed. Rows committed in (C, snapshot-export] are delivered by both the snapshot (as Op::Read) and the CDC stream (as their change op) - the bounded at-least-once overlap. The two copies carry distinct event identities:

  • Current-state / idempotent sinks (Elasticsearch, ClickHouse upsert, with version_source: source_position) converge to the correct current state - the duplicate is absorbed by last-writer-wins.
  • Append-only sinks (Kafka, Redis, HTTP, ClickHouse changelog, legacy S3) receive the overlapping rows twice, by design.

This is at-least-once delivery; DeltaForge does not claim exactly-once for the snapshot-to-CDC boundary.

Legacy-anchor warning and metric

A pipeline whose initial snapshot was completed under the older (pre-hardening) anchor is flagged so you can decide to re-snapshot:

  • a structured WARN at startup, and
  • the gauge deltaforge_snapshot_unsafe_anchor{pipeline,source} held at 1.

Re-snapshotting under this version records the safe anchor and resets the gauge to 0. See Upgrade guidance.

Resume after interruption

An interrupted snapshot is copied again in full on the next restart, as a new snapshot generation with a new anchor; it is never resumed table by table. When DeltaForge can prove it owns the now-inactive slot, it re-anchors (drops and recreates its slot for a fresh C).

A snapshot counts as complete only once its terminal barrier is committed by the sinks the commit policy requires; a restart after that completes it without copying. Completion, lagging sinks, bounds and blocking are described in Initial Snapshots.

WAL slot retention safety

DeltaForge validates replication slot health before starting a snapshot and monitors it throughout. This prevents the slot from being invalidated during a long snapshot, which would make the captured LSN unreachable for CDC resume.

Preflight checks (before any rows are read):

  • Fails hard if the slot does not exist or is already invalidated
  • Fails hard if wal_status=lost
  • Warns if wal_status=unreserved (WAL retention no longer guaranteed)
  • Estimates WAL generated during snapshot (~2× data size) against max_slot_wal_keep_size; warns at ≥50%, HIGH RISK at ≥80%

During snapshot:

  • Background task polls pg_replication_slots every 30s
  • Cancels immediately on slot invalidation or disappearance
  • Warns but continues on wal_status=unreserved

After all tables complete:

  • Synchronous final check before the generation records its rows as produced: a snapshot whose slot is lost, invalidated or missing never completes, and blocks with snapshot_anchor_unavailable (see Bounds and blocking)

If you see WAL retention risk warnings:

ALTER SYSTEM SET max_slot_wal_keep_size = '10GB';
SELECT pg_reload_conf();

Upgrade guidance

The slot-consistent-point anchor and durable slot ownership change how existing slots are handled when a snapshot runs. Steady-state CDC resume from a checkpoint is unaffected - the notes below apply only when a snapshot runs.

  • Existing slots created before this version have no ownership record. A re-snapshot on such a slot (mode: always, or mode: initial after clearing the checkpoint) cannot prove ownership and fails closed with remediation: drop the slot manually (SELECT pg_drop_replication_slot('<slot>')) so DeltaForge recreates it with ownership, then restart. New pipelines and first-run snapshots are unaffected.
  • Ownership ambiguity / foreign slots. If the slot exists but DeltaForge cannot prove exclusive ownership (missing, partial, or mismatched record - e.g. a different server system_identifier after a restore/failover) or the slot is active, it fails closed and never drops the slot. Remediation: confirm no other consumer uses it, drop it, and restart; or set snapshot.mode = never to stream from the current position without an initial load.
  • Interrupted snapshots. An owned, inactive slot left by a snapshot that was interrupted before its first checkpoint is re-anchored (dropped and recreated for a fresh consistent point) and fully re-snapshotted automatically.
  • Safe re-snapshotting. To take a fresh, correctly-anchored snapshot, stop the pipeline and apply the resnapshot recovery operation (or set mode: always), then resume. Checkpoints are kept; a slot this source owns is re-anchored, and a lost one is recreated only under the operation’s explicit authorization. A completed, safely-anchored snapshot resets deltaforge_snapshot_unsafe_anchor to 0.

Type Handling

DeltaForge preserves PostgreSQL’s native type semantics:

PostgreSQL TypeJSON Representation
booleantrue / false
integer, bigintJSON number
real, double precisionJSON number
numericJSON string (preserves precision)
text, varcharJSON string
json, jsonbParsed JSON object/array
bytea{"_base64": "..."}
uuidJSON string
timestamp, date, timeISO 8601 string
Arrays (int[], text[], etc.)JSON array
TOAST unchangedfilled from the old row under REPLICA IDENTITY FULL; otherwise left out of after and listed in unchanged_columns (see Unchanged TOAST values)

Event Format

Each captured row change produces an event with:

  • op: insert, update, delete, or truncate
  • before: Previous row state (updates and deletes, requires appropriate replica identity)
  • after: New row state (inserts and updates)
  • table: Fully qualified table name (schema.table)
  • tx_id: PostgreSQL transaction ID (xid)
  • checkpoint: LSN position for resume
  • schema_version: Schema fingerprint
  • schema_sequence: Monotonic sequence for schema correlation

WAL Management

Logical replication slots prevent WAL segments from being recycled until the consumer confirms receipt. To avoid disk space issues:

  1. Monitor slot lag: Check pg_replication_slots.restart_lsn vs pg_current_wal_lsn()
  2. Set retention limits: Configure max_slot_wal_keep_size (PostgreSQL 13+)
  3. Handle stale slots: Drop unused slots with pg_drop_replication_slot('slot_name')
-- Check slot status and lag
SELECT slot_name, 
       pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) as lag
FROM pg_replication_slots;

Troubleshooting

Connection Issues

If you see authentication errors:

-- Verify user has replication privilege
SELECT rolname, rolreplication FROM pg_roles WHERE rolname = 'deltaforge';

-- Check pg_hba.conf allows replication connections
-- Ensure the line type includes "replication" database

Missing Before-Images

If UPDATE/DELETE events have incomplete before data:

-- Check current replica identity
SELECT relname, relreplident 
FROM pg_class 
WHERE relname = 'your_table';
-- d = default, n = nothing, f = full, i = index

-- Set to FULL for complete before-images
ALTER TABLE your_table REPLICA IDENTITY FULL;

Slot/Publication Not Found

-- List existing publications
SELECT * FROM pg_publication;

-- List existing slots
SELECT * FROM pg_replication_slots;

-- Create if missing
CREATE PUBLICATION my_pub FOR TABLE public.orders;
SELECT pg_create_logical_replication_slot('my_slot', 'pgoutput');

WAL Disk Usage Growing

-- Check slot lag
SELECT slot_name, 
       active,
       pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) as lag
FROM pg_replication_slots;

-- If slot is inactive and not needed, drop it
SELECT pg_drop_replication_slot('unused_slot');

Logical Replication Not Enabled

-- Check wal_level
SHOW wal_level;  -- Should be 'logical'

-- If not, update postgresql.conf and restart PostgreSQL
-- wal_level = logical

Initial Snapshots

An initial snapshot copies the existing rows of every captured table, then hands over to the change stream at a single anchor. This page covers what PostgreSQL and MySQL share: generations, restarts, completion under the commit policy, bounds and blocking, connection limits, the upgrade from earlier releases, and measured scale. The engine-specific mechanics (anchors, locks, privileges) are on the MySQL and PostgreSQL pages.

Generations

Each snapshot is a generation: one complete copy of one sealed table plan, read in one database read view and anchored at one stream position.

  • The source discovers its tables in catalog pages and writes each table to a durable plan in the state store. The plan is sealed at the anchor and never changes.
  • Before any row of a generation is published, every sink of the pipeline moves its own checkpoint into that generation (the start barrier). Every sink must accept, including optional ones. A sink that is unavailable, or whose stored position cannot move into the generation (another snapshot chain, a later generation, an unknown format), fails the start: the pipeline stops, and each later start tries again with a new generation.
  • Rows are published chunk by chunk. Each boundary records a constant-size snapshot position, not a per-table cursor.
  • When every table is read and every final check passed, the source sends the terminal barrier, and each sink commits it once all earlier rows are durably delivered.
  • The generation is completed when the sinks that acknowledged the terminal satisfy the commit policy. The stream then continues from the anchor.

Generation numbers never repeat. Generations of one source belong to a snapshot chain, so a sink can tell a newer generation from an older one; positions of different chains are never compared.

Restarts copy the whole snapshot again

A generation lives and dies with its read view. An interrupted snapshot is never resumed table by table or chunk by chunk: a process restart, a lost worker connection or a lost read view replaces the generation, and the next generation reads every table again under a new anchor. Sinks receive the rows already sent once more (at-least-once; never loss). The replacement raises a non-blocking snapshot_replaced incident.

The exceptions:

  • If the terminal barrier was already acknowledged by enough sinks to satisfy the frozen policy (a crash after the acknowledgement), the restart completes the generation without copying and streams.
  • A blocked generation is never replaced automatically (see Blocking).
  • A completed generation streams changes and never copies rows, except with snapshot.mode: always (a new generation on every start).
  • A change to the sink cohort or commit policy before completion replaces the generation (snapshot_replaced, class policy_changed): a terminal is only judged by the cohort that started it.

Plan restart time accordingly: a crash late in a long snapshot costs a full copy.

Completion and lagging sinks

At allocation the generation freezes the pipeline’s commit policy: the mode, the quorum and every sink with its required flag. Completion is judged only against that frozen cohort:

PolicyThe generation completes when
allevery sink has committed the terminal
required (default)every required: true sink has
quorumat least quorum sinks have

The completion record keeps the acknowledging sinks; later configuration changes never reinterpret it.

A sink that lags, failing or missing a batch of the generation, does not hold completion under required or quorum:

  • A sink that missed any batch since the generation started cannot acknowledge its terminal: it would claim rows it never delivered. It is left behind the terminal.
  • Once the generation completes, every sink behind it gets a non-blocking sink_snapshot_incomplete incident naming the sink and generation, with the action rebootstrap_sink. It is excluded from the resume position, so it never pulls the stream back into the snapshot, and it continues with changes from the resume position. It does not receive the rest of that snapshot: give it a fresh baseline (a new generation, or an out-of-band load).
  • Under all, a failing sink stops the pipeline instead. Once the sink recovers and the pipeline resumes, the next start replaces the generation and every sink receives the whole snapshot.

A sink added after completion starts behind it and is treated the same way.

Bounds and blocking

Every generation runs inside explicit bounds. Crossing 80% of a bound raises a non-blocking snapshot_bound_warning. Crossing the bound stops the generation before anything is lost:

BoundSetting (default)At the bound
Anchor age (the read view held open, the log retained after the anchor)snapshot.max_anchor_age_secs (86,400 = 24 h)blocked, snapshot_anchor_unavailable (class anchor_age)
Plan storage (durable plan in the state store)snapshot.max_plan_bytes (256 MiB), snapshot.max_plan_items (1,000,000), checked during discoveryblocked before the plan is sealed, snapshot_bound_exceeded
PostgreSQL WAL retention of the snapshot’s slotfrom pg_replication_slots (wal_status, safe_wal_size)slot lost, invalidated or missing: blocked, snapshot_anchor_unavailable
MySQL binlog retentionthe anchor’s binlog file and GTID set still on the serveranchor file purged: blocked, snapshot_anchor_unavailable

The plan bound is the state store’s resource; WAL and binlog retention are the source’s. They are separate settings and separate incidents.

Two related stops are not bounds:

  • Corrupt or unknown-format snapshot state stops the source with snapshot_state_invalid and is left untouched; every start stops on it again.
  • A second process found owning the source before a publish (concurrent_owner) stops only the stale run, with a non-blocking incident; it never changes the current owner’s generation. One owner per source remains a deployment requirement (see Supported Deployment Envelope).

A blocked generation stays halted across restarts. Every start re-raises its incident and stops before any row or replacement. These failures need an operator decision (raise the bound, narrow the tables, restore retention), so nothing retries them automatically.

Recovery is the explicit, proof-bound resnapshot recovery operation, which replaces the blocked generation with a recovery allocation of the next one, audited with actor and reason. Raise the bound or restore what the incident names first; then plan, review and apply it, and resume the pipeline.

Connection limits

Every connection a snapshot opens (PostgreSQL coordinator and workers; MySQL lock connection, binlog guard and workers; intra-table readers) is taken from one process-wide pool:

  • --max-snapshot-connections (default 64) caps snapshot connections across every pipeline of the process.
  • snapshot.max_snapshot_connections caps one source’s share (default max_parallel_tables x max_parallel_chunks + 2).
  • A snapshot takes its base connections (3 on either engine) atomically, before opening any transaction, lock or anchor. Extra workers are taken when free and released when done; the base worker always progresses.
  • A snapshot that cannot get its base queues, holding nothing, and can be stopped while queued. Queued snapshots are visible in deltaforge_snapshot_queued{pipeline}; the pool in deltaforge_snapshot_connection_permits{state}.
  • A source whose share cannot hold its base, or whose share exceeds the process cap, fails its configuration check.

Oversubscribing the process cap with many sources is allowed: their snapshots queue.

Upgrading from an earlier release: one-time recopy

Snapshot state from releases before durable generations is classified at the first start after the upgrade, with its source lineage verified first:

  • Proven complete (the sink checkpoints prove the completion under the commit policy): upgraded in place; nothing is copied.
  • Not proven (an interrupted snapshot, sinks behind its anchor, a MySQL progress record without its anchor, and every PostgreSQL snapshot taken before the slot-anchor hardening): copied once more, in full, as the first generation, before streaming continues. A non-blocking snapshot_replaced incident (class legacy_recopy) records it.

This recopy reads every table again and can take as long as the original initial load. Plan the upgrade window for large catalogs. With snapshot.mode: never an unproven snapshot stops the source with an actionable error instead. State of an unknown format is refused and left untouched.

Rollback to an earlier release is safe only once every sink checkpoint of the source is a stream position (after completion, and after every sink committed a change past it). Before that, an earlier release refuses the new state rather than misreading it.

Scale

What is bounded

The durable queue bounds the snapshot’s own state, not the whole process:

  • Per boundary: one constant-size position (about 190 bytes on PostgreSQL, 240 on MySQL), independent of the table count. No per-table cursor vector and no progress record are written.
  • Durable operations per generation (counted on the storage path at 10, 30 and 90 tables): the control record is created once and updated 4 times; each table’s plan entry is written once and deleted once after completion; one control read per publish (every chunk and both barriers) checks ownership; a constant number of other control reads; plan reads are whole page walks, linear in pages.
  • Resident queue state: at most one discovery page of plan entries (discovery_page_size, default 1,000) and at most max_parallel_tables table tasks (default 8). The plan itself stays in the state store.

The whole process is not bounded by the queue. Schema caches and schema registry state grow with the tables a source uses, each within its own bounds (see Capacity envelope). Do not read the figures below as constant total memory.

1K and 10K tables

Measured once on the release candidate: single-row tables, default snapshot settings, a debug build, SQLite state store and schema registry, PostgreSQL 17 and MySQL 8.4 in local containers, on an Intel i7-1355U laptop (12 threads, 31 GB). Indicative, not a guarantee:

EngineTablesTotalPreparationRow copyBoundaries (bytes)Plan (stored)Peak RSS (before)
PostgreSQL1,00055.4 s14.5 s40.3 s1,000 (0.2 MiB)216 KB100.9 MiB (98.2)
PostgreSQL10,000582.0 s168.7 s410.8 s10,000 (1.8 MiB)2.17 MB103.6 MiB (101.0)
MySQL1,00012.3 s11.0 s0.4 s1,000 (0.2 MiB)202 KB51.3 MiB (33.4)
MySQL10,000149.8 s133.6 s9.5 s10,000 (2.3 MiB)2.04 MB99.8 MiB (51.4)

No progress writes in any run. Acceptance gates (10K against 1K), all met:

GatePostgreSQLMySQL
Boundary and progress bytes at most 12x10.00x10.08x
Wall time at most 15x10.51x12.22x
Resident queue state flat (discovery page, table tasks)1,000 / 8 at both sizes1,000 / 8 at both sizes

Peak RSS growth during the snapshot, reported, not gated: PostgreSQL 2.8 MiB at 1K and 2.5 MiB at 10K; MySQL 17.9 MiB at 1K and 48.4 MiB at 10K (2.7x). The MySQL growth is schema state, not queue state: each table’s schema is loaded once while the plan is prepared, into the source’s schema cache (up to 4,096 tables or about 64 MiB) and the schema registry’s cache, plus a compact per-table record. It grows with the tables captured, within those caches’ bounds.

For comparison, the same measurement before the durable queue (in-memory checkpoint store, same machine) took 505 s for 10,000 MySQL tables and wrote 9.6 GiB of boundaries and 1.2 GiB of progress.

PostgreSQL catalog queries

The measurement also found a quadratic cost in PostgreSQL schema loading: the query reading a table’s columns through information_schema.columns scanned the whole catalog on every call (44 ms per table at 10,000 tables). It now also filters by the requested schema and table names, so PostgreSQL resolves the table through its catalog name index: 0.4 ms per table, with identical results. Preparing 10,000 PostgreSQL tables went from 440 s to 169 s. The same query serves CDC schema loads.

What is not claimed

These measurements cover 1,000 and 10,000 tables per source. Qualification at up to 400,000 tables per source, and for fleets of 50 to 120 million tables, is a separate milestone after rc.1. It is not a current support claim. See Capacity envelope for the supported starting range.

Processors

Processors can transform, modify or take extra action per event batch between the source and sinks. They run in the order listed in spec.processors, and each receives the output of the previous one. A processor can modify events, filter them out, or add routing metadata.

Available Processors

ProcessorDescription
javascriptCustom transformations using V8-powered JavaScript
outboxTransactional outbox pattern - extracts payload, resolves topic, sets routing headers
flattenFlatten nested JSON objects into conmbined keys
filterDrop events by op type, table pattern, or field value

JavaScript

Run arbitrary JavaScript against each event batch. Uses the V8 engine via deno_core for near-native speed.

processors:
  - type: javascript
    id: enrich
    inline: |
      function processBatch(events) {
        return events.map(e => {
          e.tags = ["processed"];
          return e;
        });
      }
    limits:
      cpu_ms: 50
      mem_mb: 128
      timeout_ms: 500
FieldTypeDefaultDescription
idstring(required)Processor identifier
inlinestring(required)JavaScript source code
limits.cpu_msint–⚠️ Accepted by the parser but not yet enforced
limits.mem_mbint–⚠️ Accepted by the parser but not yet enforced
limits.timeout_msint–⚠️ Accepted by the parser but not yet enforced

⚠️ limits are not currently enforced. The fields deserialize but the JS runtime applies only a fixed ~5s reply timeout per batch — there is no CPU-time or heap limiting yet.

Performance

JavaScript processing adds a fixed overhead per batch plus linear scaling per event. Benchmarks show ~70K+ events/sec throughput with typical transforms — roughly 61× slower than native Rust processors, but sufficient for most workloads. If you need higher throughput, keep transform logic simple or move heavy processing downstream.

Tips

  • Return an empty array to drop all events in a batch.
  • Return multiple copies of an event to fan out.
  • 64-bit integers (BIGINT, bigint) reach scripts as exact decimal strings, so no value passes through a JavaScript number.

Values and types

Scripts see each event’s native JSON rendering (see Data Types). Column types stay outside the script, from the event’s schema version, and each returned row is typed again:

  • a value the script left as it was comes back exactly as it was;
  • a changed value of a table column (including one that was NULL or a PostgreSQL unchanged TOAST value) must be a valid value of the column’s declared type (ENUM and SET values must be declared labels), or the batch fails;
  • a removed field is removed;
  • a field the table does not have becomes a document extension: native JSON shows it in the row; typed sinks carry it in their extension slot (after_extensions, ClickHouse _extensions), never as a column.

If the event’s schema version cannot be resolved, a changed or added value fails the batch rather than losing its type.

Outbox

The outbox processor transforms events captured by the outbox pattern into routed, sink-ready events. It extracts business fields from the raw outbox payload, resolves the destination topic, and passes through all non-outbox events unchanged.

processors:
  - type: outbox
    topic: "${aggregate_type}.${event_type}"
    default_topic: "events.unrouted"
FieldTypeDefaultDescription
idstring"outbox"Processor identifier
tablesarray[]Only process outbox events matching these patterns. Empty = all.
topicstring—Topic template with ${field} placeholders (resolved against raw payload columns)
default_topicstring—Fallback when template resolution fails
columnsobject(defaults below)Field name mappings
additional_headersmap{}Forward extra payload fields as routing headers. Key = header name, value = column name.
raw_payloadboolfalseDeliver payload as-is, bypassing envelope wrapping

Column Defaults

KeyDefaultDescription
payload"payload"Field containing the event body
aggregate_type"aggregate_type"Domain aggregate type
aggregate_id"aggregate_id"Aggregate identifier
event_type"event_type"Domain event type
topic"topic"Optional explicit topic override in payload

See the Outbox Pattern guide for source configuration, complete examples, and multi-outbox routing.

Flatten

Flattens nested JSON objects in event payloads into top-level keys joined by a configurable separator. Works on every object-valued field present on the event without assuming any particular envelope structure - CDC before/after, outbox business payloads, or any custom fields introduced by upstream processors are all handled uniformly.

processors:
  - type: flatten
    id: flat
    separator: "__"
    max_depth: 3
    on_collision: last
    empty_object: preserve
    lists: preserve
    empty_list: drop

Input:

{
  "after": {
    "order_id": "abc",
    "customer": {
      "id": 1,
      "address": { "city": "Berlin", "zip": "10115" }
    },
    "tags": ["vip"],
    "meta": {}
  }
}

Output (with defaults — separator: "__", empty_object: preserve, lists: preserve):

{
  "after": {
    "order_id": "abc",
    "customer__id": 1,
    "customer__address__city": "Berlin",
    "customer__address__zip": "10115",
    "tags": ["vip"],
    "meta": {}
  }
}

Configuration

FieldTypeDefaultDescription
idstring"flatten"Processor identifier
separatorstring"__"Separator inserted between path segments
max_depthintunlimitedStop recursing at this depth; objects at the boundary are kept as opaque leaves
on_collisionstringlastWhat to do when two paths produce the same key. last, first, or error
empty_objectstringpreserveHow to handle {} values. preserve, drop, or null
listsstringpreserveHow to handle array values. preserve (keep as-is) or index (expand to field__0, field__1, …)
empty_liststringpreserveHow to handle [] values. preserve, drop, or null

max_depth in practice

max_depth counts nesting levels from the top of the payload object. Objects at the boundary are treated as opaque leaves rather than expanded further, still subject to empty_object policy.

# max_depth: 2

depth 0: customer               -> object, recurse
depth 1: customer__address      -> object, recurse
depth 2: customer__address__geo -> STOP, kept as leaf {"lat": 52.5, "lng": 13.4}

Without max_depth, a deeply nested or recursive payload could produce a large number of keys. Setting a limit is recommended for payloads with variable or unknown nesting depth.

Collision policy

A collision occurs when two input paths produce the same flattened key - typically when a payload already contains a pre-flattened key (e.g. "a__b": 1) alongside a nested object ("a": {"b": 2}).

  • last - the last path to write a key wins (default, never fails)
  • first - the first path to write a key wins, subsequent writes are ignored
  • error - the batch fails immediately, useful in strict pipelines where collisions indicate a schema problem

What flattens

Flatten expands JSON documents: outbox payload fields and values a processor added (document extensions, which flatten into extensions). A table’s own columns, a JSON column included, are typed values and are never expanded.

Working with outbox payloads

After the outbox processor runs, event.after holds the extracted business payload - there is no before. The flatten processor handles this naturally since it operates on whatever fields are present:

processors:
  - type: outbox
    topic: "${aggregate_type}.${event_type}"
  - type: flatten
    id: flat
    separator: "."
    empty_list: drop

Envelope interaction

The flatten processor runs on the raw Event struct before sink delivery. Envelope wrapping happens inside the sink, after all processors have run. This means the envelope always wraps already-flattened data, no special configuration needed.

Source → [flatten processor] → Sink (envelope → bytes)

All envelope formats work as expected:

// Native
{ "before": null, "after": { "customer__id": 1, "customer__address__city": "Berlin" }, "op": "c" }

// Debezium
{ "payload": { "before": null, "after": { "customer__id": 1, "customer__address__city": "Berlin" }, "op": "c" } }

// CloudEvents
{ "specversion": "1.0", ..., "data": { "before": null, "after": { "customer__id": 1, "customer__address__city": "Berlin" } } }

Outbox + raw_payload: true

When the outbox processor is configured with raw_payload: true, the sink delivers event.after directly, bypassing the envelope entirely. If the flatten processor runs after outbox, the raw payload delivered to the sink is the flattened object — which is the intended behavior for analytics sinks that can’t handle nested JSON.

processors:
  - type: outbox
    topic: "${aggregate_type}.${event_type}"
    raw_payload: true       # sink delivers event.after directly, no envelope
  - type: flatten
    id: flat
    separator: "__"
    empty_list: drop

The flatten processor runs second, so by the time the sink delivers the raw payload it is already flat.

Analytics sink example

When sending to column-oriented sinks (ClickHouse, BigQuery, S3 Parquet) that don’t handle nested JSON:

processors:
  - type: flatten
    id: flat
    separator: "__"
    lists: index          # expand arrays to indexed columns
    empty_object: drop    # remove sparse marker objects
    empty_list: drop      # remove empty arrays

Filter

Drops events that do not pass configured criteria. Each criterion is independent — omit any of them to skip that check entirely. An event must pass all configured checks to be forwarded.

processors:
  - type: filter
    id: only-active-orders
    ops: [create, update]
    tables:
      include: ["shop.orders"]
      exclude: ["*.tmp"]
    fields:
      - path: status
        op: eq
        value: "active"
      - path: total
        op: gte
        value: 100
    match: all
FieldTypeDefaultDescription
idstring"filter"Processor identifier
opslist[] (all)Operation types to keep: create, update, delete, read, truncate
tables.includelist[] (all)Table patterns to include. Uses AllowList glob syntax: db.table, shop.*, *.orders
tables.excludelist[] (none)Table patterns to exclude. Applied after include; takes priority
fieldslist[] (skip)Predicates evaluated against event.after
matchstringallHow to combine multiple field predicates: all (every predicate must pass) or any (at least one must pass)

Field operators

The path field is a dot-separated path into event.after, e.g. "status" or "order.total".

OpvalueDescription
eqscalarField equals value
nescalarField does not equal value
exists—Field is present and non-null
not_exists—Field is absent or null
gt / gtenumber or stringGreater than / greater than or equal
lt / ltenumber or stringLess than / less than or equal
inarrayField value is one of the items in the array
not_inarrayField value is not in the array
containsscalarString field contains the substring, or array field contains the element
changed—Field value differs between event.before and event.after. Creates and Deletes always pass (no pair to compare)
regexstringString field matches the regex pattern. Compiled once at startup; invalid patterns fail pipeline initialization

Notes

Numeric equality — integers compare exactly at any width (BIGINT included, though native JSON renders it as a string); an integer and a float compare by value, so 42 and 42.0 are equal. A path can descend into a JSON column (payload.status). A predicate on a PostgreSQL column whose value an update did not send (unchanged TOAST) fails the batch; changed treats it as unchanged.

not_in with a missing field — if the field is absent from the event, the event passes. Absence is not membership in any set.

regex on non-string fields — silently does not match. Use exists first if the field may be absent or non-string.

changed and the before image — the predicate reads event.before, which is only populated for update operations from sources configured with full row images. Verify your source has REPLICA IDENTITY FULL (PostgreSQL) or binlog_row_image = FULL (MySQL) before using changed in production.

Performance

The filter processor is pure Rust with no serialization overhead. Op and table checks are O(1) to O(patterns). Field predicate evaluation reads event.after directly. Regex patterns are compiled once at construction time. Put a filter early in the processor chain to reduce the batch size before heavier processors (JavaScript, flatten) run.

Examples

Drop deletes and snapshot reads:

- type: filter
  id: no-deletes
  ops: [create, update]

Alert stream - pass if status is failed OR retry count exhausted:

- type: filter
  id: alert-worthy
  match: any
  fields:
    - path: status
      op: eq
      value: "failed"
    - path: retry_count
      op: gte
      value: 3

Filter before enrichment to avoid paying JavaScript overhead on the full stream:

processors:
  - type: filter
    id: low-stock
    ops: [create, update]
    tables:
      include: ["inventory.products"]
    fields:
      - path: stock_qty
        op: lt
        value: 10
  - type: javascript
    id: enrich
    inline: |
      function processBatch(events) {
        return events.map(e => {
          e.after.alert_level = e.after.stock_qty === 0 ? "critical" : "warning";
          return e;
        });
      }

Only forward rows where a status column actually changed value (suppresses no-op updates):

- type: filter
  id: real-status-changes
  ops: [update]
  fields:
    - path: status
      op: changed

Processor Chain

Processors execute in order. Events flow through each processor sequentially:

Source → [Processor 1] → [Processor 2] → ... → Sinks

Each processor receives a Vec<Event> and returns a Vec<Event>. This means processors can:

  • Transform: Modify event fields in place
  • Filter: Return a subset of events (drop unwanted ones)
  • Fan-out: Return more events than received
  • Route: Set event.routing.topic to override sink defaults
  • Enrich: Add headers via event.routing.headers

Non-outbox events always pass through the outbox processor untouched, so it is safe to combine it with JavaScript processors in any order.

Adding Custom Processors

The processor interface is a simple trait:

#![allow(unused)]
fn main() {
#[async_trait]
pub trait Processor: Send + Sync {
    fn id(&self) -> &str;
    async fn process(&self, events: Vec<Event>) -> Result<Vec<Event>>;
}
}

To add a new processor:

  1. Implement the Processor trait in crates/processors
  2. Add a config variant to ProcessorCfg in deltaforge-config
  3. Register the build step in build_processors()

Sinks

Sinks receive batches from the coordinator after processors run. Each sink lives under spec.sinks and can be marked as required or best-effort via the required flag. Checkpoint behavior is governed by the pipeline’s commit policy.

Envelope and Encoding

All sinks support configurable envelope formats and wire encodings. See the Envelopes and Encodings page for detailed documentation.

OptionValuesDefaultDescription
envelopenative, debezium, cloudeventsnativeOutput JSON structure
encodingjson, avrojsonWire format (avro requires Schema Registry)

Quick example:

sinks:
  - type: kafka
    config:
      id: events-kafka
      brokers: localhost:9092
      topic: events
      envelope:
        type: cloudevents
        type_prefix: "com.example.cdc"
      encoding: json

Available Sinks

SinkDescription
kafkaKafka producer sink
natsNATS JetStream sink
redisRedis stream sink
httpHTTP/Webhook sink
s3S3-compatible object storage (Parquet / JSON Lines) — AWS S3, MinIO, GCS, Azure, local FS
clickhouseClickHouse (HTTP + RowBinary) — change-log or current-state (ReplacingMergeTree), auto table creation

Multiple sinks in one pipeline

You can combine multiple sinks in one pipeline to fan out events to different destinations. However, multi-sink pipelines introduce complexity that requires careful consideration.

Why multiple sinks are challenging

Different performance characteristics: Kafka might handle 100K events/sec while a downstream HTTP webhook processes 100/sec. The slowest sink becomes the bottleneck for the entire pipeline.

Independent failure modes: Each sink can fail independently. Redis might be healthy while Kafka experiences broker failures. Without proper handling, a single sink failure could block the entire pipeline or cause data loss.

No distributed transactions: DeltaForge cannot atomically commit across heterogeneous systems. If Kafka succeeds but Redis fails mid-batch, you face a choice: retry Redis (risking duplicates in Kafka) or skip Redis (losing data there).

Checkpoint semantics: The checkpoint represents “how far we’ve processed from the source.” With multiple sinks, when is it safe to advance? After one sink succeeds? All of them? A majority?

Read the required and commit_policy sections below for options to manage these challenges.

The required flag

The required flag on each sink determines whether that sink must acknowledge successful delivery before the checkpoint advances:

sinks:
  - type: kafka
    config:
      id: primary-kafka
      required: true    # Must succeed for checkpoint to advance
      
  - type: redis
    config:
      id: cache-redis
      required: false   # Best-effort; failures don't block checkpoint

When required: true (default): The sink must acknowledge the batch before the checkpoint can advance. If this sink fails, the pipeline blocks and retries until it succeeds or the operator intervenes.

When required: false: The sink is best-effort. Failures are logged but don’t prevent the checkpoint from advancing. Use this for non-critical destinations where some data loss is acceptable.

Commit policy

The commit_policy works with the required flag to determine checkpoint behavior:

PolicyBehavior
allEvery sink (regardless of required flag) must acknowledge
requiredOnly sinks with required: true must acknowledge (default)
quorumAt least N sinks must acknowledge
commit_policy:
  mode: required   # Only wait for required sinks

sinks:
  - type: kafka
    config:
      required: true   # Checkpoint waits for this
  - type: redis  
    config:
      required: false  # Checkpoint doesn't wait for this
  - type: nats
    config:
      required: true   # Checkpoint waits for this

Per-sink independent checkpoints

Each sink maintains its own checkpoint, committed independently after successful delivery. This means:

  • Faster sinks are not held back by slower ones while the pipeline runs - each sink advances its own checkpoint and the source’s read position does not rewind for a lagging sink in-session
  • The source replays from the minimum checkpoint across all sinks. Recovery is not selective per sink: on restart every sink, including ones already ahead, is re-delivered from that minimum forward, so sinks that were ahead receive duplicates and must dedup
  • Adding a new sink lowers the minimum to that sink’s earliest position, so on the next restart the source replays from there and existing sinks are re-delivered those events too (they dedup); the new sink is not backfilled in isolation
  • Removing a sink cleans up its checkpoint automatically on the next pipeline patch

This architecture avoids the common CDC pitfall where the slowest sink becomes a bottleneck for all other sinks.

Delivery guarantee tiers

SinkGuaranteeMechanismConsumer action
Kafka (exactly_once: true)Transactional atomic-batch; at-least-once across restartEach batch is one Kafka transaction (no partial batch); a restart replays a committed batch as a new transactionSet isolation.level=read_committed and dedup by event id
Kafka (exactly_once: false)At-least-once (idempotent)Retries deduped; crash-replay produces duplicatesDedup by event ID
NATS JetStreamAt-least-once + server dedupNats-Msg-Id header within duplicate_windowConfigure duplicate_window
Redis StreamsAt-least-once + consumer dedupidempotency_key field in XADD payloadCheck key before processing
HTTP/WebhookAt-least-onceRetry on 5xx/timeout; no server-side dedupConsumer must be idempotent (use event id)

DeltaForge does not claim end-to-end exactly-once for any sink. Every sink is at-least-once; Kafka exactly_once: true adds transactional atomic-batch visibility on top, and NATS/Redis add a dedup mechanism. Reaching exactly-once end to end always requires consumer cooperation (dedup on the event id).

Practical patterns

Primary + secondary: One critical sink (Kafka for durability) marked required: true, with secondary sinks (Redis for caching, testing or experimentation) marked required: false.

Quorum for redundancy: Three sinks with commit_policy.mode: quorum and quorum: 2. Checkpoint advances when any two succeed, providing fault tolerance.

All-or-nothing checkpointing: Use commit_policy.mode: all when every destination is critical - the checkpoint advances only once all sinks have acknowledged (throughput is gated by the slowest sink). This gates checkpoint advancement across sinks; it is not an atomic cross-sink commit - a batch can still land in some sinks and not others within a cycle, and those are reconciled by replay on restart.

Multi-format fan-out

For sending the same events to different consumers that expect different formats:

sinks:
  # Kafka Connect expects Debezium format
  - type: kafka
    config:
      id: connect-sink
      brokers: ${KAFKA_BROKERS}
      topic: connect-events
      envelope:
        type: debezium
      required: true

  # Lambda expects CloudEvents
  - type: kafka
    config:
      id: lambda-sink
      brokers: ${KAFKA_BROKERS}
      topic: lambda-events
      envelope:
        type: cloudevents
        type_prefix: "com.acme.cdc"
      required: false

  # Analytics wants raw events
  - type: redis
    config:
      id: analytics-redis
      uri: ${REDIS_URI}
      stream: analytics
      envelope:
        type: native
      required: false

This allows each consumer to receive events in their preferred format without post-processing.

Redis

Redis sink

The Redis sink publishes events to a Redis Stream for real-time consumption with consumer groups.

When to use Redis

Redis Streams shine when you need low-latency event delivery with simple operational requirements and built-in consumer group support.

Real-world applications

Use CaseDescription
Real-time notificationsPush database changes instantly to WebSocket servers for live UI updates
Cache invalidationTrigger cache eviction when source records change; keep Redis cache consistent
Session synchronizationReplicate user session changes across application instances in real-time
Rate limiting stateStream counter updates for distributed rate limiting decisions
Live dashboardsFeed real-time metrics and KPIs to dashboard backends
Job queuingUse CDC events to trigger background job processing with consumer groups
Feature flagsPropagate feature flag changes instantly across all application instances

Pros and cons

ProsCons
✅ Ultra-low latency - Sub-millisecond publish; ideal for real-time apps❌ Memory-bound - All data in RAM; expensive for high-volume retention
✅ Simple operations - Single binary, minimal configuration❌ Limited retention - Not designed for long-term event storage
✅ Consumer groups - Built-in competing consumers with acknowledgements❌ Durability trade-offs - AOF/RDB persistence has limitations
✅ Familiar tooling - redis-cli, widespread client library support❌ Single-threaded - CPU-bound for very high throughput
✅ Versatile - Combine with caching, pub/sub, and data structures❌ No native replay - XRANGE exists but no offset management
✅ Atomic operations - MULTI/EXEC for transactional guarantees❌ Cluster complexity - Sharding requires careful key design

Configuration

sinks:
  - type: redis
    config:
      id: orders-redis
      uri: ${REDIS_URI}
      stream: orders.events
      required: true
FieldTypeDefaultDescription
idstring—Sink identifier
uristring—Redis connection URI
streamstring—Redis stream key
requiredbooltrueGates checkpoints

Idempotency

Every XADD command includes an idempotency_key field in the stream entry payload. This key is deterministic — the same source event always produces the same key, even across replays.

Redis Streams does not deduplicate server-side (entry IDs are auto-generated), so consumers are responsible for dedup using this field. A simple approach:

# Check idempotency_key before processing
seen = redis.smembers("processed_keys")
if data[b"idempotency_key"] in seen:
    r.xack(stream, group, msg_id)  # Skip duplicate
    continue
process(event)
redis.sadd("processed_keys", data[b"idempotency_key"])

This makes Redis a Tier 2 delivery guarantee: at-least-once with idempotency keys for consumer-side dedup.

Consuming events

# Create consumer group
redis-cli XGROUP CREATE orders.events mygroup $ MKSTREAM

# Read as consumer (blocking)
redis-cli XREADGROUP GROUP mygroup consumer1 BLOCK 5000 COUNT 10 STREAMS orders.events >

# Acknowledge processing
redis-cli XACK orders.events mygroup 1234567890123-0

# Check pending (unacknowledged) messages
redis-cli XPENDING orders.events mygroup

Simple subscription

# Read latest entries
redis-cli XREAD COUNT 10 STREAMS orders.events 0-0

# Block for new entries
redis-cli XREAD BLOCK 5000 STREAMS orders.events $

Go consumer example

import "github.com/redis/go-redis/v9"

rdb := redis.NewClient(&redis.Options{Addr: "localhost:6379"})

// Create consumer group (once)
rdb.XGroupCreateMkStream(ctx, "orders.events", "mygroup", "0")

for {
    streams, err := rdb.XReadGroup(ctx, &redis.XReadGroupArgs{
        Group:    "mygroup",
        Consumer: "worker1",
        Streams:  []string{"orders.events", ">"},
        Count:    10,
        Block:    5 * time.Second,
    }).Result()
    
    if err != nil {
        continue
    }
    
    for _, stream := range streams {
        for _, msg := range stream.Messages {
            var event Event
            json.Unmarshal([]byte(msg.Values["df-event"].(string)), &event)
            process(event)
            rdb.XAck(ctx, "orders.events", "mygroup", msg.ID)
        }
    }
}

Rust consumer example

#![allow(unused)]
fn main() {
use redis::AsyncCommands;

let client = redis::Client::open("redis://localhost:6379")?;
let mut con = client.get_async_connection().await?;

// Create consumer group
let _: () = redis::cmd("XGROUP")
    .arg("CREATE").arg("orders.events").arg("mygroup").arg("0").arg("MKSTREAM")
    .query_async(&mut con).await.unwrap_or(());

loop {
    let results: Vec<StreamReadReply> = con.xread_options(
        &["orders.events"],
        &[">"],
        &StreamReadOptions::default()
            .group("mygroup", "worker1")
            .count(10)
            .block(5000)
    ).await?;
    
    for stream in results {
        for msg in stream.ids {
            let event: Event = serde_json::from_str(&msg.map["df-event"])?;
            process(event);
            con.xack("orders.events", "mygroup", &[&msg.id]).await?;
        }
    }
}
}

Python consumer example

import redis
import json

r = redis.Redis.from_url("redis://localhost:6379")

# Create consumer group (once)
try:
    r.xgroup_create("orders.events", "mygroup", id="0", mkstream=True)
except redis.ResponseError:
    pass  # Group already exists

# Consume events
while True:
    events = r.xreadgroup("mygroup", "worker1", {"orders.events": ">"}, count=10, block=5000)
    for stream, messages in events:
        for msg_id, data in messages:
            event = json.loads(data[b"df-event"])
            process(event)
            r.xack("orders.events", "mygroup", msg_id)

Failure modes

In the table below, “retries” means a bounded in-sink retry (exponential backoff, finite attempts) inside a single send_batch call. There is no unbounded pipeline-level retry loop: once in-sink attempts are exhausted on a required sink, the checkpoint is held and the batch is recovered by source replay on the next restart. Redis Streams delivery is at-least-once (XADD is not transactional across the batch); dedup on the event idempotency_key.

FailureSymptomsDeltaForge behaviorResolution
Server unavailableConnection refusedRetries with backoff; blocks checkpointRestore Redis; check network
Authentication failureNOAUTH / WRONGPASSFails fast, no retryFix auth details in URI
OOM (Out of Memory)OOM command not allowedFails batch; retriesIncrease maxmemory; enable eviction or trim streams
Stream doesn’t existAuto-created by XADDNo failureN/A (XADD creates stream)
Connection timeoutCommand hangsTimeout after configured durationCheck network; increase timeout
Cluster MOVED/ASKRedirect errorsAutomatic redirect (if cluster mode)Ensure cluster client configured
Replication lagWrites to replica failFails with READONLYWrite to master only
Max stream lengthIf MAXLEN enforcedOldest entries trimmedExpected behavior; not a failure
Network partitionIntermittent timeoutsRetries; may have gapsRestore network

Failure scenarios and data guarantees

Redis OOM during batch delivery

  1. DeltaForge sends a batch of 100 events
  2. 50 events are written, then Redis hits maxmemory and the rest fail (XADD is not atomic across the batch, so the 50 already-written entries remain)
  3. send_batch returns an error; the checkpoint does not advance
  4. DeltaForge applies bounded in-sink retry; if OOM persists, the required-sink failure holds the checkpoint and backpressures until memory is available
  5. On recovery/restart the batch is re-delivered from the source, so the already-written entries reappear as duplicates - consumers dedup on idempotency_key
  6. Checkpoint is saved only after the full batch is acknowledged

DeltaForge crash after XADD, before checkpoint

  1. Batch written to Redis stream successfully
  2. DeltaForge crashes before saving checkpoint
  3. On restart: replays from last checkpoint
  4. Result: Duplicate events in stream (at-least-once)
  5. Consumer must handle idempotently (check event.id)

Redis failover (Sentinel/Cluster)

  1. Master fails, Sentinel promotes replica
  2. In-flight XADD may fail with connection error
  3. DeltaForge reconnects to new master
  4. Bounded in-sink retry re-sends the batch (or it is replayed from the source on restart)
  5. Possible duplicates if the original write succeeded - consumers dedup on idempotency_key

Handling duplicates in consumers

# Idempotent consumer using event ID
processed_ids = set()  # Or use Redis SET for distributed dedup

for msg_id, data in messages:
    event = json.loads(data[b"df-event"])
    event_id = event["id"]
    
    if event_id in processed_ids:
        r.xack("orders.events", "mygroup", msg_id)
        continue  # Skip duplicate
    
    process(event)
    processed_ids.add(event_id)
    r.xack("orders.events", "mygroup", msg_id)

Monitoring

DeltaForge exposes these metrics for Redis sink monitoring:

# DeltaForge sink metrics (exposed at /metrics on port 9000)
deltaforge_sink_events_total{pipeline,sink}      # Events delivered
deltaforge_sink_batch_total{pipeline,sink}       # Batches delivered  
deltaforge_sink_latency_seconds{pipeline,sink}   # Delivery latency histogram
deltaforge_stage_latency_seconds{pipeline,stage="sink"}  # Stage timing

For Redis server visibility, use Redis’s built-in monitoring:

# Monitor commands in real-time
redis-cli MONITOR

# Get server stats
redis-cli INFO stats

# Check memory usage
redis-cli INFO memory

# Stream-specific info
redis-cli XINFO STREAM orders.events
redis-cli XINFO GROUPS orders.events

Stream management

# Check stream length
redis-cli XLEN orders.events

# Trim to last 10000 entries (approximate)
redis-cli XTRIM orders.events MAXLEN ~ 10000

# Trim to exact length
redis-cli XTRIM orders.events MAXLEN 10000

# View consumer group info
redis-cli XINFO GROUPS orders.events

# Check pending messages
redis-cli XPENDING orders.events mygroup

# Claim stuck messages (after 60 seconds)
redis-cli XCLAIM orders.events mygroup worker2 60000 <message-id>

# Delete processed messages (careful!)
redis-cli XDEL orders.events <message-id>

Notes

  • Redis Streams provide at-least-once delivery with consumer group acknowledgements
  • Use MAXLEN ~ trimming to prevent unbounded memory growth (approximate is faster)
  • Consider Redis Cluster for horizontal scaling with multiple streams
  • Combine with Redis pub/sub for fan-out to ephemeral subscribers
  • For durability, enable AOF persistence with appendfsync everysec or always
  • Monitor memory usage closely; Redis will reject writes when maxmemory is reached

Kafka

Kafka sink

The Kafka sink publishes batches to a Kafka topic using rdkafka.

When to use Kafka

Kafka excels as the backbone for event-driven architectures where durability, ordering, and replay capabilities are critical.

Real-world applications

Use CaseDescription
Event sourcingStore all state changes as an immutable log; rebuild application state by replaying events
Microservices integrationDecouple services with async messaging; each service consumes relevant topics
Real-time analytics pipelinesFeed CDC events to Spark, Flink, or ksqlDB for streaming transformations
Data lake ingestionStream database changes to S3/HDFS via Kafka Connect for analytics and ML
Audit loggingCapture every database mutation for compliance, debugging, and forensics
Cross-datacenter replicationUse MirrorMaker 2 to replicate topics across regions for DR

Pros and cons

ProsCons
✅ Durability - Configurable replication ensures no data loss❌ Operational complexity - Requires ZooKeeper/KRaft, careful tuning
✅ Ordering guarantees - Per-partition ordering with consumer groups❌ Latency - Batching and replication add milliseconds of delay
✅ Replay capability - Configurable retention allows reprocessing❌ Resource intensive - High disk I/O and memory requirements
✅ Ecosystem - Connect, Streams, Schema Registry, ksqlDB❌ Learning curve - Partitioning, offsets, consumer groups to master
✅ Throughput - Handles millions of messages per second❌ Cold start - Cluster setup and topic configuration overhead
✅ Transactional atomic-batch delivery - Transactions for critical workloads❌ Cost - Managed services can be expensive at scale

Configuration

sinks:
  - type: kafka
    config:
      id: orders-kafka
      brokers: ${KAFKA_BROKERS}
      topic: orders
      required: true
      exactly_once: false
      client_conf:
        message.timeout.ms: "5000"
        acks: "all"
FieldTypeDefaultDescription
idstring—Sink identifier
brokersstring—Comma-separated broker list
topicstring—Destination topic
requiredbooltrueGates checkpoints
exactly_onceboolfalseEnable the transactional producer (atomic-batch delivery)
encode_workersint1Threads that encode an Avro batch, 1 to 64 (see Avro encoding threads)
client_confmap{}librdkafka overrides

Transactional atomic-batch delivery (exactly_once: true)

When exactly_once: true, the Kafka sink uses a transactional producer. Each batch is wrapped in a Kafka transaction (begin_transaction / commit_transaction), so a read_committed consumer sees either all events in the batch or none - never a partial batch.

This is not end-to-end exactly-once. DeltaForge advances its own checkpoint only after the Kafka transaction commits, so a crash between the commit and the checkpoint persist replays that batch on restart as a new transaction, and a read_committed consumer sees it twice. Delivery is at-least-once across a restart; dedup on the event id (stable across replays) to reach exactly-once end to end. The config field is named exactly_once for historical reasons; read it as “transactional producer.”

sinks:
  - type: kafka
    config:
      id: orders-kafka
      brokers: ${KAFKA_BROKERS}
      topic: orders
      exactly_once: true

How it works

  1. DeltaForge assigns a stable transactional.id per pipeline-sink pair (deltaforge-{pipeline}-{sink_id})
  2. On startup, init_transactions() registers with the broker and fences any zombie producer from a previous instance
  3. Each batch: begin_transaction() → produce messages → commit_transaction()
  4. If the commit fails before completing, the transaction is aborted and the batch is re-attempted from the same checkpoint (bounded in-sink retry); a persistent failure holds the checkpoint and recovers by source replay on restart
  5. Consumers using isolation.level=read_committed only see committed batches; a batch replayed after a restart appears as a new committed transaction (duplicate), so consumers still dedup on event id

Requirements

  • Kafka 2.5+ (transaction support)
  • isolation.level=read_committed on consumers (otherwise they see uncommitted messages)
  • Only one DeltaForge instance per pipeline at a time (the broker will fence duplicates)

Transactional overrides

When exactly_once: true, DeltaForge automatically configures:

SettingValueWhy
transactional.iddeltaforge-{pipeline}-{sink_id}Broker fencing
transaction.timeout.ms60000Max transaction duration
enable.idempotencetrueRequired for transactions
acksallRequired for transactions
message.timeout.ms30000Must be ≤ transaction.timeout.ms
delivery.timeout.ms30000Must be ≤ transaction.timeout.ms

You do not need to set these — they are applied automatically as defaults. Your client_conf is merged last, so any value you set there takes precedence (override at your own risk; these defaults are chosen for correct transactional behavior).

Fatal errors

If the broker fences the producer (another instance started with the same transactional.id), DeltaForge treats this as a fatal error and stops the pipeline. This is not retryable — resolve the duplicate instance before restarting.

Performance impact

Transactions add ~1-3ms overhead per batch for the two-phase commit. With properly sized batches (max_events=16000, max_bytes=16MB), throughput impact is ~7-11%. See the Performance guide for tuning details and benchmark results.

Avro encoding threads

An Avro sink encodes each batch in full before it enqueues any record, so a batch whose schema cannot be resolved publishes nothing. With encode_workers: N (N > 1) the batch is split into N contiguous slices, encoded on N threads, and enqueued in its original order: the records, their order, the batch’s failure behavior and its acknowledgement are the same as with one thread. A record whose schema is not registered yet (a table’s first batch, a new schema version) is encoded on the delivery task, which registers the schema once. A record that cannot be encoded is handled as with one thread. Only one batch is encoded at a time, so memory holds one batch’s encoded records whatever the thread count; the threads exist only while a batch is encoded. JSON sinks ignore the setting.

One thread (the default) keeps encoding on the delivery task and uses the least CPU. More threads are a capacity option for saturated Avro streams on hosts with free cores. Measured on one host (a 15 W laptop CPU, MySQL to Kafka, saturated backlog, five interleaved repetitions):

encode_workersNarrow rows (4 columns)CPURSSWide rows (50 columns)CPURSS
1 (default)82-85k rows/s1.2 cores235-242 MiB29.0k rows/s1.2 cores473 MiB
297k rows/s1.8 cores240 MiB35.0k rows/s1.6 cores527 MiB
4120k rows/s2.9 cores253 MiB38.0k rows/s2.2 cores525 MiB

The extra CPU per row with more threads (1.45 to 2.44 CPU seconds per 100k narrow rows at four) is mostly lower clock speed on a power-limited CPU running more cores: the instructions per row do not grow. Records per Kafka request stay the same (about 970-980 narrow, 660 wide).

Linger defaults by format

EncodingDefault linger.msWhy
Avro1A batch is encoded in full before any record is enqueued, so linger gathers nothing more: records per request are unchanged at 1 ms (about 980 per request for narrow rows at 1, 2, 3 and 5 ms), and a longer linger only delays the batch’s last request, which the batch’s acknowledgement waits for.
JSON5Records are enqueued as they are encoded; linger gathers them into full requests (about 975 records per request at 5 ms, 310 at 1 ms).

client_conf overrides both.

SettingRecommendedDescription
acksallWait for all replicas for durability
message.timeout.ms30000Total time to deliver a message
retries2147483647Retry indefinitely (with backoff)
enable.idempotencetruePrevent duplicates on retry
compression.typelz4Balance between CPU and bandwidth

Consuming events

Kafka CLI

# Consume from beginning
kafka-console-consumer.sh \
  --bootstrap-server localhost:9092 \
  --topic orders \
  --from-beginning

# Consume with consumer group
kafka-console-consumer.sh \
  --bootstrap-server localhost:9092 \
  --topic orders \
  --group deltaforge-consumers

Go consumer example

config := sarama.NewConfig()
config.Consumer.Group.Rebalance.Strategy = sarama.BalanceStrategyRoundRobin
config.Consumer.Offsets.Initial = sarama.OffsetOldest

group, _ := sarama.NewConsumerGroup([]string{"localhost:9092"}, "my-group", config)

for {
    err := group.Consume(ctx, []string{"orders"}, &handler{})
    if err != nil {
        log.Printf("Consumer error: %v", err)
    }
}

type handler struct{}

func (h *handler) ConsumeClaim(session sarama.ConsumerGroupSession, claim sarama.ConsumerGroupClaim) error {
    for msg := range claim.Messages() {
        var event Event
        json.Unmarshal(msg.Value, &event)
        process(event)
        session.MarkMessage(msg, "")
    }
    return nil
}

Rust consumer example

#![allow(unused)]
fn main() {
use rdkafka::consumer::{Consumer, StreamConsumer};
use rdkafka::Message;

let consumer: StreamConsumer = ClientConfig::new()
    .set("bootstrap.servers", "localhost:9092")
    .set("group.id", "my-group")
    .set("auto.offset.reset", "earliest")
    .create()?;

consumer.subscribe(&["orders"])?;

loop {
    match consumer.recv().await {
        Ok(msg) => {
            let payload = msg.payload_view::<str>().unwrap()?;
            let event: Event = serde_json::from_str(payload)?;
            process(event);
            consumer.commit_message(&msg, CommitMode::Async)?;
        }
        Err(e) => eprintln!("Kafka error: {}", e),
    }
}
}

Python consumer example

from kafka import KafkaConsumer
import json

consumer = KafkaConsumer(
    'orders',
    bootstrap_servers=['localhost:9092'],
    group_id='my-group',
    auto_offset_reset='earliest',
    value_deserializer=lambda m: json.loads(m.decode('utf-8'))
)

for message in consumer:
    event = message.value
    process(event)
    consumer.commit()

Failure modes

In the table below, “retries” means a bounded in-sink retry (exponential backoff, finite attempts) inside a single send_batch call. DeltaForge does not run an unbounded pipeline-level retry loop: once in-sink attempts are exhausted on a required sink, the checkpoint is held and the batch is recovered by source replay on the next restart. “Blocks checkpoint” means the source backpressures until the sink recovers.

FailureSymptomsDeltaForge behaviorResolution
Broker unavailableConnection refused, timeoutRetries with backoff; blocks checkpointRestore broker; check network
Topic not foundUnknownTopicOrPartitionFails batch; retriesCreate topic or enable auto-create
Authentication failureSaslAuthenticationFailedFails fast, no retryFix credentials in config
Authorization failureTopicAuthorizationFailedFails fast, no retryGrant ACLs for producer
Message too largeMessageSizeTooLargeFails message permanentlyIncrease message.max.bytes or filter large events
Leader electionNotLeaderForPartitionAutomatic retry after metadata refreshWait for election; usually transient
Disk fullKafkaStorageExceptionBounded in-sink retry, then holds checkpoint and backpressuresAdd disk space; purge old segments
Network partitionTimeouts, partial failuresRetries; may produce duplicatesRestore network; idempotence prevents dups
Producer fencedProducerFenced errorFatal — pipeline stops immediatelyEnsure only one instance per pipeline; restart after resolving
Transaction timeouttransaction.timeout.ms exceededTransaction aborted; batch retriedIncrease timeout or reduce batch size
SSL/TLS errorsHandshake failuresFails fastFix certificates, verify truststore

Failure scenarios and data guarantees

Broker failure during batch delivery

  1. DeltaForge sends batch of 100 events
  2. 50 events delivered, broker crashes
  3. rdkafka detects failure, retries remaining 50
  4. If idempotence enabled: no duplicates
  5. If not: possible duplicates of events near failure point
  6. Checkpoint only saved after ALL events acknowledged

DeltaForge crash after Kafka ack, before checkpoint

  1. Batch delivered to Kafka successfully
  2. DeltaForge crashes before saving checkpoint
  3. On restart: replays from last checkpoint
  4. Without exactly_once: duplicate events in Kafka (at-least-once); consumer must handle idempotently
  5. With exactly_once: replayed batch gets a new transaction; consumers with read_committed see duplicates but each batch is atomic

Monitoring recommendations

DeltaForge exposes these metrics for Kafka sink monitoring:

# DeltaForge sink metrics (exposed at /metrics on port 9000)
deltaforge_sink_events_total{pipeline,sink}      # Events delivered
deltaforge_sink_batch_total{pipeline,sink}       # Batches delivered
deltaforge_sink_latency_seconds{pipeline,sink}   # Delivery latency histogram
deltaforge_stage_latency_seconds{pipeline,stage="sink"}  # Stage timing

For deeper Kafka broker visibility, monitor your Kafka cluster directly:

  • Broker metrics via JMX or Kafka’s built-in metrics
  • Consumer lag via kafka-consumer-groups.sh
  • Topic throughput via broker dashboards

Note: Internal rdkafka producer statistics (message queues, broker RTT, etc.) are not currently exposed by DeltaForge. This is a potential future enhancement.

Notes

  • Combine Kafka with other sinks to fan out data; use commit policy to control checkpoint behavior
  • For transactional atomic-batch delivery, set exactly_once: true and ensure your Kafka cluster supports transactions (2.5+); this is not end-to-end exactly-once, so consumers still dedup on event id across restarts
  • With exactly_once: false (default), idempotent production is still enabled — duplicates are prevented during retries but not across DeltaForge restarts
  • Adjust client_conf for durability (acks=all) or performance based on your requirements
  • Consider partitioning strategy for ordering guarantees within partitions

NATS

NATS sink

The NATS sink publishes events to a NATS JetStream stream for durable, at-least-once delivery.

When to use NATS

NATS JetStream is ideal when you need a lightweight, high-performance messaging system with persistence, without the operational overhead of Kafka.

Real-world applications

Use CaseDescription
Edge computingLightweight footprint perfect for IoT gateways and edge nodes syncing to cloud
Microservices meshRequest-reply and pub/sub patterns with automatic load balancing
Multi-cloud syncLeaf nodes and superclusters for seamless cross-cloud data replication
Kubernetes-native eventsNATS Operator for cloud-native deployment; sidecar-friendly architecture
Real-time gamingLow-latency state synchronization for multiplayer game servers
Financial data feedsStream market data with subject-based routing and wildcards
Command and controlDistribute configuration changes and commands to distributed systems

Pros and cons

ProsCons
✅ Lightweight - Single binary ~20MB; minimal resource footprint❌ Smaller ecosystem - Fewer connectors and integrations than Kafka
✅ Simple operations - Zero external dependencies; easy clustering❌ Younger persistence - JetStream newer than Kafka’s battle-tested log
✅ Low latency - Sub-millisecond message delivery❌ Community size - Smaller community than Kafka or Redis
✅ Flexible patterns - Pub/sub, queues, request-reply, streams❌ Tooling maturity - Fewer monitoring and management tools
✅ Subject hierarchy - Powerful wildcard routing (orders.>, *.events)❌ Learning curve - JetStream concepts differ from traditional queues
✅ Multi-tenancy - Built-in accounts and security isolation❌ Less enterprise adoption - Fewer case studies at massive scale
✅ Cloud-native - Designed for Kubernetes and distributed systems

Configuration

sinks:
  - type: nats
    config:
      id: orders-nats
      url: ${NATS_URL}
      subject: orders.events
      stream: ORDERS
      required: true
      send_timeout_secs: 5
      batch_timeout_secs: 30
FieldTypeDefaultDescription
idstring—Sink identifier
urlstring—NATS server URL
subjectstring—Subject to publish to
streamstring—JetStream stream name
requiredbooltrueGates checkpoints
send_timeout_secsint5Publish timeout
batch_timeout_secsint30Batch timeout
connect_timeout_secsint10Connection timeout

Authentication options

# Credentials file
credentials_file: /etc/nats/creds/user.creds

# Username/password
username: ${NATS_USER}
password: ${NATS_PASSWORD}

# Token
token: ${NATS_TOKEN}

Deduplication

DeltaForge sets the Nats-Msg-Id header on every published message using a deterministic idempotency key derived from the event’s source, transaction, and row identity. JetStream uses this header for server-side deduplication within the stream’s duplicate_window.

This means: if DeltaForge replays a batch after a crash, duplicate messages are automatically discarded by the server — no consumer-side dedup needed (within the dedup window).

Configure duplicate_window on the stream to match your maximum expected replay window:

nats stream add ORDERS \
  --subjects "orders.>" \
  --duplicate-window 5m    # Dedup messages replayed within 5 minutes

The default duplicate_window is 2 minutes. Increase this if DeltaForge might be down longer before restarting.

JetStream setup

Before using the NATS sink with JetStream, create a stream that captures your subject:

# Using NATS CLI
nats stream add ORDERS \
  --subjects "orders.>" \
  --retention limits \
  --storage file \
  --replicas 3 \
  --max-age 7d

# Verify stream
nats stream info ORDERS

Consuming events

NATS CLI

# Subscribe to subject (ephemeral)
nats sub "orders.>"

# Create durable consumer
nats consumer add ORDERS orders-processor \
  --pull \
  --ack explicit \
  --deliver all \
  --max-deliver 3 \
  --filter "orders.events"

# Consume messages
nats consumer next ORDERS orders-processor --count 10

Go consumer example

nc, _ := nats.Connect("nats://localhost:4222")
js, _ := nc.JetStream()

// Create or bind to consumer
sub, _ := js.PullSubscribe("orders.events", "orders-processor",
    nats.Durable("orders-processor"),
    nats.AckExplicit(),
)

for {
    msgs, _ := sub.Fetch(10, nats.MaxWait(5*time.Second))
    for _, msg := range msgs {
        var event Event
        json.Unmarshal(msg.Data, &event)
        process(event)
        msg.Ack()
    }
}

Rust consumer example

#![allow(unused)]
fn main() {
use async_nats::jetstream;

let client = async_nats::connect("nats://localhost:4222").await?;
let js = jetstream::new(client);

let stream = js.get_stream("ORDERS").await?;
let consumer = stream.get_consumer("orders-processor").await?;

let mut messages = consumer.messages().await?;
while let Some(msg) = messages.next().await {
    let msg = msg?;
    let event: Event = serde_json::from_slice(&msg.payload)?;
    process(event);
    msg.ack().await?;
}
}

Monitoring

DeltaForge exposes these metrics for NATS sink monitoring:

# DeltaForge sink metrics (exposed at /metrics on port 9000)
deltaforge_sink_events_total{pipeline,sink}      # Events delivered
deltaforge_sink_batch_total{pipeline,sink}       # Batches delivered
deltaforge_sink_latency_seconds{pipeline,sink}   # Delivery latency histogram
deltaforge_stage_latency_seconds{pipeline,stage="sink"}  # Stage timing

For NATS server visibility, use the NATS CLI or monitoring endpoint:

# Server info
nats server info

# JetStream account info
nats account info

# Stream statistics
nats stream info ORDERS

# Consumer statistics  
nats consumer info ORDERS orders-processor

# Real-time event monitoring
nats events

NATS also exposes a monitoring endpoint (default :8222) with JSON stats:

  • http://localhost:8222/varz - General server stats
  • http://localhost:8222/jsz - JetStream stats
  • http://localhost:8222/connz - Connection stats

Subject design patterns

PatternExampleUse Case
Hierarchicalorders.us.createdRegional routing
Wildcard singleorders.*.createdAny region, specific event
Wildcard multiorders.>All order events
Versionedv1.orders.eventsAPI versioning

Failure modes

In the table below, “retries” means a bounded in-sink retry (exponential backoff, finite attempts) inside a single send_batch call. There is no unbounded pipeline-level retry loop: once in-sink attempts are exhausted on a required sink, the checkpoint is held and the batch is recovered by source replay on the next restart. JetStream delivery is at-least-once; server-side dedup via Nats-Msg-Id within duplicate_window collapses duplicates.

FailureSymptomsDeltaForge behaviorResolution
Server unavailableConnection refusedRetries with backoff; blocks checkpointRestore NATS; check network
Stream not foundstream not found errorFails batch; no retryCreate stream or remove stream config
Authentication failureauthorization violationFails fast, no retryFix credentials
Subject mismatchno responders (core NATS)Fails if no subscribersAdd subscribers or use JetStream
JetStream disabledjetstream not enabledFails fastEnable JetStream on server
Storage fullinsufficient resourcesRetries; eventually failsAdd storage; adjust retention
Message too largemessage size exceeds maximumFails message permanentlyIncrease max_payload or filter large events
Cluster partitionIntermittent failuresRetries with backoffRestore network; wait for quorum
Slow consumerPublish backpressureSlows down; may timeoutScale consumers; increase buffer
TLS errorsHandshake failuresFails fastFix certificates

Failure scenarios and data guarantees

NATS server restart during batch delivery

  1. DeltaForge sends batch of 100 events
  2. 50 events published, server restarts
  3. async_nats detects disconnect, starts reconnecting
  4. After reconnect, DeltaForge retries remaining 50
  5. JetStream deduplication prevents duplicates (if enabled)
  6. Checkpoint only saved after ALL events acknowledged

DeltaForge crash after JetStream ack, before checkpoint

  1. Batch published to JetStream successfully
  2. DeltaForge crashes before saving checkpoint
  3. On restart: replays from last checkpoint
  4. Result: Duplicate events in stream (at-least-once)
  5. Consumer must handle idempotently (check event.id)

Stream storage exhausted

  1. JetStream stream hits max_bytes or max_msgs limit
  2. With discard: old → oldest messages removed, publish succeeds
  3. With discard: new → publish rejected
  4. Bounded in-sink retry runs; if the stream stays full, the required-sink failure holds the checkpoint and backpressures (retrying alone will not clear a full stream)
  5. Resolution: Increase limits or enable discard: old

JetStream acknowledgement levels

# Stream configuration affects durability
nats stream add ORDERS \
  --replicas 3 \           # R=3 for production
  --retention limits \     # or 'workqueue' for single consumer
  --discard old \          # Remove oldest when full
  --max-age 7d \           # Auto-expire after 7 days
  --storage file           # Persistent (vs memory)
ReplicasGuaranteeUse Case
R=1Single node; lost if node failsDevelopment, non-critical
R=3Survives 1 node failureProduction default
R=5Survives 2 node failuresCritical data

Handling duplicates in consumers

// Use event ID for idempotency
processedIDs := make(map[string]bool)  // Or use Redis/DB

for _, msg := range msgs {
    var event Event
    json.Unmarshal(msg.Data, &event)
    
    if processedIDs[event.ID] {
        msg.Ack()  // Already processed
        continue
    }
    
    if err := process(event); err == nil {
        processedIDs[event.ID] = true
    }
    msg.Ack()
}

Notes

  • When stream is specified, events are published to that JetStream stream (the stream must already exist; the sink does not create or verify it)
  • Without stream, events are published to core NATS (no persistence guarantees)
  • Connection pooling ensures efficient reuse across batches
  • Use replicated streams (--replicas 3) for production durability
  • Combine with other sinks to fan out data; use commit policy to control checkpoint behavior
  • JetStream provides at-least-once delivery with server-side deduplication via Nats-Msg-Id within the configured duplicate_window

HTTP/Webhook sink

The HTTP sink delivers CDC events via HTTP POST (or PUT) to any URL — internal services, serverless functions, third-party APIs, webhooks.

When to use HTTP

HTTP is the universal integration point. Use it when:

  • Your consumer doesn’t run Kafka/NATS/Redis
  • You need to call a REST API or webhook on every database change
  • You want the simplest possible setup (DeltaForge + any HTTP server)
  • You’re integrating with serverless functions (AWS Lambda, Cloud Functions)

Pros and cons

ProsCons
Works with any HTTP serverHigher latency than message queues
No infrastructure dependenciesNo built-in replay (consumer must be idempotent)
Simple auth (Bearer, Basic, headers)No consumer groups or partitioning
URL templates for per-table routingOne request per event (unless batch mode)

Configuration

sinks:
  - type: http
    config:
      id: my-webhook
      url: "https://api.example.com/events"
      method: POST
      headers:
        Authorization: "Bearer ${API_TOKEN}"
        X-Source: deltaforge
      batch_mode: false
      required: true
      send_timeout_secs: 10
FieldTypeDefaultDescription
idstring—Sink identifier
urlstring—Target URL (supports ${path} templates)
methodstringPOSTHTTP method (POST or PUT)
headersmap{}Static headers (values support ${ENV_VAR} expansion)
batch_modeboolfalseSend JSON array instead of per-event requests
requiredbooltrueGates checkpoints
send_timeout_secsint10Per-request timeout
batch_timeout_secsint30Batch timeout
connect_timeout_secsint5TCP connection timeout

Authentication

All auth is handled via the headers map. Values support ${ENV_VAR} shell expansion.

# Bearer token
headers:
  Authorization: "Bearer ${API_TOKEN}"

# Basic auth
headers:
  Authorization: "Basic ${BASIC_AUTH_B64}"

# Custom API key
headers:
  X-API-Key: "${MY_API_KEY}"

# HMAC signature (computed externally, injected via env)
headers:
  X-Signature: "${WEBHOOK_SIGNATURE}"

URL templates

Route events to different URLs based on event fields:

# Per-table endpoint
url: "https://api.example.com/cdc/${source.table}"
# → https://api.example.com/cdc/orders
# → https://api.example.com/cdc/customers

# Per-database endpoint
url: "https://${source.db}.api.internal/events"

# Static URL (most common)
url: "https://api.example.com/webhook"

Batch mode

By default, the sink sends one HTTP request per event. Enable batch_mode: true to send a JSON array of events in a single request:

# Per-event mode (default): one POST per event
batch_mode: false
# Body: {"id": "...", "op": "c", "after": {...}}

# Batch mode: one POST with JSON array
batch_mode: true
# Body: [{"id": "...", "op": "c", ...}, {"id": "...", "op": "u", ...}]

Batch mode reduces HTTP overhead but means the consumer must handle arrays.

Retry behavior

ConditionBehavior
2xx responseSuccess
408, 429Retry with backoff (100ms → 10s, 3 attempts)
5xxRetry with backoff
Connection errorRetry with backoff
TimeoutRetry with backoff
401, 403Auth error — fail immediately, no retry
Other 4xxPermanent failure — fail batch

Failure modes

In the table below, “retries” means a bounded in-sink retry (exponential backoff, finite attempts) inside a single send_batch call. There is no unbounded pipeline-level retry loop: once in-sink attempts are exhausted on a required sink, the checkpoint is held and the batch is recovered by source replay on the next restart. HTTP delivery is at-least-once with no server-side dedup, so the endpoint must be idempotent (dedup on event id).

FailureSymptomsDeltaForge behaviorResolution
Endpoint unavailableConnection refusedRetries with backoff; blocks checkpointRestore endpoint
Authentication failure401/403 responseFails fast, no retryFix credentials in headers
Rate limited429 responseRetries with backoffReduce throughput or increase rate limit
TimeoutRequest exceeds send_timeout_secsRetriesIncrease timeout or fix slow endpoint
URL template errorTemplate resolves to emptyEvent → DLQ (if enabled)Fix template or event data

Consuming events

Node.js / Express

app.post('/webhook', (req, res) => {
  const event = req.body;
  console.log(`${event.op} on ${event.source.table}: ${JSON.stringify(event.after)}`);
  res.sendStatus(200);
});

Python / Flask

@app.route('/webhook', methods=['POST'])
def webhook():
    event = request.json
    process(event)
    return '', 200

Go

http.HandleFunc("/webhook", func(w http.ResponseWriter, r *http.Request) {
    var event Event
    json.NewDecoder(r.Body).Decode(&event)
    process(event)
    w.WriteHeader(http.StatusOK)
})

Notes

  • Connection pooling is automatic — reqwest reuses TCP connections to the same host
  • The Content-Type header is set to application/json by default
  • At-least-once delivery: on crash, events may be re-sent. Consumers should be idempotent.
  • For per-event dedup, use the id field in the event payload (UUID v7, stable across replays)

S3 sink (Parquet / JSON Lines)

The S3 sink writes CDC events as Parquet (default) or JSON Lines files to S3-compatible object storage - AWS S3, MinIO, GCS via S3-interop, Azure Blob, R2, Wasabi, or local filesystem. Files are partitioned by table and UTC date in Hive style, with atomic multipart commits.

When to use S3

The S3 sink is the lakehouse-first path out of DeltaForge. CDC events land directly in queryable Parquet without a Kafka detour.

Real-world applications

Use caseDescription
Lakehouse ingestionCDC events → Parquet → Athena/Trino/DuckDB/Spark/BigQuery external tables, queryable in minutes
Audit and forensicsJSON Lines + gzip — every database mutation captured as readable text for compliance
Cold storage / archiveLong-term retention without paying for streaming infrastructure
Pre-Iceberg / Delta stagingFiles in Hive layout are a direct migration path to Iceberg catalogs
Cross-team analytics handoffData team consumes Parquet on their cadence; no streaming consumer required
Disaster recoveryParallel sink to Kafka — durable file copy of every event

Pros and cons

ProsCons
✅ Lakehouse-ready — Parquet is queryable by every modern analytics engine❌ Higher latency — events appear after file roll, not in milliseconds
✅ No streaming infrastructure — direct sink, no Kafka cluster required❌ File rolling tradeoffs — too small = many tiny files, too large = stale data
✅ Atomic commits — multipart-complete is the visibility point❌ Schema must come from DDL for typed columns; otherwise envelope-only
✅ Native Decimal128 support — financial precision preserved, no string fallback❌ No exactly-once at event level (Phase 1) — duplicates possible on replay
✅ Multi-cloud / on-prem — same code targets S3, MinIO, GCS, Azure, local FS❌ Lifecycle policy required for production (abandoned-multipart cleanup)
✅ Cheap at scale — object storage is the lowest-cost durable tier❌ Nested/complex types — deeply-nested lists and non-Utf8 maps fall back to a JSON-string column

Configuration

sinks:
  - type: s3
    config:
      id: orders-lake
      bucket: my-data-lake
      prefix: cdc/orders
      region: us-east-1
      access_key_id: ${S3_ACCESS_KEY}
      secret_access_key: ${S3_SECRET_KEY}
      format: parquet
      compression: snappy
      file_roll:
        max_bytes: 268435456
        max_events: 1000000
        max_age_secs: 300
        idle_age_secs: 600
      send_timeout_secs: 60
      required: true
FieldTypeDefaultDescription
idstring—Sink identifier
bucketstring—S3 bucket (or local FS root if local: true)
prefixstring""Key prefix prepended to partition path
regionstring—AWS region; required for AWS, ignored by MinIO/local
endpointstringnullOverride for MinIO/Ceph/R2/etc; null for AWS
access_key_idstringenv chainInline key, supports ${ENV}
secret_access_keystringenv chainInline secret, supports ${ENV}
virtual_hosted_styleboolfalsetrue for AWS custom domains; false for MinIO
localboolfalseUse local FS instead of S3
formatenumparquetparquet or jsonl
compressionenumsnappysnappy/gzip/zstd/none (per format)
file_rollobject(defaults below)Rolling thresholds
send_timeout_secsuint60Max seconds for a single send_batch (including any rolls). Exceeds → SinkError::Backpressure; coordinator handles per required.
requiredbooltrueGates checkpoints

File rolling defaults

FieldDefaultMeaning
max_bytes256 MiBBuffered bytes before roll (Parquet estimate; JSONL exact)
max_events1,000,000Events per file
max_age_secs300Wall-clock age since first event in the file
idle_age_secs600No-new-event window; lets low-volume partitions land data

A writer rolls when any threshold fires.

A background task sweeps every ~5 seconds, so max_age_secs and idle_age_secs fire even when the source is fully idle — you don’t need new events arriving to roll an aged or idle writer. This bounds how long a partition’s tail data stays buffered and uncommitted after a burst.

Compression options

FormatSupportedRecommended
Parquetsnappy, gzip, zstd, nonesnappy (broad compat, fast); zstd for archival
JSON Linesgzip, zstd, nonegzip for broad compat; zstd for better ratio on archival

File layout

{bucket}/{prefix}/database={database}/namespace={namespace}/table={table}/year={Y}/month={MM}/day={DD}/{ulid}.{ext}
  • database, namespace and table identify the source table: namespace is the PostgreSQL schema, or the MySQL database. Same-named tables of different databases or schemas never share a partition, even when two pipelines share a prefix

  • Each identity value is percent-escaped (every byte but ASCII letters, digits, -, . and _), so / or = in a name never adds or fakes a segment

  • ULID is monotonic-by-time and sortable; generated at writer creation

  • Extension is parquet, jsonl, jsonl.gz, or jsonl.zst

  • Partition path is Hive-style for compatibility with every analytics engine

Example:

my-data-lake/cdc/orders/database=shop/namespace=shop/table=orders/year=2026/month=05/day=19/01H8M3...ABC.parquet

Schema and column mapping

Parquet files have a flat envelope schema:

ColumnTypeSource
opUtf8c / u / d / r / t
op_tsTimestamp(ms, UTC)event.ts_ms
source_db / source_schema / source_tableUtf8Event source fields
source_positionUtf8lsn:0/... (PG) or binlog:file=...,pos=N (MySQL)
source_snapshotBooleantrue for snapshot events
event_id / schema_version / tx_idUtf8Event metadata
before_<col> / after_<col>DDL-derivedOne pair per source column

User-data column types are derived from source DDL via the same TypeConversionOpts used by Avro. Key choices:

Source typeArrow / Parquet
TINYINT / SMALLINT / INTInt32
BIGINTInt64
BIGINT UNSIGNEDUtf8 (safe default — configurable to Int64)
FLOAT / DOUBLEFloat32 / Float64
DECIMAL(p,s)Decimal128(p, s) — native, no string fallback
VARCHAR / TEXT / JSONUtf8
BLOB / BYTEABinary (decodes {"_base64": "..."} wrapper from binlog)
DATEDate32
TIMESTAMP WITH TZTimestamp(ms or µs, UTC)
DATETIME / TIMESTAMP WITHOUT TZUtf8 (ISO-8601; configurable to Timestamp(_, None))
BOOLBoolean
PG arrayList<T> (Int32/Int64/Float32/Float64/Bool/Utf8/Binary element types — nested lists fall back to JSON-string)
hstoreMap<Utf8, Utf8> (other shapes fall back to JSON-string)

The flat layout (before_id, after_id) trades nesting for usability — every analytics engine handles flat columns out of the box, and predicate pushdown stays efficient.

Delivery semantics

Quick summary (full details in Guarantees & Correctness):

GuaranteeBehavior
File-level atomicityA file is only visible at its final key after multipart-complete returns. Readers never see a partial file.
At-least-once at file granularityA crash mid-batch may re-emit events in a new file with a different ULID. Dedup downstream via MERGE INTO or event_id.
Source checkpointAdvances only after send_batch returns success. Crash before that = full replay from the last committed checkpoint.
Per-row DLQEncoder failures are isolated per row: the bad row is returned via BatchResult.dlq_failures and routed to the DLQ; the rest of the batch is written normally. Implemented via slow-path per-event retry on the same writer when the fast batched encode errors.

Exactly-once at the event level requires Iceberg (atomic snapshot commits) — Phase 2.

Production prerequisite: bucket lifecycle policy

Every production S3 sink deployment must configure this lifecycle rule:

{
  "Rules": [
    {
      "ID": "abort-abandoned-multiparts",
      "Status": "Enabled",
      "Filter": {},
      "AbortIncompleteMultipartUpload": { "DaysAfterInitiation": 1 }
    }
  ]
}

DeltaForge does not track multipart upload IDs externally — if the process crashes mid-upload, the partial parts linger on S3 until lifecycle policy expires them. Without this rule, you accumulate S3 storage cost for every aborted batch.

All major S3-compatible providers (AWS, MinIO, Wasabi, Backblaze B2, GCS S3-interop, R2) support AbortIncompleteMultipartUpload.

Examples

MinIO for development

sinks:
  - type: s3
    config:
      id: dev-minio
      bucket: deltaforge-dev
      prefix: cdc
      endpoint: "http://localhost:9000"
      region: us-east-1
      access_key_id: minioadmin
      secret_access_key: minioadmin
      virtual_hosted_style: false
      format: parquet
      compression: snappy

AWS S3 with IAM instance role

sinks:
  - type: s3
    config:
      id: prod-lake
      bucket: my-prod-lake
      prefix: cdc/orders
      region: us-east-1
      # Omit access keys — falls through to the default credential chain
      # (IAM instance role, IRSA on EKS, env vars, ~/.aws/credentials, ...)
      virtual_hosted_style: true
      format: parquet
      compression: snappy
      file_roll:
        max_bytes: 268435456    # 256 MiB
        max_events: 2000000
        max_age_secs: 600

JSON Lines for audit logs

sinks:
  - type: s3
    config:
      id: audit-bucket
      bucket: cdc-audit
      prefix: 2026
      region: us-west-2
      format: jsonl
      compression: gzip
      file_roll:
        max_bytes: 16777216     # 16 MiB — keep files small for `aws s3 cp | jq`
        max_age_secs: 60         # 1 min — low latency to readable

Local filesystem for testing

sinks:
  - type: s3
    config:
      id: local-sink
      bucket: /var/lib/deltaforge/lake
      local: true
      format: parquet

Multi-sink: Kafka + S3 fanout

sinks:
  # Hot path: real-time consumers
  - type: kafka
    config:
      id: realtime-kafka
      brokers: ${KAFKA_BROKERS}
      topic: cdc.orders
      required: true
  # Cold path: analytics on object storage
  - type: s3
    config:
      id: lake-s3
      bucket: my-lake
      prefix: cdc/orders
      region: us-east-1
      format: parquet
      compression: snappy
      required: false   # Don't gate Kafka's checkpoint on S3 outages

With commit_policy.mode: required, the Kafka sink gates checkpoints — if S3 fails, the Kafka sink’s checkpoint still advances and the source doesn’t replay for Kafka.

What required: false actually does (and doesn’t)

Short version:

  • Within one batch, sinks run concurrently — a slow S3 does not slow the Kafka call for that batch.
  • Across batches, the coordinator waits for all sink futures before pulling the next batch, so slow S3 still backpressures the source.
  • A failed S3 batch is not retried in the same session. The S3 sink’s checkpoint stays at its prior position; events are re-delivered only on pipeline restart (the source’s replay-from-MIN mechanism picks them up).

required: false buys:

  • Kafka’s checkpoint can advance even when S3 fails for this batch.
  • Pipeline doesn’t fail-loop on a persistent S3 outage.

required: false doesn’t buy:

  • Independent throughput between sinks — slow S3 still slows the source.
  • In-flight retry for S3 — events between failure and restart are only delivered on restart.

Memory bound under slow S3: max_bytes × active_partitions (default 256 MiB × N). The internal BufWriter blocks on slow uploads, propagating backpressure; no unbounded growth.

For truly independent hot/cold paths, run two pipelines (same source DSN, different sinks) or use a Kappa-style architecture (source → Kafka pipeline → second DeltaForge pipeline → S3) so Kafka’s retention is the natural retry buffer.

See Guarantees & Correctness — Required vs. optional sinks for the full failure-mode table, retry-layer breakdown, and S3 sink atomicity properties.

Querying output

DuckDB (zero-setup)

INSTALL httpfs;
LOAD httpfs;
SET s3_region='us-east-1';
SET s3_access_key_id='...';
SET s3_secret_access_key='...';

SELECT op, after_id, after_amount, op_ts
FROM read_parquet('s3://my-lake/cdc/orders/database=shop/namespace=shop/table=orders/year=2026/month=05/**/*.parquet')
WHERE op = 'c' AND after_amount > 100.00
ORDER BY op_ts DESC LIMIT 100;

AWS Athena (Hive partition discovery)

CREATE EXTERNAL TABLE orders_cdc (
  op string,
  op_ts timestamp,
  source_table string,
  before_id bigint,
  after_id bigint,
  after_amount decimal(12, 2),
  after_paid boolean
)
PARTITIONED BY (year int, month int, day int)
STORED AS PARQUET
LOCATION 's3://my-lake/cdc/orders/database=shop/namespace=shop/table=orders/';

MSCK REPAIR TABLE orders_cdc;

Trino / Spark

Standard Parquet readers; the Hive partition discovery works without configuration.

Metrics

The sink emits Prometheus metrics under deltaforge_sink_s3_*:

MetricTypeLabelsMeaning
deltaforge_sink_s3_files_committed_totalcounterpipeline, sink, reason (+ table, table_scope with per-table detail)Files atomically committed. reason = bytes / events / age / idle
deltaforge_sink_bytes_totalcounterpipeline, sink (+ table, table_scope with per-table detail)Bytes uploaded (compressed)
deltaforge_sink_s3_writer_opengaugepipeline, sinkIn-progress writers (one per active partition)
deltaforge_sink_s3_encode_errors_totalcounterpipeline, sink, reasonEncoder failures (DLQ-eligible)
deltaforge_sink_s3_put_errors_totalcounterpipeline, sink, reasonObject-store upload failures

Limitations and roadmap

Phase 1 (current):

  • Parquet + JSON Lines formats
  • S3, MinIO, GCS (via S3 interop), Azure, local FS
  • DDL-derived schemas with native Decimal128
  • File-level atomicity, multipart upload, ULID file names
  • Hive partitioning by table + UTC date

Phase 2 (planned):

  • Iceberg table format — exactly-once event-level via atomic snapshot commits, schema evolution, time travel
  • Hour-granularity partitioning — opt-in partition_by: [day, hour]
  • Delta Lake / Hudi — alternative table formats

Phase 2 shipped:

  • ✅ Per-row DLQ — slow-path per-event retry isolates bad rows
  • ✅ send_timeout_secs per-batch timeout (sink-internal)
  • ✅ Streaming JSONL writer with gzip / zstd via async-compression — memory bounded by BufWriter chunk size (~8 MiB) instead of full file size
  • ✅ List<T> and Map<Utf8, Utf8> columns as native Arrow nested types (PG arrays and hstore)
  • ✅ Coordinator-level sink_batch_deadline_secs — defense-in-depth outer timeout (see Guarantees)

Not on the roadmap:

  • CSV / TSV output (low signal-to-noise vs JSONL)
  • Avro Object Container Files (niche; Avro outside of Kafka is uncommon)

Examples

JSON Lines + gzip to MinIO (audit / forensics):

sinks:
  - type: s3
    config:
      id: orders-audit
      bucket: cdc-audit
      prefix: orders
      endpoint: "http://minio:9000"   # MinIO / Ceph / R2 (omit for AWS S3)
      access_key_id: "${S3_ACCESS_KEY}"
      secret_access_key: "${S3_SECRET_KEY}"
      format: jsonl
      compression: gzip

Local filesystem (tests / single-node) — bucket is the root directory:

sinks:
  - type: s3
    config:
      id: orders-local
      bucket: ./data/lake
      prefix: orders
      local: true
      format: parquet

S3 durable acknowledgements (durable_v2)

Operations guide for the crash-durable S3 sink. Covers what the mode guarantees, rollout, lifecycle and garbage-collection settings, alarms, rollback, and the multipart-upload posture.

What durable_v2 guarantees

A batch is acknowledged to the source only after its manifest entry is committed to _manifest/HEAD by a conditional compare-and-swap (CAS). Data objects are immutable single PUTs written before the entry; the entry is written before the HEAD CAS. A crash at any point leaves either a fully committed batch or an uncommitted orphan that reconciliation removes. Acknowledged data is never lost across a crash, and recovery is verified from the rollup chain plus the retained entry tail before a new owner takes the epoch.

durability is the default: an unspecified durability resolves to durable_v2. legacy_rolling is the explicit, non-durable rolling-file mode kept only for rollback and compatibility (see Rollback).

sinks:
  - type: s3
    bucket: my-bucket
    # durability: durable_v2   # the default; may be omitted

Object keys

Each batch writes one immutable object per table at

{prefix}/{pipeline}/{table}/wm2-{watermark digest}/{content hash}.{ext}
  • The watermark digest (64 hex digits) is a SHA-256 of the batch’s canonical source position: its position type (MySQL GTID set, MySQL binlog coordinate, PostgreSQL LSN, snapshot progress), its lineage and the position itself. Two encodings of one position share the digest (a GTID set is canonicalized: lowercase UUIDs, merged intervals), so a retry, restart or replay writes the same key.
  • The full position never enters a key. It is in the manifest entry, and the sink logs it at debug with the digest.
  • Every key stays within backend limits however large the position (a fleet gtid_executed set names many UUIDs): no path segment longer than 200 bytes and no key longer than 900 bytes. At startup the sink refuses a prefix and pipeline name that would leave no room for a table name of the longest segment. A table whose encoded name is longer than a segment stops the sink with an error rather than being shortened.
  • On recovery, an object whose wm2- digest does not match its manifest entry’s position stops the sink (a conflicting object, never accepted).

Upgrade. Releases before this layout wrote wm-{hex position} keys, whose position segment grows with the GTID set; RustFS, MinIO and other file-backed stores reject such keys (about 450 bytes for a modest MySQL GTID set). Existing wm- objects stay readable: manifests record every key verbatim, and recovery, compaction, garbage collection and reconciliation handle both layouts. New batches always use wm2-. No migration step is needed; compaction rewrites both layouts into compacted/ objects as usual.

Rollout

  1. Provision an S3-compatible bucket. The provider MUST honor If-None-Match: * (create-only) and If-Match: <etag> (CAS). The sink runs a capability probe at startup BEFORE acquiring the HEAD; a provider that silently ignores these conditions fails the probe and the sink refuses to start in durable mode. Do not force durable_v2 onto an unverified provider.
  2. Stage first against a staging bucket on the target provider and run the live integration matrix:
    export DELTAFORGE_IT_S3_ENDPOINT=<provider endpoint>
    export DELTAFORGE_IT_S3_BUCKET=deltaforge-it
    export DELTAFORGE_IT_S3_ACCESS_KEY=<access key>
    export DELTAFORGE_IT_S3_SECRET_KEY=<secret key>
    cargo test -p sinks --lib -- --include-ignored --test-threads=1 s3_server_it
    
    These tests are #[ignore]d. With the full variable set they run against that provider; with none set they start the pinned S3 test server (as the core gate does); a partial set is refused. Run explicitly, they exercise a real server or fail loudly. They cover the probe, publish/ack plus restart recovery, concurrent-writer epoch fencing, cumulative-rollup fallback after entry GC, compaction, both GC domains, orphan reconciliation, and end-to-end recoverability after combined compaction, entry expiry, original deletion, and process restart.
  3. Roll out one pipeline at a time. A single owner holds the HEAD per pipeline; epoch fencing makes a second concurrent owner safe (the stale one is fenced), but a rolling upgrade should still avoid deliberately running two writers.

Lifecycle and garbage-collection settings

Deletion is opt-in and staged. Nothing on the acknowledgement path deletes. Three background actors, all owner-fenced and serialized, reclaim space:

  • Mark entries (safety_window_ms): writes immutable GC marks for manifest entries covered by the horizon (prev_rollup.end_seq). An entry is eligible only once the current rollup generation has been HEAD-published at least safety_window_ms ago. Set safety_window_ms to comfortably exceed your recovery/restart window and any read-after-write skew. Marking is non-destructive.
  • Expire entries: consumes durable marks and deletes the marked entries.
  • Delete originals: deletes originals that a HEAD-reachable, equivalence-true compaction record has superseded. Equivalence is proven at compaction time and bound into the record; deletion never recomputes it.

Reconciliation removes unreferenced (orphan) objects:

  • Reconcile (grace_period_ms): deletes only objects unreachable from authoritative HEAD state AND older than grace_period_ms. The grace period prevents deleting an object that was just written and is about to be referenced. Set grace_period_ms to exceed the longest gap between a data PUT and its HEAD commit (seconds in normal operation; size for your worst case). Reconciliation is reachability-driven, never name-pattern-driven, and re-reads HEAD before any destructive step.

Older rollup and inventory objects are retained indefinitely, are excluded from GC, and are never treated as orphans.

Alarms

Wire these to your alerting. They are designed to be machine-readable.

  • s3_non_durable_ack_mode metric (deltaforge_sink_s3_non_durable_ack_mode, labels pipeline, sink): 1 when a sink is running in legacy_rolling (non-durable) mode, 0 for durable sinks. Alert on == 1. A prominent startup warning is logged alongside it.
  • Missing referenced object (hard alarm): recovery or reconciliation found HEAD referencing an object that is absent. This is a durability integrity violation. Page immediately; do not run any GC or reconciliation until resolved.
  • GC/reconcile stopped early: an actor stopped on epoch change, a stale plan, listing uncertainty, an ambiguous provider response, or an integrity failure. Deletion never proceeds past a stop. Investigate the reason; a persistent stop means the actor is making no progress.
  • Writer fenced: a writer lost ownership (another owner took a higher epoch). Expected once during a handover; sustained fencing means two owners are contending for one pipeline.
  • CAS conflicts rising (cas_conflicts): HEAD contention, usually two writers. Confirm single ownership per pipeline.
  • Compaction lag growing (compaction_lag): acknowledged originals are not being superseded; compaction is not keeping up and data GC has nothing to reclaim.

The durable writer also exposes an operational snapshot (epoch, seq, watermark, current/previous rollup end sequences, and the GC horizon) for dashboards.

Multipart uploads

durable_v2 does not use S3 multipart uploads. Every data object and manifest object is a single PUT, so there are no in-flight multipart sessions to abort or leak. The reconciliation report’s mpu_aborts is therefore always 0 on this path; a non-zero value would indicate objects written by some other process. No multipart lifecycle rule is required for correctness. An AbortIncompleteMultipartUpload lifecycle rule remains harmless and is fine to keep for defense in depth.

Rollback

legacy_rolling is selectable at any time for rollback:

sinks:
  - type: s3
    bucket: my-bucket
    durability: legacy_rolling   # explicit, non-durable rolling files

When a sink starts in legacy_rolling, it logs a prominent warning and sets deltaforge_sink_s3_non_durable_ack_mode = 1: acknowledged data can be lost before a roll. Rollback is a config change and restart; it does not delete or rewrite any durable_v2 state already in the bucket. To return to durable mode, remove the override (or set durability: durable_v2) and restart; recovery verifies the existing HEAD/chain and resumes.

legacy_rolling and durable_v2 use different object layouts. Running legacy against a bucket that holds durable_v2 state does not corrupt that state, but the two do not share a manifest; treat a rollback as switching writers, not as continuing the same durable stream.

S3 test backends

DeltaForge’s S3 integration tests run against two emulated backends. Each proves something different, and neither proves the sink works against real AWS S3.

BackendProvesSuites
RustFS 1.0.1, pinned by image digest in crates/s3-test-serverPortable S3-server behavior: what any S3-compatible server must do for DeltaForge to be correct on itsinks/lib-s3-server (the contract below), sinks/lib-s3-server-it (the durable_v2 live matrix), runner/s3_e2e_tests, sinks/s3_server_test
MiniStack 1.4.9, pinned by image digest in crates/runner/tests/ministackThe AWS-shaped path: AWS-style endpoints, credentials and responses, through the production legacy rolling and durable_v2 sinksrunner/s3_ministack_canary, runner/s3_ministack_durable_canary

Real-AWS qualification remains a separate, eventual step. An emulator passing does not show that AWS behaves the same way under load, across regions, or with its own consistency and rate limits.

The contract

crates/sinks/src/s3/s3_server_contract.rs checks the S3 behavior DeltaForge relies on. A server is acceptable as a test backend only while every check passes:

  • Conditional PUT, checked on the wire:
    • If-None-Match: * on an existing key returns 412 PreconditionFailed;
    • If-Match with a wrong or stale ETag returns 412;
    • If-Match on a missing key returns 404 NoSuchKey;
    • a rejected PUT writes nothing.
  • The production conditional path (ObjectStoreConditional): create-only, CAS, conflict on a stale or missing ETag, and a capability probe that passes and leaves no trace.
  • Stable ETags: the PUT response, HEAD, GET, LIST and the wire ETag header agree across repeated reads, and a read never invalidates the ETag that the durable_v2 HEAD CAS uses.
  • Basic object operations: PUT, GET, HEAD, LIST and DELETE. DELETE of a missing key returns 204.
  • Multipart uploads:
    • create, upload parts and complete produces the exact bytes;
    • an aborted upload is never visible and no open upload remains.
  • Zero-byte objects, written both unconditionally and create-only.
  • Read-after-write visibility for a write, an overwrite and a delete.
  • Persistence across a server restart: bytes and ETags survive, and conditional writes still hold.
  • Concurrent conditional writers: exactly one winner, for 16 writers over 20 rounds, both for create-only and for CAS. The final object and its ETag are the winner’s.

The suite is #[ignore]d and starts its own container:

cargo test -p sinks --lib -- --include-ignored --test-threads=1 s3_server_contract

The contract verifies S3 semantics. DeltaForge’s durable sink behavior on a real server is verified separately by the live durable_v2 matrix in s3_server_it (core gate lane sinks/lib-s3-server-it): publication and recovery, writer fencing, rollup fallback, garbage collection and orphan reconciliation. It starts the pinned server when no DELTAFORGE_IT_S3_* variable is set, uses an external server when the full set is given, and refuses a partial set. See Durable acknowledgements.

Changing the pinned image

The image, credentials, readiness check and container setup live only in crates/s3-test-server. To move to a new RustFS release:

  1. Pin the new release by tag and digest.
  2. Run the contract suite, s3_server_it, runner/s3_e2e_tests and sinks/s3_server_test.
  3. Adopt it only if every check passes unchanged. If a check fails, record the exact request, the expected AWS behavior and the actual response. Do not relax the assertion.

The manual chaos stack (docker-compose.chaos.yml) still uses its own MinIO service. It is not part of either gate tier.

ClickHouse Sink

Streams CDC events into ClickHouse over the HTTP interface using the compact RowBinary format. It supports two consumption shapes with one uniform write path — the target table’s engine decides whether you get a change-log or a mirrored current-state table.

sinks:
  - type: clickhouse
    config:
      id: ch-orders
      url: "https://clickhouse:8443"     # HTTP(S) endpoint
      database: analytics
      table: orders
      mode: upsert                       # upsert | changelog
      user: default
      password: "${CLICKHOUSE_PASSWORD}" # ${ENV} expansion

Configuration

FieldRequiredDefaultDescription
idyes—Unique sink identifier
urlyes—ClickHouse HTTP(S) endpoint (e.g. http://host:8123)
databaseyes—Target database
tableyes—Target table
modenochangelogupsert (current state) or changelog (retain all changes)
userno—ClickHouse user (${ENV} supported)
passwordno—Password/key (${ENV} supported)
tls.enablednotrueTLS for https:// endpoints
tls.insecure_skip_verifynofalseSkip certificate verification
version_sourcenosource_position_version source: source_position (LSN/binlog) or ts_ms
auto_createnotrueMust be true: the sink writes only to a table it creates (see Destination ownership); false is rejected
send_timeout_secsno30Per-batch insert timeout (timeouts → backpressure)
requirednotrueRequired (blocks) vs best-effort (log + continue)

Modes

The sink always writes the same row shape — your source columns plus the meta columns _op, _version, _deleted, _source_ts. What differs is the target table’s engine, which the sink auto-creates for you (or you pre-create it and set auto_create: false).

upsert — mirror current state

CREATE TABLE analytics.orders
( id Int64, amount Decimal(12,2),
  _op LowCardinality(String), _version UInt64, _deleted UInt8,
  _source_ts DateTime64(3) )
ENGINE = ReplacingMergeTree(_version, _deleted)
ORDER BY id;                        -- ORDER BY = source primary key

ClickHouse’s merge keeps the row with the max _version per key and drops deletes. Query current state with FINAL:

SELECT * FROM analytics.orders FINAL;

changelog — retain every change

Same columns, ENGINE = MergeTree ORDER BY (id, _version). Every insert/update/ delete is kept as a row (audit / streaming). Reconstruct current state at read time:

SELECT argMax(amount, _version) FROM analytics.orders
WHERE _deleted = 0 GROUP BY id;

Delivery guarantees

At-least-once. The checkpoint advances only after an insert acks, so no events are lost; a crash between ack and checkpoint can replay a batch. On top of that:

  • upsert mode is idempotent — duplicate or out-of-order inserts collapse by (key, _version), so the mirrored table converges to exactly the source’s current state regardless of retries. Read with FINAL for a consistent view. This is the recommended mode when correctness matters most.
  • changelog mode is at-least-once — retries are suppressed by ClickHouse’s insert_deduplication_token for simple replays (within the dedup window; Replicated tables, or non-replicated with non_replicated_deduplication_window), but that is best-effort. Dedup at read time via argMax(_version) for exact current state.

End-to-end exactly-once (Kafka-EOS style) is not claimed — ClickHouse inserts are not transactional with the DeltaForge checkpoint.

_version defaults to the source LSN/binlog position (monotonic), which is what makes ReplacingMergeTree replacement correct. Only fall back to ts_ms when the source lacks a usable position (millisecond ties per key are undefined).

Destination ownership and fencing

A ClickHouse sink writes one target table, which holds one source table.

Supported topology. The sink’s guarantees cover one ClickHouse server holding a local MergeTree (changelog) or ReplacingMergeTree (upsert) table that the sink created itself, written with synchronous inserts. Refused:

  • Replicated* engines, Distributed tables and every other engine (unsupported_topology);
  • an endpoint whose serverUUID() differs from the one recorded when the table was created (server_changed). A load balancer over several servers is unsupported.

Every insert runs with async_insert=0, and every claim proves that this takes effect: a settings profile that pins async_insert on is refused (fencing_unenforced). An asynchronous insert would be acknowledged before the fence below is checked.

The sink writes only to a table it creates. It runs CREATE TABLE without IF NOT EXISTS. The table has:

  • an immutable ownership marker as its comment, naming:
    • the owner (the deployment, which is the state store’s identity, plus the pipeline and the sink);
    • the source table;
    • the target database and table;
    • the encoding (layout, type mapping, mode, extensions column);
    • a random instance;
  • the reserved last column _df_fence LowCardinality(String);
  • the constraint df_fence_<fence> CHECK _df_fence = '<fence>'.

Every row carries its writer’s fence. A table the sink did not create is refused, with no adoption, as is a source column named _df_fence.

Claims. Before a session first writes, and again after a reconnect or a client recreation, the sink claims the table:

  1. It checks the marker, the record in the state store, the engine, the reserved column (exactly one, last, of the exact type), and that exactly one well-formed fence constraint exists.
  2. It rotates the fence with one ALTER TABLE … DROP CONSTRAINT df_fence_<old>, ADD CONSTRAINT df_fence_<new> …. That statement fails as a whole if the old constraint is gone: a compare-and-swap.

Between claims, inserts make no ownership request.

The fencing guarantee. After a claim has rotated the fence, every newly evaluated insert carrying the old fence is rejected by the server (VIOLATED_CONSTRAINT). An insert the server had already accepted may still complete, so writes around an ownership change are at least once.

A rejected batch fails whole as sink_destination_conflict (class fenced):

  • it is never dead-lettered;
  • the checkpoint does not advance;
  • the pipeline stops.

Cost. The fence adds 33 bytes per row to each insert. Stored, it compresses to under 0.01 bytes per row.

Clone and restore.

  • Two separately created state stores are two deployments (owned_by_other).
  • A copied state store has the same identity. The first copy to claim fences the other, whose next insert fails at the server.
  • A state store restored from an older backup is refused (fenced).
  • A dropped table is never recreated (destination_missing). Its ownership record is removed only by deleting the pipeline, which resets the sink’s checkpoints in the same operation.
  • Not distinguishable: a copy of the table, with its comment and constraint, on a server cloned from the same data directory (the same serverUUID()). This is outside the guarantee.

Refusals are the sink_destination_conflict incident: nothing of the batch is acknowledged and the checkpoint stays. The incident names the table, the source table and the class; it never contains row data or credentials. The classes:

  • marker_missing, marker_malformed, marker_unsupported_version;
  • owned_by_other, bound_to_another_relation;
  • destination_identity_mismatch, encoding_mismatch;
  • fenced, fence_malformed;
  • destination_missing, unrecorded_destination;
  • server_changed, unsupported_topology, fencing_unenforced.

Requirements

The sink needs the source table’s column types + primary key (RowBinary is positional and auto-create derives the DDL from them). It loads these on demand the first time it sees a table: the schema resolver queries the source database’s catalog (MySQL INFORMATION_SCHEMA, Postgres pg_catalog) and caches the result. So the sink works under any snapshot mode, including snapshot: never — no snapshot or schema sensing is required. The only prerequisite is that the source database is reachable and the table exists, which is already true for CDC. The v1 sink targets a single table; capture one source table per ClickHouse sink (not a wildcard).

Auto table creation

The sink creates the target table on the first event, deriving column types from the source DDL and the engine/ORDER BY from the mode + primary key, with its ownership marker, fence column and fence constraint (see Destination ownership). A pre-created table is refused. The sink’s user needs CREATE TABLE, ALTER TABLE (constraints), INSERT and SELECT on the target database.

Type mapping

SourceClickHouse
bigint / bigint unsignedInt64 / UInt64
int, smallint, tinyintInt32, Int16, UInt8
decimal(p,s)Decimal(p, s) (exact)
float / doubleFloat64
booleanUInt8
date / datetime / timestampDateTime64(3)
varchar / text / json / otherString

Nullable source columns map to Nullable(T). JSON is stored as text in v1.

Examples

Self-hosted — change-log (audit) table over plain HTTP, auto-created:

sinks:
  - type: clickhouse
    config:
      id: ch-audit
      url: "http://clickhouse:8123"
      database: analytics
      table: orders_changelog
      mode: changelog                # MergeTree: retains every change

Current-state mirror into a pre-created ReplacingMergeTree:

sinks:
  - type: clickhouse
    config:
      id: ch-orders-current
      url: "https://clickhouse:8443"
      database: analytics
      table: orders
      mode: upsert
      user: cdc
      password: "${CLICKHOUSE_PASSWORD}"
      auto_create: false             # operator pre-creates the table

Elasticsearch Sink

Streams CDC events into Elasticsearch as a current-state mirror: each source row becomes one document, upserted by its primary key and versioned by the source position so the index converges to the source’s current state — idempotent under retries and safe against out-of-order delivery.

sinks:
  - type: elasticsearch
    config:
      id: es-orders
      url: "https://es:9200"           # HTTP(S) endpoint
      index: "cdc-{db}.{table}"        # template or static name
      auth:
        type: basic                    # basic | api_key | none
        username: elastic
        password: "${ES_PASSWORD}"     # ${ENV} expansion

Configuration

FieldRequiredDefaultDescription
idyes—Unique sink identifier
urlyes—Elasticsearch HTTP(S) endpoint (${ENV} supported)
indexyes—Index name or template — {db}, {schema}, {table}
auto_create_indexnotrueMust be true: the sink writes only to indices it creates (see Destination ownership); false is rejected
id_fieldsno[]Fields forming _id; empty → source primary key
id_separatorno_Separator joining composite _id values
version_sourcenosource_positionExternal version: source_position (LSN/binlog) or ts_ms
authnononebasic (username/password) or api_key (api_key); ${ENV} supported
tls.enablednotrueAccepted for parity; TLS is active whenever url is https://
tls.ca_fileno—PEM CA bundle for a private CA
tls.insecure_skip_verifynofalseSkip certificate verification
send_timeout_secsno30Per-_bulk timeout (timeouts → backpressure)
requirednotrueRequired (blocks) vs best-effort (log + continue)

How it writes

Each batch is sent as one _bulk request:

  • Insert / update → index action carrying the source after document.
  • Delete → delete action.
  • Every action uses version_type=external with version = the source position, so Elasticsearch keeps the highest version per _id.

Document _id

_id is built from id_fields if set, otherwise from the source table’s primary key, joined by id_separator. Rows from a table with no primary key and no id_fields are routed to the DLQ (a per-row serialization failure) — the sink never falls back to an auto-generated id, which would silently break upsert and delete.

Index naming

index is a template: {db}, {schema} (Postgres schema, falls back to the database), and {table} are substituted per event, so one sink can span many tables. A template with no placeholders is a static index name. Index names are lowercased (an Elasticsearch requirement).

Mapping — generated from the source schema

With auto_create_index: true (default), the sink creates each index on first use with an explicit mapping derived from the source column types — so decimals stay exact and dates stay dates, instead of the mistypes you get from Elasticsearch dynamic mapping (decimals → float, dates → text). Set auto_create_index: false to rely on a pre-created index or dynamic mapping.

SourceElasticsearch
bigint / bigint unsignedlong / unsigned_long
int, smallint, tinyintinteger, short, byte
decimal(p,s)scaled_float (scaling_factor = 10^s)
float / doubledouble
booleanboolean
date / datetime / timestampdate (format accepts epoch millis + ISO/MySQL strings)
timekeyword (a duration, not a point in time)
blob / binary / varbinarybinary (base64)
jsonflattened
varchar / text / othertext with a keyword sub-field

Value handling

Source column values are normalized to match these mappings before indexing, so real CDC payloads land correctly regardless of source path:

  • TEXT/BLOB arrive base64-wrapped from the MySQL binlog ({"_base64": …}). Character columns are decoded to UTF-8 text; binary columns keep the base64 string and map to an ES binary field.
  • TIMESTAMP arrives as epoch microseconds and is converted to epoch_millis; string temporals (snapshot ISO-8601, binlog DATETIME) pass through and are parsed by the date field’s format list.
  • DECIMAL arrives as a string and is stored exactly via scaled_float.

Delivery guarantees

At-least-once, idempotent. The checkpoint advances only after the _bulk acks, so nothing is lost; a crash between ack and checkpoint can replay a batch. External versioning makes that safe:

  • Replays and out-of-order batches converge to the source’s current state — Elasticsearch keeps the highest version per _id.
  • A stale write is rejected with 409 version_conflict; the sink treats that as success (the document was already superseded), not a failure.

Per-document non-retryable errors (e.g. a mapping conflict) are routed to the DLQ with their row’s index; whole-batch retryable conditions (429/503, timeouts, connection loss) return backpressure so the coordinator replays the batch and the required policy is honored.

End-to-end exactly-once is not claimed — Elasticsearch writes are not transactional with the DeltaForge checkpoint.

Destination ownership and fencing

Documents are keyed by their source table’s primary key, so an index holds one source table. Give each source table its own index (an index template with {schema} and {table}, or {db} when several databases share a cluster).

The sink writes only to indices it creates. The index is created in one request, with:

  • an immutable ownership marker in mappings._meta.deltaforge, naming:
    • the owner (the deployment, which is the state store’s identity, plus the pipeline and the sink);
    • the source table (connector, source, database, schema, table);
    • the index (the cluster’s cluster_uuid and the index name);
    • the encoding;
    • a random instance;
  • the internal write alias df-w-<instance>-<fence>.

Every bulk action (index and delete) targets that alias, with require_alias=true. An index the sink did not create is refused, with no adoption, as is a name that is an alias or a data stream.

Claims. Before a session first writes to an index, and again after a reconnect or a client recreation, the sink claims it:

  1. It proves the marker and the record in the state store agree.
  2. It rotates the fence by one atomic _aliases request, which removes the old alias (must_exist) and adds the new one.

The state store keeps the index’s instance and its current and pending fence for crash recovery; the index enforces. Claims never write _meta, and other _meta keys and user aliases are left alone. Between claims, writes make no ownership request.

The fencing guarantee. After a claim has rotated an index’s fence, every newly evaluated write through the old alias is rejected by the cluster: index actions by require_alias, deletes by the auto-create rule below. A bulk request the cluster had already accepted may still complete, so writes around an ownership change are at least once, as everywhere else.

A rejected batch fails whole as sink_destination_conflict (class fenced):

  • it is never dead-lettered;
  • the checkpoint does not advance;
  • the pipeline stops.

Deployment requirement. require_alias does not apply to delete actions. Deletes are fenced only when the cluster refuses to auto-create indices named df-w-*, for example:

PUT _cluster/settings
{ "persistent": { "action.auto_create_index": "-df-w-*,+*" } }

Rules are evaluated in order, and the first match decides. Every claim reads the effective setting (transient, then persistent, then the default) and refuses with fencing_unenforced when it cannot read it or cannot prove that df-w-* is forbidden. The sink’s user needs:

  • the monitor cluster privilege, to read the setting;
  • on its indices: create_index, manage_aliases, write and view_index_metadata.

Clone and restore.

  • Two separately created state stores are two deployments: neither can write to the other’s indices (owned_by_other).
  • A copied state store has the same identity. The first copy to claim an index fences the other, whose next write fails at the cluster.
  • A state store restored from an older backup is refused (fenced). Restored from its latest state, it continues.
  • A deleted index is never recreated or auto-created (destination_missing). Its ownership record is removed only by deleting the pipeline, which resets the sink’s checkpoints in the same operation.

Refusals. Every refusal is the sink_destination_conflict incident: nothing of the batch is acknowledged and the checkpoint stays. The incident names the index, the source table and the class, and only a digest of another owner; it never contains row data or credentials. The classes:

  • marker_missing, marker_malformed, marker_unsupported_version;
  • owned_by_other, bound_to_another_relation;
  • destination_identity_mismatch, encoding_mismatch;
  • fenced, fence_malformed (none, or several, internal aliases);
  • alias_not_supported, destination_missing, unrecorded_destination;
  • fencing_unenforced.

Requirements

The sink needs the source column types + primary key. It loads these on demand the first time it sees a table (querying the source catalog — MySQL INFORMATION_SCHEMA, Postgres pg_catalog) and caches them, so it works under any snapshot mode, including snapshot: never. The cluster must forbid auto-creating df-w-* indices (see Destination ownership).

Examples

Elastic Cloud — API-key auth, one fixed index, explicit _id:

sinks:
  - type: elasticsearch
    config:
      id: es-customers
      url: "https://my-cluster.es.cloud:9243"
      index: customers               # static index (no placeholders)
      id_fields: [customer_id]       # override the source PK
      auth:
        type: api_key
        api_key: "${ES_API_KEY}"

Self-hosted / dev — no auth, one index per source table:

sinks:
  - type: elasticsearch
    config:
      id: es-dev
      url: "http://elasticsearch:9200"
      index: "cdc-{db}.{table}"      # per-table indices
      auth:
        type: none

Envelopes and Encodings

DeltaForge supports configurable envelope formats and wire encodings for sink output. This allows you to match the output format expected by your downstream consumers without forcing code changes on them.

Overview

Every CDC event flows through two stages before being written to a sink:

Event -> Envelope (structure) -> Encoding (bytes) -> Sink
  • Envelope: Controls the JSON structure of the output (what fields exist, how they’re nested)
  • Encoding: Controls the wire format (JSON bytes, Avro binary with Schema Registry)

Envelope Formats

Native (default)

The native envelope serializes events directly with minimal overhead. This is DeltaForge’s own format, optimized for efficiency and practical use cases.

Note

The native envelope format may evolve over time as we adapt to user needs and optimize for the lowest possible overhead. If you need a stable, standardized format, consider using debezium or cloudevents envelopes which follow their respective established specifications.

sinks:
  - type: kafka
    config:
      id: events-kafka
      brokers: localhost:9092
      topic: events
      envelope:
        type: native

Output:

{
  "before": null,
  "after": {"id": 1, "name": "Alice", "email": "alice@example.com"},
  "source": {
    "version": "0.1.0",
    "connector": "mysql",
    "name": "orders-db",
    "ts_ms": 1700000000000,
    "db": "shop",
    "table": "customers",
    "server_id": 1,
    "file": "mysql-bin.000003",
    "pos": 12345
  },
  "op": "c",
  "ts_ms": 1700000000000
}

Debezium consumers with schemas.enable=false: this is the shape they read. Kafka Connect’s JsonConverter with schemas.enable=false emits the change object itself (before, after, source, op, ts_ms), with no schema/payload wrapper, so use the native envelope (not debezium) for these consumers. Check two differences:

  • The source block carries DeltaForge’s fields, not every field a Debezium connector writes there.
  • Message keys. DeltaForge’s default key is an idempotency key, not the primary key. A key template such as key: "${after.id}" gives the primary key only for events with an after image (inserts, updates, snapshot reads): a delete has after: null, and the template then resolves to an empty key, not to the primary key or the default key, so a row’s delete can be partitioned differently from its earlier events. DeltaForge has no declarative key expression that takes the key from after or otherwise before, so primary-key keys that are consistent across inserts, updates and deletes are not available through configuration today.

When to use:

  • Maximum performance with lowest overhead
  • Consumers of Debezium output with schemas.enable=false
  • Custom consumers that parse the payload directly
  • When format stability is less important than efficiency
  • Internal systems where you control both producer and consumer

Debezium

The Debezium envelope wraps the event in a {"schema": null, "payload": ...} structure, with the payload following the Debezium event format specification.

This is not the output of JsonConverter with schemas.enable=false: that emits the payload object unwrapped, which is the native envelope’s shape. Structurally, the wrapper matches schemas-enabled Connect output (schemas.enable=true), except that schema is always null rather than the full schema, so consumers that need the per-message schema cannot use it.

sinks:
  - type: kafka
    config:
      id: events-kafka
      brokers: localhost:9092
      topic: events
      envelope:
        type: debezium

Output:

{
  "schema": null,
  "payload": {
    "before": null,
    "after": {"id": 1, "name": "Alice", "email": "alice@example.com"},
    "source": {
      "version": "0.1.0",
      "connector": "mysql",
      "name": "orders-db",
      "ts_ms": 1700000000000,
      "db": "shop",
      "table": "customers"
    },
    "op": "c",
    "ts_ms": 1700000000000
  }
}

When to use:

  • Tools that specifically parse the payload wrapper and accept schema: null
  • When you need a stable, documented format (the native envelope may evolve)

Note: When using Avro encoding with Schema Registry, schema handling is at the encoding layer — schema IDs are embedded in the Confluent wire format.

CloudEvents

The CloudEvents envelope restructures events to the CloudEvents 1.0 specification, a CNCF project that defines a vendor-neutral format for event data. This format strictly follows the CloudEvents spec and is guaranteed to remain compliant.

sinks:
  - type: kafka
    config:
      id: events-kafka
      brokers: localhost:9092
      topic: events
      envelope:
        type: cloudevents
        type_prefix: "com.example.cdc"

Output:

{
  "specversion": "1.0",
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "source": "deltaforge/orders-db/shop.customers",
  "type": "com.example.cdc.created",
  "time": "2024-01-15T10:30:00.000Z",
  "datacontenttype": "application/json",
  "subject": "shop.customers",
  "data": {
    "before": null,
    "after": {"id": 1, "name": "Alice", "email": "alice@example.com"},
    "op": "c"
  }
}

The type field is constructed from your type_prefix plus the operation:

  • com.example.cdc.created (INSERT)
  • com.example.cdc.updated (UPDATE)
  • com.example.cdc.deleted (DELETE)
  • com.example.cdc.snapshot (READ/snapshot)
  • com.example.cdc.truncated (TRUNCATE)

When to use:

  • AWS EventBridge, Azure Event Grid, or other CloudEvents-native platforms
  • Serverless architectures (Lambda, Cloud Functions)
  • Event-driven microservices using CloudEvents SDKs
  • Standardized event routing based on type field
  • When you need a vendor-neutral, CNCF-backed standard format

Wire Encodings

JSON (default)

Standard UTF-8 JSON encoding. Human-readable and widely supported.

sinks:
  - type: kafka
    config:
      id: events-kafka
      brokers: localhost:9092
      topic: events
      encoding: json

Content-Type: application/json

When to use:

  • Development and debugging
  • Consumers that expect JSON
  • When human readability matters
  • Most use cases (good default)

Avro (with Schema Registry)

Avro encoding produces compact binary payloads using the Confluent wire format:

[0x00][4-byte schema ID (big-endian)][Avro binary payload]

This format is natively understood by Kafka Connect, ksqlDB, Apache Flink, and any Confluent-compatible consumer.

sinks:
  - type: kafka
    config:
      id: events-kafka
      brokers: localhost:9092
      topic: events
      encoding:
        type: avro
        schema_registry_url: "http://schema-registry:8081"
        subject_strategy: topic_name   # default

Content-Type: application/avro

Configuration

FieldTypeDefaultDescription
schema_registry_urlstring—Confluent-compatible Schema Registry URL
subject_strategystringtopic_nameSubject naming strategy (see below)
usernamestring—Basic auth username for Schema Registry
passwordstring—Basic auth password for Schema Registry
unsigned_bigint_modestringstringHow to map MySQL BIGINT UNSIGNED (string or long)
enum_modestringstringHow to map ENUM types (string or enum)
naive_timestamp_modestringstringHow to map naive timestamps (string or timestamp)

Subject naming strategies

StrategySubject patternUse case
topic_name{topic}-valueOne schema per Kafka topic (default, most common)
record_name{record_name}One schema per record type, shared across topics
topic_record_name{topic}-{record_name}Per-topic, per-record schema

Schema source

When source DDL is available (MySQL INFORMATION_SCHEMA, PostgreSQL pg_catalog), DeltaForge derives precise Avro schemas from the actual column types and nullability. This is the recommended path for production.

When DDL is not available (e.g., processor-created synthetic events), DeltaForge falls back to inferring the Avro schema from the JSON event structure. This is less precise (no distinction between int/bigint, all fields nullable).

Schema IDs are cached per subject — only the first event per table triggers a Schema Registry HTTP call.

Type conversion policies

DeltaForge defaults to safe type mappings that prioritize correctness over convenience:

Source typeDefault Avro typeWhyOverride
MySQL BIGINT UNSIGNEDstring (exact decimal)Values ≥ 2^63 do not fit Avro long: encoding fails explicitlyunsigned_bigint_mode: long
MySQL/PG ENUMstringAvro enum symbol changes break compatibilityenum_mode: enum
MySQL DATETIMEstring (ISO-8601)Not a UTC instant — Avro timestamp-millis is semantically wrongnaive_timestamp_mode: timestamp
PG timestamp (no tz)string (ISO-8601)Same as above — naive local time, not an instantnaive_timestamp_mode: timestamp
MySQL TIMESTAMPtimestamp-millisStored as UTC — safe to use logical type—
PG timestamptztimestamp-microsStored as UTC — safe to use logical type—
DECIMAL(p,s)decimal logical typeUses exact precision/scale from DDL—
PG numeric (no precision)stringUnbounded precision can’t be expressed in Avro decimal—

Full type mapping tables for MySQL and PostgreSQL are documented in the Avro Schema Registry RFC.

Schema evolution

When the source table schema changes (column added/removed/altered), DeltaForge derives a new Avro schema and registers it as a new version. The Schema Registry’s compatibility rules control acceptance:

  • Default: BACKWARD — new schema can read old data (consumers can upgrade first)
  • DeltaForge respects existing subject compatibility settings in the SR

Schema Registry failure handling

ConditionBehavior
SR unavailable, schema cachedContinue encoding with the cached schema ID (succeeds; no failure metric fires)
SR unavailable, no cacheFail the batch — cannot encode without a schema ID
SR rejects new schema (compatibility)Try encoding with cached schema; if encoding fails → DLQ

When to use

  • Kafka Connect sinks expecting Avro (JDBC Sink, S3 Sink, Elasticsearch Sink)
  • ksqlDB streams and tables
  • Apache Flink CDC consumers
  • When you need compact binary payloads (~40-60% smaller than JSON)
  • When you want schema evolution enforcement via Schema Registry compatibility rules
  • Production pipelines where schema governance matters

Configuration Examples

Kafka with CloudEvents

sinks:
  - type: kafka
    config:
      id: orders-kafka
      brokers: ${KAFKA_BROKERS}
      topic: order-events
      envelope:
        type: cloudevents
        type_prefix: "com.acme.orders"
      encoding: json
      required: true

Redis with Debezium envelope

sinks:
  - type: redis
    config:
      id: orders-redis
      uri: ${REDIS_URI}
      stream: orders
      envelope:
        type: debezium
      encoding: json

NATS with native envelope

sinks:
  - type: nats
    config:
      id: orders-nats
      url: ${NATS_URL}
      subject: orders.events
      stream: ORDERS
      envelope:
        type: native
      encoding: json

Kafka with Avro encoding

sinks:
  - type: kafka
    config:
      id: events-avro
      brokers: ${KAFKA_BROKERS}
      topic: cdc-events
      envelope:
        type: debezium
      encoding:
        type: avro
        schema_registry_url: "http://schema-registry:8081"
        subject_strategy: topic_name
      required: true

Multi-sink with different formats

Different consumers may expect different formats. Configure each sink independently:

sinks:
  # Kafka Connect expects Debezium format
  - type: kafka
    config:
      id: connect-sink
      brokers: ${KAFKA_BROKERS}
      topic: connect-events
      envelope:
        type: debezium
      required: true

  # Lambda expects CloudEvents
  - type: kafka
    config:
      id: lambda-sink
      brokers: ${KAFKA_BROKERS}
      topic: lambda-events
      envelope:
        type: cloudevents
        type_prefix: "com.acme.cdc"
      required: false

  # Analytics wants raw events
  - type: redis
    config:
      id: analytics-redis
      uri: ${REDIS_URI}
      stream: analytics
      envelope:
        type: native

Operation Mapping

DeltaForge uses Debezium-compatible operation codes:

OperationCodeDescription
Create/InsertcNew row inserted
UpdateuExisting row modified
DeletedRow deleted
ReadrSnapshot read (initial load)
TruncatetTable truncated

These codes appear in the op field regardless of envelope format.

Performance Considerations

EnvelopeOverheadFormat StabilityUse Case
NativeBaseline (minimal)May evolveHigh-throughput, internal systems
Debezium~14 bytesStable (follows Debezium spec)Kafka Connect, Debezium ecosystem
CloudEvents~150-200 bytesStable (follows CNCF spec)Serverless, event-driven architectures
EncodingSize vs JSONCPU costSchema governance
JSONBaselineLowestNone
Avro~40-60% smallerModerate (schema lookup + binary encoding)Schema Registry enforced

The native envelope is recommended for maximum throughput when you control both ends of the pipeline. For interoperability with external systems or when format stability is critical, use debezium or cloudevents.

Defaults

If not specified, sinks use:

  • Envelope: native
  • Encoding: json

The native envelope provides the lowest overhead for high-throughput scenarios. If you need format stability guarantees, use debezium or cloudevents which adhere to their respective established specifications.

For Kafka pipelines where schema governance and compact payloads matter, use avro encoding with the Debezium envelope — this is the standard pattern for production Kafka Connect integration.

Data Types

Every row value is decoded once, at the source, by its column’s declared type, into an exact typed value. Every sink renders that value; none reinterprets source bytes or guesses a type from a value’s shape. A snapshot row and a CDC row of the same stored value produce the same output.

Mapping version

Each event carries the type-mapping version its values render with, in source.type_mapping (native JSON), the Kafka header df-type-mapping, the Avro schema (deltaforge.type_mapping, and subjects <subject>-v2) and the S3 object identity.

source:
  type: mysql
  config:
    type_mapping: v2   # default

v2 is the default and the only version PostgreSQL sources accept. MySQL sources may pin the deprecated v1, which differs from v2 only in native JSON: BIGINT, BIGINT UNSIGNED and BIT(n>1) as JSON numbers, TIMESTAMP as epoch microseconds, TIME with six fractional digits, DATETIME as YYYY-MM-DD HH:MM:SS.ffffff, FLOAT as its widened double value.

Native JSON (v2)

Source typeJSON
TINYINT … INT (signed and unsigned), smallint, integer, YEARnumber
BIGINT, BIGINT UNSIGNED, bigintexact decimal string (json_wide_integers: string, default) or number (number)
DECIMAL, numericstring with the declared scale; PostgreSQL "NaN", "Infinity", "-Infinity"
FLOAT, real / DOUBLE, double precisionnumber (shortest exact digits); non-finite PostgreSQL values as the strings above
BIT(1), booleantrue / false
BIT(n>1){"_base64": "..."} of exactly ceil(n/8) bytes
CHAR, VARCHAR, TEXT, text, varcharstring, decoded from the column’s character set (CHAR without trailing spaces)
BINARY, VARBINARY, BLOB, GEOMETRY, bytea{"_base64": "..."} (BINARY keeps its zero padding)
ENUM / SETthe label / "a,c"
JSON, json, jsonba string holding the document’s text (JSON null is the string "null"; SQL NULL is null)
DATE, date"YYYY-MM-DD"; MySQL zero dates as written ("0000-00-00"); "infinity" / "-infinity"
TIME, time"[-]HH:MM:SS[.f]" with the declared precision (MySQL TIME is a signed duration)
DATETIME, timestamp"YYYY-MM-DDTHH:MM:SS[.f]", no zone; MySQL zero "0000-00-00 00:00:00"
TIMESTAMP, timestamptzRFC 3339 UTC: "2024-03-30T23:30:00Z"
uuidlowercase hyphenated string
PostgreSQL arrays of the types aboveJSON array (nested for multi-dimensional values); a NULL array is null, a NULL element null inside the array
Any other PostgreSQL type (inet, interval, ranges, enums, domains, …)its PostgreSQL text

A PostgreSQL column that an update did not send (an unchanged TOAST value) is filled from the old row when the table has REPLICA IDENTITY FULL. Otherwise it is never rendered as a value: it is left out of after and listed in unchanged_columns.

Values a processor added to a row (see Processors) appear as ordinary fields in native JSON.

Typed sinks

Avro, Parquet and ClickHouse convert each value to its column’s target type exactly, or fail with a conversion failure (the sink_conversion_failed incident: dead-lettered when a DLQ acknowledges it; otherwise the pipeline stops and the sink’s checkpoint stays before the event, so a restart meets it again; events after it in the same batch may already be delivered and are delivered again). Nothing is rounded, truncated, wrapped or defaulted:

Source typeAvroParquet
integersint / long (BIGINT UNSIGNED: string, or long with unsigned_bigint_mode: long)Int32 / Int64 (BIGINT UNSIGNED: Utf8 or Int64)
DECIMALstringDecimal128(p, s) for p <= 38, else Utf8; PostgreSQL numeric without precision: Utf8
FLOAT / DOUBLEfloat / doubleFloat32 / Float64
BIT(1) / BIT(n)boolean / fixed(ceil(n/8))Boolean / FixedSizeBinary(ceil(n/8))
text, ENUM, SET, JSONstringUtf8
binarybytesBinary
DATEint (date)Date32
MySQL TIMElong microseconds (deltaforge.logical: mysql-time-micros)Duration(Microsecond)
PostgreSQL timelong (time-micros)Time64(Microsecond)
DATETIME / timestampstring, or local-timestamp-micros (naive_timestamp_mode: timestamp)Utf8, or Timestamp(Microsecond, no zone)
TIMESTAMP / timestamptzlong (timestamp-micros)Timestamp(Microsecond, UTC)

MySQL zero dates and timestamps, infinities, PostgreSQL 24:00:00 and values finer than a target’s unit fail in typed temporal targets. A PostgreSQL array column (any number of dimensions in PostgreSQL) maps to a one-dimensional typed list: a multi-dimensional value fails in Avro and Parquet. PostgreSQL columns are nullable in Avro and Parquet schemas (a change event’s schema cannot prove NOT NULL), identically for snapshot and change events.

Values a processor added to a row travel in a stable slot of their own, never as columns: Avro before_extensions / after_extensions (nullable map of JSON text), Parquet before_extensions / after_extensions (a JSON object as text), ClickHouse extensions_column (default _extensions, Nullable(String)). A row column the schema does not define is refused, never dropped.

PostgreSQL unchanged TOAST values: Avro, Parquet, ClickHouse and Elasticsearch need every value. A pipeline with such a sink requires REPLICA IDENTITY FULL on every captured table with a TOAST-capable column, so each unchanged value is filled from the old row; otherwise the pipeline is refused before it streams (see PostgreSQL: Unchanged TOAST values). A missing value is never written as NULL.

Table identity

source.db is the database and, for PostgreSQL, source.schema the table’s schema: a table is database + schema + table (MySQL: database + table), identically in snapshot and change events. Caches, schema versions, idempotency keys and durable S3 objects key on that full identity; Avro record names carry its escaped parts; legacy S3 partitions are database=<db>/namespace=<schema or db>/table=<table>. Same-named tables of two schemas or databases never share any of them; a name too long for an object-key segment is stored as its digest, never truncated. A ClickHouse table or Elasticsearch index holds one source table, named in the ownership marker it is created with: rows of a second one are refused (sink_destination_conflict; give each its own index, for example with {schema} and {table} in the index template).

Decoding failures

A value its declared type cannot decode exactly (bytes invalid in the column’s character set, a MySQL ENUM index 0 or one beyond the definition, a SET bit outside it, a PostgreSQL array with lower bounds other than 1) stops the source before the value’s transaction, with a source_decode_failed incident naming the table, column and type (never the value).

Dynamic Routing

Dynamic routing controls where each CDC event is delivered - which Kafka topic, Redis stream, or NATS subject receives it. By default, all events go to the single destination configured in the sink (static routing). With dynamic routing, events can be split across destinations based on their content or other attributes of events, pipeline and etc.

Overview

There are two routing mechanisms, and they compose naturally:

  1. Template strings in sink config - resolve per-event from event fields
  2. JavaScript ev.route() - programmatic per-event routing in processors

When both are used, ev.route() overrides take highest priority, then template resolution, then the static config value.

Template Routing

Replace static topic/stream/subject strings with templates containing ${...} variables. Templates are compiled once at startup and resolved per-event with zero regex overhead.

Kafka

sinks:
  - type: kafka
    config:
      id: kafka-routed
      brokers: ${KAFKA_BROKERS}
      topic: "cdc.${source.db}.${source.table}"
      key: "${after.customer_id}"
      envelope:
        type: debezium

Events from shop.orders -> topic cdc.shop.orders, partitioned by customer_id.

Redis

sinks:
  - type: redis
    config:
      id: redis-routed
      uri: ${REDIS_URI}
      stream: "events:${source.table}"
      key: "${after.id}"

Events from orders -> stream events:orders. The key value appears as the df-key field in each stream entry.

NATS

sinks:
  - type: nats
    config:
      id: nats-routed
      url: ${NATS_URL}
      subject: "cdc.${source.db}.${source.table}"
      key: "${after.id}"
      stream: CDC

Events from shop.orders -> subject cdc.shop.orders. The key value appears as the df-key NATS header.

Available Variables

VariableDescriptionExample value
${source.table}Table nameorders
${source.db}Database name (PostgreSQL: the database, never the schema)shop
${source.schema}Schema name (PostgreSQL; absent for MySQL). Same-named tables of two schemas need it in a topic or subject to stay apartpublic
${source.connector}Source typemysql
${op}Operation codec, u, d, r, t
${after.<field>}Field from after image42, cust-abc
${before.<field>}Field from before imageold-value
${tenant_id}Pipeline tenant IDacme

Missing fields resolve to an empty string. A warning is logged once per unique template, not per event.

Static strings (no ${...}) are detected at parse time and have zero overhead on the hot path - no allocation, no resolution.

Env Vars vs Templates

Both use ${...} syntax. The config loader expands environment variables first. Unknown variables pass through as templates for runtime resolution:

brokers: ${KAFKA_BROKERS}      # env var - expanded at load time
topic: "cdc.${source.table}"   # template - passed through to runtime
key: "${after.customer_id}"    # template - resolved per-event

JavaScript Routing

For routing logic that goes beyond field substitution, use ev.route() in a JavaScript processor. This lets you make conditional routing decisions based on event content.

processors:
  - type: javascript
    id: smart-router
    inline: |
      function processBatch(events) {
        for (const ev of events) {
          if (!ev.after) continue;

          if (ev.after.total_amount > 10000) {
            ev.route({
              topic: "orders.priority",
              key: String(ev.after.customer_id),
              headers: {
                "x-tier": "high-value",
                "x-amount": String(ev.after.total_amount)
              }
            });
          } else {
            ev.route({
              topic: "orders.standard",
              key: String(ev.after.customer_id)
            });
          }
        }
        return events;
      }

ev.route() fields

FieldTypeDescription
topicstringOverride destination (topic, stream, or subject)
keystringOverride message/partition key
headersobjectKey-value pairs added to the message

All fields are optional. Only set fields override; omitted fields fall through to config templates or static values.

Calling ev.route() replaces any previous routing on that event - it does not merge.

How headers are delivered

SinkKey deliveryHeader delivery
KafkaKafka message keyKafka message headers
Redisdf-key field in stream entrydf-headers field (JSON)
NATSdf-key NATS headerIndividual NATS headers

Resolution Order

For each event, the destination is resolved in priority order:

ev.route() override  →  config template  →  static config value

Specifically:

  1. If the event has routing.topic set (via ev.route() or programmatically), use it
  2. If the sink config contains a template (has ${...}), resolve it from event fields
  3. Otherwise, use the static config string

The same order applies independently to key and headers.

Examples

See the complete example configurations:

Outbox Pattern

The transactional outbox pattern guarantees that domain events are published whenever the corresponding database change is committed - no two-phase commit required. DeltaForge supports this natively for both MySQL and PostgreSQL with zero application-side polling.

How It Works

┌─────────────────┐     ┌──────────────┐     ┌──────────────────┐     ┌──────┐
│  Application    │────>│   Database   │────>│   DeltaForge     │────>│ Sink │
│  (writes data   │     │  (outbox     │     │  (captures +     │     │      │
│   + outbox msg) │     │   table/WAL) │     │   transforms)    │     │      │
└─────────────────┘     └──────────────┘     └──────────────────┘     └──────┘
  1. Your application writes business data and an outbox message in the same transaction.
  2. DeltaForge captures the outbox event through the database’s native replication stream.
  3. The OutboxProcessor extracts the payload, resolves the destination topic, and sets routing headers.
  4. The transformed event flows to sinks like any other CDC event.

Because the outbox write is part of the application transaction, the event is guaranteed to be published if and only if the transaction commits.

Source Configuration

Each database uses its native mechanism — there is nothing to install or poll.

PostgreSQL — WAL Messages

PostgreSQL uses pg_logical_emit_message() to write messages directly into the WAL. No table is needed.

-- In your application transaction:
BEGIN;
INSERT INTO orders (id, total) VALUES (42, 99.99);
SELECT pg_logical_emit_message(
  true,     -- transactional: tied to the enclosing TX
  'outbox', -- prefix: matched by DeltaForge
  '{"aggregate_type":"Order","aggregate_id":"42","event_type":"OrderCreated","payload":{"total":99.99}}'
);
COMMIT;

Source config:

source:
  type: postgres
  config:
    id: orders-pg
    dsn: ${POSTGRES_DSN}
    slot: deltaforge_orders
    publication: orders_pub
    tables: [public.orders]
    outbox:
      prefixes: [outbox]
FieldTypeDescription
outbox.prefixesarrayWAL message prefixes to capture. Supports glob patterns: outbox (exact), outbox_% (prefix match), * (all).

The message prefix becomes source.table on the event, so processors can filter by prefix when multiple outbox channels share a pipeline.

MySQL — Table Inserts

MySQL uses a regular table. For production, use the BLACKHOLE storage engine so rows are written to the binlog but never stored on disk.

-- Create outbox table (BLACKHOLE = no disk storage, binlog only)
CREATE TABLE outbox (
  id        INT AUTO_INCREMENT PRIMARY KEY,
  aggregate_type VARCHAR(64),
  aggregate_id   VARCHAR(64),
  event_type     VARCHAR(64),
  payload        JSON
) ENGINE=BLACKHOLE;
-- In your application transaction:
BEGIN;
INSERT INTO orders (id, total) VALUES (42, 99.99);
INSERT INTO outbox (aggregate_type, aggregate_id, event_type, payload)
VALUES ('Order', '42', 'OrderCreated', '{"total": 99.99}');
COMMIT;

Source config:

source:
  type: mysql
  config:
    id: orders-mysql
    dsn: ${MYSQL_DSN}
    tables:
      - shop.orders
      - shop.outbox
    outbox:
      tables: ["shop.outbox"]
FieldTypeDescription
outbox.tablesarrayTable patterns to tag as outbox events. Supports globs: shop.outbox (exact), *.outbox (any database), shop.outbox_% (prefix).

Note: The outbox table must be included in the source’s tables list so DeltaForge subscribes to its binlog events. Only INSERTs are captured - UPDATE and DELETE on the outbox table are ignored.

Outbox Processor

The OutboxProcessor transforms raw outbox events into routed, sink-ready events. Add it to your processors list:

processors:
  - type: outbox
    topic: "${aggregate_type}.${event_type}"
    default_topic: "events.unrouted"
FieldTypeDefaultDescription
idstring"outbox"Processor identifier
tablesarray[]Filter: only process outbox events matching these patterns. Empty = all outbox events.
topicstring-Topic template resolved against the raw payload using ${field} placeholders
default_topicstring-Fallback topic when template resolution fails and no topic column exists
columnsobject(see below)Column name mappings for extracting outbox fields
additional_headersmap{}Forward extra payload fields as routing headers. Key = header name, value = column name.
raw_payloadboolfalseWhen true, deliver the extracted payload as-is to sinks, bypassing envelope wrapping (native/debezium/cloudevents). Metadata is still available via routing headers.
keystring-Key template resolved against raw payload. Sets routing.key for sink partitioning. Default: aggregate_id value.
strictboolfalseWhen true, fail the batch if required fields are missing (topic, payload, aggregate_type, aggregate_id, event_type). When false, missing fields are silently skipped.

Column Mappings

Column mappings control header extraction and payload rewriting - they tell the processor which fields correspond to aggregate_type, aggregate_id, etc. for setting df-* headers. The topic template resolves directly against the raw payload, so you reference your actual column names there.

ColumnDefaultHeaderDescription
payload"payload"-Event body. Extracted and promoted to event.after.
aggregate_type"aggregate_type"df-aggregate-typeAggregate root type (e.g. Order).
aggregate_id"aggregate_id"df-aggregate-idAggregate root ID. Also used as default routing key.
event_type"event_type"df-event-typeDomain event type (e.g. OrderCreated).
topic"topic"-Per-row topic override (used when template is absent).
event_id"id"df-event-idEvent identity for idempotency/dedup.

If your outbox payload uses non-default field names, override them:

processors:
  - type: outbox
    topic: "${aggregate_type}.${event_type}"
    columns:
      payload: data           # default: "payload"
      aggregate_type: type    # default: "aggregate_type"
      aggregate_id: key       # default: "aggregate_id"
      event_type: action      # default: "event_type"
      topic: destination      # default: "topic"
      event_id: uuid          # default: "id"

Additional Headers

Forward arbitrary payload fields as routing headers. This is useful when migrating from Debezium’s table.fields.additional.placement or when downstream consumers need extra metadata:

processors:
  - type: outbox
    topic: "${aggregate_type}.${event_type}"
    additional_headers:
      x-trace-id: trace_id
      x-correlation-id: correlation_id
      x-source-region: region

Each key becomes a header name, each value is the column name in the outbox payload. Missing columns are silently skipped - no error if a row doesn’t contain the field.

Typed Extraction

Header values are extracted as strings regardless of the source JSON type. Numeric IDs, booleans, and string values are all stringified automatically:

JSON valueHeader value
"abc-123"abc-123
4242
truetrue
null / missing(skipped)
{} / [](skipped)

What the Processor Does

  1. Identifies outbox events by the __outbox sentinel on source.schema (set by the source).
  2. Extracts aggregate_type, aggregate_id, event_type, and payload from event.after.
  3. Resolves the topic using a three-step cascade:
    • Template resolved against the raw payload (e.g. ${domain}.${action} — use your actual column names)
    • Column value (a topic field in the payload, configurable via columns.topic)
    • default_topic fallback
  4. Rewrites event.after to just the payload content.
  5. Sets routing headers: df-event-id, df-aggregate-type, df-aggregate-id, df-event-type, plus any additional_headers mappings.
  6. Sets routing key using the key template (or falls back to aggregate_id).
  7. Marks raw delivery if raw_payload: true — sinks serialize event.after directly, skipping envelope wrapping.
  8. Clears the __outbox sentinel so the event looks like a normal CDC event to sinks.
  9. Drops non-INSERT outbox events (UPDATE/DELETE on the outbox table are meaningless).
  10. Validates in strict mode (strict: true): fails the batch with an error if any required field is missing (topic, payload, aggregate_type, aggregate_id, event_type). The error names the missing fields so operators can fix the schema. In lenient mode (default), missing fields are silently skipped.
  11. Passes through all non-outbox events unchanged.

Multi-Outbox Routing

When a source captures multiple outbox channels, use the tables filter to scope each processor:

processors:
  - type: outbox
    tables: [orders_outbox]
    topic: "orders.${event_type}"

  - type: outbox
    tables: [payments_outbox]
    topic: "payments.${event_type}"
    columns:
      payload: data

Complete Examples

PostgreSQL → Kafka

apiVersion: deltaforge/v1
kind: Pipeline
metadata:
  name: order-events
  tenant: acme
spec:
  source:
    type: postgres
    config:
      id: pg1
      dsn: ${POSTGRES_DSN}
      publication: orders_pub
      slot: orders_slot
      tables: [public.orders]
      outbox:
        prefixes: [outbox]

  processors:
    - type: outbox
      topic: "${aggregate_type}.${event_type}"
      default_topic: "events.unrouted"
      raw_payload: true

  sinks:
    - type: kafka
      config:
        id: k1
        brokers: ${KAFKA_BROKERS}
        topic: "events.fallback"

The raw_payload: true means outbox events hit the wire as the extracted payload JSON. Regular CDC events (from public.orders) still use the sink’s configured envelope.

MySQL → Kafka (Multi-Outbox)

apiVersion: deltaforge/v1
kind: Pipeline
metadata:
  name: shop-events
  tenant: acme
spec:
  source:
    type: mysql
    config:
      id: m1
      dsn: ${MYSQL_DSN}
      tables:
        - shop.orders
        - shop.orders_outbox
        - shop.payments_outbox
      outbox:
        tables: ["shop.*_outbox"]

  processors:
    - type: outbox
      tables: [orders_outbox]
      topic: "orders.${event_type}"
      raw_payload: true

    - type: outbox
      tables: [payments_outbox]
      topic: "payments.${event_type}"
      raw_payload: true

  sinks:
    - type: kafka
      config:
        id: k1
        brokers: ${KAFKA_BROKERS}
        topic: "events.default"

Migrating from Debezium

If you’re using Debezium’s outbox event router with a custom schema, DeltaForge’s column mappings and additional_headers map directly. For example, given this Debezium-style outbox table:

CREATE TABLE outbox_events (
  id UUID PRIMARY KEY,
  aggregatetype VARCHAR(64),
  aggregateid VARCHAR(64),
  type VARCHAR(64),
  payload JSONB,
  traceid VARCHAR(64),
  tenant VARCHAR(32)
);

Debezium config:

transforms.outbox.table.field.event.id=id
transforms.outbox.table.field.event.key=aggregateid
transforms.outbox.table.field.event.type=type
transforms.outbox.table.field.event.payload=payload
transforms.outbox.table.fields.additional.placement=traceid:header,tenant:header

DeltaForge equivalent:

processors:
  - type: outbox
    topic: "${aggregatetype}.${type}"
    key: "${aggregateid}"
    raw_payload: true
    columns:
      event_id: id
      aggregate_type: aggregatetype
      aggregate_id: aggregateid
      event_type: type
      payload: payload
    additional_headers:
      x-trace-id: traceid
      x-tenant: tenant

Key differences from Debezium:

  • Topic template references raw column names directly — ${aggregatetype}, not ${aggregate_type}
  • raw_payload: true delivers the extracted payload as-is — same behavior as Debezium’s outbox event router
  • Column mappings only affect header extraction (df-* headers) and payload rewriting
  • additional_headers replaces table.fields.additional.placement
  • No SMT chain — everything is in one processor config

Observability

The outbox processor emits Prometheus-compatible metrics:

MetricLabelsDescription
deltaforge_outbox_transformed_total-Events successfully transformed
deltaforge_outbox_dropped_totalreasonEvents dropped or rejected

Drop reasons:

ReasonMeaning
non_insertUPDATE/DELETE on outbox table (expected, harmless)
non_objectevent.after is not a JSON object
null_payloadevent.after is null
strict_missing_fieldsStrict mode: required field missing (batch fails)

In strict mode, strict_missing_fields increments the counter and returns an error that halts the batch - the pipeline will not silently lose events.

Tips

  • PostgreSQL WAL messages are lightweight. No table, no index, no vacuum - just a single WAL entry per message. Prefer this over table-based outbox when using PostgreSQL.
  • MySQL BLACKHOLE engine avoids storing outbox rows on disk. The row is written to the binlog and immediately discarded. Use it in production to avoid unbounded table growth.
  • Outbox events coexist with normal CDC. The processor passes through all non-outbox events untouched, so you can mix regular table capture and outbox in the same pipeline.
  • Topic templates use ${field} syntax, same as dynamic routing. The template resolves directly against the raw outbox payload columns - use your actual column names like ${domain}.${action}, no remapping needed.
  • At-least-once delivery applies to outbox events just like regular CDC events. Downstream consumers should be idempotent - use the df-event-id header for idempotency, or aggregate_id + event_type as a composite dedup key.
  • Malformed events are logged and dropped by default. Non-INSERT operations, null payloads, and non-object payloads produce a WARN-level log with the table name and reason. They do not propagate to sinks. Enable strict: true to fail the batch instead of dropping - this ensures operators are alerted to schema issues before events are lost.

Guarantees & Correctness

This page defines DeltaForge’s data delivery guarantees, ordering model, transaction semantics, failure handling, and operational boundaries. The guarantees below describe current behavior. Where a verification is planned rather than already in place, the Correctness Test Matrix marks it explicitly, so read a guarantee together with its test status.

Delivery Guarantees

Per-sink delivery tiers

SinkDelivery guaranteeDedup mechanismConsumer action required
Kafka (exactly_once: true)Transactional atomic-batch delivery; at-least-once across restartEach batch is one Kafka transaction, so read_committed consumers never see a partial batch; a restart can replay an already-committed batch as a new transaction (duplicate)Set isolation.level=read_committed, and dedup by event ID for cross-restart duplicates
Kafka (default)At-least-once (idempotent producer)Retries are deduped by rdkafka; crash-replay produces duplicatesDedup by event ID or idempotency key
NATS JetStreamAt-least-once + server-side dedupNats-Msg-Id header within duplicate_windowConfigure duplicate_window on stream
Redis StreamsAt-least-once + consumer-side dedupidempotency_key field in XADD payloadCheck idempotency_key before processing
HTTP/WebhookAt-least-onceRetry on 5xx/timeout; no server-side dedupConsumer must be idempotent (use event id)
S3 (Parquet / JSON Lines)At-least-once at file granularity, atomic at file boundarySame file may be re-emitted with a different ULID on retry/replayDedup downstream via MERGE INTO or event_id

Terminology rule: DeltaForge does not claim end-to-end exactly-once delivery for any sink. Every sink is at-least-once; the differences are in what each sink adds on top. Kafka exactly_once: true adds transactional atomic-batch visibility (a read_committed consumer sees a whole batch or none of it) but still delivers at-least-once across a restart, so consumers must dedup to reach exactly-once end to end. NATS and Redis add a dedup mechanism (server-side or consumer-side) whose effectiveness depends on configuration or consumer behavior outside DeltaForge’s control. Calling any of these “exactly-once” without the consumer’s cooperation would be misleading.

What “at-least-once” means

  • No data loss: every event from the source is delivered to the sink at least once. Checkpoints are saved only after the sink acknowledges delivery — never before.
  • Duplicates on crash recovery: if DeltaForge crashes after delivering a batch but before saving the checkpoint, that batch is replayed on restart. Consumers must handle duplicates (see Consumer Guidance below).
  • No silent drops: events are never discarded. On a transient delivery error a sink applies a bounded in-sink retry (exponential backoff, finite attempts). If those attempts are exhausted on a required sink, the checkpoint does not advance and the batch is re-delivered from the source on the next pipeline restart (source replay) - there is no unbounded in-session retry loop. See Error Classification & Retry.

What Kafka transactions guarantee (exactly_once: true)

exactly_once: true gives transactional atomic-batch delivery, not end-to-end exactly-once. Each batch is wrapped in a Kafka transaction (begin_transaction / commit_transaction), so consumers using isolation.level=read_committed see a whole batch or none of it - never a partial batch. If a transaction fails before commit, it is aborted and re-attempted from the same checkpoint position.

The restart boundary is still at-least-once: DeltaForge advances its own checkpoint only after the Kafka transaction commits, so a crash between the commit and the checkpoint persist replays that batch on restart as a new transaction. A read_committed consumer then sees the batch a second time. To reach exactly-once end to end, consumers must dedup on the event id (or idempotency key), which is stable across replays.

The transactional-producer overhead is ~7-11% with properly tuned batch sizes. See the Performance guide for benchmark details.

Ordering Model

Within a source

Events are emitted in the source’s native order:

  • MySQL: binlog file + position order. WriteRowsEvent batches preserve row order within each binlog event.
  • PostgreSQL: LSN (Log Sequence Number) order. One WAL message per row change.

DeltaForge does not reorder events. The source order is preserved through the pipeline.

Within a batch

All events in a batch maintain their source order. The delivery task processes batches in FIFO order from a bounded channel — no reordering between batches.

Per-primary-key ordering (the core guarantee)

DeltaForge guarantees per-primary-key ordering within a table under non-sharded operation. This means: for any single row identified by its primary key, all changes (INSERT, UPDATE, DELETE) are delivered to the sink in the exact order they occurred in the source database.

For Kafka specifically: the default message key is the serialized primary key, so events for the same row always go to the same partition and arrive in order. With dynamic routing (key template), ordering follows the resolved key — events with the same key are ordered; different keys may land in different partitions.

Cross-table ordering

There is no global ordering across tables. Events from different tables may be interleaved across batches. This is by design — enforcing global ordering would require single-threaded delivery, which would cap throughput.

However, when batch.respect_source_tx: true (the default), all rows from a single database transaction are kept in the same batch (see Transaction Boundaries below). This preserves causal ordering within a transaction.

Ordering under retries

When a batch delivery fails and is retried, the batch is re-delivered as a unit in the same order. No reordering occurs within or across retries. The max_inflight=1 setting (default) ensures strict ordering; with max_inflight > 1, batches are still delivered in FIFO order by the single-threaded delivery task.

Cross-sink ordering

All sinks receive the same batch simultaneously. The relative order of events is identical across all sinks.

Transaction Boundaries

How it works

When batch.respect_source_tx: true (the default), the coordinator checks each event’s tx_end flag before splitting a batch:

  • MySQL: tx_end is set on the last row of each XID (transaction commit) event.
  • PostgreSQL: tx_end is set on the COMMIT WAL record.

The batch accumulator will not split a batch at a point that would separate rows from the same transaction. If the batch limit (max_events or max_bytes) is reached mid-transaction, the batch grows beyond the limit to include all remaining rows in that transaction.

What this guarantees

  • All rows from one database transaction appear in the same batch.
  • A batch is DeltaForge’s unit of delivery accounting: a sink’s checkpoint advances only if the whole batch succeeds. Whether a consumer observes the batch atomically depends on the sink:
    • Kafka exactly_once: true: the batch is one Kafka transaction, so a read_committed consumer sees all rows from the DB transaction together or not at all - within a single delivery. Across a restart the batch can be replayed as a new transaction, so the consumer may see the same transaction twice (at-least-once); dedup on event id to collapse it.
    • Non-transactional sinks (Redis, HTTP, NATS, S3, ClickHouse, Elasticsearch): DeltaForge does not make the batch atomic for the consumer. A sink failure partway through a batch can leave some rows visible downstream, and a retry or restart re-delivers the batch, producing duplicates. These sinks are at-least-once; use their stated dedup mechanism.
  • Cross-table transactions: a transaction spanning tables A and B is emitted as a single batch containing events for both tables, tagged with the same tx_id, and delivered as a unit. This is stronger than “tagged but not grouped” - all events from one DB transaction are in one batch. Consumer-visible atomicity for that batch still follows the per-sink rule above.

Precise transaction semantics

To avoid ambiguity, here is exactly what DeltaForge guarantees about transactions:

  • Events from one source transaction are emitted contiguously within a single batch.
  • Multi-table transactions preserve commit grouping — all rows from tables A and B in one DB transaction appear in the same batch.
  • Within a single sink, the batch is the unit of delivery accounting: the sink’s checkpoint advances only if the whole batch succeeds. Consumer-visible atomicity of that batch holds only for a transactional sink (Kafka exactly_once: true) and only within one delivery; see What this guarantees.
  • Across heterogeneous sinks, DeltaForge does not guarantee atomic commit. Kafka may commit a transaction while Redis is still retrying. Each sink’s checkpoint tracks its own progress independently.
  • Retries and restarts do not break transaction grouping (a replayed batch contains the same events in the same order), but they can re-deliver a transaction that was already delivered. Delivery is at-least-once; consumers dedup on event id.

Edge cases

  • A single database transaction that exceeds max_events or max_bytes is still kept in one batch. The limits are exceeded rather than the transaction being split.
  • With respect_source_tx: false, batches are split purely by size/time limits regardless of transaction boundaries. Cross-table transaction atomicity is not preserved in this mode.

Disconnect inside a transaction (PostgreSQL)

When the replication stream ends inside a transaction (the connection is closed or reset), the source reconnects to the same verified server, resumes at the end of the last transaction it handed on, abandons the cut-off transaction (an internal abort marker, never a CDC event) and receives it again from its beginning:

ModeDisconnect inside a transaction
respect_source_tx: true (default)The buffered prefix is discarded; the transaction is delivered once, with one commit boundary
respect_source_tx: falseRows already delivered for it cannot be recalled; its replay may deliver them again (at-least-once duplicates)
Either modeThe checkpoint never advances for the abandoned attempt; a process restart resumes from the durable checkpoint

Each abandoned transaction logs a warning (source, transaction, last complete position, events abandoned, whether duplicates are possible). Metrics:

MetricMeaning
deltaforge_source_reconnects_totalThe source reconnected after a transient stream error
deltaforge_source_transaction_aborts_totalThe source found its stream interrupted inside an open transaction and abandoned it
deltaforge_source_replayed_events_totalEvents the source had already handed on for an abandoned attempt and sends again with the replay. Includes events the coordinator only buffered (never delivered) under respect_source_tx: true: it is not a count of downstream duplicates
deltaforge_abandoned_tx_total{outcome="discarded"}The coordinator discarded the abandoned attempt’s buffered prefix (no duplicates)
deltaforge_abandoned_tx_total{outcome="may_duplicate"}respect_source_tx: false: part of the abandoned attempt may already have reached sinks and may be delivered again

respect_source_tx is true unless a pipeline sets it to false: BatchConfig fills every key a pipeline’s batch section omits from its defaults (respect_source_tx: true), and the coordinator applies the same defaults when there is no batch section at all. Keep it true for production. In that mode an open transaction is buffered in memory up to max_tx_events / max_tx_bytes, beyond which the oversized_tx policy applies.

Initial-snapshot anchoring

The initial snapshot and the CDC stream meet at a single anchor so that no committed row is lost across the boundary. The checks below run only when a snapshot runs; CDC-only pipelines (snapshot.mode = never) are unaffected.

  • PostgreSQL anchors CDC at the replication slot’s consistent point C. Every row committed at or before C is in the snapshot; every row after C is in the CDC stream. Rows committed in (C, snapshot-export] fall in both - a bounded at-least-once overlap, not exactly-once. Current-state sinks (with version_source: source_position) converge via last-writer-wins; append-only sinks receive the overlap twice. A snapshot completed under the older anchor is flagged by the gauge deltaforge_snapshot_unsafe_anchor = 1 until a safe re-snapshot.
  • MySQL brackets the anchor under a brief FLUSH TABLES WITH READ LOCK: all snapshot workers open their consistent-snapshot transactions and the binlog position + GTID set are captured while the lock is held, so every worker shares one view that matches the captured position exactly. This closes a real initial-snapshot data-loss window present in earlier versions (independent per-worker snapshots with the position captured afterward). It requires gtid_mode = ON, binlog_format = ROW, InnoDB tables, and the global RELOAD privilege; managed MySQL without RELOAD fails closed rather than snapshotting unsafely (no silent fallback).

Snapshot completion follows the commit policy. A snapshot is complete only once its terminal barrier is committed by the sinks the policy requires; an interrupted snapshot is copied again in full (at-least-once, never resumed table by table); a sink that missed part of a completed snapshot is reported (sink_snapshot_incomplete) and excluded from the resume position rather than silently treated as complete. See Initial Snapshots.

Failure Isolation

Per-sink independence

All sinks deliver concurrently. One sink’s failure does not block other sinks:

  1. The coordinator dispatches the same batch to all sinks simultaneously.
  2. Each sink’s delivery result is collected independently.
  3. Only sinks that delivered successfully get their checkpoints advanced.
  4. Failed sinks remain at their prior checkpoint position — they will receive the same batch again on retry or restart.

Coordinator-level deadline (defense in depth)

In addition to each sink’s own internal timeout (e.g. send_timeout_secs on the S3/Kafka/Redis/NATS/HTTP sinks), the pipeline supports an outer deadline that the coordinator applies to every send_batch call:

spec:
  sink_batch_deadline_secs: 60   # outer bound on any one sink's send_batch

Behavior:

  • Each sink’s future is wrapped in tokio::time::timeout(deadline, ...). If a sink exceeds the deadline, the coordinator gives up on it for that batch, classifies it as SinkError::Backpressure, and continues with the others.
  • A timed-out required sink causes its checkpoint to stay behind (same as any failure); the source replays from MIN(checkpoints) on restart.
  • A timed-out optional sink advances none of its state for that batch; same in-session-loss / on-restart-recovery semantics as documented in Required vs. optional sinks.
  • Metric: deltaforge_coordinator_sink_timeout_total{pipeline,sink}.
  • Default: None (no outer bound). Set this for defense-in-depth against a sink that hangs despite its own internal timeout (bug, deadlock, infinite retry loop in a dependency).

The two timeout layers are complementary:

LayerCatchesGranularity
send_timeout_secs (sink-internal)Misbehavior of the sink’s underlying API/library (e.g. object_store retries, rdkafka queue)Per sink, per batch
sink_batch_deadline_secs (coordinator)Misbehavior of the sink itself (deadlock, slow Rust code, ignored cancellation)All sinks, per batch

Required vs. optional sinks

Each sink is marked required: true (default) or required: false:

  • Required: must succeed for the pipeline to consider the batch delivered. Each sink applies bounded sink-local retries (finite attempts with backoff) within a single send_batch; if those are exhausted for a required sink, no checkpoint advances for any sink and the pipeline stops. There is no in-session pipeline-level retry of the batch - recovery is by source replay from MIN(checkpoints) on the next restart.
  • Optional (best-effort): failures are logged but don’t prevent the pipeline from advancing. The failed sink’s own checkpoint stays at its last-successful position; the source’s MIN-checkpoint reader doesn’t go back, but on the next pipeline restart, the source replays from the failed sink’s stuck position and the failed sink catches up.

What an optional sink failure means in practice

When an optional sink fails for batch B (events at LSN 100):

  1. Required sinks succeed → their checkpoints advance to LSN 100.
  2. The optional sink’s checkpoint stays at its prior position (LSN 50, say).
  3. The pipeline continues to batch B+1 (events at LSN 101+). The source’s in-memory position is now 101, not 50.
  4. The optional sink receives batch B+1 with events at LSN 101+. It may succeed or fail again: its checkpoint stays at LSN 50 for the rest of the session either way. A later success never moves a sink’s checkpoint past a batch that sink failed (in both batching modes).
  5. Until restart: if the optional sink stays failing, the source’s in-memory position keeps marching forward. The failed events between the sink’s stuck checkpoint and “now” are not in any retry queue for this session.
  6. On restart: the source reads MIN(required_cp, optional_cp) = 50 and replays from 50. The optional sink (back from outage, presumably) catches up. The replay is source-level: every sink receives it, so sinks already past LSN 50 see duplicates and dedup or accept (idempotent design).

Before the source delivers anything, every configured sink without a checkpoint (a new pipeline, or a newly added sink) gets one at the position the source starts from. A sink that fails its very first batch is therefore part of MIN(...) and the batch is replayed to it on restart. If that initial checkpoint cannot be persisted, the source does not start (fail closed).

This is the practical reality of required: false:

  • ✅ A failed optional sink does not stall the pipeline.
  • ✅ A failed optional sink does not lose data permanently — restart-replay recovers it.
  • ❌ A failed optional sink does not catch up in-flight — events between failure and restart are only delivered on restart.
  • ❌ A long-running pipeline with a chronically failing optional sink accumulates a growing replay gap — the longer between failures and restart, the more events the failed sink will re-deliver after restart.

For the S3 sink specifically (where extended outages are realistic — S3 throttling, regional issues), the operational choice is:

GoalConfiguration
S3 must not miss any event in-flight, accept source backpressurerequired: true (default). Slow/down S3 backpressures the source.
Kafka must keep flowing during S3 outages, accept replay-on-restart latency for S3 catch-uprequired: false on S3, required: true on Kafka. Pipeline keeps moving for Kafka; S3 catches up after the next restart.
Truly independent throughputRun two pipelines with the same source DSN — one for Kafka, one for S3. Each has its own coordinator and backpressure. Or use a Kappa-style architecture (Kafka → second pipeline → S3) with Kafka’s retention as the natural retry buffer.

Multi-batch retries vs in-batch retries

Two distinct retry layers exist and should not be conflated:

  1. In-batch retries (within a single send_batch call): each sink’s internal retry policy (exponential backoff, finite attempts). These are fast (<1s typical) and apply to transient errors like Queue full or Connection timeout.
  2. Pipeline-level retries (across batches): triggered by required: true failures. The coordinator does NOT auto-retry the same batch from the source channel — instead, the source-side replay mechanism handles recovery on restart.

required: false skips pipeline-level retries for the failing sink. There is no “background queue” that retries the failed batch later in the same session.

Commit policy

The commit policy determines when checkpoints advance:

PolicyBehavior
required (default)All required: true sinks must acknowledge
allEvery sink (required and optional) must acknowledge
quorumAt least N sinks must acknowledge

The policy is checked before any checkpoint is committed. If the policy isn’t satisfied, no sink advances — this prevents optional sinks from getting ahead of failed required sinks.

Per-sink checkpoints

Each sink maintains its own checkpoint key ({source_id}::sink::{sink_id}). On restart, the source replays from the minimum checkpoint across all sinks. This means:

  • A fast sink is never held back by a slow one during normal operation: while the pipeline keeps running, the source’s in-memory read position does not rewind for a lagging sink.
  • Recovery is not selective per sink. On restart the source resumes from the MIN checkpoint across all sinks, so every sink - including ones already ahead - is re-delivered the events from that minimum forward. Sinks that were ahead receive duplicates and must dedup (see On crash recovery).
  • Adding a new sink lowers the MIN to that sink’s earliest position, so on the next restart the source replays from there and the existing sinks are re-delivered those events too (they dedup). The new sink is not backfilled in isolation.

Fatal errors

Some errors are unrecoverable and stop the pipeline immediately:

  • Kafka ProducerFenced: another producer instance started with the same transactional.id. The broker fences the old producer permanently.
  • Permanent auth revocation: credentials are invalid and retrying won’t help.

Fatal errors return SinkError::Fatal and are not retried. The pipeline stops and requires operator intervention.

MySQL replay across a schema change

MySQL rows are decoded by column position. Each rows event is decoded with the schema version proven for its position in the binlog - the definition the table had when the rows were written - not with the table’s current definition, so a restart that replays rows written before a DDL decodes them with the old definition and the rows after it with the new one. How a version is proven is described in Schema tracking. The proven version must also agree with the column layout the binlog recorded for those rows (column count, storage types and type metadata; with binlog_row_metadata=FULL also names, signedness, charsets and the primary key); if it does not, the source stops fail-closed.

When no version can be proven, the source stops fail-closed: it emits no event for those rows and does not advance the checkpoint, rather than put values under the wrong columns. With binlog_row_metadata=MINIMAL (the MySQL default) this happens when retained rows predate a DDL the source never observed (for example a checkpoint older than a schema change made while an earlier deployment ran), after a statement that cannot be attributed to specific tables (a versioned-comment DDL, several statements in one event, ALTER DATABASE or DROP DATABASE of a tracked database, among others; the tables it may affect stay unproven until later proof for each exists - usually the table’s next rows, once its definition is captured and proven unchanged since them, or a later DDL of the table that the source proves), and when two DDLs of one table both happen before the source reads the first, with rows between them. The source never chooses a version from the MINIMAL layout alone: two tables or columns with the same storage types cannot be told apart without names.

Recovery: the safe default is to re-snapshot (restart the pipeline once with snapshot mode always), which rebuilds current state without decoding the retained rows. Moving the source position past the DDL instead is a manual, lossy action: it intentionally abandons every retained change between the checkpoint and the new position, for all captured tables (the binlog position is shared by the whole stream), and should only be taken after an operator assessment of that data loss.

Set binlog_row_metadata=FULL to remove most of these stops: without positional proof the source then selects the one recorded definition that exactly matches the rows’ metadata, and records that choice durably before emitting them. Rows whose definition was never recorded (the shape between two DDLs the source did not see separately) still stop the source; FULL lets them through only when that definition was already recorded and is the single exact match.

S3 sink atomicity guarantees

The S3 sink commits at file granularity, not event granularity. Specifically:

  • File-level atomicity: a Parquet or JSONL file is only visible at its final S3 key after the multipart-complete call returns success. Readers never see a partial file. Verified by tests (abandon_all_produces_no_visible_file, drop_pool_without_close_produces_no_visible_file).
  • At-least-once per file: if send_batch succeeds and the process crashes before the source checkpoint is committed, the same events will be re-delivered on restart and produce a new file with a different ULID. The original file is also retained — there is no automatic deduplication. Downstream consumers must dedup via MERGE INTO or event_id.
  • Mid-upload crash: a process killed during a multipart upload leaves orphan parts on S3 (not a visible file). The bucket lifecycle policy AbortIncompleteMultipartUpload: DaysAfterInitiation=1 reclaims them within 24h. This policy is an operational prerequisite — without it, abandoned uploads accumulate storage cost. See deployment.
  • Per-row DLQ: encoder failures are isolated per row via a slow-path retry on the same writer. The bad row appears in BatchResult.dlq_failures and is routed to the DLQ; the rest of the batch is written normally. The fast path (success) costs nothing extra — per-event retry only triggers on encoder errors. ParquetFileWriter and JsonLinesFileWriter both roll back their state on per-call failure so the writer remains safe to reuse during the retry loop.

For exactly-once at the event level (i.e., dedup without consumer cooperation), the planned Iceberg sink in Phase 2 uses atomic snapshot commits to make file appearance and event commit equivalent operations.

Error Classification & Retry

Retry behavior by sink

All sinks use exponential backoff with jitter. The classification determines whether an error is retried:

Kafka:

ErrorClassificationBehavior
Queue fullRetryableBackoff, retry (100ms base, 10s max, 3 attempts)
Message timeoutRetryableBackoff, retry
Broker connection failureRetryableBackoff, retry
Authentication failureNon-retryableFail immediately
Message too largeNon-retryableFail immediately
Producer fencedFatalPipeline stops
Transaction commit failure (fatal)FatalPipeline stops

NATS:

ErrorClassificationBehavior
Connection failureRetryableBackoff, retry (50ms base, 5s max, 3 attempts)
Publish timeoutRetryableBackoff, retry
Authentication failureNon-retryableFail immediately
No respondersNon-retryableFail immediately

Redis:

ErrorClassificationBehavior
Connection failureRetryableBackoff, retry (50ms base, 5s max, 3 attempts)
Command timeoutRetryableBackoff, retry
NOAUTH / WRONGPASSNon-retryableFail immediately
Permission deniedNon-retryableFail immediately

S3:

ErrorClassificationBehavior
Network timeout / partitionRetryable (in-batch)object_store retries the part upload with its built-in backoff; if still failing, send_batch returns SinkError::Io
503 SlowDown (throttling)Retryable (in-batch)object_store honors Retry-After; send_batch waits
send_timeout_secs exceededBounded waitAfter send_timeout_secs (default 60s), send_batch returns SinkError::Backpressure. Caps the worst-case latency a single batch can contribute. Coordinator routes per required — block (default) or log+continue. Useful when upstream object_store retries would otherwise stack into multi-minute waits.
403 AccessDenied / SigV4 mismatchNon-retryableSinkError::Io immediately; check credentials and region
NoSuchBucketNon-retryableSinkError::Io; create the bucket or fix the config
Encoder failure (e.g. value doesn’t fit Decimal128 precision)Per-row isolatedSlow-path per-event retry isolates the bad row; it lands in BatchResult.dlq_failures and is routed to the DLQ. The other events in the batch are written normally.
Mid-multipart abandon (process killed)N/AFile never becomes visible at the destination key. Orphan parts cleaned by the bucket’s lifecycle policy (operational prerequisite — see deployment)

After retry exhaustion

If all retry attempts fail for a retryable error, the error is propagated to the coordinator. The coordinator’s behavior depends on the commit policy:

  • Required sink: the batch is not committed and its checkpoint stays put. The coordinator does not re-inject the same batch from the source channel in-session; recovery happens by source replay from MIN(checkpoints) on the next restart (see Multi-batch retries vs in-batch retries).
  • Optional sink: the failure is logged, and the pipeline continues with other sinks.

Checkpoint Semantics

When checkpoints are saved

The checkpoint commit follows a strict sequence:

1. Accumulate events from source into a batch
2. Run processors (transform, filter)
3. Deliver batch to ALL sinks concurrently
4. Check commit policy (required/all/quorum)
5. Commit per-sink checkpoints (only for successful sinks)

Key invariant: a checkpoint is saved only after the sink has acknowledged delivery AND the commit policy is satisfied. This is the foundation of at-least-once delivery.

On crash recovery

  1. DeltaForge reads per-sink checkpoints from the checkpoint store.
  2. The source resumes from the minimum checkpoint across all sinks.
  3. Sinks that were already ahead of the minimum position receive duplicate events - they must handle these idempotently (Kafka read_committed still sees the replayed batch as a new transaction, so dedup on event id there too).
  4. Sinks that were behind receive their missing events.

Checkpoint storage

Checkpoints are stored in SQLite (default) with WAL mode and synchronous=NORMAL for durability. The checkpoint store survives SIGKILL — no graceful shutdown required for checkpoint safety.

Event replay

Event replay re-delivers captured commit units from the durable journal to selected sinks. Its correctness guarantees:

  • At-least-once re-delivery. The durable cursor is advanced only after a sink acknowledges an envelope. A crash between the acknowledgement and the cursor persist causes the acknowledged envelope to be delivered again on restart, so dedup-capable sinks must absorb duplicates (the same idempotency key applies).
  • No checkpoint mutation during replay. A running job never advances or rewinds the source or per-sink checkpoints; only normal live delivery moves checkpoints after the handoff.
  • Pause and handoff. Selected sinks are excluded from live delivery and from commit-policy evaluation while a job runs, and rejoin the live set only at an acknowledged handoff to a frozen tail H; the first live delivery to a restored sink is strictly after H.
  • Fail-closed capture. A commit unit that cannot be captured verbatim aborts the pipeline rather than advancing the checkpoint past uncaptured data.

Backpressure

DeltaForge implements end-to-end backpressure without dropping events:

Source → [event channel] → Accumulator → [batch channel (max_inflight)] → Delivery → Sinks
  1. Sink slow: delivery task blocks waiting for sink acknowledgement.
  2. Batch channel full: accumulator blocks waiting to enqueue the next batch (bounded by max_inflight).
  3. Event channel full: source blocks waiting to enqueue the next event.
  4. Source slows: the database connection idles until the channel has capacity.

No events are dropped at any stage. Backpressure propagates from the slowest sink all the way back to the source connection.

max_inflight controls the pipeline depth: higher values allow overlapping batch building with delivery (better throughput), lower values reduce memory usage and latency.

Consumer Guidance

Idempotency key

Every event has a deterministic idempotency key in the format:

{tenant}|{db}.{table}|{tx_id}|{event_id}

This key is identical across replays — the same source event always produces the same key.

  • Kafka with exactly_once: true: set isolation.level=read_committed on consumers. No application-level dedup needed.
  • Kafka without exactly_once: use the event’s id field (UUID v7) or the idempotency key to detect duplicates.
  • NATS JetStream: server-side dedup via Nats-Msg-Id header. Configure duplicate_window on the stream to cover your maximum expected downtime (default: 2 minutes).
  • Redis Streams: check the idempotency_key field in the stream entry before processing. Use a Redis SET or application-level tracking to remember processed keys.

Dedup window

How long should consumers remember processed event IDs? Match your maximum expected DeltaForge downtime:

ScenarioRecommended window
Normal operation (no crashes)No dedup needed (at-most-once per run)
Planned restarts5 minutes
Unplanned crashes with auto-restart15-30 minutes
Disaster recoveryMatch your RPO

Correctness Test Matrix

This matrix maps guarantees to their verification. Rows marked Exists have a test in the suite today; rows marked Planned do not yet - treat those guarantees as design intent pending verification, not as tested behavior.

GuaranteeTestTypeStatus
No data loss (at-least-once)crash_recovery chaos scenarioChaosExists
Kafka transactional atomic-batch deliveryexactly_once chaos scenario + kafka_sink_exactly_once_*Chaos + IntegrationExists
At-least-once across restart (committed row reaches Kafka after outage + restart)kafka_outage_restart_e2eIntegrationExists
Producer fencing detectionkafka_sink_exactly_once_producer_fencingIntegrationExists
Per-primary-key orderingEvents keyed by PK → same Kafka partitionBy designVerified via Kafka partition assignment
Transaction boundary preservationrespect_source_tx + check_and_split coordinator logicUnitExists
Per-sink checkpoint independencetest_per_sink_checkpoint_only_advances_on_successUnitExists
Per-sink checkpoint legacy fallbackper_sink_proxy_falls_back_to_legacy_keyUnitExists
Commit policy gate before checkpointtest_per_sink_checkpoint_only_advances_on_successUnitExists
DLQ routes per-event failurestest_dlq_routes_failed_events_and_pipeline_continuesUnitExists
DLQ all-fail batchtest_dlq_all_events_fail_no_sendUnitExists
Required sink + un-routable row + no DLQ holds checkpointtest_required_sink_dlq_failure_no_writer_holds_checkpointUnitExists
Required sink + DLQ cannot persist (full/reject) holds checkpointtest_required_sink_dlq_full_reject_holds_checkpointUnitExists
Optional sink drop holds its own checkpoint (pipeline continues)test_optional_sink_dlq_failure_holds_its_checkpointUnitExists
Every configured sink is initialized at the start position before delivery; failure stops startupsink_checkpoints_are_initialized_only_where_absent, sink_checkpoint_initialization_fails_closed, startup_fails_closed_without_sink_checkpoints, pg_startup_fails_closed_without_sink_checkpointsUnit / IntegrationExists
A pipeline reports running only once its capture boundary is fixed and durable; a write after running is delivered; start-up retries, a stop while starting and a stale run never report running; a failure before the stream opens resumes at the persisted boundarycapture_boundary_e2e, a_pipeline_is_starting_until_its_run_publishes_its_boundary, a_stale_run_never_marks_its_replacement_running, ready_returns_503_while_a_pipeline_is_startingUnit / IntegrationExists
A first-batch failure replays after restart (MySQL, PostgreSQL, both batching modes)*_first_batch_failure_replays_after_restartUnit / IntegrationExists
A failed sink stays held through later successes; restart replays the batch to it (both batching modes)aligned_/unaligned_optional_failure_replays_after_restart, *_consecutive_failures_hold, *_final_batch_failure_replays_after_idleUnitExists
Out-of-range DLQ failure index fails closedtest_out_of_range_dlq_index_holds_checkpointUnitExists
DLQ Block overflow is bounded (fails closed, no hang)dlq::overflow_block_times_out_and_fails_closedUnitExists
DLQ overflow (drop_oldest)dlq::overflow_drop_oldestUnitExists
DLQ overflow (reject)dlq::overflow_reject_drops_newUnitExists
DLQ overflow (block)dlq::overflow_block_waits_for_ackUnitExists
DLQ cleanup expireddlq::cleanup_expired_removes_old_entriesUnitExists
Partial batch timer flushtest_partial_batch_flushed_by_timerUnitExists
Network partition recoverynetwork_partition chaos scenarioChaosExists
Sink outage recoverysink_outage chaos scenarioChaosExists
Schema drift handlingschema_drift chaos scenarioChaosExists
MySQL failover detectionfailover chaos scenarioChaosExists
Postgres failover detectionpg_failover chaos scenarioChaosExists
Binlog purge detectionbinlog_purge chaos scenarioChaosExists
Replication slot drop detectionslot_dropped chaos scenarioChaosExists
NATS dedup within windowVerify Nats-Msg-Id prevents duplicatesIntegrationPlanned
Redis idempotency keyVerify consumer-side dedup via keyIntegrationPlanned
Snapshot → CDC handoffNo gaps; engine-specific documented overlapIntegrationExists
Snapshot crash, replacement and policy completionsnapshot_restart_e2e, postgres_snapshot_e2e, s3_ministack_durable_canaryIntegrationExists

Limitations

These are not guaranteed and are documented honestly:

  • No cross-table global ordering — events from different tables may be interleaved. This is by design; enforcing global order would require single-threaded delivery and cap throughput. Use respect_source_tx: true to preserve ordering within database transactions.
  • No stateful stream processing — DeltaForge does not support joins, aggregations, or windowing. For stateful processing, consume DeltaForge’s output with Apache Flink, ksqlDB, or Kafka Streams.
  • Dead letter queue - when journal.enabled: true, poison events (serialization/routing failures) are durably appended to the DLQ before the sink acknowledges, so the pipeline advances without losing the row. A row is only treated as isolated once its DLQ append is confirmed. If there is no DLQ, or the DLQ cannot durably capture the row (overflow drop/reject, or a backend write failure), the affected sink does not acknowledge and its own checkpoint is held (fail closed) so restart replay can recover the row. A required sink in that state also stops the pipeline (commit policy unsatisfied); an optional sink does not stop the pipeline, but its checkpoint still stays behind until the row is recovered. The Block overflow policy waits for space but is bounded - it fails closed rather than hanging. Configure a DLQ with a non-dropping overflow policy so poison rows are isolated and every sink keeps moving. See the DLQ page.
  • No in-session retry for optional sinks — when required: false and a sink fails, the failed batch is not retried in the same session. The failed sink’s checkpoint stays at its prior position; events are re-delivered only on pipeline restart via source replay. For sinks with realistic outage windows (e.g., S3 throttling, cross-region issues), the operator must weigh source backpressure (required: true) against replay-on-restart latency (required: false). See Required vs. optional sinks.
  • S3 sink — at-least-once at file granularity — duplicate events across a crash boundary appear in two files with different ULIDs. Per-row DLQ isolates encoder failures, but does not provide exactly-once delivery on its own; that requires the Phase 2 Iceberg sink (atomic snapshot commits). Consumers must dedup downstream via MERGE INTO or event_id. See S3 sink atomicity guarantees.
  • S3 sink — lifecycle policy required for production — DeltaForge does not track multipart upload IDs externally. Abandoned multiparts are reclaimed by the bucket’s AbortIncompleteMultipartUpload lifecycle rule. Without this rule, S3 storage cost accumulates on every aborted batch.
  • Snapshot duplicates - snapshot rows can be delivered more than once: PostgreSQL’s bounded snapshot-to-stream overlap, and every row of a snapshot copied again after an interruption or upgrade. Consumers should resolve them by source position or idempotency key. See Initial Snapshots.

Dead Letter Queue

The Dead Letter Queue (DLQ) routes poison events — events that fail serialization, exceed size limits, or have invalid routing — to a durable queue instead of blocking the pipeline.

How it works

  1. The coordinator dispatches a batch to each sink
  2. Each sink pre-serializes events individually. If an event fails serialization or routing, it is flagged as a DLQ failure instead of failing the entire batch
  3. The remaining (healthy) events are sent to the sink normally
  4. Failed events are written to the DLQ queue with error context
  5. The pipeline continues — one bad event does not block thousands of good ones

Only per-event attributable failures go to the DLQ:

ErrorDLQ eligibleWhy
Serialization failureYesEvent can’t be encoded — specific to this event’s data
Routing failureYesTemplate resolves to empty/invalid for this event’s fields
Message too largeYesThis specific event exceeds the sink’s max message size
Connection failureNoEntire sink is down — not caused by one event
Auth failureNoCredentials invalid — affects all events
Timeout / backpressureNoTransient — will resolve with retry
Producer fencedNoFatal — pipeline stops

Configuration

DLQ is opt-in. It is configured under journal in the pipeline spec — the journal is DeltaForge’s internal event storage system that backs the DLQ (and will support replay in a future release). Enabling the journal with a dlq stream activates per-event failure routing:

spec:
  journal:
    enabled: true
    max_event_bytes: 262144      # 256KB — truncate larger payloads
    dlq:
      max_entries: 10000         # bounded queue size
      max_age_secs: 604800       # 7 days — auto-purge older entries
      overflow_policy: drop_oldest

Overflow policies

When the DLQ reaches max_entries:

PolicyBehavior
drop_oldest (default)Evict the oldest DLQ entry to make room for the new one. Most recent failures are usually most valuable for investigation.
rejectReject the new DLQ entry. The failed event is lost — it is not stored in the DLQ, not retried, and not delivered to the sink. The pipeline continues processing other events normally. An error is logged and deltaforge_dlq_rejected_total is incremented.
blockBlock the pipeline until space is available. No events (good or bad) are processed until the operator acks DLQ entries via the REST API. Visible as degraded on the health endpoint.

Payload truncation

Events that caused MessageTooLarge may also be too large for the DLQ. If the event payload exceeds max_event_bytes (default 256KB), the before and after fields are truncated and payload_truncated: true is set. All event metadata (source, table, op, id, timestamp) is always preserved.

REST API

EndpointMethodDescription
GET /pipelines/{name}/journal/dlqGETPeek entries (oldest first). Params: ?limit=50&sink_id=...&error_kind=...
GET /pipelines/{name}/journal/dlq/countGETCount of unacked entries
POST /pipelines/{name}/journal/dlq/ackPOSTDismiss (remove) entries up to seq. Body: {"up_to_seq": 42}. Dismissed entries are permanently deleted — they are not retried or reprocessed.
DELETE /pipelines/{name}/journal/dlqDELETEPurge all entries

Filters (sink_id, error_kind) affect listing only. Ack is always cumulative from the queue head — it removes all entries up to up_to_seq, regardless of filters used when viewing.

Example: inspect DLQ entries

# Peek the first 10 DLQ entries
curl -s http://localhost:8080/pipelines/my-pipeline/journal/dlq?limit=10 | jq .

# Filter by sink
curl -s "http://localhost:8080/pipelines/my-pipeline/journal/dlq?sink_id=kafka-primary&limit=5"

# Check DLQ size
curl -s http://localhost:8080/pipelines/my-pipeline/journal/dlq/count
# {"count": 42}

# Ack (remove) entries up to sequence 100
curl -s -X POST http://localhost:8080/pipelines/my-pipeline/journal/dlq/ack \
  -H "Content-Type: application/json" \
  -d '{"up_to_seq": 100}'
# {"acked": 12}

# Purge all entries
curl -s -X DELETE http://localhost:8080/pipelines/my-pipeline/journal/dlq
# {"purged": 42}

Example DLQ entry

{
  "seq": 42,
  "timestamp": 1743350400,
  "pipeline": "orders-pipeline",
  "stream": "dlq",
  "event_id": "01961234-5678-7abc-def0-123456789abc",
  "source_cursor": {"file": "mysql-bin.000005", "pos": 12345},
  "payload_truncated": false,
  "event": {
    "event_id": "01961234-5678-7abc-def0-123456789abc",
    "source": {"db": "orders", "table": "events"},
    "op": "c",
    "type_mapping": "v2",
    "after": {"c": [["id", {"t": "int", "w": 32, "v": "99"}],
                    ["big", {"t": "uint", "w": 64, "v": "18446744073709551615"}]]}
  },
  "meta": {
    "sink_id": "kafka-primary",
    "error_kind": "serialization error",
    "error_message": "failed to serialize field 'metadata': invalid UTF-8 sequence",
    "attempts": 1
  }
}

The event is the event’s lossless typed form: each value with its type (integers as exact decimal text, bytes in base64, documents as canonical JSON text) and the event’s type_mapping, so an entry can be inspected or re-delivered without guessing types. Entries written by earlier releases (untyped JSON) stay readable as they are.

Metrics

MetricTypeLabelsPurpose
deltaforge_dlq_events_totalcounterpipeline, sink, error_kindEvents routed to DLQ
deltaforge_dlq_entriesgaugepipelineCurrent unacked entries
deltaforge_dlq_evicted_totalcounterpipelineEvicted by drop_oldest
deltaforge_dlq_rejected_totalcounterpipelineRejected by reject policy
deltaforge_dlq_write_failures_totalcounterpipelineDLQ storage write failures
deltaforge_dlq_saturation_ratiogaugepipelineCurrent / max_entries (0.0-1.0)

Health signals:

  • Warning log at 80% saturation
  • Error log at 95% saturation

Storage

The DLQ is built on DeltaForge’s existing StorageBackend queue primitives (queue_push, queue_peek, queue_ack). It automatically uses whatever storage backend your pipeline is configured with (SQLite, PostgreSQL, or memory). No additional infrastructure is needed.

Background Cleanup

When max_age_secs is configured (default: 7 days), a background task runs every 60 seconds and removes entries older than the threshold. Age is calculated from insertion time, not source event time. A best-effort startup cleanup pass (bounded to 5 seconds) runs when the pipeline starts.

Cleanup and overflow eviction are independent — drop_oldest may remove entries before age-based cleanup fires.

Operator Workflow

DLQ events are not retried automatically. The intended workflow is:

  1. Alert: monitor deltaforge_dlq_events_total or deltaforge_dlq_entries in Grafana
  2. Inspect: GET /pipelines/{name}/journal/dlq?sink_id=... to see what failed and why
  3. Fix: resolve the root cause (schema mismatch, oversized column, broken routing template)
  4. Dismiss: POST /pipelines/{name}/journal/dlq/ack to remove reviewed entries — dismissed entries are permanently deleted

Future versions may add a retry endpoint (POST .../dlq/retry) that re-injects events into the pipeline, but this raises ordering and idempotency concerns and is deferred.

Limitations

  • DLQ is per-pipeline, not per-sink. Use the sink_id filter to view entries for a specific sink.
  • The block overflow policy blocks the entire pipeline, not just the failing sink.

Event Replay

Event replay re-delivers historical events from the durable replay journal to selected sinks, without re-reading the source. Use it to recover from a consumer bug (re-deliver a range after fixing a downstream processor), or to catch a sink up after an outage. Replay is not a snapshot rebuild; it delivers exactly the commit units that were captured to the journal.

Security requirement. Replay endpoints start and cancel are mutating operations, and the DeltaForge HTTP API currently has no request-level authentication or authorization (this applies to every mutating endpoint, not just replay). Until platform-wide authN/authZ exists, you MUST restrict the API at the network layer (private network, firewall, service mesh, or an authenticating reverse proxy). Do not expose it to untrusted callers.

How it works

Replay builds on the journal (the same internal storage that backs the DLQ):

  1. When journal.replay is enabled, the coordinator captures every committed source commit unit (a transaction, a standalone CDC row, or a snapshot chunk) into a durable, append-only, incarnation-scoped log, idempotently (a retried capture does not duplicate).
  2. A replay job reads that log from a starting sequence and re-delivers each commit unit to the job’s selected sinks, running the pipeline’s CURRENT processors and schema sensing (so replay picks up processor fixes).
  3. While a job runs, its selected sinks are paused from live delivery and excluded from commit-policy evaluation, so a paused required sink does not stall the rest of the pipeline. Unselected sinks keep receiving live traffic.
  4. When the job catches up to the journal tail, it performs a handoff: ingestion is briefly quiesced to a stable tail H, the selected sinks are delivered through H, then returned to the live set. The first live delivery to a restored sink is strictly after H.

Replay never advances or rewinds the source or per-sink checkpoints during the historical and catch-up phases; only normal live delivery moves checkpoints after the handoff.

Configuration

Replay is opt-in and requires BOTH the journal master switch and the replay sub-switch:

spec:
  journal:
    enabled: true               # journal master switch (also gates the DLQ)
    max_event_bytes: 262144
    replay:
      enabled: true
      retention_secs: 86400     # drop journal entries older than this (0 = no age limit)
      max_entries: 0            # capacity cap (0 = unbounded)
      max_bytes: 0              # capacity cap (0 = unbounded)
      max_envelope_bytes: 8388608   # 8 MiB; a commit unit larger than this fails closed

Replay capture requires transaction-aligned batching: a replay-enabled pipeline must run with batch.respect_source_tx = true, otherwise it is rejected at startup (the legacy path ignores transaction markers and would capture nothing).

Retention never truncates a range an active job still needs: while a job runs, its cursor pins the journal so entries it has not yet replayed are retained.

REST API

All endpoints live under a pipeline’s journal namespace, alongside the DLQ endpoints.

Start (or dry-run) a replay

POST /pipelines/{name}/journal/replay
Content-Type: application/json

{
  "selected_sinks": ["kafka"],      // existing live sinks to pause and replay to (required)
  "from_seq": 0,                     // replay journal entries with seq > from_seq
  "through_seq": null,               // optional inclusive upper bound of the historical range
  "encoder_schema_policy": "current",// optional; see Limitations
  "dry_run": false                   // optional; report the range/targets, deliver nothing
}

Response 200:

{ "job_id": "018f..." }

Errors:

StatusCodeWhen
400BAD_REQUESTmalformed request: unknown/unsupported encoder policy, invalid range (through_seq < from_seq), a selected sink that is not part of the pipeline, an empty target set, a staged sink (not supported), or a replay that would leave the commit quorum unsatisfiable
409CONFLICTa replay job is already active for this pipeline, or the pipeline changed (stop/restart/reconfigure) during the start
500INTERNAL_ERRORstorage or internal failure

Get the current job status

GET /pipelines/{name}/journal/replay

Response 200 is the current (or most recent) job, or null when none exists:

{
  "job_id": "018f...",
  "phase": "catching_up",
  "cursor": 4210,
  "from_seq": 0,
  "through_seq": null,
  "selected_sinks": ["kafka"],
  "staged_sinks": [],
  "dry_run": false,
  "created_at_ms": 1750000000000,
  "updated_at_ms": 1750000000500,
  "error": null
}

Cancel the active job

POST /pipelines/{name}/journal/replay/cancel

Returns 204 No Content. Cancellation is idempotent when no job is active; it persists the cancellation durably before returning the paused sinks to the live set.

Job phases

Running -> CatchingUp -> HandoffQuiesced(H) -> DeliveredThrough(H) -> LiveRestored -> Completed
  • running - delivering the requested historical range to the selected sinks.
  • catching_up - delivering entries captured after the job started, toward the tail.
  • handoff_quiesced / delivered_through - the acknowledged handoff at the frozen tail H.
  • live_restored - the sinks have rejoined the live set; live delivery resumes after H.
  • completed - terminal, live delivery fully restored.
  • cancelled / failed - terminal off-ramps (failed records a reason in error).

The job phase and cursor are durable. A restart resumes from the recorded phase: an in-flight historical/catch-up job continues from its cursor, and a job interrupted mid-handoff re-quiesces and finishes. Delivery is at-least-once, so a re-run may re-deliver units the sink already received.

Guarantees

  • At-least-once. An interruption after a sink acknowledges an envelope but before the cursor is persisted causes that envelope to be re-delivered on restart. Dedup-capable sinks absorb the duplicate; append-only sinks receive it twice by design.
  • Fail-closed capture. A commit unit that cannot be captured (missing event id, serialize failure, or an envelope over max_envelope_bytes) aborts the pipeline rather than advancing the checkpoint past uncaptured data.
  • Pause/handoff. Selected sinks are excluded from live delivery and commit policy while a job runs, and rejoin the live set only at the handoff, with the first live delivery strictly after the frozen tail H.
  • No checkpoint mutation. Replay does not touch the source or per-sink checkpoints during the historical and catch-up phases.
  • Typed captures only. Envelopes hold each event’s typed values and mapping version (capture format 2). A job first checks every envelope of its range; a capture of format 1 (untyped JSON, written before typed values) is refused there, before any event is delivered or the cursor moves: start a new capture.

Limitations (current release)

  • Encoder schema policy. Only the default current policy is honored (the sink API cannot yet select a schema). at_capture_seq and pinned:<seq> are rejected at start.
  • Staged-sink backfill. Adding a brand-new sink and backfilling it (staged sinks) is not supported yet; staged_sinks must be empty. Replay targets existing pipeline sinks only.
  • One job per pipeline. At most one active replay job exists per pipeline incarnation; a second start returns 409.

Operational validation (canary)

Before relying on replay in an environment, run the disposable-pipeline canary in docs/ops/replay-canary/. It verifies selected-sink pause/restoration, at-least-once duplicate handling, restart-during-replay resume, retention and journal-growth metrics, and the network-restriction requirement above.

Recovery Operations

Some failures stop a pipeline in a way no restart can fix: a snapshot generation that blocked on a bound, a lost replication position, a PostgreSQL source whose checkpoints predate continuity records on a server that has switched timeline. For these, DeltaForge has recovery operations: explicit, reviewed, audited changes to the pipeline’s durable state, applied by the running server to a stopped pipeline.

Every operation is planned first. The plan shows exactly what will change, what is kept, and the consequences, and ends with a proof: a digest of everything the plan was computed from. Apply takes that proof, recomputes the plan, and refuses to write anything if the state moved in between. The state is never edited by hand: there is no supported way to delete checkpoint or snapshot records from the state store, and doing so can lose data.

Operations in this release:

OperationForWhat it changes
resnapshota blocked snapshot generation, a lost replication position, or an operator’s decision to copy everything againallocates the next snapshot generation, so the next start copies every table again
pg-adopt-timelinePostgreSQL pg_continuity_unproven with class timeline_unrecordedrecords the server’s current timeline for checkpoints written before continuity was recorded

Setting up access

Recovery is served on a separate admin listener, never on the public API.

  • --admin-addr (default 127.0.0.1:9091). Loopback only: the server refuses to start with any other address. For remote administration, open an SSH tunnel to the host’s loopback port (for example ssh -L 9091:127.0.0.1:9091 host) and point the CLI at the local end.

  • --admin-token-file: required; without it the admin listener does not start. The file must:

    • be a regular file, not a symlink;
    • not be readable or writable by group or others (chmod 600 or 400);
    • contain one token of at least 32 bytes, with no carriage return, NUL or inner newline (one trailing newline is allowed and is not part of the token).

    The server reads it at startup. The token is never logged or returned in errors. Requests must also come from a loopback peer; forwarded headers are never trusted.

head -c 32 /dev/urandom | base64 | tr -d '\n' > /etc/deltaforge/admin.token
chmod 600 /etc/deltaforge/admin.token
deltaforge --config pipelines/ --admin-token-file /etc/deltaforge/admin.token

The CLI reads the token from a file too (never from the command line) and talks to loopback http only:

deltaforge recover --admin-token-file /etc/deltaforge/admin.token diagnose orders

--admin-url defaults to http://127.0.0.1:9091.

The workflow

  1. Stop the pipeline (POST /pipelines/{name}/stop), or let it fail. Apply needs it quiescent: stopped or failed, with its tasks finished.

  2. Diagnose (read-only): the pipeline’s state, any recovery operation in progress, open incidents with their explanation and the operations that answer them.

    deltaforge recover --admin-token-file T diagnose orders
    
  3. Plan (read-only). Name the incident the operation answers:

    deltaforge recover --admin-token-file T plan resnapshot --pipeline orders --incident <id>
    

    The plan prints its bindings, steps (each with the exact state it expects before and after), consequences, values observed but not part of the proof, whether apply is possible right now, the apply command, and, last, proof <sha256>.

  4. Review the plan, especially its consequences.

  5. Apply exactly that plan, with your name and reason:

    deltaforge recover --admin-token-file T apply resnapshot --pipeline orders \
      --incident <id> --expect-proof <sha256> --actor alice --reason "binlog purged during snapshot"
    

    Apply recomputes the plan while holding the pipeline’s recovery lock. If the proof differs, nothing is written and the new proof is shown: review it with plan again. It is never used automatically.

  6. Resume the pipeline explicitly (POST /pipelines/{name}/resume). Apply never starts the pipeline.

While an operation is being applied, or is unfinished, the pipeline cannot be started, resumed, paused, patched or deleted. Its status shows recovery_pending and /ready reports it not ready.

Outcomes, timeouts and exit codes

Exit codeMeaning
0done
2invalid input, configuration or authentication; unknown pipeline, operation or incident; operation not applicable (for example snapshot.mode: never)
3proof mismatch: the state changed since the plan. Review the new plan
4pipeline state: not stopped yet, being deleted, another request in progress, or a source precondition (for example a standby endpoint, or an active slot)
5a recovery operation is pending, diverged, stopped, or the state needs manual repair
6transport or server failure, malformed response, or timeout: the outcome may be unknown

The CLI waits up to 310 seconds for an apply. The operation itself runs to its end on the server even if the request is abandoned. Apply is never retried automatically. After a timeout or a lost connection, run diagnose: if the operation completed, re-applying the same proof reports it as already completed and writes nothing; if it is still pending, re-apply the same proof.

Pending and diverged operations

An operation is recorded durably before its first write, step by step. Every step names the exact state it changes and the state it leaves. If the server stops mid-operation:

  • the pipeline stays stopped (recovery_pending) across restarts; the server never continues or abandons it on its own;
  • re-apply with the same proof to finish it. The resume uses the plan stored with the operation (never a recomputed one). Each remaining step finds either its expected starting state (and performs it) or its expected result (and skips it).

If a step finds neither, the operation diverges: it stops without writing anything further and keeps the record as evidence. diagnose shows the step, the expected states and what was found, and the next safe action. Bring that state back to the expected starting state, then re-apply the same proof. There is no abandonment once writing began.

resnapshot

Allocates the next snapshot generation so the next start copies every table again under a new anchor (see Initial Snapshots).

Consequences (printed first in every plan):

  • The complete snapshot is copied again. Every captured table is read in full.
  • Duplicates are expected. Sinks receive every row again (at-least-once).
  • Sink checkpoints are kept, never deleted or advanced. Each sink moves into the new generation through the generation’s start barrier.
  • The pipeline is not started; only the incident named in the plan is resolved.

What it writes:

  • the next generation of the same snapshot chain (or, for a source without snapshot state, a first one), marked as a recovery allocation, unblocked, its start barrier pending, freezing the configuration of now (table patterns, commit policy and cohort). The next start runs it in place; if the configuration or source changed before then, that start replaces it as usual;
  • the replaced generation’s plan is reclaimed;
  • PostgreSQL slot. A slot this source created and lost is never recreated implicitly:
    • lost or invalidated and provably this source’s (finalized ownership record for this source, pipeline, server, database, slot and plugin): the plan drops it as an explicit step;
    • absent, or dropped by the plan: the plan writes a single-use slot recreation authorization bound to the new generation and the ownership record. Only the start of that generation can use it, before creating the slot. A start interrupted after taking it finishes the creation; one interrupted after creating the slot keeps exactly that slot. The audit records the authorization and, after the start, the recreation;
    • foreign, ambiguous, active, or with a missing or changed ownership record: refused (manual repair).

Refused: snapshot.mode: never (change the mode first); snapshot state that cannot be interpreted; snapshot-chain positions without their snapshot state; a generation of another source lineage; checkpoints that are malformed or not provably this source’s; a generation that already produced all its rows (resume instead: it completes or is replaced).

pg-adopt-timeline

For a PostgreSQL source stopped with pg_continuity_unproven, class timeline_unrecorded: its checkpoints predate continuity records and the server has switched timeline since. Adoption records the server’s current timeline as the start of the source’s continuity chain, proven at the checkpoint position F.

It never moves a checkpoint. It creates the continuity record once (transition 0, proven at F). At the next start, the ordinary continuity proof runs against that record, and every checkpoint is stamped into the chain at its existing position before the first change is consumed.

Prerequisites, all observed on one replication session of the kind the stream itself uses:

  • the endpoint is the primary, not a standby;
  • the server’s system identifier and database OID are the source’s recorded lineage;
  • the slot exists, is a persistent logical slot, is not invalidated or lost, and is not in use;
  • the slot’s restart and confirmed positions are at or before F, and the server’s WAL reaches F;
  • every sink has a checkpoint; each is a stream position without a continuity stamp, and they can be ordered. F is the earliest of them, with no sink left out;
  • the source has no continuity record yet.

Anything else is refused and nothing is written. At apply, the same session observations are made again and must be exactly the planned ones.

Consequence: you assert that the server history from F to now is this timeline’s. A rewind on the same timeline cannot be detected and is unsupported.

Manual repair

Some states are never changed by a recovery operation, because nothing proves what they mean: a corrupt or unknown-format snapshot or recovery record, snapshot positions without their snapshot state, foreign-lineage state, a replication slot DeltaForge cannot prove it owns, a diverged operation whose state cannot be brought back. Operations refuse these with manual_repair (exit 5) and leave them untouched. Repair them from evidence: the incident’s evidence, diagnose, and the audit trail. Do not delete records from the state store to get past a refusal.

How identifiers in a plan are made

Some operations create identifiers, for example a new snapshot chain or a continuity chain. These are derived, not random, so recomputing a plan gives the same result:

  1. The plan is built with each such identifier set to a fixed placeholder.
  2. The plan seed is the SHA-256, under its own domain, of that canonical plan: every binding (source identity, lineage, checkpoints and their digests, observed server facts, incident), step and consequence.
  3. Each identifier is 128 bits derived from the seed under a separate domain and a purpose (snapshot_chain, continuity_chain).
  4. The identifiers go into the final plan, and the proof is the SHA-256 of that final plan.

The proof is never an input to what it proves.

Audit

Every apply is audited per pipeline: the operation, proof, asserted actor, how the request authenticated, the peer address, the reason, the incidents it resolved, and each step’s recorded outcome. Each incident it resolves records the same fields in its own audit trail, in one transition. Later effects of an operation are audited too (for example slot_recreated when the authorized start created the slot).

The actor is asserted, not verified. The admin token proves possession of the token, not who is using it; the audit records actor_verified: false.

Not in this release

  • Enabling PostgreSQL 17 failover slots on an existing slot (pg-enable-failover-slot).
  • Replaying a Kafka sink from its held checkpoint after an uncertain transaction (kafka-replay-from-checkpoint).
  • A new source id is a configuration change, not an operation: configure the new id (it starts with a snapshot).

Architecture

This document describes DeltaForge’s internal architecture, design decisions, and how the major components interact.

Design Principles

Source-Owned Semantics

DeltaForge avoids imposing a universal data model on all sources. Instead, each database source defines and owns its schema semantics:

  • MySQL captures MySQL-specific types, collations, and engine information
  • PostgreSQL captures PostgreSQL-specific types, OIDs, and replica identity
  • Future sources (ClickHouse, TiDB) will capture their native semantics

This approach means downstream consumers receive schemas that accurately reflect the source database rather than a lowest-common-denominator normalization.

Delivery Guarantees First

The checkpoint system is designed around a single invariant:

Checkpoints are only saved after events have been successfully delivered.

This ordering guarantees at-least-once delivery. A crash between checkpoint and delivery would lose events; DeltaForge prevents this by always checkpointing after sink acknowledgment.

Configuration Over Code

Pipelines are defined declaratively in YAML. This enables:

  • Version-controlled pipeline definitions
  • Environment-specific configuration via variable expansion
  • Rapid iteration without recompilation

Component Overview

┌─────────────────────────────────────────────────────────────────┐
│                        DeltaForge Runtime                       │
├─────────────┬─────────────┬─────────────┬─────────────┬─────────┤
│   Sources   │   Schema    │ Coordinator │    Sinks    │ Control │
│             │  Registry   │  + Batch    │             │  Plane  │
├─────────────┼─────────────┼─────────────┼─────────────┼─────────┤
│ MySQL       │ Durable     │ Batching    │ Kafka       │ REST API│
│ PostgreSQL  │ Schema      │ Commit      │ Redis / NATS│ Metrics │
│             │ Registry    │ Policy      │ HTTP / S3   │ Health  │
└─────────────┴──────┬──────┴─────────────┴─────────────┴─────────┘
                     │
          ┌──────────┴──────────┐
          │   Storage Backend   │
          │  (SQLite / PG /     │
          │   Memory)           │
          ├─────────────────────┤
          │ KV · Log · Slot     │
          │ Queue               │
          └─────────────────────┘

Data Flow

Event Lifecycle

1. Source reads from database log (binlog/WAL)
        │
        ▼
2. Schema loader maps table_id to schema
        │
        ▼
3. Event constructed with before/after images
        │
        ▼
4. Event sent to coordinator via channel
        │
        ▼
5. Coordinator batches events
        │
        ▼
6. Processors transform batch (JavaScript)
        │
        ▼
7. Sinks deliver batch concurrently
        │
        ▼
8. Commit policy evaluated
        │
        ▼
9. Checkpoint saved (if policy satisfied)

Event Structure

Every CDC event shares a common structure:

#![allow(unused)]
fn main() {
pub struct Event {
    pub source_id: String,          // Source identifier
    pub database: String,           // Database name
    pub table: String,              // Table name
    pub op: Op,                     // Insert, Update, Delete, Ddl
    pub tx_id: Option<u64>,         // Source transaction ID
    pub before: Option<Value>,      // Previous row state
    pub after: Option<Value>,       // New row state
    pub schema_version: Option<String>,  // Schema fingerprint
    pub schema_sequence: Option<u64>,    // For replay lookups
    pub ddl: Option<Value>,         // DDL payload if op == Ddl
    pub timestamp: DateTime<Utc>,   // Event timestamp
    pub checkpoint: Option<CheckpointMeta>,  // Position info
    pub size_bytes: usize,          // For batching
}
}

Schema Registry

Role

The schema registry serves three purposes:

  1. Map table IDs to schemas: Binlog events reference tables by ID; the registry resolves these to full schema metadata
  2. Detect schema changes: Fingerprint comparison identifies when DDL has modified a table
  3. Enable replay: Sequence numbers correlate events with the schema active when they were produced

Schema Sensing

At startup, the schema loader auto-discovers tables via pattern expansion and loads their schemas from the live database catalog before any CDC events arrive.

Pattern expansion supports wildcards:

PatternMatches
db.tableExact table
db.*All tables in database
db.prefix%Tables matching prefix
%.tableTable in any database

DDL detection works through cache invalidation. When the binlog delivers a QueryEvent (DDL), the affected database’s cache is cleared. On the next row event for that table, the schema is re-fetched from INFORMATION_SCHEMA, fingerprinted, and registered as a new version. No separate DDL history log is maintained — the live catalog is always the source of truth.

Failover reconciliation (verifying schemas against a new primary after server identity change) is planned but not yet implemented.

Schema versions are persisted via DurableSchemaRegistry, which uses the StorageBackend Log primitive. On startup the log is replayed to populate an in-memory cache, so hot-path reads have the same performance as the previous in-memory implementation. Cold-start reconstruction is always possible from the log alone.

Schema Registration Flow

1. Schema loader fetches from INFORMATION_SCHEMA
        │
        ▼
2. Compute fingerprint (SHA-256 of structure)
        │
        ▼
3. Check registry for existing schema with same fingerprint
        │
        ├── Found: Return existing version (idempotent)
        │
        └── Not found: Allocate new version number
                │
                ▼
4. Store with: version, fingerprint, JSON, timestamp, sequence, checkpoint

Sequence Numbers

The registry maintains a global monotonic counter. Each schema version receives a sequence number at registration. Events carry this sequence, enabling accurate schema lookup during replay:

Timeline:
─────────────────────────────────────────────────────────────►
     │              │                    │
Schema v1      Schema v2           Schema v3
(seq=1)        (seq=15)            (seq=42)
     │              │                    │
     └──events 1-14─┘──events 15-41─────┘──events 42+──►

Replay at seq=20: Use schema v2 (registered at seq=15, before seq=42)

Checkpoint Store

Timing Guarantee

The checkpoint is saved only after sinks acknowledge delivery:

┌────────┐   events   ┌────────┐   ack    ┌────────────┐
│ Source │ ─────────▶ │  Sink  │ ───────▶ │ Checkpoint │
└────────┘            └────────┘          │   Store    │
                                          └────────────┘

If the process crashes after sending to sink but before checkpoint, events will be replayed. This is the “at-least-once” guarantee — duplicates are possible, but loss is not.

Storage Backends

Checkpoints are stored via BackendCheckpointStore, a thin adapter over the StorageBackend KV primitive. See Storage for backend configuration and the full namespace map.

BackendPersistenceUse Case
SqliteStorageBackendSQLite fileSingle-instance production
MemoryStorageBackendNoneTesting, ephemeral deployments
PostgresStorageBackendExternal DBHA, multi-instance

Checkpoint-Schema Correlation

When registering schemas, the current checkpoint can be attached:

#![allow(unused)]
fn main() {
registry.register_with_checkpoint(
    tenant, db, table,
    &fingerprint,
    &schema_json,
    Some(&checkpoint_bytes),  // Current binlog position
).await?;
}

This creates a link between schema versions and source positions, enabling accurate schema lookup during replay.

Coordinator

The coordinator orchestrates event flow between source and sinks:

Batching

Events are accumulated until a threshold triggers flush:

  • max_events: Event count limit
  • max_bytes: Total serialized size limit
  • max_ms: Time since batch started
  • respect_source_tx: Never split source transactions

Commit Policy

When multiple sinks are configured, the commit policy determines when the checkpoint advances:

#![allow(unused)]
fn main() {
match policy {
    All => required_acks == total_sinks,
    Required => required_acks == sinks.filter(|s| s.required).count(),
    Quorum(n) => required_acks >= n,
}
}

Processor Pipeline

Processors run in declared order, transforming batches:

events ──▶ Processor 1 ──▶ Processor 2 ──▶ ... ──▶ transformed events

Each processor can filter, transform, or enrich events. The JavaScript processor uses deno_core for sandboxed execution.

Hot Paths

Critical performance paths have been optimized:

  1. Event construction - Minimal allocations, reuse buffers
  2. Checkpoint serialization - Opaque bytes avoid repeated JSON encoding
  3. Sink delivery - Batch operations reduce round trips
  4. Schema lookup - In-memory cache with stable fingerprints

Benchmarking

Performance is tracked via:

  • Micro-benchmarks for specific operations
  • End-to-end benchmarks using the Coordinator component
  • Regression detection in CI

Future Architecture

Planned enhancements:

  • Event store: time-based replay and schema evolution using the Log primitive
  • Distributed coordination: leader election via the Slot primitive with TTL-based leases
  • Additional sources: SQL Server, TiDB
  • PostgreSQL storage validation: chaos/recovery testing to bring it to production parity with SQLite

Checkpoints

Checkpoints record pipeline progress so ingestion can resume from the last successfully delivered position. DeltaForge guarantees at-least-once delivery by saving checkpoints only after events have been acknowledged by sinks.

Core Guarantee: At-Least-Once Delivery

Checkpoints are only saved after events have been successfully delivered to sinks.

If a checkpoint were saved before delivery, a crash between those two points would silently lose events. DeltaForge prevents this by always checkpointing after sink acknowledgment.

┌─────────────┐     ┌─────────────┐     ┌─────────────┐     ┌─────────────┐
│   Source    │────▶│  Processor  │────▶│    Sink     │────▶│ Checkpoint  │
│   (read)    │     │ (transform) │     │  (deliver)  │     │   (save)    │
└─────────────┘     └─────────────┘     └─────────────┘     └─────────────┘

On crash: events since the last checkpoint are replayed. Consumers should be idempotent or use deduplication.

Storage

Checkpoints are stored via the unified StorageBackend - see Storage for backend configuration. All pipelines share the same storage backend; each pipeline’s checkpoint is keyed by its source ID.

BackendPersistenceUse Case
SqliteStorageBackendSQLite file on diskSingle-instance production
MemoryStorageBackendNone (lost on restart)Testing
PostgresStorageBackendExternal databaseHA, multi-instance

Checkpoint Contents

MySQL

Tracks binlog position:

file:     binlog.000042
pos:      12345
gtid_set: (optional, if GTID replication is enabled)

PostgreSQL

Tracks replication stream LSN:

lsn:    0/1A2B3C4D
tx_id:  (optional)

Checkpoint-Schema Correlation

When a schema change is detected, the current checkpoint position is recorded alongside the new schema version. This ensures that during replay, each event is interpreted with the schema that was active when it was produced - even if the table has since been altered.

Commit Policy

When multiple sinks are configured, the commit policy controls when the checkpoint advances:

PolicyBehaviour
allAll sinks must acknowledge
requiredOnly sinks marked required: true must acknowledge
quorum(n)At least n sinks must acknowledge

Set required: true only on sinks where delivery is mandatory for correctness. Optional sinks can fail without blocking the checkpoint.

Operations

Inspecting Checkpoints

sqlite3 ./data/deltaforge.db \
  "SELECT key, length(val) FROM df_kv WHERE ns = 'checkpoints';"

Resetting a Pipeline

Checkpoints are never edited or deleted by hand: a deleted or rewritten checkpoint can skip or lose changes, and the snapshot and continuity state that refers to it would no longer match. To copy a source’s data again, use the reviewed, audited resnapshot recovery operation (it keeps every checkpoint and moves each sink into the new snapshot through its start barrier). To start a source from scratch, configure a new source id (it starts with a snapshot).

MySQL GTID state from earlier versions

Before exact GTID sets, a MySQL GTID checkpoint could claim transactions the server had not executed (a set with a gap, u:1-8:13, was stored as u:1-13), and resuming from it skipped them once they were committed. Such state cannot be repaired later, so this version refuses it: a stored MySQL GTID checkpoint without gtid_format: 2 does not load, older MySQL GTID durable watermarks (before version 3) are incomparable, and older activation records and failover anchors are refused. The source stops with an error naming the cause. Configure it under a new source id to start it again with a snapshot. File/position checkpoints are unaffected.

Best Practices

  • Back up deltaforge.db regularly - it contains both checkpoints and schema history
  • Monitor checkpoint lag to detect stuck pipelines
  • Use smaller batch sizes for more frequent checkpoints at the cost of throughput
  • Test recovery by killing the process and verifying no events are lost or duplicated

Storage

DeltaForge uses a unified storage layer that backs all runtime state: checkpoints, schema registry, FSM state, leases, and quarantine queues. A single StorageBackend instance is shared across subsystems, so there is one operational concern instead of many.

Backends

BackendPersistenceUse Case
MemoryStorageBackendNone (lost on restart)Testing, ephemeral deployments
SqliteStorageBackendSQLite file on diskSingle-instance production
PostgresStorageBackendExternal databaseHA, multi-instance deployments

Configuration

The backend is selected via CLI flags:

# SQLite (default for production)
deltaforge --config pipeline.yaml --storage-backend sqlite --storage-path ./data/deltaforge.db

# In-memory (testing only — all state lost on restart)
deltaforge --config pipeline.yaml --storage-backend memory

Or via the config file:

storage:
  backend: sqlite
  path: ./data/deltaforge.db

# or:
storage:
  backend: postgres
  dsn: "host=localhost dbname=deltaforge_storage user=df password=secret"

The SQLite backend creates the database file and parent directories automatically on first start. The PostgreSQL backend runs schema migrations on first connect - no manual table creation is needed.

PostgreSQL backend is implemented and available under the postgres feature flag, but has not yet received the same chaos/recovery validation as the SQLite backend. Treat it as beta for production use.

Primitives

The StorageBackend trait exposes four primitives. All operations are namespaced - different subsystems share the same backend without key collisions.

KV (Key-Value with optional TTL)

General-purpose persistent key-value store. Used for checkpoints, FSM state, and leases.

#![allow(unused)]
fn main() {
backend.kv_put("checkpoints", "mysql-pipe1", &bytes).await?;
backend.kv_put_with_ttl("leases", "pipe1", b"alive", ttl_secs).await?;
backend.kv_get("checkpoints", "mysql-pipe1").await?;
backend.kv_delete("checkpoints", "mysql-pipe1").await?;
backend.kv_list("checkpoints", Some("mysql-")).await?; // prefix filter
}

Log (Append-only, globally sequenced)

Immutable append-only log with a global monotonic sequence counter. Used for the schema registry. The sequence is global across all keys in the namespace - interleaved appends to different keys produce strictly increasing sequence numbers.

#![allow(unused)]
fn main() {
let seq = backend.log_append("schemas", "acme/shop/orders", &entry).await?;
let all  = backend.log_list("schemas", "acme/shop/orders").await?;
let tail = backend.log_since("schemas", "acme/shop/orders", since_seq).await?;
}

Slot (Versioned, compare-and-swap)

Single versioned value with CAS semantics. Used for snapshot cursors and leader election. Concurrent CAS operations with the same expected version are serialized - exactly one wins.

#![allow(unused)]
fn main() {
let ver = backend.slot_upsert("snapshots", "pipe/orders", &state).await?;
let won = backend.slot_cas("snapshots", "pipe/orders", ver, &new_state).await?;
let cur = backend.slot_get("snapshots", "pipe/orders").await?; // (version, bytes)
}

Queue (Ordered, ack-based)

Ordered queue with explicit acknowledgement. Used for quarantine and DLQ. Items are not removed until acked; oldest items can be dropped when the queue is full.

#![allow(unused)]
fn main() {
let id = backend.queue_push("quarantine", "pipe/orders", &event_bytes).await?;
let items = backend.queue_peek("quarantine", "pipe/orders", 10).await?;
backend.queue_ack("quarantine", "pipe/orders", id).await?;
backend.queue_drop_oldest("quarantine", "pipe/orders", n).await?;
let len = backend.queue_len("quarantine", "pipe/orders").await?;
}

Namespace Map

Each subsystem owns a fixed namespace. Keys within a namespace never collide across subsystems.

NamespacePrimitiveUsed byKey format
checkpointsKVBackendCheckpointStore{source-id}
schemasLogDurableSchemaRegistry{tenant}/{db}/{table}
schemasKVDurableSchemaRegistry{tenant}/{db}/{table} (index)
snapshotsSlotSnapshot/backfill (planned){pipeline}/{table}
fsmKVFSM state (planned){pipeline}
leasesKV + TTLLeader election (planned){pipeline}
quarantineQueueQuarantine (planned){pipeline}/{table}
dlqQueueDead-letter queue (planned){pipeline}

Adapters

Existing pipeline code uses higher-level interfaces - it never touches the StorageBackend directly.

BackendCheckpointStore

Implements the CheckpointStore trait on top of KV. Drop-in replacement for the old FileCheckpointStore and SqliteCheckpointStore.

#![allow(unused)]
fn main() {
let store = BackendCheckpointStore::new(Arc::clone(&backend));
// CheckpointStore trait methods work unchanged
store.put_raw("mysql-pipe1", &bytes).await?;
store.get_raw("mysql-pipe1").await?;
}

DurableSchemaRegistry

Implements the schema registry on top of the Log primitive. On startup it replays the log to populate an in-memory cache, so hot-path reads are identical in performance to the old InMemoryRegistry. Writes go through the log, enabling cold-start reconstruction.

#![allow(unused)]
fn main() {
// Production: async construction with log replay
let registry = DurableSchemaRegistry::new(Arc::clone(&backend)).await?;

// Tests: sync construction, no replay (MemoryStorageBackend, empty log)
let registry = DurableSchemaRegistry::for_testing();
}

PipelineManager Wiring

PipelineManager::with_backend() wires both subsystems from a single backend:

#![allow(unused)]
fn main() {
let backend: ArcStorageBackend = SqliteStorageBackend::open("./data/deltaforge.db")?;
let manager = PipelineManager::with_backend(backend).await?;
}

Internally this creates a BackendCheckpointStore and a DurableSchemaRegistry from the same backend instance. All pipelines share the same storage file.

Schema Registry

DeltaForge’s schema registry tracks table schemas across time, enabling accurate event interpretation during replay and providing change detection for DDL operations.

Design Philosophy: Source-Owned Schemas

DeltaForge takes a fundamentally different approach to schema handling than many CDC tools. Rather than normalizing all database schemas into a universal type system, each source defines and owns its schema semantics.

This means:

  • MySQL schemas capture MySQL semantics - column types like bigint(20) unsigned, varchar(255), and json are preserved exactly as MySQL defines them
  • PostgreSQL schemas capture PostgreSQL semantics - arrays, custom types, and pg-specific attributes remain intact
  • No lossy normalization - you don’t lose precision or database-specific information by forcing everything into a common format

This design avoids the maintenance burden of keeping a universal type system synchronized across all databases, and it ensures that downstream consumers receive schemas that accurately reflect the source database’s capabilities and constraints.

The SourceSchema Trait

Every CDC source implements the SourceSchema trait, which provides a common interface for fingerprinting and column access while allowing source-specific schema representations:

#![allow(unused)]
fn main() {
pub trait SourceSchema: Serialize + DeserializeOwned + Send + Sync {
    /// Source type identifier (e.g., "mysql", "postgres").
    fn source_kind(&self) -> &'static str;

    /// Content-addressable fingerprint for change detection.
    /// Two schemas with the same fingerprint are identical.
    fn fingerprint(&self) -> String;

    /// Column/field names in ordinal order.
    fn column_names(&self) -> Vec<&str>;

    /// Primary key column names.
    fn primary_key(&self) -> Vec<&str>;

    /// Human-readable description.
    fn describe(&self) -> String;
}
}

Fingerprinting

Schema fingerprints use SHA-256 hashing over JSON-serialized content to provide:

  • Stability - the same schema always produces the same fingerprint
  • Change detection - any structural change produces a different fingerprint
  • Content-addressability - fingerprints can be used as cache keys or deduplication identifiers
#![allow(unused)]
fn main() {
pub fn compute_fingerprint<T: Serialize>(value: &T) -> String {
    let json = serde_json::to_vec(value).unwrap_or_default();
    let hash = Sha256::digest(&json);
    format!("sha256:{}", hex::encode(hash))
}
}

The fingerprint only includes structurally significant fields. For MySQL, this means columns and primary key are included, but engine and charset are excluded since they don’t affect how CDC events should be interpreted.

For PostgreSQL, the columns (including each column’s type OID and type modifier, pg_attribute.atttypmod) and the primary key are included. The type modifier was added in this release: after upgrading, each PostgreSQL table with a type-modified column (for example varchar(n), numeric(p,s) or timestamp(p)) registers one new schema version the first time its schema is loaded. Versions stored before the upgrade still load unchanged.

MySQL Schema Implementation

The MySqlTableSchema struct captures comprehensive MySQL table metadata:

#![allow(unused)]
fn main() {
pub struct MySqlTableSchema {
    /// Columns in ordinal order
    pub columns: Vec<MySqlColumn>,
    
    /// Primary key column names
    pub primary_key: Vec<String>,
    
    /// Storage engine (InnoDB, MyISAM, etc.)
    pub engine: Option<String>,
    
    /// Default charset
    pub charset: Option<String>,
    
    /// Default collation
    pub collation: Option<String>,
}

pub struct MySqlColumn {
    pub name: String,
    pub column_type: String,      // e.g., "bigint(20) unsigned"
    pub data_type: String,        // e.g., "bigint"
    pub nullable: bool,
    pub ordinal_position: u32,
    pub default_value: Option<String>,
    pub extra: Option<String>,    // e.g., "auto_increment"
    pub comment: Option<String>,
    pub char_max_length: Option<i64>,
    pub numeric_precision: Option<i64>,
    pub numeric_scale: Option<i64>,
}
}

Schema information is fetched from INFORMATION_SCHEMA at startup and cached for the pipeline’s lifetime.

Schema Registry Architecture

The schema registry serves three core functions:

  1. Version tracking - maintains a history of schema versions per table
  2. Change detection - compares fingerprints to detect DDL changes
  3. Replay correlation - associates schemas with checkpoint positions for accurate replay

Schema Versions

Each registered schema version includes:

FieldDescription
versionPer-table version number (starts at 1)
hashContent fingerprint for deduplication
schema_jsonFull schema as JSON
registered_atRegistration timestamp
sequenceGlobal monotonic sequence number
checkpointSource checkpoint at registration time

Sequence Numbers for Replay

The registry maintains a global monotonic sequence counter. When a schema is registered, it receives the next sequence number. Events carry this sequence number, enabling the replay engine to look up the correct schema version:

#![allow(unused)]
fn main() {
// During replay: find schema active at event's sequence
let schema = registry.get_at_sequence(tenant, db, table, event.schema_sequence);
}

This ensures events are always interpreted with the schema that was active when they were produced, even if the table structure has since changed.

Checkpoint Correlation

Schemas can be registered with an associated checkpoint, creating a correlation between schema versions and source positions:

#![allow(unused)]
fn main() {
registry.register_with_checkpoint(
    tenant, db, table, 
    &fingerprint, 
    &schema_json,
    Some(checkpoint_bytes),  // Optional: binlog position when schema was observed
).await?;
}

This correlation supports scenarios like:

  • Replaying events from a specific checkpoint with the correct schema
  • Determining which schema was active at a particular binlog position
  • Rolling back schema state along with checkpoint rollback

Schema Loader

The MySqlSchemaLoader handles schema discovery and caching:

Pattern Expansion

Tables are specified using patterns that support wildcards:

PatternDescription
db.tableExact match
db.*All tables in database
db.prefix% / db.prefix*Tables starting with prefix (the wildcard counts only as the last character; _ is literal)
%.table / tableTable in any database

The initial snapshot expands patterns to exactly the tables CDC captures.

Resolution at first use

A CDC start does not enumerate the configured tables or load their schemas: each table is resolved when it is first used (its first Relation message in PostgreSQL, its first rows in MySQL), from durable history or the live catalog, and registered. Only an initial snapshot expands the table patterns and loads the tables it copies. Concurrent first uses of one table share one load.

Caching

Resolved schemas are cached per source. The resident schemas are capped at 4,096 tables or about 64 MiB of serialized schema per source, whichever is reached first (a fixed limit, not configurable); beyond it the least recently used are evicted (deltaforge_source_schema_cache_evictions_total{tenant, source_id, engine}). Eviction never changes what a table resolves to: the source keeps a compact record (version, sequence, fingerprint) of every table resolved since it started or last changed lineage, and an evicted table is rebuilt from durable history as exactly that version, with no catalog query. These records are not bounded and are not part of the 64 MiB. If that version cannot be read back exactly, the source stops with a schema error rather than resolving the table again. Only an explicit reload (or a DDL of the table) resolves it again.

DDL Handling

Each DDL statement is attributed once, from its text and the event’s session database, to the objects it acts on, and that one attribution drives everything:

DDLSchema cacheRegistry
CREATE TABLE, ALTER TABLE, CREATE/DROP INDEX of a tracked tablethe table’s cached shape is forgottenits new version is registered only by a positional proof (captured after the DDL and proven unchanged since)
DROP TABLE, the source of RENAME TABLEforgotten, never reloadednothing registered
TRUNCATEunchanged (same shape)nothing registered
DROP DATABASE, ALTER DATABASE (database barrier)every cached table of that database forgottenlater rows need new proof
Statements that cannot be attributed (lineage barrier)the whole cache forgottenlater rows need new proof
CREATE VIEW, GRANT, account statementsunchangednothing registered

A DDL never reloads a schema from the live catalog and never touches a table it does not name. A qualified name (db.t) is attributed to db whatever database the session has active; the session database only resolves unqualified names.

API Endpoints

Reload Schemas

Force reload schemas from the database:

curl -X POST http://localhost:8080/pipelines/{name}/schemas/reload

This re-fetches and re-registers the schemas of the tables currently in use (cached), narrowed by the pipeline’s table patterns. It never enumerates the catalog: other tables load on their next use. Concurrent first uses of a table share one load.

List Cached Schemas

View currently cached schemas:

curl http://localhost:8080/pipelines/{name}/schemas

Limitations

  • Durable by default - Schema versions are persisted via the storage backend’s Log primitive (SQLite by default) and replayed on restart, so they survive process restarts. A pure in-memory backend is also available for tests.
  • No cross-pipeline sharing - Each pipeline maintains its own registry instance
  • Pattern expansion at startup - New tables matching patterns require pipeline restart or reload

Best Practices

  1. Use explicit table patterns in production to avoid accidentally capturing unwanted tables
  2. Monitor schema reload times - slow reloads may indicate overly broad patterns
  3. Trigger schema reload after DDL if your deployment process modifies schemas
  4. Include schema version in downstream events for consumers that need schema evolution awareness

Troubleshooting

Unknown table_id Errors

WARN write_rows for unknown table_id, table_id=42

The binlog contains row events for a table not in the table_map. This happens when:

  • A table was created after the CDC stream started
  • Table patterns don’t match the table

Solution: Trigger a schema reload via the REST API.

Schema Fetch Returned 0 Columns

WARN schema fetch returned 0 columns, db=shop, table=orders

Usually indicates:

  • Table doesn’t exist
  • MySQL user lacks SELECT privilege on INFORMATION_SCHEMA
  • Table was dropped between detection and schema load

Slow Schema Loading

WARN slow schema fetch, db=shop, table=orders, ms=350

Consider:

  • Narrowing table patterns to reduce the number of tables
  • Using exact table names instead of wildcards
  • Verifying network latency to the MySQL server

Confluent Schema Registry Integration

DeltaForge supports the Confluent Schema Registry as the external schema store for Avro encoding. This is separate from DeltaForge’s internal schema registry (described above) which tracks source table schemas.

How it works

When a sink is configured with encoding: { type: avro, schema_registry_url: "..." }:

  1. Schema derivation: DeltaForge derives an Avro schema from the first event’s JSON structure
  2. Registration: The schema is registered with the Confluent Schema Registry (idempotent — same schema returns the same ID)
  3. Caching: The schema ID is cached per subject, so subsequent events skip the HTTP call
  4. Wire format: Events are encoded as [0x00][schema_id:4][avro_binary] (Confluent wire format)

Relationship to internal schema registry

AspectDeltaForge Internal RegistryConfluent Schema Registry
PurposeTrack source table schema evolutionStore Avro schemas for consumers
SchemasSource-owned (MySQL/Postgres types)Avro schemas (derived from event structure)
StorageEmbedded (SQLite/Postgres)External HTTP service
Used bySchema sensing, replay correlationKafka Connect, ksqlDB, Flink

The two registries serve complementary purposes — the internal registry tracks what the source looks like, while the Confluent registry stores what consumers need to decode the wire format.

Configuration

See the Envelopes and Encodings page for full Avro encoding configuration, including subject naming strategies and authentication.

Schema Sensing

Schema sensing automatically infers and tracks schema structure from JSON event payloads. This complements the schema registry by discovering schema from data rather than database metadata.

When to Use Schema Sensing

Schema sensing is useful when:

  • Source doesn’t provide schema: Some sources emit JSON without metadata
  • JSON columns: Database JSON/JSONB columns have dynamic structure
  • Schema evolution tracking: Detect when payload structure changes over time
  • Downstream integration: Generate JSON Schema for consumers
  • Dynamic map keys: Session IDs, trace IDs, or other high-cardinality keys in JSON

How It Works

┌──────────────┐     ┌─────────────────┐     ┌──────────────────┐
│    Event     │────▶│  Schema Sensor  │────▶│  Inferred Schema │
│   Payload    │     │   (sampling)    │     │   + Fingerprint  │
└──────────────┘     └─────────────────┘     └──────────────────┘
                              │
                              ▼
                     ┌─────────────────┐
                     │ Structure Cache │
                     │ + HC Classifier │
                     └─────────────────┘
  1. Observation: Events flow through the sensor during batch processing
  2. Sampling: Not every event is fully analyzed (configurable rate)
  3. Deep inspection: Nested JSON structures are recursively analyzed
  4. High-cardinality detection: Dynamic map keys are classified and normalized
  5. Fingerprinting: Schema changes are detected via SHA-256 fingerprints
  6. Caching: Repeated structures skip full analysis for performance

High-Cardinality Key Handling

JSON payloads often contain dynamic keys like session IDs, trace IDs, or user-generated identifiers:

{
  "id": 1,
  "sessions": {
    "sess_abc123": {"user_id": 42, "started_at": 1700000000},
    "sess_xyz789": {"user_id": 43, "started_at": 1700000001}
  }
}

Without special handling, each unique key (sess_abc123, sess_xyz789) triggers a “schema evolution” event, causing:

  • 0% cache hit rate
  • Constant false evolution alerts
  • Unbounded schema growth

How It Works

DeltaForge uses probabilistic data structures (HyperLogLog, SpaceSaving) to classify fields:

ClassificationDescriptionExample
Stable fieldsAppear in most eventsid, type, timestamp
Dynamic fieldsUnique per event, high cardinalitysess_*, trace_*, uuid_*

When dynamic fields are detected, the schema sensor:

  1. Normalizes keys: Replaces sess_abc123 with <dynamic> placeholder
  2. Uses adaptive hashing: Structure cache ignores dynamic key names
  3. Produces stable fingerprints: Same schema despite different keys

Results

ScenarioWithout HCWith HC
Nested dynamic keys100% evolution rate<1% evolution rate
Top-level dynamic keys0% cache hits>99% cache hits
Stable structsBaseline~20% overhead during warmup, then ~0%

Configuration

Example

spec:
  schema_sensing:
    enabled: true
    
    deep_inspect:
      enabled: true
      max_depth: 3
      max_sample_size: 500
    
    sampling:
      warmup_events: 50
      sample_rate: 5
      structure_cache: true
      structure_cache_size: 50
    
    high_cardinality:
      enabled: true
      min_events: 100
      stable_threshold: 0.5
      min_dynamic_fields: 5
      confidence_threshold: 0.7
      reevaluate_interval: 10000

Options

FieldTypeDefaultDescription
enabledboolfalseEnable schema sensing
deep_inspect
enabledbooltrueInspect nested JSON
max_depthint10Max nesting depth
max_sample_sizeint1000Max events for deep analysis
sampling
warmup_eventsint1000Full analysis before sampling
sample_rateint10After warmup, analyze 1 in N
structure_cachebooltrueCache structure hashes
structure_cache_sizeint100Max cached per table
high_cardinality
enabledbooltrueDetect dynamic map keys
min_eventsint100Events before classification
stable_thresholdfloat0.5Frequency for stable fields
min_dynamic_fieldsint5Min unique for map detection
confidence_thresholdfloat0.7Required confidence
reevaluate_intervalint10000Re-check interval (0=never)

Inferred Types

Schema sensing infers these JSON types:

TypeDescription
nullJSON null value
booleantrue/false
integerWhole numbers
numberFloating point numbers
stringText values
arrayJSON arrays (element types tracked)
objectNested objects (fields recursively analyzed)

For fields with varying types across events, all observed types are recorded.

Schema Evolution

When schema structure changes, the sensor:

  1. Detects change: Fingerprint differs from previous version
  2. Increments sequence: Monotonic version number increases
  3. Logs evolution: Emits structured log with old/new fingerprints
  4. Updates cache: New structure becomes current

Evolution events are available via the REST API and can trigger alerts.

Stabilization

After observing enough events, a schema “stabilizes”:

  • Warmup phase completes
  • Structure stops changing
  • Sampling rate takes effect
  • Cache hit rate increases

Stabilized schemas have stabilized: true in API responses.

API Access

List Inferred Schemas

curl http://localhost:8080/pipelines/my-pipeline/sensing/schemas

Get Schema Details

curl http://localhost:8080/pipelines/my-pipeline/sensing/schemas/orders

Export as JSON Schema

curl http://localhost:8080/pipelines/my-pipeline/sensing/schemas/orders/json-schema

Cache Statistics

curl http://localhost:8080/pipelines/my-pipeline/sensing/stats

Dynamic Map Classifications

curl http://localhost:8080/pipelines/my-pipeline/sensing/schemas/orders/classifications

Response:

{
  "table": "orders",
  "paths": {
    "": {"stable_fields": ["id", "type", "timestamp"], "has_dynamic_fields": false},
    "sessions": {"stable_fields": [], "has_dynamic_fields": true, "unique_keys": 1523},
    "metadata": {"stable_fields": ["version"], "has_dynamic_fields": true, "unique_keys": 42}
  }
}

Drift Detection

Schema sensing integrates with drift detection to compare:

  • Expected schema: From database metadata (schema registry)
  • Observed schema: From event payloads (schema sensing)

When mismatches occur, drift events are recorded:

Drift TypeDescription
unexpected_nullNon-nullable column has null values
type_mismatchObserved type differs from declared type
undeclared_columnField in data not in schema
missing_columnSchema field never seen in data
json_structure_changeJSON column structure changed

Access drift data via:

curl http://localhost:8080/pipelines/my-pipeline/drift

Performance Considerations

Sampling Tradeoffs

SettingEffect
Higher warmup_eventsBetter initial accuracy, slower stabilization
Higher sample_rateLower CPU usage, slower evolution detection
Larger structure_cache_sizeMore memory, better hit rate

High-throughput pipelines (>10k events/sec):

sampling:
  warmup_events: 100
  sample_rate: 10
  structure_cache: true
  structure_cache_size: 100
high_cardinality:
  enabled: true
  min_events: 200

Schema evolution monitoring:

sampling:
  warmup_events: 25
  sample_rate: 2
  structure_cache: true
high_cardinality:
  enabled: true
  min_events: 50

Payloads with dynamic keys (session stores, feature flags):

sampling:
  structure_cache: true
  structure_cache_size: 50
high_cardinality:
  enabled: true
  min_events: 100
  min_dynamic_fields: 3
  stable_threshold: 0.5

Development/debugging:

sampling:
  warmup_events: 10
  sample_rate: 1  # Analyze every event
high_cardinality:
  enabled: true
  min_events: 20  # Faster classification

Example: JSON Column Sensing

For tables with JSON columns, sensing reveals the internal structure:

# Database schema shows: metadata JSON
# Sensing reveals:
fields:
  - name: "metadata.user_agent"
    types: ["string"]
    nullable: false
  - name: "metadata.ip_address"
    types: ["string"]
    nullable: true
  - name: "metadata.tags"
    types: ["array"]
    array_element_types: ["string"]

This enables downstream consumers to understand JSON column structure without manual documentation.

Metrics

Schema sensing emits these Prometheus metrics:

MetricTypeLabelsDescription
deltaforge_schema_events_totalCounterpipelineTotal events observed
deltaforge_schema_sensing_cache_hits_totalCounterpipelineStructure cache hits
deltaforge_schema_sensing_cache_misses_totalCounterpipelineStructure cache misses
deltaforge_schema_evolutions_totalCounterpipelineSchema evolutions detected
deltaforge_schema_tables_totalGaugepipelineTables with detected schemas
deltaforge_schema_dynamic_maps_totalGaugepipelinePaths classified as dynamic maps
deltaforge_schema_sensing_secondsHistogrampipelinePer-event sensing latency

With per-table detail enabled, the counters and the histogram also carry table and table_scope (bounded by metrics.per_table.max_tables).

Example Queries

# Cache hit rate per pipeline
sum(rate(deltaforge_schema_sensing_cache_hits_total[5m])) by (pipeline)
/
sum(rate(deltaforge_schema_events_total[5m])) by (pipeline)

# Schema evolution rate (should be near zero after warmup)
sum(rate(deltaforge_schema_evolutions_total[5m])) by (pipeline)

# P99 sensing latency
histogram_quantile(0.99, rate(deltaforge_schema_sensing_seconds_bucket[5m]))

Failover Handling

DeltaForge detects a change of database server automatically. A MySQL source resumes streaming on the new primary without operator intervention when it can prove the position to continue from. A PostgreSQL source never resumes on another cluster: it stops before reading anything from it (see PostgreSQL: another cluster is refused). After a promotion of a physical standby it continues only when the promoted server provably continues its checkpoint (see PostgreSQL: promotion continuity). This page explains how detection works, what happens during MySQL reconciliation, and how to configure behaviour when the new primary has a different schema.

How Detection Works

Every time the source reconnects - at startup or after a transient error - it queries the server’s stable identity:

  • MySQL: @@server_uuid from performance_schema.replication_group_members
  • PostgreSQL: system_identifier from pg_control_system(), together with the database OID

The result is compared against the value stored in DeltaForge’s storage backend. Three outcomes are possible:

ResultMeaningAction
FirstSeenNo identity stored yetStore and continue
SameSame server as beforeVerify checkpoint GTID is still reachable, then continue
ChangedServer identity differsMySQL: run failover reconciliation. PostgreSQL: stop (refused)

Identity is written to the durable storage backend (SQLite or PostgreSQL), so it survives process restarts and is correctly preserved across pipeline reloads.

PostgreSQL: another cluster is refused

A PostgreSQL checkpoint is an LSN in one cluster’s WAL history, and a replication slot is a position in that same history. A server with another system_identifier (an independently initialised cluster, a logical replica, a dump/restore), or the same cluster with a dropped and recreated database, has unrelated positions: an LSN that compares equal or larger there proves nothing, and resuming from it could silently skip changes.

So the PostgreSQL source compares the live system_identifier and database OID with the ones it recorded, before it writes any lineage or identity state, takes a snapshot, creates or reads a slot position, or sends START_REPLICATION: at startup, again right before the stream opens, and before every reconnect. A difference stops the source with a typed lineage error, and nothing is streamed, snapshotted or recorded:

source lineage error: source '<id>' is bound to PostgreSQL ... but is connected to .... Its checkpoint and replication slot position belong to the first server's WAL history and are never resumed on another server; nothing was streamed, snapshotted or recorded. To capture this server, configure a new source id (it starts with a snapshot).

To capture the other cluster, configure a new source id; it starts with a snapshot.

This is a cross-cluster check. A promoted physical standby keeps the cluster’s system_identifier; continuing on it is decided by the continuity proof below.

PostgreSQL: promotion continuity

Before every START_REPLICATION (startup, reconnect and credential rotation), the source proves on the replication session that then streams - with IDENTIFY_SYSTEM, TIMELINE_HISTORY and a read of the slot on that same authenticated session - that the server continues its durable checkpoint F. All of these must hold; anything unknown stops the source:

  1. the same system_identifier and database OID, on a primary (not a server in recovery);
  2. the server is on the timeline F was written on, or on a timeline that descends from it, with that timeline’s switch point at or after F and at or after the position the stream reads from (changes already read past the switch point are not in the new history);
  3. after a timeline switch: PostgreSQL 17 or later, and the slot is a synchronized failover slot (failover and synced true). A slot of the same name is not enough;
  4. the slot is persistent and logical, not invalidated, its WAL is not lost, and restart_lsn and confirmed_flush_lsn are at or before F;
  5. the server’s WAL extends at least to F.

When a stream becomes authoritative (before its first event is consumed), its proven continuity is recorded durably: a chain id created once for the source, the number of proven timeline transitions in that chain, and the timeline. Every checkpoint carries this stamp. Timeline numbers alone prove no ancestry (two standbys promoted from the same primary get sibling timelines), so checkpoints are ordered only within one chain: by LSN within a transition, by the proven transition across transitions; checkpoints of different chains, or one transition stamped with two timelines, are incomparable. A checkpoint from before this release orders only with others like it and with the first link of a chain; while the chain is at its first link, every start adopts all of the source’s checkpoints (each sink’s, or the single aggregate one) into the chain before anything is consumed, keeping their positions, so no later transition meets an unstamped checkpoint. A checkpoint that cannot be adopted (another chain or transition, a partial stamp, a malformed checkpoint) stops the source (checkpoint_chain_mismatch) with nothing rewritten. A credential rotation never crosses a transition: a replacement stream that would change the stamp is refused before anything is recorded, and the ordinary reconnect that follows proves the transition. A failed proof stops the source with a pg_continuity_unproven incident whose class names the condition: timeline_not_descended, switch_before_checkpoint, switch_before_read_position, checkpoint_chain_mismatch, slot_not_synced, failover_unsupported_version, timeline_unrecorded, server_in_recovery, wal_behind_checkpoint, slot_not_persistent_logical, slot_beyond_checkpoint, slot_missing, slot_invalidated, wal_lost or unknown_slot_position. Nothing is streamed and the checkpoint is not moved.

A session on a server in recovery (server_in_recovery) is retried like a connection failure for up to 2 minutes, since the endpoint may be mid-failover (an open auto_retry incident; stopping the pipeline withdraws it), then the source stops on the same incident as operator action: route the source to the writable primary. A promoted server (no longer in recovery) continues only after the timeline and synchronized-slot proof above succeeds.

Upgrade limitation (timeline_unrecorded). Sources created before this release have checkpoints but no recorded timeline. On a server that has never switched timeline (timeline 1) the timeline is recorded automatically at the first start. On a server that has switched timeline (any earlier promotion or point-in-time recovery), the source stops with timeline_unrecorded: nothing on the server distinguishes that history from another one. Recover with the pg-adopt-timeline recovery operation, which records the current timeline for those checkpoints without moving them (after verifying the endpoint, slot and checkpoint position), or with resnapshot. Check SELECT timeline_id FROM pg_control_checkpoint() before upgrading.

HA setup (PostgreSQL 17)

Continuation across a promotion needs PostgreSQL 17 logical slot synchronization:

  • DeltaForge creates its slot as a failover slot (failover => true) on PostgreSQL 17. A pre-existing slot without it is reported as a running-degraded pg_failover_slot_unavailable incident: streaming continues on the current primary, but a promotion stops the source (slot_not_synced). Recreate the slot as a failover slot (or ALTER_REPLICATION_SLOT ... (FAILOVER true) on a replication connection) to clear it.
  • Standby: sync_replication_slots = on, hot_standby_feedback = on, primary_slot_name set to a physical slot on the primary, and a dbname in primary_conninfo.
  • Primary: synchronized_standby_slots naming that physical slot, so logical changes are sent to DeltaForge only after the standby has received them. Without it, a promotion can lose changes DeltaForge already read from the old primary, and the source then stops with switch_before_read_position.
  • HA tools that recreate slots on the promoted server (for example Patroni permanent_slots without PostgreSQL 17 synchronization) produce slots that are not synced; they are refused after a promotion.

Before PostgreSQL 17, a timeline switch always stops the source (failover_unsupported_version). PostgreSQL 17 is the validated version.

Unsupported (documented residual): a filesystem-level rewind on the same timeline that later diverges cannot be detected; nothing on the server distinguishes it from the original history.

What Happens During Failover (MySQL)

When a Changed identity is detected, DeltaForge runs reconciliation before allowing any events to flow. Reconciliation is idempotent - if the process dies mid-run, it will re-execute correctly on the next startup.

1. Position reachability check

DeltaForge verifies that the checkpoint position from the old primary still exists on the new primary:

  • MySQL: checks whether the GTID set from the last checkpoint is present in B’s executed GTID history or purged range
  • PostgreSQL (same server only, on every resume): checks that the replication slot exists, is not invalidated and has not lost its WAL

If the position is confirmed lost, the source stops immediately with an error and /health returns 503. This covers two distinct cases:

  • Server changed (Changed): B’s GTID history does not contain A’s checkpoint (e.g. B was a lagging replica).
  • Same server, history wiped (Same): RESET BINARY LOGS AND GTIDS was run on the same server, clearing all GTID state without changing the server UUID. DeltaForge detects this on the first reconnect by checking GTID_SUBSET(checkpoint, @@gtid_executed).

In both cases the error message is:

position lost: <reason>. Re-snapshot required.

Silently skipping data is worse than halting. Restart the pipeline with a fresh snapshot to recover.

If reachability cannot be determined (e.g. the health query fails transiently), DeltaForge logs a warning and continues — it does not halt on uncertainty.

2. Schema drift detection

DeltaForge compares each table’s schema last registered under the old primary with its schema on the new primary.

Per table, when the table is first used on the new primary - its first rows, a DDL of it, or a snapshot of it - not when the connection is re-established. Nothing is enumerated, so tables matched by wildcard patterns are covered. The comparison is against the table’s shape at the exact failover position, proven by capturing the table and scanning the new primary’s binlog. If anything between the failover position and the table’s first rows could have changed the table (a DDL of the table, a statement DeltaForge cannot attribute to tables, a purged interval), or the table’s first event is itself a DDL, the comparison is unprovable. The outcome is recorded durably per table, so a completed check is not repeated.

3. Resume

After reconciliation, DeltaForge stores B’s identity and resumes streaming. The first events from B use the updated schema.

Position Adjustment

A subtle but critical detail: simply reconnecting at A’s checkpoint position can cause data loss on its own, before reconciliation even runs.

MySQL: DeltaForge detects the identity change before opening the binlog stream and continues on B only from the exact position it proved: the GTID set the stream resumes from, which B must have executed in full (checked on a connection verified to be B). It never skips to B’s binlog tail. If B has not executed that set, or the source runs without GTID mode (binlog file positions are not comparable across servers), the source stops with a typed error and changes nothing; re-snapshot from B or restore the missing transactions. That proven position is also the failover position the per-table schema drift checks are anchored to.

PostgreSQL: no position is adjusted. Another cluster is refused before START_REPLICATION is sent to it (see above), so neither the checkpoint nor any slot on that server moves.

Schema Drift Policy

By default DeltaForge adapts to the new primary’s schema and continues streaming. This is safe for additive drift (B has a new column A didn’t have) but can be risky if B is missing columns that A had - row events encoded against A’s schema may decode incorrectly against B’s.

The on_schema_drift field controls this behaviour:

source:
  type: mysql
  config:
    id: my-pipeline
    dsn: ${MYSQL_DSN}
    tables: [shop.orders]
    on_schema_drift: halt   # default: adapt
ValueBehaviour
adaptRecord drift, use the new primary’s schema, continue streaming. Default. On MySQL an unprovable comparison is recorded as such and normal schema proof continues.
haltStop the source when schema drift is detected (on MySQL also when drift cannot be ruled out), before anything is registered or emitted for the table. Requires operator intervention.

On MySQL the source stops at the table’s first event on the new primary, with an error naming the table:

table shop.orders after failover: schema drift since the failover (1 change(s)) and on_schema_drift=halt.
Nothing was registered or emitted. ...

On PostgreSQL a cross-cluster change stops the source regardless of on_schema_drift; the policy applies to schema changes in the stream (and to a change made while the source was down, at each table’s first Relation message).

Use halt when your failover environments do not guarantee DDL sync to replicas before promotion.

What DeltaForge Does Not Handle

DSN switching is external. DeltaForge detects a new server by comparing identities, not by monitoring cluster topology. The DSN must already point to B before the pipeline reconnects - this is typically handled by a load balancer VIP, DNS failover, or connection proxy. If the DSN still resolves to A, the pipeline will retry A’s dead connection rather than discovering B.

Data loss from replica lag is not recoverable. If B was a lagging replica and never received transactions that A committed before failing, those rows are gone at the database level. DeltaForge can detect the position gap but cannot reconstruct missing data. A re-snapshot from B is required in this case.

Mid-flight DDL during active streaming is handled separately by the normal schema reload mechanism, not by failover reconciliation. Failover reconciliation only runs when the server identity changes.

Infrastructure Requirements

For clean automatic failover:

  • MySQL: GTID mode must be enabled (gtid_mode=ON, enforce_gtid_consistency=ON). Without GTID, DeltaForge falls back to file/position coordinates which are meaningless across servers.
  • PostgreSQL: continuation on another cluster is not supported (it is refused). After a promotion of a physical standby the source continues only when promotion continuity is proven, which needs PostgreSQL 17 synchronized failover slots.
  • MySQL: the CDC user must exist on B with the same privileges as on A.

Deployment

Docker

docker run --rm \
  -e MYSQL_USER=cdc_user \
  -e MYSQL_PASSWORD=s3cret \
  -v $(pwd)/pipeline.yaml:/etc/deltaforge/pipeline.yaml:ro \
  ghcr.io/vnvo/deltaforge:latest \
  --config /etc/deltaforge/pipeline.yaml

Docker Compose

See the chaos testing environment for a full Docker Compose example with MySQL, Kafka, Prometheus, Grafana, and Loki.

Kubernetes (Helm)

Install

helm install deltaforge ./deploy/helm/deltaforge \
  --set secrets.create=true \
  --set secrets.data.MYSQL_USER=cdc_user \
  --set secrets.data.MYSQL_PASSWORD=s3cret

What it deploys

ResourcePurpose
StatefulSetDeltaForge pod with stable identity
ConfigMapPipeline YAML configuration
PVCPersistent storage for checkpoints + DLQ
ServiceClusterIP with API (8080) and metrics (9000) ports
ServiceAccountPod identity
ServiceMonitorPrometheus Operator integration (optional)
PodDisruptionBudgetAvailability guard during voluntary disruptions
SecretCredentials (optional, dev only)

Secrets

Secrets contain only credentials (username, password, tokens). Connection details (host, port, topic) stay in the pipeline config. Pipeline config uses ${VAR_NAME} shell expansion at startup:

# Pipeline config (in ConfigMap)
dsn: "mysql://${MYSQL_USER}:${MYSQL_PASSWORD}@mysql-primary:3306/orders"
brokers: "kafka:9092"     # not secret — stays in config

For production, create K8s Secrets separately and reference them:

secrets:
  existingSecrets:
    - name: mysql-creds           # keys: MYSQL_USER, MYSQL_PASSWORD
    - name: kafka-sasl-creds      # keys: KAFKA_SASL_USER, KAFKA_SASL_PASSWORD

Health probes

The chart configures liveness and readiness probes automatically:

  • Liveness (/health): returns 200 while the process and its API are alive, whatever the pipelines’ state (a failed or starting pipeline is a readiness concern). Kubernetes restarts the pod only when this fails.
  • Readiness (/ready): returns 200 with pipeline status JSON; 503 while a pipeline is failed, blocked by an incident, held by a recovery, or starting (its capture boundary not yet fixed, including for the whole of an initial snapshot). A 503 removes the pod from the Service’s endpoints and never restarts it: while an initial snapshot runs, the API and metrics are not reachable through the Service (use the pod directly, for example kubectl port-forward). The chart defines no startup probe, so readiness staying false never leads to a restart.

Monitoring

Prometheus annotations are enabled by default (prometheus.io/scrape: "true"). For Prometheus Operator, enable the ServiceMonitor:

serviceMonitor:
  enabled: true
  interval: 15s

Storage

Development/testing: SQLite on a PersistentVolume (default). Simple, no external dependencies.

Production: PostgreSQL is recommended. Benefits:

  • Survives pod rescheduling without PVC migration
  • Proper backup/restore via pg_dump
  • Supports multiple replicas sharing state (future operator/sharding)
  • Better concurrency under high checkpoint commit rates
storage:
  backend: postgres

persistence:
  enabled: false    # no PVC needed with Postgres

secrets:
  existingSecrets:
    - name: deltaforge-storage    # must contain key: STORAGE_DSN

The STORAGE_DSN Secret should contain a PostgreSQL connection string:

postgresql://deltaforge:password@postgres.infra:5432/deltaforge

Create the database and user beforehand:

CREATE USER deltaforge WITH PASSWORD 'password';
CREATE DATABASE deltaforge OWNER deltaforge;

DeltaForge creates its tables automatically on first connection.

Full values reference

See the Helm chart README for all configurable values.

S3 sink prerequisites

If any pipeline uses the S3 sink, the target bucket must have a lifecycle rule that aborts incomplete multipart uploads. DeltaForge does not track multipart upload IDs externally; partial uploads from process crashes are reclaimed by the bucket’s lifecycle policy.

Required policy:

{
  "Rules": [{
    "ID": "abort-abandoned-multiparts",
    "Status": "Enabled",
    "Filter": {},
    "AbortIncompleteMultipartUpload": { "DaysAfterInitiation": 1 }
  }]
}

Apply via AWS CLI:

aws s3api put-bucket-lifecycle-configuration \
  --bucket my-data-lake \
  --lifecycle-configuration file://lifecycle.json

All major S3-compatible providers (AWS S3, MinIO, Wasabi, Backblaze B2, GCS S3-interop, R2) support this rule. Without it, S3 storage costs accumulate with every aborted DeltaForge batch.

IAM permissions

The S3 sink needs s3:PutObject, s3:GetObject, s3:ListBucket, s3:AbortMultipartUpload, and s3:ListBucketMultipartUploads on the target bucket and its key prefix:

{
  "Statement": [{
    "Effect": "Allow",
    "Action": [
      "s3:PutObject",
      "s3:GetObject",
      "s3:ListBucket",
      "s3:AbortMultipartUpload",
      "s3:ListBucketMultipartUploads"
    ],
    "Resource": [
      "arn:aws:s3:::my-data-lake",
      "arn:aws:s3:::my-data-lake/*"
    ]
  }]
}

On EKS, prefer IRSA (IAM Roles for Service Accounts) over inline access keys. DeltaForge’s S3 sink uses the AWS default credential chain when access_key_id is omitted from the sink config, so IRSA works without code changes.

Observability playbook

DeltaForge already ships a Prometheus exporter, structured logging, and a panic hook. The runtime now emits source ingress counters, batching/processor histograms, and sink latency/throughput so operators can build production dashboards immediately. The tables below capture what is wired today and the remaining gaps to make the platform production ready for data and infra engineers.

What exists today

  • Prometheus endpoint served at /metrics with descriptors for pipeline counts, source/sink counters, and a stage latency histogram. The recorder is installed automatically when metrics are enabled.

Metrics endpoint address and exposure

The metrics listener honors --metrics-addr (host:port). It defaults to 0.0.0.0:9000 (all interfaces), which is what the Helm ServiceMonitor and typical cross-pod Prometheus scraping expect.

Exposure. The /metrics endpoint has no authentication. Binding to 0.0.0.0 exposes it on every interface. Where the scraper is local, restrict it to loopback (--metrics-addr 127.0.0.1:9000); otherwise confine it with a firewall, Kubernetes NetworkPolicy, or a private interface. (The /metrics path is also merged onto the REST API port, so the same network restrictions that protect the API apply there too.)

An invalid --metrics-addr (not host:port) or an unavailable one (port already in use) fails startup with a clear error rather than leaving the process running without a metrics endpoint.

  • Structured logging via tracing_subscriber with JSON output by default, optional targets, and support for RUST_LOG overrides.
  • Panic hook increments a deltaforge_panics_total counter and logs captured panics before delegating to the default hook.

Metric cardinality and per-table detail

By default no metric carries a table label: a pipeline’s series count does not depend on how many tables it captures. The metrics that can carry table detail are:

MetricDefault labels
deltaforge_source_events_totalpipeline, source, op
deltaforge_snapshot_rows_totalpipeline
deltaforge_sink_s3_files_committed_totalpipeline, sink, reason
deltaforge_sink_bytes_total (S3 sink)pipeline, sink
deltaforge_schema_events_total, deltaforge_schema_sensing_seconds, deltaforge_schema_sensing_cache_hits_total, deltaforge_schema_sensing_cache_misses_total, deltaforge_schema_evolutions_totalpipeline
deltaforge_source_table_lag_secondsnot emitted

deltaforge_schema_tables_total and deltaforge_schema_dynamic_maps_total carry pipeline (previously no label, so pipelines overwrote each other).

With metrics.per_table.enabled, these metrics also carry table (the qualified name: schema.table on PostgreSQL, database.table on MySQL) and table_scope:

  • The first max_tables distinct tables the pipeline reports get exact series, table="<name>", table_scope="exact". Admission is held per pipeline name for the life of the DeltaForge process: it survives pipeline restarts, disabling and re-enabling per-table detail, and deleting and recreating the pipeline, so a table’s history never moves between its own series and the overflow and the name never has more than max_tables exact tables (the recorder cannot remove a series). Once the pipeline has started with per-table detail (or has table series), max_tables is fixed for the process, whether or not a table has been admitted yet: a create or patch that changes it is refused before anything stops; restart DeltaForge to change it. A deleted pipeline that never recorded table series leaves nothing behind; one that did keeps its admissions (at most max_tables names and a 1 KiB estimate).
  • Every other table shares one overflow series, table="__other__", table_scope="overflow". Select by table_scope, not by the table value: a real table named __other__ keeps table_scope="exact".
  • deltaforge_source_table_lag_seconds is the lag of each table’s latest event in a batch (the overflow series reports the largest among its tables). A table’s series is removed after lag_idle_secs without events for it, and comes back with its next event: a quiet table’s detailed lag disappears. This does not mean DeltaForge detected that the table was dropped or deselected. Deleting the pipeline removes all of its series.

Series count with per-table detail: at most max_tables + 1 table values per metric and remaining label set. Truncation is visible without table labels:

MetricMeaning
deltaforge_metrics_tables_admitted{pipeline}Tables with exact series.
deltaforge_metrics_table_overflow_total{pipeline}Observations reported under the overflow series.
deltaforge_metrics_tables_overflowed{pipeline}Estimated distinct tables reported under the overflow (a fixed 1 KiB HyperLogLog: about 3% standard error, near exact for small counts). Counting them exactly would need memory per table.

To migrate dashboards that relied on per-table series: aggregate away the label (sum without (table) (...)), or enable per-table detail for the pipelines that need it and select table_scope="exact".

Instrumentation gaps and recommendations

The sections below call out concrete metrics and log events to add per component. All metrics should include pipeline, tenant, and component identifiers where applicable so users can aggregate across fleets.

Sources (MySQL/Postgres)

StatusMetric/logRationale
✅ Implementeddeltaforge_source_events_total{pipeline,source,op} counter increments when events are handed to the coordinator (table, table_scope with per-table detail enabled).Surfaces ingress per pipeline and operation.
✅ Implementeddeltaforge_source_reconnects_total{pipeline,source} counter when binlog reads reconnect.Makes retry storms visible.
✅ Implementeddeltaforge_source_lag_seconds{pipeline} gauge — replication lag based on last event timestamp vs. wall clock.Alert when sources fall behind.
✅ Implementeddeltaforge_source_table_lag_seconds{pipeline,table,table_scope} gauge - per-table replication lag within each batch, only with per-table detail enabled.Identify which tables are lagging.
✅ Implementeddeltaforge_wal_feedback_reads_total{pipeline,source} counter (PostgreSQL) of durable-checkpoint reads for WAL feedback. The source re-reads after each persisted sink checkpoint commit, on resume, reconnect and credential rotation, and at least every 30 s, not per event.A rate near the event rate means feedback has regressed to per-event state-store reads.
✅ Implementeddeltaforge_wal_feedback_delay_seconds{pipeline,source,stage} histogram (PostgreSQL), how long an acknowledged position waits before PostgreSQL hears of it, in two stages. stage="refresh": from the persistence of a durable checkpoint change the source observes to the call that raises the replication client’s applied position (a change that does not raise it, because another sink still holds the minimum back, is not measured). stage="send": from the client’s first pending notification of a raised position to the successful status write that reports it (at most about the 1 s status interval while the connection is healthy; a wait carries over a reconnect or credential rotation). Both stages coalesce positions and keep the oldest pending start, so refresh + send is a diagnostic decomposition, not an exact per-event end-to-end sum. deltaforge_wal_feedback_delay_observations_total{pipeline,source,stage} counts measurements and deltaforge_wal_feedback_delay_dropped_total{pipeline,source,stage} measurements abandoned without one (pending when the source stopped).Alert on slow or stuck WAL feedback (growing retained WAL); a rising observations_total with low delay is healthy, a flat one means no measurements, not zero delay.
✅ Implementeddeltaforge_pg_unchanged_toast_total{pipeline,source,outcome} counter (PostgreSQL) of unchanged TOAST values in updates: outcome="reconstructed" filled from the full old row (REPLICA IDENTITY FULL), outcome="unavailable" left out (partial-row sinks only).Shows how often large values go unsent, and confirms reconstruction where complete rows are required.
✅ Implementeddeltaforge_wal_feedback_store_unavailable{pipeline,source} gauge (PostgreSQL): 1 while the checkpoint store cannot be read for WAL feedback. Feedback holds at the last durable position, retries every second, and the source stops after 60 s.Alert before retained WAL grows or the source stops.
🚧 Gapdeltaforge_source_idle_seconds{pipeline,source} gauge updated when no events arrive within the inactivity window.Catch stuck readers before downstream backlogs form.

Coordinator and batching

StatusMetric/logRationale
✅ Implementeddeltaforge_batch_events{pipeline} and deltaforge_batch_bytes{pipeline} histograms in Coordinator::process_deliver_and_maybe_commit.Tune batching policies with data.
✅ Implementeddeltaforge_bytes_total{pipeline} counter — cumulative bytes processed through the pipeline. rate() gives bytes/s throughput.Monitor data volume and bandwidth utilization.
✅ Implementeddeltaforge_source_bytes_total{pipeline,source} counter — cumulative raw bytes received from the source (WAL/binlog).Track source-side data ingestion rate.
✅ Implementeddeltaforge_stage_latency_seconds{pipeline,stage,trigger} histogram for processor stage.Provides batch timing per trigger (timer/limits/shutdown).
✅ Implementeddeltaforge_processor_latency_seconds{pipeline,processor} histogram around every processor invocation.Identify slow user functions.
🚧 Gapdeltaforge_pipeline_channel_depth{pipeline} gauge from mpsc::Sender::capacity()/len().Detect backpressure between sources and coordinator.
✅ Implementeddeltaforge_checkpoints_total{pipeline} counter — successful checkpoint commits.Monitor checkpoint throughput.

Sinks (Kafka/Redis/custom)

StatusMetric/logRationale
✅ Implementeddeltaforge_sink_events_total{pipeline,sink} counter and deltaforge_sink_latency_seconds{pipeline,sink} histogram around each send.Throughput and responsiveness per sink.
✅ Implementeddeltaforge_sink_batch_total{pipeline,sink} counter for send.Number of batches sent per sink.
✅ Implementeddeltaforge_sink_errors_total{pipeline,sink} counter with per-sink error tracking.Alert on sink failures.
✅ Implementeddeltaforge_sink_txn_commits_total{pipeline,sink} counter - Kafka transaction commits/s.Track transactional-commit throughput.
✅ Implementeddeltaforge_sink_txn_aborts_total{pipeline,sink} counter — Kafka transaction aborts/s. Should be ~0.Detect fencing or broker issues.
✅ Implementeddeltaforge_sink_checkpoint_status{pipeline,sink} gauge (1=ok, 0=behind).Per-sink checkpoint health.
✅ Implementeddeltaforge_sink_last_checkpoint_ts{pipeline,sink} epoch timestamp.Per-sink checkpoint age.
🚧 GapBackpressure gauge for client buffers (rdkafka queue, Redis pipeline depth).Early signal before errors occur.

Avro encoding (when encoding: avro is configured)

StatusMetric/logRationale
✅ Implementeddeltaforge_avro_encode_total{path} counter — path=ddl (DDL-derived schema) or path=inferred (JSON fallback).Track which schema source is being used. DDL is preferred.
✅ Implementeddeltaforge_avro_schema_registrations_total counter — successful Schema Registry registrations.Monitor schema registration activity.
✅ Implementeddeltaforge_avro_encode_failure_total{reason} counter — reason=sr_unavailable (no cache, can’t encode) or reason=schema_mismatch (DDL changed, old schema can’t encode event).Alert on encoding failures; events with these errors are routed to DLQ.

Pipeline lifecycle

StatusMetric/logRationale
✅ Implementeddeltaforge_pipeline_status{pipeline} gauge reflecting the current lifecycle state of each pipeline.Single gauge to alert on stopped or failed pipelines and drive dashboards.
✅ Implementeddeltaforge_e2e_latency_seconds{pipeline} histogram measuring wall-clock time from when an event was received by DeltaForge to when it was delivered to the sink.Measures pipeline delivery latency independently of source clock precision.
✅ Implementeddeltaforge_source_lag_seconds{pipeline} gauge — replication lag based on event timestamp vs. wall clock.Alert when the source is behind real time.
✅ Implementeddeltaforge_checkpoints_total{pipeline} counter — checkpoint commits/s.Monitor checkpoint throughput.
✅ Implementeddeltaforge_last_checkpoint_ts{pipeline} epoch timestamp — pipeline-level checkpoint age.Alert on stale checkpoints.

deltaforge_pipeline_status value semantics

The gauge uses a numeric encoding so operators can alert on any non-running state with a single threshold:

ValueStateMeaning
1.0runningThe current run’s capture boundary is fixed and durable (capture_start): changes committed from now on are captured
0.25startingStarted, but the capture boundary is not fixed yet (start-up checks, retries, or a snapshot whose generation is not yet completed); changes committed now may not be captured
0.5pausedSource connection alive; event processing suspended
0.0stoppedDisconnected from source; checkpoint saved; resumable
< 0failedUnrecoverable error (position lost, server changed, etc.)

Example PromQL:

# Count pipelines in each state
count(deltaforge_pipeline_status == 1)    # running
count(deltaforge_pipeline_status == 0.25) # starting
count(deltaforge_pipeline_status == 0.5) # paused
count(deltaforge_pipeline_status == 0)   # stopped
count(deltaforge_pipeline_status < 0)    # failed

# Alert if any pipeline is not running
count(deltaforge_pipeline_status != 1) > 0

deltaforge_e2e_latency_seconds note

E2E latency is measured from the wall-clock time the event was received and parsed by DeltaForge, not from the binlog header.timestamp. MySQL binlog timestamps have one-second precision, which would introduce up to 1 s of phantom latency in the histogram. Using the internal receive time gives sub-millisecond accuracy regardless of source clock granularity.

The replication lag metric (separate from E2E latency) uses the binlog timestamp and measures how far behind the source is relative to real time — that one-second precision is acceptable for lag alerting.

Dead Letter Queue

StatusMetric/logRationale
✅ Implementeddeltaforge_dlq_events_total{pipeline,sink,error_kind} counter.Track rate of events routed to DLQ.
✅ Implementeddeltaforge_dlq_entries{pipeline} gauge — current unacked entries.Monitor DLQ backlog size.
✅ Implementeddeltaforge_dlq_saturation_ratio{pipeline} gauge (0.0-1.0).Alert at 80% (warning) and 95% (critical).
✅ Implementeddeltaforge_dlq_evicted_total{pipeline} counter — entries lost to drop_oldest overflow.Track data loss from overflow.
✅ Implementeddeltaforge_dlq_rejected_total{pipeline} counter — entries lost to reject overflow.Track data loss from rejection.
✅ Implementeddeltaforge_dlq_write_failures_total{pipeline} counter — DLQ storage failures.Alert on DLQ infrastructure issues.

Control plane and health endpoints

NeedSuggested metric/logRationale
API request accountingdeltaforge_api_requests_total{route,method,status} counter and latency histogram using Axum middleware.Production-grade visibility of operator actions.
Ready/Liveness transitionsLogs with pipeline counts and per-pipeline status when readiness changes.Explain probe failures in log aggregation.
Pipeline lifecycle countersCounters for create/patch/stop/resume actions with success/error labels.Auditable control-plane operations.

Grafana Dashboard

A production-ready Grafana dashboard is included in the repository, optimized for fleet operations with hundreds of pipelines:

Download: deltaforge.json

Import it via Grafana UI → Dashboards → Import → Upload JSON file.

What’s included

RowPanelsPurpose
Fleet OverviewRunning/unhealthy count, total events/s, total data/s, max lag, DLQ total, reconnects, txn aborts, sink errorsOne-glance health across all pipelines
Top PipelinesTop 10 laggiest, top 10 throughput, top 10 DLQ backlogsIdentify outliers without drowning in 300 series
ThroughputAggregate events/s, per-pipeline events/s, data throughputCapacity planning and anomaly detection
Latency & LagE2E latency p50/p95, source lag, per-table lag (top 10, with per-table detail enabled)SLA monitoring, identify slow tables
Checkpoints & transactionsPer-sink status, commit rate, txn commits/abortsTransactional-commit health, checkpoint freshness
Dead Letter QueueEntries, events/s, saturation, overflow rateDLQ monitoring and alerting
Errors & ReliabilitySink errors, reconnects, pipeline state timelineIncident detection
Batching & KafkaBatch size, batch bytes, sink latency (collapsed)Tuning reference
InfrastructureContainer CPU, memory (collapsed)Resource monitoring

Template variables

The dashboard includes dropdown filters at the top:

  • Instance — select DeltaForge instances
  • Tenant — filter by tenant (from deltaforge_pipeline_info metric)
  • Pipeline — select specific pipelines
  • Sink — filter by sink

Prerequisites

  • Prometheus scraping DeltaForge metrics on port 9000 (/metrics)
  • Prometheus datasource configured in Grafana as “Prometheus”
  • For container metrics: cAdvisor scraping enabled

Performance Tuning

This guide covers throughput optimization for DeltaForge CDC pipelines, based on profiling and benchmarking with the chaos test suite.

Note: These results and recommendations are a starting point. Every deployment has unique requirements — hardware, network topology, database workload patterns, event sizes, and downstream consumer capacity all affect real-world throughput. Profile your own workload and iterate.

Development-machine measurements

These are observations from tuning runs, not a supported or comparable throughput figure: single runs on Docker containers on a developer machine (not dedicated infrastructure, contention not controlled), draining a 1-10M row backlog to a single-node Kafka broker. DeltaForge publishes no headline throughput number (Capacity Envelope); comparative numbers will come only from runs that follow the benchmark design, published with their method and environment. Use the figures below to see the relative effect of the settings, and measure your own workload.

batch.max_events=16000, batch.max_bytes=16MB, batch.max_inflight=4, linger.ms=0

SourceModeAvg (events/s)Peak (events/s)
MySQLat-least-once151K159K
MySQLtransactional134K143K
Postgresat-least-once57K64K
Postgrestransactional53K55K

Transactional-producer overhead is ~7-11% when batch sizes are properly tuned.

Why max_bytes matters

These results show the impact of a small max_bytes (3MB) with max_events=8000:

SourceModeAvg (events/s)Peak (events/s)
MySQLat-least-once110K122K
MySQLtransactional48K57K

A 3MB byte limit caps batches at ~6,000 events regardless of max_events, making transaction commits proportionally expensive. The default max_bytes is 16MB — sufficient for batches up to ~32K events at typical event sizes.

Your numbers will differ based on hardware, network latency, event size, and Kafka/database configuration.

Key Tuning Parameters

Batch Size (batch.max_events + batch.max_bytes)

The single most impactful setting. Larger batches amortize per-batch overhead (Kafka produce, transaction commit, checkpoint write, metrics recording) across more events.

Important: max_events and max_bytes both cap batch size — whichever triggers first wins. If you set max_bytes too low, it will silently cap your batches regardless of max_events. The default (16MB) accommodates batch sizes up to ~32K events at typical event sizes.

Recommended for high-throughput drain/catch-up:

spec:
  batch:
    max_events: 16000
    max_bytes: 16777216   # 16MB — room for 16K events
    max_ms: 100
    max_inflight: 4

For steady-state pipelines with lower latency requirements, the defaults (max_events=2000, max_bytes=16MB) are fine.

Kafka Linger (linger.ms)

Controls how long rdkafka waits before sending a produce request. This is the most common throughput bottleneck when left at high values.

  • linger.ms=20: each small batch waits 20ms before sending, capping throughput
  • linger.ms=5 (sink built-in default): good balance for steady-state
  • linger.ms=0: maximum throughput for drain/catch-up/high-intensity workloads

The internal coordinator enqueues entire batches (hundreds to thousands of messages) in a tight loop, so rdkafka batches naturally without needing linger time. Higher linger values only add idle wait per produce.

Override via client_conf in the sink config:

sinks:
  - type: kafka
    config:
      client_conf:
        linger.ms: "0"

Batch Pipelining (batch.max_inflight)

Controls how many batches can be queued between the accumulation loop and the delivery task. Higher values overlap batch building with Kafka delivery.

  • max_inflight=1: sequential delivery (default)
  • max_inflight=4: recommended for high-throughput workloads (adjust per need)

The delivery task processes batches in FIFO order, so checkpoint and event delivery ordering is always preserved regardless of the inflight setting. This config essentially allows the read from source to continue without waiting for processing in other parts of the pipline.

spec:
  batch:
    max_events: 4000
    max_ms: 100
    max_inflight: 4

Schema Sensing

Disable schema sensing during drain/catch-up for maximum throughput:

spec:
  schema_sensing:
    enabled: false

Re-enable for steady-state operation when schema tracking is needed. Be mindful, schema sensing is a CPU-intensive task.

Proxy Bypass - Chaos/Bench Testing

When running with Toxiproxy (chaos testing), use --no-proxy to bypass the proxy for direct database and Kafka connections. The proxy adds measurable overhead to throughput.

Source-Specific Tuning

MySQL

MySQL binlog is inherently efficient because WriteRowsEvent batches multiple rows into a single event.

MySQL server settings that affect CDC throughput:

  • binlog-row-image=FULL — required for CDC but sends all columns per row. If your use case allows it, MINIMAL reduces binlog event size significantly (only changed columns are sent).
  • binlog_transaction_dependency_tracking=WRITESET — enables parallel replication metadata. While DeltaForge reads sequentially, this can reduce replication lag on replicas feeding DeltaForge.
  • max_allowed_packet — increase if you have large blob/text columns. The default (64MB) is usually sufficient.
  • binlog_expire_logs_seconds — set high enough that DeltaForge can recover from outages without losing its checkpoint position. 7 days is a safe starting point.

DeltaForge settings for MySQL:

  • tables — be specific. Subscribing to *.* forces DeltaForge to process table map events for every table, even those it discards.
  • snapshot.chunk_size — for initial snapshots of large tables, increase chunk size (default 10,000) to reduce round trips.

PostgreSQL

Postgres logical replication (pgoutput) sends one WAL message per row change, making it more per-message intensive than MySQL binlog.

PostgreSQL server settings that affect CDC throughput:

  • wal_level=logical — required. No throughput impact vs. replica.
  • max_wal_senders — ensure enough slots for DeltaForge plus any replicas. Default (10) is usually sufficient.
  • wal_sender_timeout — increase from the default (60s) if DeltaForge pauses processing for extended periods (e.g., during pipeline restarts). 300s is a safer value.
  • wal_keep_size — set large enough to cover outage windows. If DeltaForge disconnects and WAL is recycled, the replication slot becomes invalid and requires re-snapshot.
  • Replica identity — ALTER TABLE ... REPLICA IDENTITY FULL sends full row images for updates/deletes. DEFAULT (primary key only) reduces WAL message size but limits the before image in CDC events.
  • Publication scope — create publications with explicit table lists (FOR TABLE ...) rather than FOR ALL TABLES to reduce WAL decoding overhead on the server.

DeltaForge settings for PostgreSQL:

  • Batch writes in transactions — if your writer can group inserts into BEGIN; INSERT ...; INSERT ...; COMMIT, the server sends fewer BEGIN/COMMIT WAL messages, reducing per-event overhead.
  • tables — use specific patterns. Broad patterns force schema loading and filtering for unneeded tables.

The throughput gap between Postgres and MySQL is primarily due to protocol-level differences (one WAL message per row vs. batched rows), not code inefficiency.

Transactional-Producer Overhead

Enabling exactly_once: true on sinks adds per-batch transaction overhead:

Kafka Transactional Producer

Each batch is wrapped in begin_transaction() / commit_transaction(). The transaction commit adds a constant ~1-3ms per batch (broker-side two-phase commit), so larger batches amortize the cost better.

Measured overhead (Docker containers, single developer machine, 1-10M row drain):

Sourceat-least-oncetransactionalOverhead
MySQL151K events/s134K events/s~11%
Postgres57K events/s53K events/s~7%

These numbers use tuned batch settings (max_events=16000, max_bytes=16MB). With a small max_bytes (e.g. 3MB), batches are capped at ~6K events regardless of max_events, making the transaction commit disproportionately expensive. The default max_bytes (16MB) is sufficient for most workloads.

Key considerations:

  • transaction.timeout.ms (set to 60s by DeltaForge): if a batch takes longer than this to deliver, the broker aborts the transaction. Increase for very large batches or high-latency networks.
  • transactional.id must be unique per pipeline-sink pair. DeltaForge sets this automatically to deltaforge-{pipeline_id}-{sink_id}.
  • Producer fencing: if two producers share the same transactional.id, the broker fences (kills) the older one. This is detected as a Fatal error and stops the pipeline. Ensure only one instance of each pipeline runs at a time.

NATS JetStream

Deduplication uses the Nats-Msg-Id header (server-side). No client-side transaction overhead, but the server maintains a dedup window. Configure duplicate_window on the stream to match your replay window (default 2 minutes).

Redis Streams

Idempotency keys are embedded in the XADD payload. No transaction overhead on the Redis side — deduplication is the consumer’s responsibility. This is a Tier 2 guarantee (at-least-once with idempotency keys for consumer-side dedup).

Per-Sink Checkpoints

Each sink maintains its own checkpoint, committed independently after successful delivery. The source replays from the minimum checkpoint across all sinks. This means:

  • Faster sinks are not held back by slower ones — they advance their own checkpoints independently.
  • Adding a new sink lowers the minimum checkpoint to that sink’s earliest position, so on the next restart the source replays from there and all existing sinks are re-delivered those events too (they dedup); the new sink is not backfilled in isolation.
  • Checkpoint storage overhead scales linearly with the number of sinks (one key per sink per source).

Profiling

Use the chaos UI’s built-in CPU profiler to capture flamegraphs during drain runs:

  1. Start a drain scenario from the chaos UI
  2. Once the drain phase starts (step 5/6), click Record on the target container
  3. The generated flamegraph SVG includes pipeline config, batch settings, and connection mode in the subtitle automatically

Or from the command line:

# Start drain in terminal 1
cargo run -p chaos --release -- --scenario backlog-drain --source mysql --no-proxy

# Capture flamegraph in terminal 2 (after drain phase starts)
docker exec <container-name> perf record -F 99 -p 1 -g --call-graph dwarf -o /tmp/perf.data -- sleep 30

Requires the profiling image (deltaforge:dev-profile) which includes perf and debug symbols.

Key areas to watch in flamegraphs:

AreaWhat it means
serialize_event / format_escaped_strJSON serialization — consider smaller batches if dominant
recv / [unknown] kernel stacksI/O wait for source data — protocol-bound
_rjem_je_*jemalloc allocation pressure — large batches increase this
rd_kafka_* / LZ4_compressKafka produce and compression overhead
check_and_splitCoordinator batch accumulation
epoll_wait / park_timeoutIdle time — pipeline is I/O bound, not CPU bound

Running the Drain Benchmark

The backlog drain benchmark measures catch-up throughput: how fast DeltaForge replays a pre-built backlog of 1M rows.

# MySQL — requires the soak compose profile
docker compose -f docker-compose.chaos.yml --profile soak up -d
cargo run -p chaos --release -- --scenario backlog-drain --source mysql --no-proxy \
  --drain-max-events 4000 --drain-max-ms 100 --drain-kafka-conf linger.ms=0

# Postgres — requires the pg-soak compose profile
docker compose -f docker-compose.chaos.yml --profile pg-soak up -d
cargo run -p chaos --release -- --scenario backlog-drain --source postgres --no-proxy \
  --drain-max-events 4000 --drain-max-ms 100 --drain-kafka-conf linger.ms=0

The benchmark:

  1. Stops the pipeline and saves its checkpoint
  2. Writes 1M rows to the source database using 32 concurrent writers
  3. Resumes the pipeline and measures how fast events appear in Kafka
  4. Reports avg/p50/peak events/s with full configuration in the output

Tune --drain-max-events, --drain-max-ms, and --drain-kafka-conf to experiment with different settings. The chaos UI also exposes these as form fields for interactive tuning.

Avro Encoding Performance

Avro encoding trades slightly higher producer CPU for significantly smaller payloads.

Expected impact

MetricJSON (baseline)AvroNotes
Payload sizeBaseline~40-60% smallerNo field names, binary encoding
Producer CPUBaseline~10-20% higherSchema lookup + Avro binary encoding
Kafka broker disk/networkBaseline~40-60% lessSignificant at scale
First-event latencyInstant+5-50msOne-time Schema Registry call per table
Steady-state latencyBaselineComparableSchema cached after first event

The system-level throughput (events/sec end-to-end) usually stays the same or improves because the bottleneck is typically network/disk I/O, not producer CPU. Smaller payloads reduce broker-side pressure.

Comparing JSON vs Avro

Run both soak tests side by side (different containers, same source):

docker compose -f docker-compose.chaos.yml \
  --profile base --profile mysql-infra --profile kafka-infra \
  --profile soak --profile avro-soak up -d

# JSON baseline
cargo run -p chaos -- --scenario soak-stable --duration-mins 30

# Avro comparison
cargo run -p chaos -- --scenario soak-stable-avro --duration-mins 30

Compare in Grafana: rate(deltaforge_sink_events_total[1m]) filtered by instance port 9001 (JSON) vs 9006 (Avro).

Avro-specific flamegraph areas

AreaWhat it means
encode_event / encode_with_schemaAvro encoding path — schema lookup + binary encoding
register_schemaSchema Registry HTTP call (only on first event per table or DDL change)
json_to_avro / to_avro_datumJSON → Avro value conversion + binary serialization
resolve (in avro module)Schema cache lookup — should be near-instant

Examples

Complete pipeline configurations demonstrating common DeltaForge use cases. Each example is ready to run with minimal modifications.

Available Examples

ExampleSourceSink(s)Key Features
MySQL to RedisMySQLRedisJavaScript processor, PII redaction
PostgreSQL to NATSPostgreSQLNATSLogical replication, CloudEvents
Multi-Sink Fan-OutMySQLKafka + Redis + NATSMultiple envelopes, selective checkpointing
Event FilteringMySQLKafkaJavaScript filtering, PII redaction
Schema SensingPostgreSQLKafkaJSON schema inference, drift detection
Production KafkaPostgreSQLKafkaSASL/SSL auth, transactional delivery, tuning
Cache InvalidationMySQLRedisCDC stream for cache invalidation workers
Audit TrailPostgreSQLKafkaCompliance logging, PII redaction
Analytics PreprocessingMySQLKafka + RedisMetrics enrichment, analytics stream
Outbox PatternMySQLKafkaTransactional outbox, raw payload, per-aggregate routing

Quick Start

  1. Set environment variables for your database and sink connections
  2. Copy the example to a .yaml file
  3. Run DeltaForge:
    cargo run -p runner -- --config your-pipeline.yaml
    

Examples by Category

Getting Started

ExampleDescription
MySQL to RedisSimple pipeline with JavaScript transformation
PostgreSQL to NATSPostgreSQL logical replication to NATS

Production Patterns

ExampleDescription
Production KafkaAuthentication, transactional delivery, performance tuning
Multi-Sink Fan-OutMultiple sinks with different formats
Cache InvalidationCDC stream for cache invalidation
Outbox PatternTransactional outbox with raw payload delivery

Data Processing

ExampleDescription
Event FilteringFilter, drop, and redact events
Schema SensingAutomatic JSON schema discovery
Analytics PreprocessingPrepare events for analytics platforms

Compliance & Auditing

ExampleDescription
Audit TrailSOC2/HIPAA/GDPR-compliant change tracking

Examples by Feature

Envelope Formats

JavaScript Processors

Use CaseExample
PII RedactionMySQL to Redis, Audit Trail
Event FilteringEvent Filtering
EnrichmentAnalytics Preprocessing
Cache Key GenerationCache Invalidation
Audit MetadataAudit Trail

Multi-Sink Patterns

PatternExample
Fan-out with different formatsMulti-Sink Fan-Out
Primary + best-effort secondaryMulti-Sink Fan-Out, Analytics Preprocessing

Customizing Examples

Change Envelope Format

All sinks support configurable envelopes. Add to any sink config:

sinks:
  - type: kafka
    config:
      # ... other config
      envelope:
        type: cloudevents          # or: native, debezium
        type_prefix: "com.example" # required for cloudevents
      encoding: json

See Envelopes and Encodings for details.

Add Multiple Sinks

Fan out to multiple destinations with different formats:

sinks:
  - type: kafka
    config:
      id: primary-kafka
      envelope:
        type: debezium
      required: true    # Must succeed for checkpoint
  - type: redis
    config:
      id: cache-redis
      envelope:
        type: native
      required: false   # Best-effort, won't block checkpoint

See Sinks documentation for multi-sink patterns.

Enable Schema Sensing

Automatically discover JSON structure in your data:

schema_sensing:
  enabled: true
  deep_inspect:
    enabled: true
    max_depth: 3
  sampling:
    warmup_events: 100
    sample_rate: 10

See Schema Sensing for configuration options.

More Resources

MySQL to Redis

This example streams MySQL binlog events into a Redis stream with an inline JavaScript transformation for PII redaction.

Overview

ComponentConfiguration
SourceMySQL binlog CDC
ProcessorJavaScript email redaction
SinkRedis Streams
EnvelopeNative (configurable)

Pipeline Configuration

metadata:
  name: orders-mysql-to-redis
  tenant: acme

spec:
  source:
    type: mysql
    config:
      id: orders-mysql
      dsn: ${MYSQL_DSN}
      tables:
        - shop.orders

  processors:
    - type: javascript
      id: redact-email
      inline: |
        function processBatch(events) {
          return events.map((event) => {
            if (event.after && event.after.email) {
              event.after.email = "[redacted]";
            }
            return event;
          });
        }
      limits:
        timeout_ms: 500

  sinks:
    - type: redis
      config:
        id: orders-redis
        uri: ${REDIS_URI}
        stream: orders
        envelope:
          type: native
        encoding: json
        required: true

  batch:
    max_events: 500
    max_bytes: 1048576
    max_ms: 1000

  commit_policy:
    mode: required

Running the Example

1. Set Environment Variables

export MYSQL_DSN="mysql://user:password@localhost:3306/shop"
export REDIS_URI="redis://localhost:6379"

2. Start DeltaForge

# Save config as mysql-redis.yaml
cargo run -p runner -- --config mysql-redis.yaml

3. Insert Test Data

INSERT INTO shop.orders (email, total, status)
VALUES ('alice@example.com', 99.99, 'pending');

4. Verify in Redis

./dev.sh redis-read orders 10

You should see the event with the email redacted:

{
  "before": null,
  "after": {
    "id": 1,
    "email": "[redacted]",
    "total": 99.99,
    "status": "pending"
  },
  "op": "c",
  "ts_ms": 1700000000000
}

Variations

With Debezium Envelope

For Kafka Connect compatibility downstream:

sinks:
  - type: redis
    config:
      id: orders-redis
      uri: ${REDIS_URI}
      stream: orders
      envelope:
        type: debezium

Multi-Sink Fan-Out

Add Kafka alongside Redis for durability:

sinks:
  - type: kafka
    config:
      id: orders-kafka
      brokers: ${KAFKA_BROKERS}
      topic: orders
      envelope:
        type: debezium
      required: true    # Critical path

  - type: redis
    config:
      id: orders-redis
      uri: ${REDIS_URI}
      stream: orders
      envelope:
        type: native
      required: false   # Best-effort

With this configuration, checkpoints only wait for Kafka. Redis failures won’t block the pipeline.

With Schema Sensing

Automatically track schema changes:

spec:
  # ... source and sinks config ...

  schema_sensing:
    enabled: true
    deep_inspect:
      enabled: true
      max_depth: 3
    sampling:
      warmup_events: 100
      sample_rate: 10

Key Concepts Demonstrated

  • JavaScript Processors: Transform events in-flight with custom logic
  • PII Redaction: Mask sensitive data before it reaches downstream systems
  • Envelope Configuration: Choose output format based on consumer needs
  • Commit Policy: Control checkpoint behavior with required flag

PostgreSQL to NATS

This one streams PostgreSQL logical replication changes to NATS JetStream with CloudEvents envelope format, for serverless architectures for example.

Overview

ComponentConfiguration
SourcePostgreSQL logical replication
ProcessorNone (passthrough)
SinkNATS JetStream
EnvelopeCloudEvents 1.0

Use Case

You have a PostgreSQL database and want to:

  • Stream changes to NATS for event-driven microservices
  • Use CloudEvents format for AWS Lambda, Azure Functions, or Knative
  • Leverage JetStream for durable, replay-capable event streams

Pipeline Configuration

apiVersion: deltaforge/v1
kind: Pipeline
metadata:
  name: users-postgres-to-nats
  tenant: acme

spec:
  source:
    type: postgres
    config:
      id: users-postgres
      dsn: ${POSTGRES_DSN}
      slot: deltaforge_users
      publication: users_pub
      tables:
        - public.users
        - public.profiles
        - public.user_sessions
      start_position: earliest

  sinks:
    - type: nats
      config:
        id: users-nats
        url: ${NATS_URL}
        subject: users.events
        stream: USERS
        envelope:
          type: cloudevents
          type_prefix: "com.acme.users"
        encoding: json
        required: true
        send_timeout_secs: 5
        batch_timeout_secs: 30

  batch:
    max_events: 500
    max_ms: 500
    respect_source_tx: true

  commit_policy:
    mode: required

Prerequisites

PostgreSQL Setup

-- Enable logical replication (postgresql.conf)
-- wal_level = logical

-- Create publication for the tables
CREATE PUBLICATION users_pub FOR TABLE users, profiles, user_sessions;

-- Verify publication
SELECT * FROM pg_publication_tables WHERE pubname = 'users_pub';

NATS JetStream Setup

# Start NATS with JetStream enabled
./dev.sh up

# Create the stream
./dev.sh nats-stream-add USERS 'users.>'

Running the Example

1. Set Environment Variables

export POSTGRES_DSN="postgres://user:password@localhost:5432/mydb"
export NATS_URL="nats://localhost:4222"

2. Start DeltaForge

cargo run -p runner -- --config postgres-nats.yaml

3. Insert Test Data

INSERT INTO users (name, email, created_at)
VALUES ('Alice', 'alice@example.com', NOW());

UPDATE users SET email = 'alice.new@example.com' WHERE name = 'Alice';

4. Verify in NATS

./dev.sh nats-sub 'users.>'

You should see CloudEvents formatted messages:

{
  "specversion": "1.0",
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "source": "deltaforge/users-postgres/public.users",
  "type": "com.acme.users.created",
  "time": "2025-01-15T10:30:00.000Z",
  "datacontenttype": "application/json",
  "subject": "public.users",
  "data": {
    "before": null,
    "after": {
      "id": 1,
      "name": "Alice",
      "email": "alice@example.com",
      "created_at": "2025-01-15T10:30:00.000Z"
    },
    "op": "c"
  }
}

Variations

With Debezium Envelope

For compatibility with existing Debezium consumers:

sinks:
  - type: nats
    config:
      id: users-nats
      url: ${NATS_URL}
      subject: users.events
      stream: USERS
      envelope:
        type: debezium

With Authentication

sinks:
  - type: nats
    config:
      id: users-nats
      url: ${NATS_URL}
      subject: users.events
      stream: USERS
      envelope:
        type: cloudevents
        type_prefix: "com.acme.users"
      credentials_file: /path/to/nats.creds
      # Or use username/password:
      # username: ${NATS_USER}
      # password: ${NATS_PASS}

Starting from Latest

Skip existing data and only capture new changes:

source:
  type: postgres
  config:
    id: users-postgres
    dsn: ${POSTGRES_DSN}
    slot: deltaforge_users
    publication: users_pub
    tables:
      - public.users
    start_position: latest

Key Concepts Demonstrated

  • PostgreSQL Logical Replication: Production-ready CDC with slot management
  • CloudEvents Format: Standard envelope for cloud-native event routing
  • JetStream Durability: Replay-capable event streams with consumer acknowledgment
  • Transaction Preservation: respect_source_tx: true keeps related changes together

Multi-Sink Fan-Out

This example demonstrates streaming changes to multiple destinations simultaneously, each with a different envelope format tailored to its consumers.

Overview

ComponentConfiguration
SourceMySQL binlog CDC
ProcessorJavaScript enrichment
SinksKafka (Debezium) + Redis (Native) + NATS (CloudEvents)
PatternFan-out with format adaptation

Use Case

You have a MySQL database and need to:

  • Send to Kafka Connect (requires Debezium format)
  • Populate a Redis cache (native format for efficiency)
  • Trigger serverless functions via NATS (CloudEvents format)
  • Handle sink failures independently without blocking the pipeline

Pipeline Configuration

apiVersion: deltaforge/v1
kind: Pipeline
metadata:
  name: orders-fan-out
  tenant: acme

spec:
  source:
    type: mysql
    config:
      id: orders-mysql
      dsn: ${MYSQL_DSN}
      tables:
        - shop.orders
        - shop.order_items

  processors:
    - type: javascript
      id: enrich
      inline: |
        function processBatch(events) {
          return events.map(event => {
            // Add routing hints via tags (tags is a valid Event field)
            event.tags = event.tags || [];
            
            if (event.table.includes('orders')) {
              event.tags.push('entity:order');
              // High-value orders get priority tag
              if (event.after && event.after.total > 1000) {
                event.tags.push('priority:high');
              }
            }
            
            event.tags.push('enriched');
            return event;
          });
        }
      limits:
        timeout_ms: 500

  sinks:
    # Primary: Kafka for data warehouse / Kafka Connect
    # Uses Debezium format for ecosystem compatibility
    - type: kafka
      config:
        id: warehouse-kafka
        brokers: ${KAFKA_BROKERS}
        topic: orders.cdc
        envelope:
          type: debezium
        encoding: json
        required: true           # Must succeed for checkpoint
        exactly_once: false
        client_conf:
          acks: "all"

    # Secondary: Redis for real-time cache
    # Uses native format for minimal overhead
    - type: redis
      config:
        id: cache-redis
        uri: ${REDIS_URI}
        stream: orders:cache
        envelope:
          type: native
        encoding: json
        required: false          # Best-effort, won't block pipeline

    # Tertiary: NATS for serverless triggers
    # Uses CloudEvents for Lambda/Functions compatibility
    - type: nats
      config:
        id: serverless-nats
        url: ${NATS_URL}
        subject: orders.events
        stream: ORDERS
        envelope:
          type: cloudevents
          type_prefix: "com.acme.orders"
        encoding: json
        required: false          # Best-effort

  batch:
    max_events: 500
    max_bytes: 1048576
    max_ms: 500
    respect_source_tx: true

  commit_policy:
    mode: required    # Only wait for required sinks (Kafka)

How It Works

Checkpoint Behavior

With commit_policy.mode: required:

Source Event
    │
    ▼
┌─────────────────────────────────────────────────────┐
│              Parallel Sink Delivery                 │
├─────────────────┬─────────────────┬─────────────────┤
│ Kafka           │ Redis           │ NATS            │
│ required: true  │ required: false │ required: false │
│                 │                 │                 │
│ ✓ Must succeed  │ ✓ Best-effort   │ ✓ Best-effort   │
└────────┬────────┴────────┬────────┴──────┬──────────┘
         │                 │               │
         ▼                 │               │
    Checkpoint <───────────┘               │
    advances                               │
    (even if Redis/NATS fail)              │

Output Formats

Kafka (Debezium):

{
  "payload": {
    "before": null,
    "after": {"id": 1, "total": 1500.00, "status": "pending"},
    "source": {"connector": "mysql", "db": "shop", "table": "orders"},
    "op": "c",
    "ts_ms": 1700000000000
  }
}

Redis (Native):

{
  "before": null,
  "after": {"id": 1, "total": 1500.00, "status": "pending"},
  "source": {"connector": "mysql", "db": "shop", "table": "orders"},
  "op": "c",
  "ts_ms": 1700000000000
}

NATS (CloudEvents):

{
  "specversion": "1.0",
  "id": "evt-123",
  "source": "deltaforge/orders-mysql/shop.orders",
  "type": "com.acme.orders.created",
  "time": "2025-01-15T10:30:00.000Z",
  "data": {
    "before": null,
    "after": {"id": 1, "total": 1500.00, "status": "pending"},
    "op": "c"
  }
}

Running the Example

1. Set Environment Variables

export MYSQL_DSN="mysql://user:password@localhost:3306/shop"
export KAFKA_BROKERS="localhost:9092"
export REDIS_URI="redis://localhost:6379"
export NATS_URL="nats://localhost:4222"

2. Start Infrastructure

./dev.sh up
./dev.sh k-create orders.cdc 6
./dev.sh nats-stream-add ORDERS 'orders.>'

3. Start DeltaForge

cargo run -p runner -- --config fan-out.yaml

4. Insert Test Data

INSERT INTO shop.orders (customer_id, total, status)
VALUES (1, 1500.00, 'pending');

5. Verify Each Sink

# Kafka
./dev.sh k-consume orders.cdc --from-beginning

# Redis
./dev.sh redis-read orders:cache 10

# NATS
./dev.sh nats-sub 'orders.>'

Variations

Quorum Mode

Require 2 of 3 sinks to succeed:

sinks:
  - type: kafka
    config:
      id: kafka-1
      required: true   # Counts toward quorum
  - type: redis
    config:
      id: redis-1
      required: true   # Counts toward quorum
  - type: nats
    config:
      id: nats-1
      required: true   # Counts toward quorum

commit_policy:
  mode: quorum
  quorum: 2            # Any 2 must succeed

All-or-Nothing

Require all sinks to succeed (strongest consistency):

commit_policy:
  mode: all

⚠️ Warning: mode: all means any sink failure blocks the entire pipeline. Use only when all destinations are equally critical.

Key Concepts Demonstrated

  • Multi-Sink Fan-Out: Single source to multiple destinations
  • Format Adaptation: Different envelope per consumer requirement
  • Selective Checkpointing: required flag controls which sinks gate progress
  • Failure Isolation: Non-critical sinks don’t block the pipeline
  • Tag-Based Enrichment: Use event.tags for routing metadata

Processor Constraints: JavaScript processors can only modify event.before, event.after, and event.tags. Arbitrary top-level fields would be lost during serialization.

Event Filtering with JavaScript

This example demonstrates using JavaScript processors to filter and selectively drop events before they reach sinks.

Overview

ComponentConfiguration
SourceMySQL binlog CDC
ProcessorJavaScript filter + redaction
SinkKafka
PatternEvent filtering and transformation

Use Case

You have a MySQL database and want to:

  • Filter out events from certain tables or with specific conditions
  • Drop low-value events to reduce downstream load
  • Redact sensitive fields conditionally

Pipeline Configuration

apiVersion: deltaforge/v1
kind: Pipeline
metadata:
  name: filtered-orders
  tenant: acme

spec:
  source:
    type: mysql
    config:
      id: orders-mysql
      dsn: ${MYSQL_DSN}
      tables:
        - shop.orders
        - shop.order_items
        - shop.audit_log        # We'll filter most of these out

  processors:
    - type: javascript
      id: filter-and-redact
      inline: |
        function processBatch(events) {
          return events
            // 1. Filter out audit_log events except errors
            .filter(event => {
              if (event.table === 'shop.audit_log') {
                // Only keep error-level audit events
                return event.after && event.after.level === 'error';
              }
              return true;
            })
            
            // 2. Filter out soft-deleted records
            .filter(event => {
              if (event.after && event.after.deleted_at !== null) {
                return false;  // Drop soft-deleted records
              }
              return true;
            })
            
            // 3. Filter out test/staging data
            .filter(event => {
              if (event.after && event.after.email) {
                // Drop test accounts
                if (event.after.email.endsWith('@test.local')) {
                  return false;
                }
              }
              return true;
            })
            
            // 4. Transform remaining events
            .map(event => {
              // Redact PII for non-admin tables
              if (event.after) {
                if (event.after.email) {
                  event.after.email = maskEmail(event.after.email);
                }
                if (event.after.phone) {
                  event.after.phone = '[redacted]';
                }
              }
              
              // Add filter tag (tags is a valid Event field)
              event.tags = (event.tags || []).concat(['filtered']);
              
              return event;
            });
        }
        
        // helper: mask email keeping domain
        function maskEmail(email) {
          const [local, domain] = email.split('@');
          if (!domain) return '[invalid-email]';
          const masked = local.charAt(0) + '***' + local.charAt(local.length - 1);
          return masked + '@' + domain;
        }
      limits:
        timeout_ms: 1000
        mem_mb: 128

  sinks:
    - type: kafka
      config:
        id: filtered-kafka
        brokers: ${KAFKA_BROKERS}
        topic: orders.filtered
        envelope:
          type: native
        encoding: json
        required: true

  batch:
    max_events: 500
    max_ms: 500
    respect_source_tx: true

  commit_policy:
    mode: required

Filter Patterns

Drop Events (Return Empty Array Element)

.filter(event => {
  // return false to drop the event
  if (event.table === 'internal_logs') {
    return false;
  }
  return true;
})

Conditional Field-Based Filtering

.filter(event => {
  // drop events where status is 'draft'
  if (event.after && event.after.status === 'draft') {
    return false;
  }
  return true;
})

Drop by Operation Type

.filter(event => {
  // only keep inserts and updates, drop deletes
  return event.op === 'c' || event.op === 'u';
})

Sample Events (Rate Limiting)

// keep only 10% of events (for high-volume tables)
.filter(event => {
  if (event.table === 'high_volume_table') {
    return Math.random() < 0.1;
  }
  return true;
})

Running the Example

1. Set Environment Variables

export MYSQL_DSN="mysql://user:password@localhost:3306/shop"
export KAFKA_BROKERS="localhost:9092"

2. Start DeltaForge

cargo run -p runner -- --config filtered-orders.yaml

3. Insert Test Data

-- This will be captured and transformed
INSERT INTO shop.orders (customer_email, total, status)
VALUES ('alice@example.com', 99.99, 'pending');

-- This will be filtered out (test account)
INSERT INTO shop.orders (customer_email, total, status)
VALUES ('test@test.local', 50.00, 'pending');

-- This will be filtered out (soft-deleted)
INSERT INTO shop.orders (customer_email, total, status, deleted_at)
VALUES ('bob@example.com', 75.00, 'pending', NOW());

-- Audit log - will be filtered out (not error level)
INSERT INTO shop.audit_log (level, message)
VALUES ('info', 'User logged in');

-- Audit log - will be kept (error level)
INSERT INTO shop.audit_log (level, message)
VALUES ('error', 'Payment failed');

4. Verify Filtered Output

./dev.sh k-consume orders.filtered --from-beginning

You should only see:

  • Alice’s order (with masked email: a***e@example.com)
  • The error-level audit log entry

Performance Considerations

Tip: Filtering early reduces downstream load. If you’re filtering out 50% of events, your sinks process half the data.

processors:
  - type: javascript
    id: filter
    inline: |
      function processBatch(events) {
        // Filter FIRST, then transform
        return events
          .filter(e => shouldKeep(e))  // Reduces array size
          .map(e => transform(e));      // Processes fewer events
      }
    limits:
      timeout_ms: 500    # Increase if filtering logic is complex
      mem_mb: 128        # Increase for large batches

Key Concepts Demonstrated

  • Event Filtering: Drop events before they reach sinks
  • Conditional Logic: Filter based on table, operation, or field values
  • PII Redaction: Mask sensitive data in remaining events
  • Sampling: Rate-limit high-volume event streams

Processor Constraints: JavaScript processors can only modify event.before, event.after, and event.tags. Arbitrary top-level fields like event.filtered_at would be lost during serialization.

Schema Sensing and Drift Detection

This example demonstrates DeltaForge’s automatic schema inference for JSON columns and drift detection capabilities.

Overview

ComponentConfiguration
SourcePostgreSQL logical replication
ProcessorNone
SinkKafka
FeatureSchema sensing with deep JSON inspection

Use Case

You have a PostgreSQL database with JSON/JSONB columns and want to:

  • Automatically discover the structure of JSON payloads
  • Detect when JSON schemas change over time (drift)
  • Export inferred schemas as JSON Schema for downstream validation
  • Monitor schema evolution without manual tracking

Pipeline Configuration

apiVersion: deltaforge/v1
kind: Pipeline
metadata:
  name: products-with-sensing
  tenant: acme

spec:
  source:
    type: postgres
    config:
      id: products-postgres
      dsn: ${POSTGRES_DSN}
      slot: deltaforge_products
      publication: products_pub
      tables:
        - public.products
        - public.product_variants
      start_position: earliest

  sinks:
    - type: kafka
      config:
        id: products-kafka
        brokers: ${KAFKA_BROKERS}
        topic: products.changes
        envelope:
          type: debezium
        encoding: json
        required: true

  batch:
    max_events: 500
    max_ms: 1000
    respect_source_tx: true

  commit_policy:
    mode: required

  # Schema sensing configuration
  schema_sensing:
    enabled: true
    
    deep_inspect:
      enabled: true
      max_depth: 5           # How deep to traverse nested JSON
      max_sample_size: 500   # Events to analyze for deep inspection
    
    sampling:
      warmup_events: 100     # Analyze every event during warmup
      sample_rate: 20        # After warmup, analyze 1 in 20 events
      structure_cache: true  # Cache seen structures to avoid re-analysis
      structure_cache_size: 100
    
    tracking:
      null_rates: true       # Track per-field null rates
      enum_detection: true   # Detect low-cardinality enum-like fields
    
    output:
      expose_api: true          # Expose inferred schemas via the REST API
      export_json_schema: true  # Emit JSON Schema for downstream consumers

Table Structure

CREATE TABLE products (
  id SERIAL PRIMARY KEY,
  name TEXT NOT NULL,
  sku TEXT UNIQUE,
  price DECIMAL(10,2),
  
  -- JSON columns that schema sensing will analyze
  metadata JSONB,          -- Product attributes, tags, etc.
  specifications JSONB,    -- Technical specs
  
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE product_variants (
  id SERIAL PRIMARY KEY,
  product_id INTEGER REFERENCES products(id),
  variant_name TEXT,
  
  -- Nested JSON with variable structure
  attributes JSONB,        -- Color, size, material, etc.
  pricing JSONB,           -- Regional pricing, discounts
  
  created_at TIMESTAMPTZ DEFAULT NOW()
);

-- Create publication
CREATE PUBLICATION products_pub FOR TABLE products, product_variants;

Sample Data

-- Insert products with JSON metadata
INSERT INTO products (name, sku, price, metadata, specifications) VALUES
(
  'Wireless Headphones',
  'WH-1000',
  299.99,
  '{
    "brand": "AudioTech",
    "category": "Electronics",
    "tags": ["wireless", "bluetooth", "noise-canceling"],
    "ratings": {"average": 4.5, "count": 1250}
  }',
  '{
    "battery_life_hours": 30,
    "driver_size_mm": 40,
    "frequency_response": {"min_hz": 20, "max_hz": 20000},
    "connectivity": ["bluetooth", "aux"]
  }'
);

-- Insert variant with nested attributes
INSERT INTO product_variants (product_id, variant_name, attributes, pricing) VALUES
(
  1,
  'Midnight Black',
  '{
    "color": {"name": "Midnight Black", "hex": "#1a1a2e"},
    "material": "Premium Plastic",
    "weight_grams": 250
  }',
  '{
    "base_price": 299.99,
    "regional": {
      "US": {"price": 299.99, "currency": "USD"},
      "EU": {"price": 279.99, "currency": "EUR"}
    },
    "discounts": [
      {"code": "SAVE20", "percent": 20, "expires": "2025-12-31"}
    ]
  }'
);

Running the Example

1. Set Environment Variables

export POSTGRES_DSN="postgres://user:password@localhost:5432/shop"
export KAFKA_BROKERS="localhost:9092"

2. Start DeltaForge

cargo run -p runner -- --config products-sensing.yaml

3. Insert Data and Let Sensing Analyze

-- Insert several products to build schema profile
INSERT INTO products (name, sku, price, metadata, specifications) VALUES
('Smart Watch', 'SW-200', 399.99, 
 '{"brand": "TechWear", "tags": ["fitness", "smart"]}',
 '{"battery_days": 7, "water_resistant": true}');

Using the Schema Sensing API

List Inferred Schemas

curl http://localhost:8080/pipelines/products-with-sensing/sensing/schemas

Response:

{
  "schemas": [
    {
      "table": "public.products",
      "columns": {
        "metadata": {
          "type": "object",
          "inferred_at": "2025-01-15T10:30:00Z",
          "sample_count": 150
        },
        "specifications": {
          "type": "object",
          "inferred_at": "2025-01-15T10:30:00Z",
          "sample_count": 150
        }
      }
    }
  ]
}

Get Detailed Schema for a Table

curl http://localhost:8080/pipelines/products-with-sensing/sensing/schemas/public.products

Response:

{
  "table": "public.products",
  "json_columns": {
    "metadata": {
      "inferred_schema": {
        "type": "object",
        "properties": {
          "brand": {"type": "string"},
          "category": {"type": "string"},
          "tags": {"type": "array", "items": {"type": "string"}},
          "ratings": {
            "type": "object",
            "properties": {
              "average": {"type": "number"},
              "count": {"type": "integer"}
            }
          }
        }
      },
      "sample_count": 150,
      "last_updated": "2025-01-15T10:35:00Z"
    }
  }
}

Export as JSON Schema

curl http://localhost:8080/pipelines/products-with-sensing/sensing/schemas/public.products/json-schema

Response:

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "title": "public.products.metadata",
  "type": "object",
  "properties": {
    "brand": {"type": "string"},
    "category": {"type": "string"},
    "tags": {
      "type": "array",
      "items": {"type": "string"}
    },
    "ratings": {
      "type": "object",
      "properties": {
        "average": {"type": "number"},
        "count": {"type": "integer"}
      }
    }
  }
}

Check Drift Detection

curl http://localhost:8080/pipelines/products-with-sensing/drift

Response:

{
  "drift_detected": true,
  "tables": {
    "public.products": {
      "metadata": {
        "new_fields": ["promotion", "seasonal"],
        "removed_fields": [],
        "type_changes": [],
        "drift_percentage": 0.15,
        "first_seen": "2025-01-15T11:00:00Z"
      }
    }
  }
}

Get Sensing Statistics

curl http://localhost:8080/pipelines/products-with-sensing/sensing/stats

Response:

{
  "total_events_analyzed": 1500,
  "total_events_sampled": 250,
  "cache_hits": 1250,
  "cache_misses": 250,
  "tables_tracked": 2,
  "json_columns_tracked": 4
}

Performance Tuning

Performance tip: Schema sensing can be CPU-intensive. Tune based on your throughput needs.

High-Throughput Configuration

schema_sensing:
  enabled: true
  deep_inspect:
    enabled: true
    max_depth: 3           # Limit depth for faster processing
    max_sample_size: 200   # Fewer samples
  sampling:
    warmup_events: 50      # Shorter warmup
    sample_rate: 100       # Analyze 1 in 100 events
    structure_cache: true
    structure_cache_size: 200  # Larger cache

Development/Debugging Configuration

schema_sensing:
  enabled: true
  deep_inspect:
    enabled: true
    max_depth: 10          # Full depth
    max_sample_size: 1000  # More samples
  sampling:
    warmup_events: 500     # Longer warmup
    sample_rate: 1         # Analyze every event

Key Concepts Demonstrated

  • Automatic Schema Inference: Discover JSON structure without manual definition
  • Deep JSON Inspection: Traverse nested objects and arrays
  • Drift Detection: Alert when schemas change unexpectedly
  • JSON Schema Export: Generate standard schemas for validation
  • Sampling Strategy: Balance accuracy vs. performance

Production Kafka Configuration

This example demonstrates a production-ready Kafka sink configuration with authentication, high availability settings, and some performance tuning.

Overview

ComponentConfiguration
SourcePostgreSQL logical replication
ProcessorNone
SinkKafka with SASL/SSL authentication
PatternProduction-grade reliability

Use Case

You’re deploying DeltaForge to production and need:

  • Secure authentication (SASL/SCRAM or mTLS)
  • High availability with proper acknowledgment settings
  • Optimal batching and compression for throughput
  • Transactional atomic-batch delivery for critical data

Pipeline Configuration

apiVersion: deltaforge/v1
kind: Pipeline
metadata:
  name: orders-to-kafka-prod
  tenant: acme

spec:
  source:
    type: postgres
    config:
      id: orders-postgres
      dsn: ${POSTGRES_DSN}
      slot: deltaforge_orders
      publication: orders_pub
      tables:
        - public.orders
        - public.order_items
        - public.payments
      start_position: earliest

  sinks:
    - type: kafka
      config:
        id: orders-kafka
        brokers: ${KAFKA_BROKERS}
        topic: orders.cdc.events
        envelope:
          type: debezium
        encoding: json
        required: true
        
        # enable the transactional producer (atomic-batch delivery)
        exactly_once: true
        
        # timeout for individual sends
        send_timeout_secs: 60
        
        # librdkafka client configuration
        client_conf:
          # security - SASL/SCRAM authentication
          security.protocol: "SASL_SSL"
          sasl.mechanism: "SCRAM-SHA-512"
          sasl.username: "${KAFKA_USERNAME}"
          sasl.password: "${KAFKA_PASSWORD}"
          
          # SSL/TLS configuration
          ssl.ca.location: "/etc/ssl/certs/kafka-ca.pem"
          ssl.endpoint.identification.algorithm: "https"
          
          # reliability - wait for all replicas
          acks: "all"
          
          # idempotence (required for the transactional producer)
          enable.idempotence: "true"
          
          # retries and timeouts
          retries: "2147483647"
          retry.backoff.ms: "100"
          delivery.timeout.ms: "300000"
          request.timeout.ms: "30000"
          
          # kafka batching for throughput
          batch.size: "65536"
          linger.ms: "10"
          
          # compression
          compression.type: "lz4"
          
          # buffer management
          queue.buffering.max.messages: "100000"
          queue.buffering.max.kbytes: "1048576"

  batch:
    max_events: 1000
    max_bytes: 1048576
    max_ms: 100
    respect_source_tx: true
    max_inflight: 4

  commit_policy:
    mode: required

Security Configurations

SASL/SCRAM (Username/Password)

client_conf:
  security.protocol: "SASL_SSL"
  sasl.mechanism: "SCRAM-SHA-512"
  sasl.username: "${KAFKA_USERNAME}"
  sasl.password: "${KAFKA_PASSWORD}"
  ssl.ca.location: "/etc/ssl/certs/ca.pem"

mTLS (Mutual TLS)

client_conf:
  security.protocol: "SSL"
  ssl.ca.location: "/etc/ssl/certs/kafka-ca.pem"
  ssl.certificate.location: "/etc/ssl/certs/client.pem"
  ssl.key.location: "/etc/ssl/private/client.key"
  ssl.key.password: "${SSL_KEY_PASSWORD}"

AWS MSK with IAM

client_conf:
  security.protocol: "SASL_SSL"
  sasl.mechanism: "AWS_MSK_IAM"
  sasl.jaas.config: "software.amazon.msk.auth.iam.IAMLoginModule required;"
  sasl.client.callback.handler.class: "software.amazon.msk.auth.iam.IAMClientCallbackHandler"

Confluent Cloud

client_conf:
  security.protocol: "SASL_SSL"
  sasl.mechanism: "PLAIN"
  sasl.username: "${CONFLUENT_API_KEY}"
  sasl.password: "${CONFLUENT_API_SECRET}"

Performance Tuning

High Throughput

Optimize for maximum events per second:

client_conf:
  acks: "1"                    # Leader-only ack (faster, less safe)
  batch.size: "131072"         # 128KB batches
  linger.ms: "50"              # Wait longer to fill batches
  compression.type: "lz4"      # Fast compression
  
batch:
  max_events: 5000             # Larger batches
  max_ms: 200                  # More time to accumulate
  max_inflight: 8              # More concurrent requests

Low Latency

Optimize for minimal delay:

client_conf:
  acks: "1"                    # Don't wait for replicas
  batch.size: "16384"          # Smaller batches
  linger.ms: "0"               # Send immediately
  compression.type: "none"     # Skip compression
  
batch:
  max_events: 100              # Smaller batches
  max_ms: 10                   # Flush quickly
  max_inflight: 2              # Limit in-flight

Maximum Durability

Optimize for zero data loss:

client_conf:
  acks: "all"                  # All replicas must ack
  enable.idempotence: "true"   # Prevent duplicates
  max.in.flight.requests.per.connection: "5"  # Required for idempotence
  retries: "2147483647"        # Infinite retries
  
exactly_once: true             # Transactional producer

batch:
  respect_source_tx: true      # Preserve source transactions
  max_inflight: 1              # Strict ordering

Running the Example

1. Set Environment Variables

export POSTGRES_DSN="postgres://user:password@localhost:5432/orders"
export KAFKA_BROKERS="kafka1:9093,kafka2:9093,kafka3:9093"
export KAFKA_USERNAME="deltaforge"
export KAFKA_PASSWORD="secret"

2. Create Kafka Topic

kafka-topics.sh --create \
  --topic orders.cdc.events \
  --partitions 12 \
  --replication-factor 3 \
  --config min.insync.replicas=2 \
  --bootstrap-server ${KAFKA_BROKERS}

3. Start DeltaForge

cargo run -p runner --release -- --config kafka-prod.yaml

Monitoring

Key Metrics to Watch

  • deltaforge_sink_events_sent_total — Events delivered
  • deltaforge_sink_send_latency_seconds — Delivery latency
  • deltaforge_sink_errors_total — Delivery failures
  • deltaforge_checkpoint_lag_events — Events pending checkpoint

Health Check

curl http://localhost:8080/health

Pipeline Status

curl http://localhost:8080/pipelines/orders-to-kafka-prod

Key Concepts Demonstrated

  • SASL/SSL Authentication: Secure broker connections
  • Transactional atomic-batch delivery: transactional producer; consumers use read_committed and dedup on event id for exactly-once end to end (at-least-once across restart)
  • Acknowledgment Modes: Trade-off between durability and latency
  • Batching & Compression: Optimize throughput
  • Production Tuning: Real-world configuration patterns

Redis Cache Invalidation

This example demonstrates streaming database changes to a Redis stream where a worker can consume them to invalidate cache entries, ensuring cache consistency without polling.

Overview

ComponentConfiguration
SourceMySQL binlog CDC
ProcessorJavaScript cache key generator
SinkRedis Streams
PatternCDC-driven cache invalidation

Use Case

You have a MySQL database with Redis caching and want to:

  • Stream change events that trigger cache invalidation
  • Avoid stale cache issues without TTL-based expiration
  • Generate cache keys matching your application’s key format

Architecture

┌─────────┐     ┌─────────────┐     ┌────────────────┐     ┌─────────┐
│  MySQL  │────>│ DeltaForge  │────>│ Redis Stream   │────>│ Worker  │
│         │     │             │     │ (invalidations)│     │         │
└─────────┘     └─────────────┘     └────────────────┘     └────┬────┘
                                                                │
                                    ┌───────────────┐           │
                                    │  Redis Cache  │<──────────┘
                                    │  (DEL keys)   │
                                    └───────────────┘

Pipeline Configuration

apiVersion: deltaforge/v1
kind: Pipeline
metadata:
  name: cache-invalidation
  tenant: acme

spec:
  source:
    type: mysql
    config:
      id: app-mysql
      dsn: ${MYSQL_DSN}
      tables:
        - app.users
        - app.products
        - app.orders
        - app.inventory

  processors:
    - type: javascript
      id: generate-cache-keys
      inline: |
        function processBatch(events) {
          return events.map(event => {
            const keys = generateCacheKeys(event);
            const strategy = getStrategy(event);
            
            // Store cache keys in event.after metadata
            // (we can modify event.after since it's a JSON Value)
            if (event.after) {
              event.after._cache_keys = keys;
              event.after._invalidation_strategy = strategy;
            } else if (event.before) {
              // For deletes, add to before
              event.before._cache_keys = keys;
              event.before._invalidation_strategy = strategy;
            }
            
            // Add tags for routing/filtering
            event.tags = (event.tags || []).concat([
              'cache:invalidate',
              `strategy:${strategy}`,
              `keys:${keys.length}`
            ]);
            
            return event;
          });
        }
        
        function generateCacheKeys(event) {
          const table = event.table.split('.')[1];
          const keys = [];
          const record = event.after || event.before;
          
          if (!record || !record.id) return keys;
          const id = record.id;
          
          switch (table) {
            case 'users':
              keys.push(`user:${id}`);
              if (record.email) {
                keys.push(`user:email:${record.email}`);
              }
              // If email changed, invalidate old email key
              if (event.before && event.before.email && 
                  event.before.email !== record.email) {
                keys.push(`user:email:${event.before.email}`);
              }
              keys.push(`user:${id}:orders`);
              keys.push(`user:${id}:profile`);
              break;
              
            case 'products':
              keys.push(`product:${id}`);
              keys.push(`product:${id}:details`);
              if (record.category_id) {
                keys.push(`category:${record.category_id}:products`);
              }
              break;
              
            case 'orders':
              keys.push(`order:${id}`);
              if (record.user_id) {
                keys.push(`user:${record.user_id}:orders`);
              }
              break;
              
            case 'inventory':
              keys.push(`inventory:${record.product_id}`);
              keys.push(`product:${record.product_id}:stock`);
              break;
          }
          
          return keys;
        }
        
        function getStrategy(event) {
          const table = event.table.split('.')[1];
          if (table === 'inventory') return 'immediate';
          if (event.op === 'd') return 'immediate';
          return 'batched';
        }
      limits:
        timeout_ms: 500
        mem_mb: 128

  sinks:
    - type: redis
      config:
        id: invalidation-stream
        uri: ${REDIS_URI}
        stream: cache:invalidations
        envelope:
          type: native
        encoding: json
        required: true

  batch:
    max_events: 100
    max_ms: 100
    respect_source_tx: true

  commit_policy:
    mode: required

JavaScript Processor Constraints

Important: The processor stores cache keys in event.after._cache_keys (or event.before for deletes) because we can only modify existing Event fields. Arbitrary top-level fields like event.cache_keys would be lost.

Cache Worker (Consumer)

// cache-invalidation-worker.js
const Redis = require('ioredis');

const redis = new Redis(process.env.REDIS_URI);
const streamKey = 'cache:invalidations';
const consumerGroup = 'cache-workers';
const consumerId = `worker-${process.pid}`;

async function processInvalidations() {
  try {
    await redis.xgroup('CREATE', streamKey, consumerGroup, '0', 'MKSTREAM');
  } catch (e) {
    if (!e.message.includes('BUSYGROUP')) throw e;
  }

  console.log(`Starting cache invalidation worker: ${consumerId}`);

  while (true) {
    try {
      const results = await redis.xreadgroup(
        'GROUP', consumerGroup, consumerId,
        'COUNT', 100, 'BLOCK', 5000,
        'STREAMS', streamKey, '>'
      );

      if (!results) continue;

      for (const [stream, messages] of results) {
        for (const [id, fields] of messages) {
          const event = JSON.parse(fields[1]);
          await invalidateKeys(event);
          await redis.xack(streamKey, consumerGroup, id);
        }
      }
    } catch (error) {
      console.error('Worker error:', error);
      await new Promise(r => setTimeout(r, 1000));
    }
  }
}

async function invalidateKeys(event) {
  // Get cache keys from the event payload
  const record = event.after || event.before;
  const cacheKeys = record?._cache_keys || [];
  
  if (cacheKeys.length === 0) return;

  for (const key of cacheKeys) {
    if (key.includes('*')) {
      await scanAndDelete(key);
    } else {
      await redis.del(key);
    }
  }

  console.log(`Invalidated ${cacheKeys.length} keys for ${event.table}`);
}

async function scanAndDelete(pattern) {
  let cursor = '0';
  do {
    const [nextCursor, keys] = await redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100);
    cursor = nextCursor;
    if (keys.length > 0) {
      await redis.del(...keys);
    }
  } while (cursor !== '0');
}

processInvalidations().catch(console.error);

Running the Example

1. Set Environment Variables

export MYSQL_DSN="mysql://user:password@localhost:3306/app"
export REDIS_URI="redis://localhost:6379"

2. Start DeltaForge

cargo run -p runner -- --config cache-invalidation.yaml

3. Start Worker(s)

node cache-invalidation-worker.js

4. Test Invalidation

UPDATE app.users SET email = 'alice.new@example.com' WHERE id = 1;

Worker output:

Invalidated 5 keys for app.users

Key Concepts Demonstrated

  • CDC to Stream: DeltaForge captures changes and writes to Redis Streams
  • Custom Key Generation: Processor computes cache keys for downstream worker
  • Consumer Groups: Scalable worker processing with acknowledgments
  • Before/After Diffing: Compute invalidation keys for both old and new values

Note: DeltaForge streams events to Redis; a separate worker (shown above) consumes them and performs the actual cache invalidation.

Audit Trail and Compliance Logging

This example demonstrates building an immutable audit trail for compliance requirements (SOC2, HIPAA, GDPR) by capturing all database changes with full context.

Overview

ComponentConfiguration
SourcePostgreSQL logical replication
ProcessorJavaScript audit enrichment
SinkKafka (durable audit log)
PatternCompliance-grade change tracking

Use Case

You need to meet compliance requirements and want to:

  • Capture every change to sensitive tables with before/after values
  • Add audit metadata (classification, retention, regulations)
  • Redact sensitive fields while preserving change records
  • Store immutable audit logs for required retention periods

Pipeline Configuration

apiVersion: deltaforge/v1
kind: Pipeline
metadata:
  name: compliance-audit-trail
  tenant: acme

spec:
  source:
    type: postgres
    config:
      id: app-postgres
      dsn: ${POSTGRES_DSN}
      slot: deltaforge_audit
      publication: audit_pub
      tables:
        - public.users
        - public.user_profiles
        - public.payment_methods
        - public.transactions
        - public.roles
        - public.permissions
        - public.user_roles
      start_position: earliest

  processors:
    - type: javascript
      id: audit-enrichment
      inline: |
        function processBatch(events) {
          return events.map(event => {
            const table = event.table.split('.')[1];
            
            // Build audit tags (event.tags is a valid Event field)
            const auditTags = [
              'audited',
              `sensitivity:${classifySensitivity(table)}`,
              `classification:${getDataClassification(table)}`,
              `retention:${getRetentionPeriod(table)}d`
            ];
            
            // Add regulation tags
            for (const reg of getApplicableRegulations(table)) {
              auditTags.push(`regulation:${reg}`);
            }
            
            // Track changed fields for updates
            if (event.before && event.after) {
              const changed = detectChangedFields(event.before, event.after);
              for (const field of changed) {
                auditTags.push(`changed:${field}`);
              }
            }
            
            event.tags = (event.tags || []).concat(auditTags);
            
            // Redact sensitive fields in before/after
            if (event.before) {
              event.before = sanitizeRecord(event.before, table);
            }
            if (event.after) {
              event.after = sanitizeRecord(event.after, table);
            }
            
            return event;
          });
        }
        
        function classifySensitivity(table) {
          const highSensitivity = ['users', 'payment_methods', 'transactions'];
          const mediumSensitivity = ['user_profiles', 'user_roles'];
          
          if (highSensitivity.includes(table)) return 'HIGH';
          if (mediumSensitivity.includes(table)) return 'MEDIUM';
          return 'LOW';
        }
        
        function sanitizeRecord(record, table) {
          if (!record) return null;
          const sanitized = { ...record };
          
          const sensitiveFields = {
            'users': ['password_hash', 'ssn', 'tax_id'],
            'payment_methods': ['card_number', 'cvv', 'account_number'],
            'user_profiles': ['date_of_birth']
          };
          
          const fieldsToMask = sensitiveFields[table] || [];
          
          for (const field of fieldsToMask) {
            if (sanitized[field] !== undefined) {
              sanitized[`_${field}_redacted`] = true;
              sanitized[field] = '[REDACTED]';
            }
          }
          
          return sanitized;
        }
        
        function detectChangedFields(before, after) {
          const changed = [];
          const allKeys = new Set([...Object.keys(before), ...Object.keys(after)]);
          for (const key of allKeys) {
            if (JSON.stringify(before[key]) !== JSON.stringify(after[key])) {
              changed.push(key);
            }
          }
          return changed;
        }
        
        function getRetentionPeriod(table) {
          if (['transactions', 'payment_methods'].includes(table)) return 2555;
          if (['users', 'user_profiles'].includes(table)) return 2190;
          return 1095;
        }
        
        function getDataClassification(table) {
          if (['payment_methods', 'transactions'].includes(table)) return 'PCI';
          if (['users', 'user_profiles'].includes(table)) return 'PII';
          return 'INTERNAL';
        }
        
        function getApplicableRegulations(table) {
          const regs = [];
          if (['users', 'user_profiles'].includes(table)) regs.push('GDPR', 'CCPA');
          if (['payment_methods', 'transactions'].includes(table)) regs.push('PCI-DSS', 'SOX');
          return regs;
        }
      limits:
        timeout_ms: 1000
        mem_mb: 256

  sinks:
    - type: kafka
      config:
        id: audit-kafka
        brokers: ${KAFKA_BROKERS}
        topic: audit.trail.events
        envelope:
          type: debezium
        encoding: json
        required: true
        exactly_once: true
        client_conf:
          acks: "all"
          enable.idempotence: "true"
          compression.type: "gzip"

  batch:
    max_events: 500
    max_bytes: 1048576
    max_ms: 1000
    respect_source_tx: true

  commit_policy:
    mode: required

JavaScript Processor Constraints

Important: The JavaScript processor can only modify fields that exist on DeltaForge’s Event struct. You can:

  • Modify event.before and event.after values (JSON objects)
  • Set event.tags (array of strings)
  • Filter out events (return empty array)

You cannot add arbitrary top-level fields like event.audit or event.metadata - they will be lost during serialization. This limitation will be addressed soon.

This example uses event.tags to store audit metadata as key:value strings that downstream systems can parse.

PostgreSQL Setup

-- Create publication for audited tables
CREATE PUBLICATION audit_pub FOR TABLE 
  public.users,
  public.user_profiles,
  public.payment_methods,
  public.transactions,
  public.roles,
  public.permissions,
  public.user_roles
WITH (publish = 'insert, update, delete');

-- Enable REPLICA IDENTITY FULL to capture before values on updates
ALTER TABLE public.users REPLICA IDENTITY FULL;
ALTER TABLE public.user_profiles REPLICA IDENTITY FULL;
ALTER TABLE public.payment_methods REPLICA IDENTITY FULL;
ALTER TABLE public.transactions REPLICA IDENTITY FULL;

Sample Audit Event Output

With Debezium envelope:

{
  "payload": {
    "before": {
      "id": 42,
      "email": "alice@old-domain.com",
      "name": "Alice Smith",
      "password_hash": "[REDACTED]",
      "_password_hash_redacted": true
    },
    "after": {
      "id": 42,
      "email": "alice@new-domain.com",
      "name": "Alice Smith",
      "password_hash": "[REDACTED]",
      "_password_hash_redacted": true
    },
    "source": {
      "version": "0.1.0",
      "connector": "postgres",
      "name": "app-postgres",
      "ts_ms": 1705312199123,
      "db": "app",
      "schema": "public",
      "table": "users"
    },
    "op": "u",
    "ts_ms": 1705312200000,
    "tags": [
      "audited",
      "sensitivity:HIGH",
      "classification:PII",
      "retention:2190d",
      "regulation:GDPR",
      "regulation:CCPA",
      "changed:email"
    ]
  }
}

Parsing Audit Tags

Downstream consumers can parse the structured tags:

// Parse audit tags into an object
function parseAuditTags(tags) {
  const audit = {};
  for (const tag of tags || []) {
    if (tag.includes(':')) {
      const [key, value] = tag.split(':', 2);
      if (audit[key]) {
        if (!Array.isArray(audit[key])) {
          audit[key] = [audit[key]];
        }
        audit[key].push(value);
      } else {
        audit[key] = value;
      }
    } else {
      audit[tag] = true;
    }
  }
  return audit;
}

// Example: parseAuditTags(event.tags)
// Returns: {
//   audited: true,
//   sensitivity: "HIGH",
//   classification: "PII",
//   retention: "2190d",
//   regulation: ["GDPR", "CCPA"],
//   changed: "email"
// }

Kafka Topic Configuration

kafka-topics.sh --create \
  --topic audit.trail.events \
  --partitions 12 \
  --replication-factor 3 \
  --config retention.ms=189216000000 \
  --config cleanup.policy=delete \
  --config min.insync.replicas=2 \
  --config compression.type=gzip \
  --bootstrap-server ${KAFKA_BROKERS}

Running the Example

1. Set Environment Variables

export POSTGRES_DSN="postgres://user:password@localhost:5432/app"
export KAFKA_BROKERS="kafka1:9092,kafka2:9092,kafka3:9092"

2. Start DeltaForge

cargo run -p runner -- --config audit-trail.yaml

3. Verify Audit Events

./dev.sh k-consume audit.trail.events --from-beginning

Querying Audit Data

-- Find high-sensitivity changes
SELECT * FROM audit_events 
WHERE ARRAY_CONTAINS(payload.tags, 'sensitivity:HIGH');

-- Find all email changes
SELECT * FROM audit_events
WHERE ARRAY_CONTAINS(payload.tags, 'changed:email');

-- Find PCI-regulated changes
SELECT * FROM audit_events
WHERE ARRAY_CONTAINS(payload.tags, 'regulation:PCI-DSS');

Key Concepts Demonstrated

  • Full Change Capture: Before and after values with REPLICA IDENTITY FULL
  • PII Redaction: Sensitive fields masked, presence tracked via _field_redacted
  • Tag-Based Metadata: Audit info stored in event.tags as parseable strings
  • Immutable Storage: Transactional atomic-batch delivery to append-only Kafka log (at-least-once across restart; dedup on event id)
  • Compliance Tagging: Retention periods, classifications, regulations as tags

Real-Time Analytics Preprocessing Pipeline

This example demonstrates preparing CDC events for real-time analytics by enriching events with dimensions, metrics, and routing tags (pre-processing).

Overview

ComponentConfiguration
SourceMySQL binlog CDC
ProcessorJavaScript analytics enrichment
SinksKafka (stream processing) + Redis (real-time counters)
PatternAnalytics-ready event preparation

Use Case

You have an e-commerce MySQL database and want to:

  • Stream order events to real-time dashboards
  • Feed a worker that maintains live counters in Redis
  • Prepare events for stream processing (Flink, Spark Streaming)

Pipeline Configuration

apiVersion: deltaforge/v1
kind: Pipeline
metadata:
  name: ecommerce-analytics
  tenant: acme

spec:
  source:
    type: mysql
    config:
      id: ecommerce-mysql
      dsn: ${MYSQL_DSN}
      tables:
        - shop.orders
        - shop.order_items
        - shop.payments
        - shop.cart_events

  processors:
    - type: javascript
      id: analytics-enrichment
      inline: |
        function processBatch(events) {
          return events.map(event => {
            const table = event.table.split('.')[1];
            const record = event.after || event.before;
            
            // Add analytics metadata to event.after
            // (we can modify event.after since it's a JSON Value)
            if (event.after) {
              event.after._analytics = {
                event_type: `${table}.${mapOperation(event.op)}`,
                dimensions: extractDimensions(table, record, event),
                metrics: extractMetrics(table, record)
              };
            }
            
            // Add routing tags
            event.tags = generateTags(table, record, event);
            
            return event;
          });
        }
        
        function mapOperation(op) {
          return { 'c': 'created', 'u': 'updated', 'd': 'deleted', 'r': 'snapshot' }[op] || op;
        }
        
        function extractDimensions(table, record, event) {
          if (!record) return {};
          
          const dims = {
            hour_of_day: new Date(event.timestamp).getUTCHours(),
            day_of_week: new Date(event.timestamp).getUTCDay()
          };
          
          switch (table) {
            case 'orders':
              dims.customer_id = record.customer_id;
              dims.status = record.status;
              dims.channel = record.channel || 'web';
              break;
            case 'order_items':
              dims.order_id = record.order_id;
              dims.product_id = record.product_id;
              break;
            case 'payments':
              dims.order_id = record.order_id;
              dims.payment_method = record.method;
              break;
          }
          
          return dims;
        }
        
        function extractMetrics(table, record) {
          if (!record) return {};
          
          const metrics = { event_count: 1 };
          
          switch (table) {
            case 'orders':
              metrics.order_total = parseFloat(record.total) || 0;
              metrics.item_count = parseInt(record.item_count) || 0;
              break;
            case 'order_items':
              metrics.quantity = parseInt(record.quantity) || 1;
              metrics.line_total = parseFloat(record.line_total) || 0;
              break;
            case 'payments':
              metrics.payment_amount = parseFloat(record.amount) || 0;
              break;
          }
          
          return metrics;
        }
        
        function generateTags(table, record, event) {
          const tags = [table, event.op];
          
          if (!record) return tags;
          
          if (table === 'orders' && record.total > 500) {
            tags.push('high_value');
          }
          if (record.status) {
            tags.push(`status:${record.status}`);
          }
          
          return tags;
        }
      limits:
        timeout_ms: 500
        mem_mb: 256

  sinks:
    - type: kafka
      config:
        id: analytics-kafka
        brokers: ${KAFKA_BROKERS}
        topic: analytics.events
        envelope:
          type: native
        encoding: json
        required: true
        client_conf:
          compression.type: "lz4"

    - type: redis
      config:
        id: realtime-redis
        uri: ${REDIS_URI}
        stream: analytics:realtime
        envelope:
          type: native
        encoding: json
        required: false

  batch:
    max_events: 500
    max_bytes: 1048576
    max_ms: 100
    respect_source_tx: false

  commit_policy:
    mode: required

JavaScript Processor Constraints

Important: Analytics metadata is stored in event.after._analytics because the processor can only modify existing Event fields (before, after, tags). Arbitrary top-level fields would be lost during serialization.

Sample Event Output

{
  "before": null,
  "after": {
    "id": 98765,
    "customer_id": 12345,
    "total": 299.99,
    "status": "pending",
    "channel": "mobile",
    "_analytics": {
      "event_type": "orders.created",
      "dimensions": {
        "hour_of_day": 10,
        "day_of_week": 3,
        "customer_id": 12345,
        "status": "pending",
        "channel": "mobile"
      },
      "metrics": {
        "event_count": 1,
        "order_total": 299.99,
        "item_count": 3
      }
    }
  },
  "source": {
    "connector": "mysql",
    "db": "shop",
    "table": "orders"
  },
  "op": "c",
  "ts_ms": 1705312200000,
  "tags": ["orders", "c", "status:pending"]
}

Redis Counter Worker

const Redis = require('ioredis');
const redis = new Redis(process.env.REDIS_URI);

async function processAnalyticsEvents() {
  let lastId = '0';
  
  while (true) {
    const results = await redis.xread(
      'COUNT', 100, 'BLOCK', 1000,
      'STREAMS', 'analytics:realtime', lastId
    );
    
    if (!results) continue;
    
    for (const [stream, messages] of results) {
      for (const [id, fields] of messages) {
        const event = JSON.parse(fields[1]);
        await updateCounters(event);
        lastId = id;
      }
    }
  }
}

async function updateCounters(event) {
  const pipe = redis.pipeline();
  const now = new Date();
  const hourKey = `${now.getUTCFullYear()}:${now.getUTCMonth()+1}:${now.getUTCDate()}:${now.getUTCHours()}`;
  const dayKey = `${now.getUTCFullYear()}:${now.getUTCMonth()+1}:${now.getUTCDate()}`;
  
  // Get analytics from event.after
  const analytics = event.after?._analytics || {};
  const eventType = analytics.event_type || `${event.table}.${event.op}`;
  const metrics = analytics.metrics || {};
  const dimensions = analytics.dimensions || {};
  
  // Event counts
  pipe.hincrby(`stats:events:${hourKey}`, eventType, 1);
  
  // Order-specific counters
  if (eventType === 'orders.created') {
    pipe.incr(`stats:orders:count:${hourKey}`);
    pipe.incrbyfloat(`stats:orders:revenue:${hourKey}`, metrics.order_total || 0);
    
    if (dimensions.channel) {
      pipe.hincrby(`stats:orders:channel:${dayKey}`, dimensions.channel, 1);
    }
  }
  
  // High-value alerts
  if (event.tags?.includes('high_value')) {
    pipe.lpush('alerts:high_value_orders', JSON.stringify({
      order_id: event.after?.id,
      total: metrics.order_total
    }));
    pipe.ltrim('alerts:high_value_orders', 0, 99);
  }
  
  pipe.expire(`stats:events:${hourKey}`, 90000);
  await pipe.exec();
}

processAnalyticsEvents().catch(console.error);

Running the Example

1. Set Environment Variables

export MYSQL_DSN="mysql://user:password@localhost:3306/shop"
export KAFKA_BROKERS="localhost:9092"
export REDIS_URI="redis://localhost:6379"

2. Create Kafka Topic

./dev.sh k-create analytics.events 12

3. Start DeltaForge

cargo run -p runner -- --config analytics-pipeline.yaml

4. Start Counter Worker

node realtime-counter-worker.js

Key Concepts Demonstrated

  • Event Enrichment: Add dimensions/metrics in event.after._analytics
  • Tag-Based Filtering: Use event.tags for high-value order detection
  • Multi-Sink Fan-Out: Kafka for stream processing + Redis for worker consumption
  • Worker Pattern: Separate worker consumes Redis stream to update counters

Outbox Pattern

This example demonstrates the transactional outbox pattern - writing business data and a domain event in the same database transaction, then streaming the event to Kafka via DeltaForge.

Overview

ComponentConfiguration
SourceMySQL binlog CDC
ProcessorOutbox (extract + route)
SinkKafka (per-aggregate topics)
PatternTransactional outbox with raw payload delivery

Use Case

Your application writes orders and needs to publish OrderCreated, OrderShipped, etc. events to Kafka. You want:

  • Atomicity: event published if and only if the transaction commits
  • Clean payloads: consumers receive the application’s JSON, not CDC envelopes
  • Per-aggregate routing: events land on Order.OrderCreated, Payment.PaymentReceived, etc.
  • Zero polling: DeltaForge tails the binlog, no application-side outbox relay

Database Setup

-- Business table
CREATE TABLE orders (
  id         INT AUTO_INCREMENT PRIMARY KEY,
  customer   VARCHAR(64),
  total      DECIMAL(10,2),
  status     VARCHAR(32),
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Outbox table (BLACKHOLE = binlog only, no disk storage)
CREATE TABLE outbox (
  id             INT AUTO_INCREMENT PRIMARY KEY,
  aggregate_type VARCHAR(64),
  aggregate_id   VARCHAR(64),
  event_type     VARCHAR(64),
  payload        JSON
) ENGINE=BLACKHOLE;

Application Code

Write both in the same transaction:

BEGIN;

INSERT INTO orders (customer, total, status)
VALUES ('alice', 149.99, 'pending');

INSERT INTO outbox (aggregate_type, aggregate_id, event_type, payload)
VALUES (
  'Order',
  LAST_INSERT_ID(),
  'OrderCreated',
  JSON_OBJECT('customer', 'alice', 'total', 149.99, 'status', 'pending')
);

COMMIT;

If the transaction rolls back, neither the order nor the event exist. If it commits, both do.

Pipeline Configuration

apiVersion: deltaforge/v1
kind: Pipeline
metadata:
  name: orders-outbox
  tenant: acme

spec:
  source:
    type: mysql
    config:
      id: orders-mysql
      dsn: ${MYSQL_DSN}
      tables:
        - shop.orders
        - shop.outbox
      outbox:
        tables: ["shop.outbox"]

  processors:
    - type: outbox
      topic: "${aggregate_type}.${event_type}"
      default_topic: events.unrouted
      raw_payload: true

  sinks:
    - type: kafka
      config:
        id: events-kafka
        brokers: ${KAFKA_BROKERS}
        topic: "cdc.${source.db}.${source.table}"
        envelope:
          type: debezium
        encoding: json
        required: true

  batch:
    max_events: 500
    max_ms: 500
    respect_source_tx: true

  commit_policy:
    mode: required

What lands on Kafka

Outbox event –> topic Order.OrderCreated:

{"customer": "alice", "total": 149.99, "status": "pending"}

Raw payload, no envelope. Kafka headers carry metadata: df-aggregate-type: Order, df-aggregate-id: 1, df-event-type: OrderCreated.

CDC event –> topic cdc.shop.orders (from sink’s topic template):

{
  "payload": {
    "before": null,
    "after": {"id": 1, "customer": "alice", "total": 149.99, "status": "pending"},
    "source": {"connector": "mysql", "db": "shop", "table": "orders"},
    "op": "c",
    "ts_ms": 1700000000000
  }
}

Full Debezium envelope. Both flow through the same pipeline - the outbox processor only touches events tagged as outbox.

Running the Example

1. Start Infrastructure

./dev.sh up
./dev.sh k-create Order.OrderCreated 3
./dev.sh k-create Order.OrderShipped 3
./dev.sh k-create cdc.shop.orders 3

2. Set Environment Variables

export MYSQL_DSN="mysql://deltaforge:dfpw@localhost:3306/shop"
export KAFKA_BROKERS="localhost:9092"

3. Start DeltaForge

cargo run -p runner -- --config outbox.yaml

4. Insert Test Data

BEGIN;
INSERT INTO shop.orders (customer, total, status) VALUES ('alice', 149.99, 'pending');
INSERT INTO shop.outbox (aggregate_type, aggregate_id, event_type, payload)
VALUES ('Order', LAST_INSERT_ID(), 'OrderCreated',
        '{"customer":"alice","total":149.99,"status":"pending"}');
COMMIT;

5. Verify

# Outbox event (raw payload, per-aggregate topic)
./dev.sh k-inspect Order.OrderCreated

# CDC event (Debezium envelope, per-table topic)
./dev.sh k-inspect cdc.shop.orders

Variations

PostgreSQL with WAL Messages

PostgreSQL doesn’t need an outbox table - write directly to the WAL:

BEGIN;
INSERT INTO orders (customer, total, status) VALUES ('alice', 149.99, 'pending');
SELECT pg_logical_emit_message(
  true, 'outbox',
  '{"aggregate_type":"Order","aggregate_id":"1","event_type":"OrderCreated","payload":{"customer":"alice","total":149.99}}'
);
COMMIT;
source:
  type: postgres
  config:
    id: orders-pg
    dsn: ${POSTGRES_DSN}
    slot: deltaforge_orders
    publication: orders_pub
    tables: [public.orders]
    outbox:
      prefixes: [outbox]

No table, no index, no vacuum - just a WAL entry.

Multiple Outbox Channels

Route order events and payment events to different topic hierarchies:

processors:
  - type: outbox
    tables: [orders_outbox]
    topic: "orders.${event_type}"
    raw_payload: true

  - type: outbox
    tables: [payments_outbox]
    topic: "payments.${event_type}"
    raw_payload: true
    columns:
      payload: data

Additional Headers for Tracing

Forward trace and correlation IDs as Kafka headers:

processors:
  - type: outbox
    topic: "${aggregate_type}.${event_type}"
    raw_payload: true
    additional_headers:
      x-trace-id: trace_id
      x-correlation-id: correlation_id
INSERT INTO outbox (aggregate_type, aggregate_id, event_type, payload, trace_id, correlation_id)
VALUES ('Order', '42', 'OrderCreated', '{"total":99.99}', 'abc-123', 'req-456');

Migrating from Debezium

If your outbox table uses Debezium’s column names (aggregatetype, aggregateid, type):

processors:
  - type: outbox
    topic: "${aggregatetype}.${type}"
    raw_payload: true
    columns:
      aggregate_type: aggregatetype
      aggregate_id: aggregateid
      event_type: type
    additional_headers:
      x-trace-id: traceid
      x-tenant: tenant

Column mappings control header extraction. The topic template uses raw column names directly.

Key Concepts Demonstrated

  • Transactional outbox: atomicity without distributed transactions
  • BLACKHOLE engine: binlog-only storage for zero-cost outbox tables on MySQL
  • Raw payload delivery: raw_payload: true bypasses envelope wrapping - consumers get exactly what the application wrote
  • Mixed pipeline: outbox events and CDC events coexist in the same pipeline with different serialization
  • Per-aggregate routing: topic template routes events by aggregate_type and event_type

Troubleshooting

Common issues and quick checks when running DeltaForge.

  • 🩺 Health-first: start with /health and /ready to pinpoint failing components.

Runner fails to start

  • Confirm the config path passed to --config exists and is readable.
  • Validate YAML syntax and that required fields like metadata.name and spec.source are present.
  • Ensure environment variables referenced in the spec are set (dsn, brokers, uri, etc.).

Pipelines remain unready

  • Check the /ready endpoint for per-pipeline status and error messages.
  • Verify upstream credentials allow replication (MySQL binlog). Other engines are experimental unless explicitly documented.
  • Inspect sink connectivity; a required sink that cannot connect will block checkpoints.

Slow throughput

  • Increase batch.max_events or batch.max_bytes to reduce flush frequency.
  • Adjust max_inflight to allow more concurrent batches if sinks can handle parallelism.
  • Reduce processor work or add guardrails (limits) to prevent slow JavaScript from stalling the pipeline.

Snapshot copied again or a sink reported incomplete

  • snapshot_replaced: a generation was replaced (restart, lost read view, policy change, or the one-time upgrade recopy); every table is copied again and sinks receive duplicates.
  • sink_snapshot_incomplete: a sink missed part of a completed snapshot. It continues with changes but needs a fresh baseline. See Completion and lagging sinks.
  • A snapshot that never starts may be queued for connections: check deltaforge_snapshot_queued.

Checkpoints not advancing

  • Review the commit policy: mode: all or required sinks that are unavailable will block progress.
  • Look for sink-specific errors (for example, Kafka broker unreachability or Redis backpressure).
  • Pause and resume the pipeline to force a clean restart after addressing the underlying issue.

/health returns 503

A 503 means at least one pipeline has entered a permanently failed state — it will not recover on its own. Common causes:

CauseLog messageResolution
Failover to a server with no GTID overlapposition lost after failoverRe-snapshot from the new primary
RESET BINARY LOGS AND GTIDS run on same servercheckpoint GTID set no longer reachableApply the resnapshot recovery operation for the incident; never delete checkpoints by hand
Unrecoverable source errorrun task ended with errorCheck source logs; fix the root cause and restart
Snapshot generation blockedincident snapshot_anchor_unavailable or snapshot_bound_exceededFix the cause (retention, anchor age, plan bounds or tables), then apply the resnapshot recovery operation; a restart alone stops again. See Bounds and blocking
PostgreSQL source stops with timeline_unrecordedincident pg_continuity_unprovenApply pg-adopt-timeline on the primary, or resnapshot
Pipeline will not start: recovery_pendingstatus recovery_pendingRun deltaforge recover diagnose, then re-apply the pending operation with its own proof. See Pending and diverged operations
Snapshot state unusableincident snapshot_state_invalidStored snapshot state is corrupt or of an unknown format and was left untouched; do not downgrade past it. See Initial Snapshots

Use GET /pipelines to see which pipeline has "status": "failed" and check its logs for the specific error. After fixing the root cause, restart the DeltaForge process (or the container) to reset pipeline state.

Avro encoding issues

Schema Registry connection failed

WARN Schema Registry unavailable — using cached schema

DeltaForge is encoding with a previously cached schema ID. Events are still flowing, but new schema registrations (e.g., after DDL changes) will fail until the SR recovers. Check:

  • Schema Registry connectivity: curl http://<sr-url>/subjects
  • Network/firewall between DeltaForge and the SR
  • Monitor deltaforge_avro_encode_failure_total{reason="sr_unavailable"} — if increasing, the SR is unreachable and there’s no cached schema to fall back to

Schema registration rejected (compatibility failure)

WARN DDL change for {table} produced incompatible Avro schema — encoding with previous version

The source table DDL changed and the new Avro schema was rejected by the Schema Registry’s compatibility rules. DeltaForge attempts to encode with the previous schema version:

  • If encoding succeeds: events continue flowing (the old schema still covers the new data)
  • If encoding fails: events are routed to DLQ with schema_mismatch error

Resolution: Either relax the SR subject compatibility mode (e.g., NONE or FORWARD) or handle the DLQ entries after updating consumer schemas.

Events routed to DLQ with schema_mismatch

A DDL change produced events that can’t be encoded under the cached schema. This happens when a non-backward-compatible change is made (e.g., column type change, NOT NULL added to existing column).

Resolution:

  1. Check deltaforge_avro_encode_failure_total{reason="schema_mismatch"}
  2. Identify the DDL change from source DB logs
  3. Update the SR subject compatibility if needed
  4. Restart the pipeline to clear the schema cache and re-register

BIGINT UNSIGNED overflow warning

WARN BIGINT UNSIGNED column {col} mapped to long — values >= 2^63 will fail encoding

Only appears when unsigned_bigint_mode: long is configured. Values through 2^63 - 1 (i64::MAX) encode exactly. A value ≥ 2^63 does not fit an Avro long: encoding fails explicitly (<value> does not fit an Avro long) and the event is never written wrapped, zeroed or as a floating-point number. Without the event journal’s DLQ the pipeline halts with an incident; the failure is DLQ-eligible when the journal is enabled. The default unsigned_bigint_mode: string encodes every value, through 2^64 - 1, as its exact decimal string.

Using inferred schema instead of DDL

DEBUG no DDL schema available — falling back to JSON inference (Path C)

DeltaForge couldn’t look up the source table schema and is using a less precise JSON-inferred schema. This happens when:

  • The schema loader hasn’t cached the table schema yet (first events at startup)
  • The table doesn’t match the configured table patterns
  • The source type doesn’t support schema loading

Check deltaforge_avro_encode_total{path="inferred"} — if this counter is growing while path="ddl" is not, investigate why DDL lookup is failing.

DeltaForge

Development Guide

Use this guide to build, test, and extend DeltaForge. It covers local workflows, optional dependency containers, and how to work with Docker images.

All contributions are welcome and highly appreciated.

Local prerequisites

  • Rust toolchain 1.89+ (install via rustup).
  • Optional: Docker or Podman for running the dev dependency stack and the container image.

Workspace layout

  • crates/deltaforge-core : shared event model, pipeline engine, and checkpointing primitives.
  • crates/deltaforge-config : YAML config parsing, environment variable expansion, and pipeline spec types.
  • crates/sources : database CDC readers (MySQL binlog, Postgres logical replication) implemented as pluggable sources.
  • crates/processors : JavaScript-based processors and support code for transforming batches.
  • crates/sinks : sink implementations (Kafka producer, Redis streams, NATS JetStream) plus sink utilities.
  • crates/rest-api : HTTP control plane with health/readiness and pipeline lifecycle endpoints.
  • crates/runner : CLI entrypoint that wires the runtime, metrics, and control plane together.
  • crates/chaos : end-to-end chaos scenario runner, benchmarks, and interactive playground UI.

Use these crate boundaries as reference points when adding new sources, sinks, or pipeline behaviors.

Start dev dependencies

Bring up the optional backing services (MySQL, Kafka, Redis) with Docker Compose:

docker compose -f docker-compose.dev.yml up -d

Each service is exposed on localhost for local runs (5432, 3306, 9092, 6379). The MySQL container seeds demo data from ./init-scripts and configures binlog settings required for CDC.

Prefer the convenience dev.sh wrapper to keep common tasks consistent:

./dev.sh up     # start the dependency stack
./dev.sh down   # stop and remove it
./dev.sh ps     # see container status

Build and test locally

Run the usual Rust workflow from the repo root:

cargo fmt --all
cargo clippy --workspace --all-targets --all-features
cargo test --workspace

Or use the helper script for a single command that mirrors CI expectations:

./dev.sh build         # build project (debug)
./dev.sh build-release # build project (release)
./dev.sh run           # run with examples/dev.yaml
./dev.sh fmt           # format code
./dev.sh lint          # clippy with warnings as errors
./dev.sh test          # full test suite
./dev.sh check         # fmt --check + clippy + tests (mirrors CI)
./dev.sh cov           # generate coverage report

Docker images

Use pre-built images

Multi-arch images (amd64/arm64) are published to GHCR and Docker Hub:

# Minimal (~57MB, scratch-based, no shell)
docker pull ghcr.io/vnvo/deltaforge:latest
docker pull vnvohub/deltaforge:latest

# Debug (~140MB, includes shell for troubleshooting)
docker pull ghcr.io/vnvo/deltaforge:latest-debug
docker pull vnvohub/deltaforge:latest-debug
VariantSizeBaseUse case
latest~57MBscratchProduction
latest-debug~140MBdebian-slimTroubleshooting, has shell

Build locally

Two Dockerfiles are provided:

# Minimal image (~57MB)
docker build -t deltaforge:local .

# Debug image (~140MB, includes shell)
docker build -t deltaforge:local-debug -f Dockerfile.debug .

Or use the dev helper:

./dev.sh docker            # build minimal image
./dev.sh docker-debug      # build debug image
./dev.sh docker-test       # test minimal image runs
./dev.sh docker-test-debug # test debug image runs
./dev.sh docker-all        # build and test all variants
./dev.sh docker-shell      # open shell in debug container

Build multi-arch locally

To build for both amd64 and arm64:

./dev.sh docker-multi-setup  # create buildx builder (once)
./dev.sh docker-multi        # build both architectures

Note: Multi-arch builds use QEMU emulation and take ~30-35 minutes. The images are not loaded locally - use --push to push to a registry.

Run the image

Run the container by mounting your pipeline specs and exposing the API and metrics ports:

docker run --rm \
  -p 8080:8080 -p 9000:9000 \
  -v $(pwd)/examples/dev.yaml:/etc/deltaforge/pipeline.yaml:ro \
  -v deltaforge-checkpoints:/app/data \
  ghcr.io/vnvo/deltaforge:latest \
  --config /etc/deltaforge/pipeline.yaml

Notes:

  • The container listens on 0.0.0.0:8080 for the control plane API with metrics on :9000.
  • State (checkpoints, schema registry) is written to the SQLite database at /app/data/deltaforge.db; mount a volume to persist it across restarts.
  • Environment variables inside the YAML are expanded before parsing.
  • Pass any other runner flags as needed (e.g., --api-addr or --metrics-addr).

Debug a running container

Use the debug image to troubleshoot:

# Run with shell access
docker run --rm -it --entrypoint /bin/bash ghcr.io/vnvo/deltaforge:latest-debug

# Exec into a running container
docker exec -it <container_id> /bin/bash

Dev helper commands

The dev.sh script provides shortcuts for common tasks:

./dev.sh help  # show all commands

Infrastructure

./dev.sh up           # start MySQL, Kafka, Redis
./dev.sh down         # stop and remove containers
./dev.sh ps           # list running services

Kafka

./dev.sh k-list                         # list topics
./dev.sh k-create <topic>               # create topic
./dev.sh k-consume <topic> --from-beginning
./dev.sh k-produce <topic>              # interactive producer

Redis

./dev.sh redis-cli                      # open redis-cli
./dev.sh redis-read <stream>            # read from stream

Database shells

./dev.sh pg-sh       # psql into Postgres
./dev.sh mysql-sh    # mysql into MySQL

Documentation

./dev.sh docs        # serve docs locally (opens browser)
./dev.sh docs-build  # build docs

Pre-release checks

./dev.sh release-check  # run all checks + build all Docker variants

Chaos testing

End-to-end resilience tests and benchmarks run against a live Docker Compose stack with fault injection via Toxiproxy. Scenarios cover network partitions, sink outages, crash recovery, server failover, schema drift, binlog purge, long-running endurance runs, and binlog backlog drain benchmarks.

Prerequisites

Build the debug image first (includes a shell, needed for some scenarios):

docker build -t deltaforge:dev-debug -f Dockerfile.debug .

Stack profiles

The df compose profile starts 3 DeltaForge instances — one per build variant:

InstancePortImageUse case
deltaforge-release8080deltaforge:latestProduction behavior, regression testing
deltaforge-debug8081deltaforge:dev-debugVerbose logging, assertions, chaos scenarios
deltaforge-profile8082deltaforge:dev-profileFlamegraphs, CPU profiling, benchmarks

Pipeline configs are selected dynamically — either via the chaos UI config dropdown, or the CLI --port flag. All 3 instances start with a default config (mysql-to-kafka.yaml) and can be swapped at runtime.

Start the chaos environment

docker compose -f docker-compose.chaos.yml \
  --profile base --profile mysql-infra --profile kafka-infra --profile df up -d

Add --profile pg-infra if testing PostgreSQL scenarios.

Run resilience scenarios

# Target the debug instance (default --port 8080, override with --port)
cargo run -p chaos -- --scenario all --source mysql
cargo run -p chaos -- --scenario network-partition --port 8081
cargo run -p chaos -- --scenario all --source postgres --port 8080

Exit code is 0 on full pass, 1 on any failure — suitable for CI.

Run endurance and benchmark scenarios

Before running soak/drain, apply the appropriate config via the UI or REST API:

# Apply soak config to the profile instance (for flamegraphs)
curl -X POST http://localhost:7474/api/apply-config \
  -H 'Content-Type: application/json' \
  -d '{"port": 8082, "config": "mysql-soak.yaml"}'

# Soak — long-running with random fault injection
cargo run -p chaos -- --scenario soak --port 8082 --topic chaos.soak

# Soak-stable — same workload, no faults (baseline)
cargo run -p chaos -- --scenario soak-stable --port 8082 --topic chaos.soak --duration-mins 30

# Backlog-drain — measures catch-up throughput (1M row replay)
cargo run -p chaos -- --scenario backlog-drain --port 8082 --topic chaos.soak --no-proxy

# Backlog-drain with custom tuning
cargo run -p chaos -- --scenario backlog-drain --port 8082 --topic chaos.soak --no-proxy \
  --drain-max-events 4000 --drain-max-ms 100 \
  --drain-kafka-conf linger.ms=0

# TPC-C — apply tpcc config first, then run
curl -X POST http://localhost:7474/api/apply-config \
  -H 'Content-Type: application/json' \
  -d '{"port": 8081, "config": "mysql-tpcc.yaml"}'
cargo run -p chaos -- --scenario tpcc --port 8081 --duration-mins 30

Avro encoding tests

# Unit + mock tests (no Docker needed)
cargo test -p sinks --test avro_encoding_tests

# Real Schema Registry integration tests (Docker, needs kafka-infra for SR)
cargo test -p sinks --test avro_encoding_tests -- --include-ignored --nocapture --test-threads=1

# Avro chaos scenario: apply Avro config, then run SR outage
curl -X POST http://localhost:7474/api/apply-config \
  -H 'Content-Type: application/json' \
  -d '{"port": 8081, "config": "mysql-to-kafka-avro.yaml"}'
cargo run -p chaos -- --scenario sr-outage --port 8081

# JSON vs Avro throughput comparison:
# Instance 1 (debug): JSON soak
curl -X POST http://localhost:7474/api/apply-config \
  -H 'Content-Type: application/json' \
  -d '{"port": 8081, "config": "mysql-soak.yaml"}'
cargo run -p chaos -- --scenario soak-stable --port 8081 --topic chaos.soak --duration-mins 30

# Instance 2 (profile): Avro soak
curl -X POST http://localhost:7474/api/apply-config \
  -H 'Content-Type: application/json' \
  -d '{"port": 8082, "config": "mysql-soak-avro.yaml"}'
cargo run -p chaos -- --scenario soak-stable --port 8082 --topic chaos.soak.avro --duration-mins 30

Compare in Grafana: rate(deltaforge_sink_events_total[1m]) by instance port.

See Performance Tuning for detailed throughput optimization guidance and profiling instructions.

Playground UI

The chaos binary also ships an interactive web UI for manual exploration:

cargo run -p chaos -- --scenario ui
# Open http://localhost:7474

The UI provides:

  • Live service status with health dots, port badges, and Docker image selector
  • Stale image detection — warns when a container is running an older image after a rebuild
  • Activity bar — shows current operation with per-button loading state and task history
  • Console log — unified output for all actions (infra, faults, scenarios) with smart auto-scroll
  • One-click fault injection via Toxiproxy (partitions, latency, bandwidth throttle)
  • Scenario runner with proxy bypass toggle, drain settings, and live log streaming
  • Pipeline API browser for any DeltaForge instance
  • Config Lab for A/B config comparison with presets
  • CPU profiler — captures flamegraphs from running containers with pipeline context in the subtitle

Data management

The UI includes a Data Management card for resetting persistent state between test runs:

  • Reset Checkpoints — stops all DeltaForge instances and deletes their SQLite checkpoint databases (GTID positions, replication offsets). Source databases and Kafka are untouched. Use this when switching branches or after a binlog purge leaves stale checkpoint state.
  • Reset All Volumes — runs docker compose down -v across all profiles, removing every named volume (MySQL data, Kafka state, Postgres data, checkpoints, Grafana). Full clean slate that requires re-initialization of all services.

Teardown

docker compose -f docker-compose.chaos.yml --profile app down -v
docker compose -f docker-compose.chaos.yml down -v

See crates/chaos/README.md for the full scenario catalogue, network topology, all CLI flags, and instructions for adding new scenarios.

Contributing

  1. Fork the repository
  2. Create a branch from main (e.g., feature/new-sink, fix/checkpoint-bug)
  3. Make your changes
  4. Run ./dev.sh check to ensure CI will pass
  5. Submit a PR against main

Things to Remember

Tests

There a few #[ignore] tests, run them when making deep changes to the sources, pipeline coordination and anything with impact on core functionality.

Logging hygiene

  • Include pipeline, tenant, source_id/sink_id, and batch_id fields on all warnings/errors to make traces joinable in log aggregation tools.
  • Normalize retry/backoff logs so they include the attempt count and sleep duration; consider a structured reason field alongside error details for dashboards.
  • Add info-level summaries on interval (e.g., every N batches) reporting batches processed, average batch size, lag, and sink latency percentiles pulled from the metrics registry to create human-friendly breadcrumbs.
  • Add metrics in a backward-compatible way: prefer new metric names over redefining existing ones to avoid breaking dashboards. Validate cardinality (bounded label sets) before merging.
  • Gate noisy logs behind levels (debug for per-event traces, info for batch summaries, warn/error for retries and failures).
  • Exercise the new metrics in integration tests by asserting counters change when sending synthetic events through pipelines.

Roadmap

Completed

  • S3 / Parquet / JSON Lines sink - direct lakehouse path; AWS S3, MinIO, GCS, Azure, local FS via object_store. DDL-derived Arrow schemas with native Decimal128. Hive-style partitioning by table + UTC date. File rolling on size/events/age/idle. See S3 sink docs.
  • Crash-durable S3 acknowledgements (durable_v2) - the default S3 durability mode makes the acknowledgement point the durability point (immutable content-addressed objects + manifest entry + HEAD compare-and-swap), so acknowledged data is never lost across a crash. legacy_rolling remains available for rollback. See durable acks docs.
  • ClickHouse sink - HTTP + RowBinary; change-log (MergeTree) or current-state upsert (ReplacingMergeTree); auto table creation from source DDL. See ClickHouse sink docs.
  • Elasticsearch sink - _bulk API current-state mirror; upsert by PK with version_type=external; delete propagation; DDL-derived index mappings. See Elasticsearch sink docs.
  • Avro encoding with Confluent Schema Registry - DDL-derived Avro schemas, Confluent wire format, all sinks supported, type conversion policies, Schema Registry failure handling with cached fallback
  • HTTP/Webhook sink - POST/PUT to any URL, URL templates, batch mode, retry with backoff
  • Dead Letter Queue - per-event failure routing, overflow policies, REST API for inspection
  • Event replay - re-deliver captured commit units from the durable journal to selected sinks (recover from consumer bugs, catch a sink up after an outage) with a durable, resumable job model, an acknowledged pause/handoff back to live delivery, at-least-once semantics, and a REST API (start/dry-run/status/cancel). See Event Replay.
  • Transaction-aware batching - a commit unit contains only whole transactions; the checkpoint only ever lands at a transaction boundary; resume restarts on a clean boundary. Oversized-transaction fail-closed with a typed error. See Guarantees & Correctness.
  • Per-sink independent checkpoints - each sink advances independently, source replays from minimum
  • Transactional atomic-batch delivery - Kafka transactional producer (at-least-once across restart) with producer fencing detection
  • Helm chart - StatefulSet, ConfigMap, PVC, ServiceMonitor, PDB
  • Schema sensing - automatic schema inference from payloads, high-cardinality key detection

In Progress

  • Avro encoding performance - hot-path optimization (47K events/s steady-state). TD-001 (direct ColumnValue→Avro conversion, eliminating JSON intermediary) planned for further gains.
  • Avro Schema Registry Phase 2 - publish sensed schemas to external Schema Registry as catalog metadata (under sensed. prefix). See RFC.

Planned

  • Deterministic rebuild (snapshot + replay) (next) - rebuild a sink’s full state deterministically from a snapshot plus journal replay. Builds on the completed event-replay journal and job model.
  • Iceberg / Delta Lake table formats - exactly-once at event level via atomic snapshot commits; schema evolution and time travel on top of the S3 sink
  • Kubernetes operator - PipelineTemplate + PipelinePool for fleet management
  • OpenAPI spec generation - auto-generated REST API documentation