Streaming Contract Validation¶
The streaming validation engine validates live records against data contracts, routes invalid records to a DLQ, and writes valid records to your chosen sink — in five lines of Python or via a long-lived stream worker process.
Kafka is optional. The engine is source/sink agnostic: bundled examples include WebSocket→file (streaming-massive-file), WebSocket→Kafka (streaming-massive-kafka), Kafka→file (streaming-kafka-file), and SQS→Kafka (streaming-ecommerce-sqs). See src/pycharter/data/seed/examples/README-streaming.md. Use kafka when you need cross-language fan-out; use file or postgres/database sinks for local dev and direct persistence.
This guide covers the production surface. For a five-minute walkthrough see the Streaming Validation Tutorial. For the implementation plan and design decisions see Streaming Validation Plan.
Experimental → stable: Run tab + dev stream-worker supervisor,
contract.annotate, database sinks, durable window checkpoints, and quality reducers are implemented. Remaining production deployment guidance (k8spycharter stream runDeployments, exactly-once constraints) stays documented below.
When to use it¶
| You want… | Use this. |
|---|---|
| Validate Kafka messages in your Python service before processing them | Library mode (pycharter.kafka.consume) |
| Validate a topic across many consumers with different languages | Worker mode (pycharter stream run) — the worker republishes valid records to a separate topic |
| Roll out validation gradually without breaking downstream consumers | Operating modes — start in detect, move to shadow, finalize at enforce |
| Replay the DLQ after fixing a contract | Replay CLI (pycharter stream replay) — re-validates DLQ envelopes against a chosen contract version, with --dry-run for previewing |
| Edit a stored worker config in the browser | Editing stored configs in the web UI — the unified pipeline editor opens streaming configs |
Library mode¶
Three public entry points, all in pycharter.kafka:
from pycharter import kafka
# 1. Producer-side helper — stamps the contract headers automatically.
await kafka.produce(
topic="orders",
value={"order_id": "abc", "amount": 50.0, "ok": True},
contract="orders@2",
bootstrap_servers="kafka:9092",
key="user-7", # preserved for partition routing
)
# 2. Consumer-side iterator — only validated records reach the loop.
async for record in kafka.consume(
topic="orders",
bootstrap_servers="kafka:9092",
group_id="my-service",
contract_dir="contracts/orders", # or contract_store=..., or validator_factory=...
):
process(record.payload)
# 3. Sync wrapper — for notebooks / legacy ETL that can't go async.
for record in kafka.consume_sync(
topic="orders",
bootstrap_servers="kafka:9092",
group_id="notebook",
contract_dir="contracts/orders",
):
process(record.payload)
Validator factories¶
consume() accepts exactly one of three validator-factory inputs:
contract_dir=Path(...)— single directory holding one contract; convenience for one-topic-one-contract setups.contract_store=ContractStoreClient(...)— a real registry. The engine resolves each(contract_id, version)from the message headers and caches the resultingValidatorin-process.validator_factory=callable— direct callable for advanced use (custom registries, test fakes).
Static fallback for migration¶
If some producers can't stamp the headers yet:
async for record in kafka.consume(
topic="orders",
contract_dir="contracts/orders",
static_contract="orders@2", # ContractRef or "name@version"
on_missing_header="fallback_to_static", # opt-in policy
...
):
Records with the headers are validated against whichever version they declare; records without the headers fall back to orders@2.
Worker mode¶
The worker is the same engine driven by config:
id: example/orders
version: "1.0.0"
streams:
- name: orders-validator
source:
type: kafka
topic: orders
bootstrap_servers: kafka:9092
consumer_group: orders-validator
contract:
ref: { type: file, path: /app/contracts/orders }
resolution: static
load:
target: { type: kafka, topic: orders-validated, bootstrap_servers: kafka:9092 }
dlq:
target: { type: kafka, topic: orders-dlq, bootstrap_servers: kafka:9092 }
mode: enforce
delivery: at_least_once
observability:
health_port: 8080
One process can run many streams. SIGTERM triggers graceful shutdown — every stream finishes its in-flight message, sinks flush, the process exits 0.
stream run accepts the same variable overrides as pipeline run:
--var KEY=VALUE (repeatable) merges over the config's variables:
block before ${VAR} resolution.
Validate before you run¶
pycharter stream validate config.yaml # local file
pycharter stream validate --name orders/prod # stored config
pycharter stream validate config.yaml --var REGION=eu-west-1
stream validate is the streaming sibling of pipeline validate: it
parses the config, resolves every stream's contract (file directories
are loaded and parsed; contract stores are connected and static refs
resolved), checks source/sink types and their required keys, parses
window specs, and applies the exactly_once constraints — all without
any Kafka I/O. Issues are collected per stream:
Validation FAILED: 2 preflight issue(s)
[orders-validator] contract: contract directory not found: /app/contracts/orders
[trades] load: load.target type 'file' requires 'path'
Exit codes: 0 clean, 1 preflight issues, 2 the config failed to
load or parse. The same checks are available in Python as
StreamWorker.from_file(...).preflight().
Preview with --dry-run¶
--dry-run consumes, resolves, and validates without writing to any
sink — no valid-topic produce, no DLQ writes, no extraction side
effects, and window checkpoints are neither restored nor overwritten.
Kafka sources are switched to an ephemeral consumer group
(pycharter-preview-<random>), so a preview never advances a real
group's offsets. --max-records N bounds the preview; per-stream
totals (consumed / valid / invalid) are logged at exit. Dry-run is
rejected for exactly_once streams — a transactional preview would
still commit offsets.
Discover and inspect configs¶
pycharter stream list # scan for worker.yaml files
pycharter stream list --store # list stored configs
pycharter stream inspect config.yaml # summary: streams, contracts, sinks
pycharter stream inspect --name orders/prod
Start/stop from the editor (dev)¶
When PYCHARTER_STREAM_SUPERVISOR_ENABLED=1 (default when ENVIRONMENT is not production), the API can spawn a local stream worker subprocess for a saved config. In production (or when PYCHARTER_STREAM_JOBS_ENABLED=1 with the supervisor off), Start enqueues a streaming_jobs row instead — a separate pycharter stream launch process claims jobs and spawns pycharter stream run children.
| Path | When | Command |
|---|---|---|
| Dev supervisor | ENVIRONMENT != production (default) |
API spawns subprocess |
| Job queue | Production / PYCHARTER_STREAM_JOBS_ENABLED=1 |
pycharter stream launch |
API endpoints:
POST /api/v1/streaming/configs/{name}/{version}/start— supervisor spawn or enqueue (202)POST /api/v1/streaming/configs/{name}/{version}/stop— cancel job + SIGTERMPOST /api/v1/streaming/jobs— explicit enqueueGET /api/v1/streaming/jobs/stats— queue depth for the Run tab / opsPOST /api/v1/streaming/jobs/{job_id}/cancel
Launcher (production):
Run one or more launcher replicas (k8s Deployment). This is not pycharter worker — the job worker handles finite validation/pipeline jobs; the stream launcher handles long-lived stream workers.
Explicitly invoked owned SQL queue methods provide local PID/cancellation state guarded by the exact current fence. The current launcher does not yet carry those fences; a queue acknowledgement or stored PID does not establish that an external process stopped or authorize a later process signal.
Opt-in seed hook: set post_seed.run_streaming: true in an example manifest.yaml to start (or print) the stream worker after pycharter db seed.
See the config schema reference for every field.
Editing stored configs in the web UI¶
Worker configs can be persisted in the streaming-config registry
(/api/v1/streaming/configs, keyed by stream_name + stream_version)
and managed from the web UI. The configs list lives at
Pipelines → Streaming → Configs (/pipelines/streaming/configs).
New config opens a dialog that collects the config's name, version,
and a stream template, stores the seeded config, and continues into the
unified pipeline editor — the same surface used for batch pipeline
configs and, since 2026-06-06, the single place where stored streaming
configs are edited (the former standalone raw-YAML Edit page is retired).
Opening a config in the editor¶
Three paths lead to the same editor tab:
- Editor Browse tab. In the editor's pipeline mode, the Browse tab shows
a collapsible Streaming section below the batch pipeline tree. Each row
carries a
streamingkind badge. A single click opens the config in a preview tab; a double click opens a permanent tab. - Configs list. Each row on
/pipelines/streaming/configshas an Open in editor action. - Deep link. The editor route is the batch route plus a query parameter:
/editor/pipelines/<name>/<version>?kind=streaming. Without the parameter, the same URL opens the batch pipeline of that name and version. The parameter survives both in-app navigation and pasting the URL into a fresh browser session.
Editing on the canvas¶
Each entry in streams[] renders as a three-node chain —
source → process → sink — plus a DLQ sidecar attached to the
process node. Streams are independent horizontal lanes; no edges cross
between them. All four nodes are editable:
- Inspectors. Selecting a node opens its inspector. Each inspector has
a Form tab for the modeled fields and a Raw JSON tab (parse-guarded
Apply) for everything else:
- Source — source type (Kafka, RabbitMQ, HTTP, WebSocket, SSE, SQS) plus type-specific fields.
- Process — stream name, contract reference,
validate/coerceflags,mode,delivery,on_error. Its Raw JSON tab shows the full stream body, so unmodeled per-stream keys are edited here. - Sink and DLQ — the
load/dlqtarget block (Kafka topic or JSONL file).
Fields the forms do not model stay editable in the Raw JSON tabs, and
unknown keys survive every save verbatim.
- Palette templates. The Components tab offers three drag-and-drop
stream templates: Kafka → Kafka, Kafka → File, and
Empty stream. Dropping one onto the canvas appends a complete,
schema-valid chain (every template includes a DLQ block because the
schema requires one). Templates reference a placeholder contract
(my_contract@1.0.0) — it passes save-time validation, but you must
point it at a real contract in the process inspector before a worker
can run the stream.
- Deleting. Deleting the process node removes the whole stream
(its chain and DLQ sidecar). Deleting the
DLQ sidecar drops only the dlq block. Source and sink nodes
cannot be deleted on their own — the chain shape is structural. To
re-add a deleted DLQ, add the dlq: block back in the Form tab YAML
(see below).
- Node positions. Layout adjustments persist locally in the
browser (per config name + version), not in the stored config —
moving nodes never dirties the workspace, and positions do not follow
the config to other machines.
Editing as YAML (Form tab)¶
The Phase-1 workflow remains fully supported. The Form tab shows the whole config as YAML (or JSON via the format toggle):
- Apply parses the text, merges it into the in-memory config,
re-projects the canvas, and marks the workspace dirty. Syntax errors and
an empty
streamsarray are rejected inline without touching the loaded config. Apply never contacts the server. - Save (toolbar button) persists the applied config with an update to
the loaded
(stream_name, stream_version). There is no save modal — the identity is locked when the config is loaded, so editingstream_nameorstream_versionin the YAML does not rename or fork the stored config. To create a new config, use the New config dialog on the configs list page (name + version + template), which stores a seeded config and opens it in the editor.
Sections of the config the canvas does not model (unknown stream keys,
metadata) ride along verbatim: loading a config and saving it without
edits produces an equivalent stored body. metadata edits made in the
YAML persist on save (see the limitations note below for the one caveat).
Bottom panel — Observability and Source Tap¶
Streaming editor tabs replace the batch bottom panel (Variables / Settings / Run) with two streaming-specific tabs:
- Observability — a structured editor for the worker-level
observabilityblock (health/metrics ports, log format) shared by every stream in the config. Edits mark the workspace dirty and persist through the normal Save. - Source Tap — a bounded, read-only dry-run of one selected stream's
source against its contract (the same engine as the standalone
/pipelines/streaming/configs/tappage). The tap runs against the last saved version of the config — a hint appears when the workspace has unsaved edits, since they are not reflected until you Save.
Validation on save¶
Every save goes through the server-side validation guard: the API validates
the assembled config with StreamingConfig.model_validate and rejects bad
shapes with 422 before anything is persisted. A rejected save shows a
Save failed: … message inline under the toolbar, and the workspace stays
dirty — fix the config, Apply (if editing YAML), and Save again
without losing edits.
Known limitations¶
| Limitation | Detail |
|---|---|
metadata cannot be cleared |
The block persists on save (added 2026-06-06), but an update cannot remove it entirely — an absent or null metadata on update means "leave the stored value unchanged". To blank it out, save an explicit empty-valued block. |
| Node positions are per-browser | Canvas layout is stored in browser local storage, not in the config — other machines and other users see the default lane layout. |
Operating modes¶
Three modes ship out of the box. Configure on the stream (mode: field) or via the mode= kwarg on consume().
| Mode | What happens to invalid records |
|---|---|
enforce (default) |
Routed to DLQ. The application loop never sees them. |
detect |
Passed through to the application loop and counted in metrics. No DLQ writes. Use this for safe rollouts — observe what would break before flipping to enforce. |
shadow |
Passed through to the application loop and routed to the DLQ. Use this when downstream still needs the bad record while you investigate. |
Recommended rollout: ship in detect, watch the violation rate in pycharter_streaming_messages_total{outcome="validation_failed"}, flip to shadow for a release, then finalize in enforce.
Delivery semantics¶
| Mode | Behavior on crash |
|---|---|
at_least_once (default) |
Commit offset after the sink confirms. Crash before commit → message reprocessed → duplicate in sink. Validation is deterministic so duplicates are byte-identical. |
at_most_once |
Commit offset immediately on consume, before validation. Crash during validation → message lost. |
For data validation at-least-once is the only sane default — losing a message means a bad record might escape unnoticed, defeating the purpose. Switch to at_most_once only when downstream demands no-duplicates and you accept the loss tradeoff.
DLQ envelope¶
Every DLQ record carries a structured envelope so replay tooling has full context:
{
"original_payload": {"order_id": "abc", "amount": -1, "ok": false},
"original_headers": {
"pycharter-contract": "orders",
"pycharter-contract-version": "2",
"x-trace": "..."
},
"error_reason": "validation_failed",
"error_message": "record failed contract validation",
"validation_errors": [
{"path": "$.amount", "message": "must be >= 0"}
],
"contract_id": "orders",
"contract_version": "2",
"source": {
"topic": "orders",
"partition": 3,
"offset": 12345,
"timestamp_ms": 1746000000000
},
"dlq_timestamp": "2026-05-09T01:00:00+00:00"
}
Headers on the DLQ message itself include pycharter-dlq-reason: validation_failed (or decode_error, no_contract_header, etc.) so DLQ consumers can filter without parsing the JSON body.
Observability¶
The worker (and library mode if you wire the observers manually) exposes:
| Endpoint | Purpose |
|---|---|
GET /healthz |
Liveness probe — 200 while the listener is up. |
GET /readyz |
Readiness probe — 200 only when every stream's registry breaker is closed. 503 + JSON body of unready streams otherwise. |
GET /metrics |
Prometheus exposition. |
Prometheus metrics:
| Metric | Type | Labels |
|---|---|---|
pycharter_streaming_messages_total |
counter | stream, outcome |
pycharter_streaming_validate_latency_seconds |
histogram | stream |
pycharter_streaming_circuit_breaker_state |
gauge | resource |
Outcomes (counter label values): valid, decode_error, no_contract_header, unknown_contract, unknown_contract_version, validation_failed, oversized_payload, registry_unavailable.
The WindowedQualityReducer (also wired by default in worker mode) maintains rolling counters per stream over configurable windows (default 1m / 5m / 1h). Snapshot via reducer.snapshot() returns one WindowSnapshot per (stream, window) with valid/invalid counts, violation rate, and p99 validate latency.
Failure handling¶
| Failure | Where detected | Behavior |
|---|---|---|
| Decode error (malformed JSON) | source | Route to DLQ as decode_error; never crash. |
Header missing, strict policy |
resolver | Route to DLQ as no_contract_header. |
| Unknown contract id / version | resolver | Route to DLQ as unknown_contract. |
| Validation fails | validator | Route to DLQ as validation_failed with errors. |
| Sink write fails (transient) | sink | Internal retry; do not commit offset. |
| Registry unreachable | resolver | Block on retry; trip circuit breaker after timeout (default 60s) → /readyz returns 503; k8s removes pod from service. |
SIGTERM |
runtime | Drain in-flight message, flush sinks, commit offsets, exit 0. |
The engine never silently drops a record: every failure is either DLQed or surfaces as a worker unready state.
Authentication¶
Kafka auth is configured on every source/sink config or via consume() / produce() kwargs:
| Mode | Required fields |
|---|---|
| PLAINTEXT (dev) | (none — defaults) |
| SASL/SCRAM-SHA-256 / 512 | security_protocol: SASL_PLAINTEXT (or SASL_SSL), sasl_mechanism, sasl_username, sasl_password |
| mTLS | security_protocol: SSL, ssl_cafile |
Never inline credentials in YAML. The recommended pattern is to load them from the environment:
…or pass them in via os.environ to consume()/produce() directly.
Migration from the legacy KafkaExtractor¶
pycharter.pipeline_generator.extractors.KafkaExtractor continues to exist and the legacy batch interface (extract_streaming) is unchanged. The streaming validation engine adds a new per-record interface (iter_messages + commit + close) that every extractor inherits from BaseExtractor. New code should prefer the streaming surface; legacy pipelines keep working with no edits.
If you previously rolled your own validation around KafkaExtractor:
# Before
extractor = KafkaExtractor(topics=["orders"], ...)
async for batch in extractor.extract({}):
for record in batch:
result = my_validator.validate(record)
if result.is_valid:
process(record)
else:
send_to_dlq(record, result.errors)
await extractor.acknowledge(batch_index, success=True)
…replace it with:
# After
async for record in pycharter.kafka.consume(
topic="orders",
bootstrap_servers="kafka:9092",
group_id="my-service",
contract_dir="contracts/orders",
):
process(record.payload)
The engine handles the validation, the DLQ routing, the offset commit, and the failure-mode handling.
Gateway mode (Pattern A)¶
The same engine can run as a standalone gateway that sits between producers and consumers, republishing valid records to a separate topic. This is what "Pattern A" referred to in the design discussion.
The gateway is just the worker (pycharter stream run) with load.target pointed at a Kafka topic — there's no separate code path. Topology:
producer ──► orders-raw ──► [pycharter worker] ──► orders-validated ──► consumer-1
│ consumer-2 (any language)
└──► orders-dlq consumer-3
Worker config:
streaming:
streams:
- name: orders-gateway
source:
type: kafka
topic: orders-raw
bootstrap_servers: kafka:9092
consumer_group: pycharter-gateway
contract:
ref: { type: file, path: /app/contracts/orders }
resolution: static
load:
target: { type: kafka, topic: orders-validated, bootstrap_servers: kafka:9092 } # downstream consumers read here
dlq:
target: { type: kafka, topic: orders-dlq, bootstrap_servers: kafka:9092 }
mode: enforce
delivery: at_least_once
When to choose gateway over library mode:
| Library | Gateway | |
|---|---|---|
| Languages | Python only (consumer imports pycharter.kafka.consume) |
Any (downstream reads plain Kafka) |
| Topology | One topic, in-process validation | Two topics + a worker process |
| Latency | One hop fewer | One additional hop (~10–50 ms) |
| Operational footprint | None — runs inside your service | One pod / deployment to manage |
| Observability scope | Per-consumer | Centralised at the gateway |
Most teams pick library mode first and only adopt the gateway when they introduce non-Python consumers downstream.
Schema compatibility check¶
When a contract version changes, you can preview the impact before producers start stamping the new version. The schema-compatibility checker diffs two JSON schemas and classifies the change:
from pycharter.streaming._compatibility import (
Compatibility,
check_compatibility,
)
old_schema = {
"type": "object",
"properties": {
"order_id": {"type": "string"},
"amount": {"type": "number"},
},
"required": ["order_id", "amount"],
}
new_schema = {
"type": "object",
"properties": {
"order_id": {"type": "string"},
"amount": {"type": "number"},
"currency": {"type": "string"}, # added optional field
},
"required": ["order_id", "amount"],
}
report = check_compatibility(old_schema, new_schema)
print(report.verdict) # Compatibility.FULL — adding optional field is safe
for change in report.changes:
print(change.path, change.kind, change.detail)
The verdict is one of:
| Verdict | Meaning |
|---|---|
FULL |
Both directions safe. Producers can flip immediately. |
BACKWARD |
New consumers can read old data. Roll out new consumers first, then producers. |
FORWARD |
Old consumers can still read new data. Producers can flip whenever. |
NONE |
Breaking change. Bump the major version; rollout requires consumer + producer in lockstep. |
The check is conservative — it errs on the side of flagging changes as incompatible. Treat the report as a starting point for review, not the final word. Limitations: no $ref resolution, no oneOf / anyOf / allOf composition. Treat reports involving those features as "investigate manually".
Replay CLI¶
When a contract version changes, the records that ended up in the DLQ under the old contract may now be valid. pycharter stream replay reads from the DLQ topic, parses each envelope, and re-validates the original payload against the contract you specify:
# Preview: count how many DLQ records would now pass validation under
# orders@3, but don't write anything.
pycharter stream replay \
--mode dlq \
--source-topic orders-dlq \
--bootstrap-servers kafka:9092 \
--contract-file contracts/orders-v3.yaml \
--contract orders@3 \
--dry-run
Drop --dry-run and add --target-topic orders-validated to forward the now-valid records into the validated topic. Replay is non-destructive — it never deletes from the DLQ; rerunning is safe.
--contract-file takes a single kind: DataContract manifest and is the
form that carries field_bindings, so replayed records go through the
same privacy gate as a live stream: --privacy-mode detect (the default)
reports flagged fields, --privacy-mode enforce tokenizes pii /
redacts restricted (add --clearance for a sink allowed the clear
value; the org secret comes from PYCHARTER_KB_ORG_SECRET). Records that
cannot be handled safely under enforce are withheld — counted in
withheld=, never forwarded in clear. The deprecated split-directory
--contract-dir form carries no bindings: it still validates, but
enforce refuses it and detect warns that the privacy report will be
empty. See Privacy flags.
--mode range reads raw messages from a source topic instead of parsing DLQ envelopes (useful for backfilling new contract versions across historical data).
Stability¶
pycharter.kafka and pycharter.streaming are experimental through the 0.x series. The public API is unlikely to change but minor breaking adjustments may land between minor releases. Both modules graduate to stable at 1.0 once two consecutive patch releases ship without breaking changes.
If you need stability today, pin to an exact pycharter version. Breaking changes will be called out in the release notes.