Bytewax Runtime¶
loom.streaming.bytewax is the production adapter that turns a declarative
StreamFlow into a real Bytewax dataflow.
For a runnable end-to-end reference implementation, see: dummy-loom-streaming.
What it does¶
compiles the flow
resolves config bindings
wires Kafka sources and sinks
applies
With/WithAsyncruns observability hooks
Public entrypoint¶
from loom.streaming.bytewax import StreamingRunner
When to use it¶
Use the Bytewax adapter when you want:
topic-to-topic streaming
typed message envelopes
branch-aware fan-out
CollectBatchbeforeWithAsyncruntime observability for batch and node lifecycle events
Flow contract¶
The clean authoring contract is:
from loom.streaming import CollectBatch, IntoTopic, Process, WithAsync
process = Process(
CollectBatch(max_records=50, timeout_ms=2000),
WithAsync(
process=Process(
# per-message async step
...
IntoTopic(...),
),
max_concurrency=50,
),
)
CollectBatch belongs before WithAsync when you want batch aggregation as an
input shape. If you want to wait for a batch outside the async step, keep that
logic in the parent flow, not inside the task itself.
Writing your own destination¶
IntoTopic and IntoTable are the destinations loom ships. A destination it
does not ship is a frozen dataclass satisfying IntoSink: no base class, no
registration, no loom import beyond the payload type. The compiler recognises it
by structure and resolves its configuration from streaming.sinks.<name>.
from collections.abc import Sequence
from dataclasses import dataclass
from typing import Any, ClassVar
@dataclass(frozen=True)
class IntoJsonl:
payload: type[IncidentEvent]
name: str = "incidents" # the key under streaming.sinks
router_branch_safe: ClassVar[bool] = True
def build_partition(
self,
config: Any, # your own resolved section
worker_index: int,
worker_count: int,
bridge: Any = None,
session_manager: Any = None,
) -> "JsonlPartition":
return JsonlPartition(config["path"])
class JsonlPartition:
def __init__(self, path: str) -> None:
self._path, self._buffer = path, []
def write_batch(self, items: Sequence[IncidentEvent]) -> None:
self._buffer.extend(items) # once per Bytewax epoch
def close(self) -> None:
flush(self._path, self._buffer) # must be idempotent
Process(IntoJsonl(payload=IncidentEvent)) type-checks under mypy --strict:
the three attributes are read-only in the protocol, so a frozen dataclass, a
msgspec struct or a class variable all satisfy them, and config and
session_manager are opaque, so your sink narrows them to whatever its backend
resolves. write_batch runs per epoch, per worker; close may be called even
when write_batch never was.
Booting from YAML (resolvers=)¶
StreamingRunner.from_yaml(flow, path) loads the config and builds the runner;
run(config_path=...) does the same load on an existing runner. Both register
loom’s built-in AWS resolvers, secrets (Secrets Manager) and ssm (SSM
Parameter Store), by default, so a flow booted through the factory reads a
secret with no code beyond the factory call:
# config/streaming.yaml
kafka:
consumer:
brokers:
- ${ssm:/prod/incidents/kafka-broker}
group_id: incidents-service
topics: ["incidents.in"]
security:
protocol: SASL_SSL
sasl_mechanism: SCRAM-SHA-512
sasl_username: incidents
sasl_password: "${secrets:/prod/incidents/kafka-password}"
from loom.streaming.bytewax import StreamingRunner
runner = StreamingRunner.from_yaml(incident_flow, "config/streaming.yaml")
runner.run()
The built-in resolvers use boto3’s default region and credential chain, and
create their client only when a placeholder resolves: a YAML with no
${secrets:...} or ${ssm:...} never touches AWS and boots without boto3.
When a placeholder does resolve and boto3 is missing, the error names the extra
to install, loom-kernel[config-ssm].
resolvers= adds your own prefixes or overrides a built-in by name, on
from_yaml and on run(config_path=...). Any object with a name and a
resolve(key) -> object works:
class VaultResolver:
name = "vault"
def resolve(self, key: str) -> str:
return read_vault_secret(key)
runner = StreamingRunner.from_yaml(
incident_flow, "config/streaming.yaml", resolvers=[VaultResolver()]
)
A resolver you pass with the same name as a built-in wins; a built-in default
never replaces a resolver already registered earlier in the process, so
calling the factory more than once is safe. load_config registers no
defaults, and resolvers passed to it explicitly replace an earlier registration
of the same name.
Runtime notes¶
WithAsyncexecutes one message per task, concurrently.CollectBatchemits observable batch events.Errors are routed through the explicit error wiring.
msgandpayloadare public expression roots for routing predicates.