Kafka
forze[kafka] implements the commit-stream (offset-log) contracts on Apache
Kafka or any Kafka-protocol broker — an ordered, partitioned, replayable log
produced and consumed behind the stream ports, with consumer groups that
acknowledge by committed offset. It is the production backend for the
commit-stream delivery model.
Install¶
uv add 'forze[kafka]'
Needs a reachable Kafka-protocol broker (the client is aiokafka). Protocol
compatibility is what matters, not the vendor — Apache Kafka and Redpanda both
work.
The client¶
from forze_kafka import KafkaClient
kafka = KafkaClient()
The client owns one shared producer and admin client, and pools data-plane
consumers per (group, member, topics). RoutedKafkaClient resolves a
per-tenant cluster instead (with routed_kafka_lifecycle_step).
Wire it¶
Register produce routes (streams) and consumer-group routes (commit_groups),
keyed by StreamSpec.name; connect from the lifecycle plan:
from forze.application.execution import DepsRegistry, LifecyclePlan
from forze_kafka import (
KafkaClient,
KafkaCommitStreamGroupConfig,
KafkaConfig,
KafkaDepsModule,
KafkaStreamConfig,
kafka_lifecycle_step,
)
deps = DepsRegistry.from_modules(
KafkaDepsModule(
client=kafka,
streams={"events": KafkaStreamConfig()},
commit_groups={"events": KafkaCommitStreamGroupConfig()},
),
)
lifecycle = LifecyclePlan.from_steps(
kafka_lifecycle_step(bootstrap_servers="localhost:9092", config=KafkaConfig()),
)
KafkaConfig defaults are chosen for the offset-log guarantee: acks="all"
with an idempotent producer (broker-side dedup of retried produces), and no
auto-commit knob at all — the consumer runner commits explicitly, after
processing. Security is PLAINTEXT by default; set security_protocol (and the
sasl_* fields) for authenticated clusters.
What it provides¶
| Contract | Methods | Keyed by |
|---|---|---|
| Stream produce | append |
StreamSpec.name (streams) |
| Commit-stream consumer group | read, tail, commit, seek_to_committed |
StreamSpec.name (commit_groups) |
| Commit-stream admin | ensure_topic, ensure_group, reset_offsets, lag |
StreamSpec.name (commit_groups) |
The contract surface is
Streaming & pub/sub; the
transactional outbox relay's to_stream
publishes through the same produce port, so outbox → Kafka needs no extra
wiring.
The delivery model¶
A produced message's key becomes the native Kafka message key — same key, same
partition, so per-key ordering holds; headers ride native record headers.
Consumption is a consumer group over the partitioned log: a single committed
offset acknowledges every message up to it on that partition, and the broker
reassigns partitions across live members on a rebalance.
The transport is at-least-once. Pair the consumer with the
inbox — dedup on a stable message id
inside the same transaction as the handler's writes — and a redelivery is a
no-op: exactly-once effect, the same equation as every other at-least-once
backend. The dedup id is the outbox event id when the message carries one, else
the canonical stream:partition:offset — never key, which many distinct
events share.
Consuming¶
CommitStreamGroupConsumer (from forze_kits.integrations.consumer) is the
consume loop: read a batch, process each message through process_with_inbox in
one transaction, and commit the offset only after the handler commits —
never auto-commit, which would decouple acknowledgment from processing. Run it
supervised as a lifecycle step:
from forze_kits.integrations.consumer import (
commit_stream_consumer_background_lifecycle_step,
)
step = commit_stream_consumer_background_lifecycle_step(
topics=["events"],
group="projections",
consumer="worker-1",
stream_spec=events_stream,
handler=handle_event,
inbox_spec=inbox,
tx_route="main",
max_attempts=3,
dlq_stream="events.dlq",
)
Startup spawns a consume-forever task; shutdown cancels it. A crash of the consume itself (broker connection loss, a transient fault) restarts the run after a jittered backoff — and the restart is loss-free: the supervisor first rewinds the group to its committed offset, so records fetched but not yet committed are re-fetched, never skipped, then deduped by the inbox. One step runs one sequential consumer; scale out with more steps or processes and let the broker rebalance partitions across members.
Poison and rebalance¶
A log cannot requeue one message without wedging its partition, so failure handling is offset-shaped:
- Handler poison — a message that exhausts
max_attemptsis produced todlq_streamas received and the offset is committed past it, freeing the partition. An end-to-end-sealed envelope is forwarded still sealed, with the event-id/tenant headers its AAD is bound to — the dead-letter copy stays decryptable and correlates back to the source event. With nodlq_stream, the run pauses and alerts instead: the offset stays uncommitted for redelivery, never silently skipped. - Decode or decrypt poison — a malformed record never aborts its batch (the read path substitutes a poison marker instead of raising); it, and an undecryptable envelope (tampering, unknown key), always pause-and-alert — there is no decodable payload to forward to a dead-letter stream, so an operator must inspect it.
- A transient decrypt fault is not poison — the keyring's KMS being unavailable or throttled while unwrapping a cold data key is crash-shaped: the consumer commits the successes so far and raises, and the supervisor restarts it with backoff; the message is redelivered once the blip clears. Only a non-retryable decrypt failure pauses.
- Pause is consumer-wide. A pause stops every subscribed topic of that consumer, and the supervisor deliberately does not restart it (a restart would re-fetch the same poison and pause again) — it alerts and waits for an operator. Run one consumer per isolation boundary if poison in one topic must not stall others.
A rebalance is routine, not a failure: when the broker revokes partitions mid-batch, the adapter drops their routing so a late commit on a revoked partition is skipped (the records are redelivered to the new owner and deduped by the inbox) rather than crashing the run, and freshly assigned partitions are sought back to their committed offset so nothing is skipped or double-read.
Replay and lag¶
The admin port is the control plane. ensure_topic provisions a topic
idempotently; ensure_group positions a fresh group; reset_offsets replays
by repositioning the group's committed offset — to OffsetReset.EARLIEST /
LATEST, OffsetReset.at_timestamp(...), or OffsetReset.at_offset(...); and
lag reports each partition's committed offset against the log end:
admin = ctx.stream.commit_admin(events_stream)
await admin.reset_offsets("projections", "events", to=OffsetReset.EARLIEST)
for entry in await admin.lag("projections"):
print(entry.stream, entry.partition, entry.end_offset - entry.committed_offset)
Encryption¶
A StreamSpec route declares encryption="end_to_end" to seal payloads
through the broker: the producer encrypts, Kafka only ever stores
ciphertext, and the consumer decrypts after inbox dedup. A wired
CryptoDepsModule is required, and a deployment required_reach floor is
enforced at wiring; there is no at_rest tier on this transport (nothing
between producer and consumer decrypts). See
Encryption for the
tier model.
Tenancy¶
Kafka offers two isolation tiers (there is no tagged tier — a topic has no
server-side row filter):
namespace— set a per-tenantnamespaceon the route config; the topic is prefixed per tenant ({namespace}.{topic}), resolved from the bound tenant on every produce, read, and admin call.dedicated— aRoutedKafkaClientresolves a per-tenant cluster; topic names stay shared.
Declare a floor with KafkaDepsModule(required_tenant_isolation=...) and wiring
refuses any route below it, fail-closed — the same
declared-minimum model as every
other integration.
Notes¶
- Ordering is per partition. Same key, same partition, in order; across keys and partitions there is no global order — design consumers accordingly.
- Provision topics deliberately.
ensure_topicis available (and idempotent), but partition count and replication are capacity decisions; broker-side topology usually lives with your infrastructure config. auto_offset_reset(latestby default, per client or per group config) only applies when a group has no committed offset — a first consume; after that the committed offset always wins.- Alert on a paused consumer. A run that returns with
failed > 0is a stopped consumer awaiting an operator — surface it in your alerting.