Publish & Subscribe¶
@publish and @subscribe are the two halves of fire-and-forget, 1-to-many
messaging — event streaming. Like RPC, it rides directly on Zenoh with no broker,
but unlike RPC the producer never waits for a reply and any number of consumers
react independently.
@publishis the producer. A function's return value is serialized and sent to everyone subscribed to its key expression.@subscribeis the consumer. A function runs every time data arrives on a matching key expression.
How it works¶
sequenceDiagram
participant Pub as @publish (producer)
participant Net as Zenoh
participant Sub1 as @subscribe (consumer 1)
participant Sub2 as @subscribe (consumer 2)
Pub->>Net: put drone/telemetry {...}
Net->>Sub1: deliver
Net->>Sub2: deliver
By default this is at-most-once: a consumer that is offline when a message is sent simply misses it. For replay and recovery, see Brokerless Durable Messaging.
@publish — the producer¶
import asyncio
from istos import Istos
app = Istos()
@app.publish("drone/status")
def status():
# The return value is published to everyone on drone/status.
return {"status": "online", "uptime": 999}
Calling the wrapper computes the function, serializes the result, and puts it on the
fabric. The wrapper is async — you must await it:
@app.publish("drone/telemetry")
def telemetry():
return {"battery": 85, "altitude": 120}
async def loop():
while True:
await telemetry() # <-- await; a sync body runs off-thread internally
await asyncio.sleep(1.0)
Options¶
| Option | Default | Meaning |
|---|---|---|
prefix |
— | Key expression to publish on. |
serializer |
JsonSerializer() |
Wire format. MsgPackSerializer() for binary. Must match subscribers. |
use_shm |
False |
Zero-copy shared memory (same host only). |
durable |
False |
Publish through an AdvancedPublisher with a replay cache — see Durable Messaging. |
cache / heartbeat |
1000 / 1.0 |
Durable-only: replay depth and gap-detection heartbeat. |
reliability / congestion_control |
None |
Durable-only overrides (default RELIABLE + BLOCK). |
durable=True and use_shm=True cannot be combined.
Dependencies¶
Publishers support Depends(...) too — leading positional args are your call
arguments, the rest are resolved per publish:
@app.publish("orders/created")
async def created(order: dict, db=Depends(get_db)):
await db.save(order)
return order
@subscribe — the consumer¶
@app.subscribe("drone/telemetry")
def on_telemetry(data):
# Called automatically on every message.
print(f"Battery: {data['battery']}%")
The subscriber deserializes the incoming payload and passes it as the first argument.
Options¶
| Option | Default | Meaning |
|---|---|---|
prefix |
— | Key expression (wildcards allowed) to listen on. |
serializer |
JsonSerializer() |
How to decode the payload. Must match the publisher. |
retry |
None |
Retries the callback on failure (int or RetryPolicy). |
durable |
False |
Subscribe via an AdvancedSubscriber with history + recovery — see Durable Messaging. |
replay / recover |
1000 / True |
Durable-only: history depth on join, and gap recovery. |
on_miss |
None |
Durable-only: on_miss(source, nb) on an unrecoverable gap. |
The payload is validated at the boundary¶
A subscriber's message is untrusted network input, so — just like @handle's params
— the payload is validated and coerced against the first parameter's type hint
before your code runs. Annotate it with a pydantic model and you receive a real,
validated instance:
from pydantic import BaseModel
class Telemetry(BaseModel):
battery: float
altitude: int
@app.subscribe("drone/telemetry")
def on_telemetry(data: Telemetry): # data is a validated Telemetry instance
if data.battery < 20:
alert("low battery")
Scalar hints are coerced too (data: int turns "42" into 42). A payload that
fails validation raises SchemaValidationError; on the live subscriber path that
error is logged and the bad event is dropped (there is no reply channel to return it
on). Leave the parameter untyped to opt out and receive the raw deserialized
value:
Dependencies¶
The payload fills the first positional slot; Depends(...) params are resolved per
message:
@app.subscribe("orders/created")
async def on_created(event, db=Depends(get_db)):
await db.record(event)
Wildcard patterns¶
@app.subscribe("drone/*/telemetry")
def on_any_drone(data):
# matches drone/alpha/telemetry, drone/beta/telemetry, ...
...
Retry¶
If the callback raises, retry re-runs it with exponential backoff. A callback that
still fails after its retries is logged (it never crashes the subscriber loop):
@app.subscribe("payments/events", retry=3)
async def on_payment(event):
await downstream.forward(event) # transient failures are retried
Durable pub/sub¶
Add durable=True to both ends to get replay for late joiners and recovery after a
disconnect — the producer becomes its own bounded log, peer-to-peer, still no broker.
Full details, guarantees, and honest limits are in
Brokerless Durable Messaging.
@app.publish("orders/created", durable=True, cache=1000)
async def created(order: dict):
return order
@app.subscribe("orders/created", durable=True, replay=1000,
on_miss=lambda source, nb: alert(f"lost {nb} from {source}"))
async def on_created(event: dict):
await process(event)
High-performance: shared memory¶
For large payloads (video frames, tensors) between processes on the same host, enable zero-copy shared memory:
@app.publish("video/feed", use_shm=True)
def send_frame():
return large_numpy_array # Istos manages the Zenoh ShmProvider
Same host only
Shared memory works only between processes on one machine. Cross-network traffic
falls back to standard Zenoh transport automatically. use_shm cannot be combined
with durable.
Imperative actions (no decorator)¶
| Call | What it does |
|---|---|
await app.publish_once(prefix, data, use_shm=…, serializer=…) |
One-shot fire-and-forget put (the non-durable path). |
await app.delete_once(prefix) |
Network-wide DELETE on a key expression. |
await app.publish_once("drone/telemetry", {"battery": 80})
await app.delete_once("robot/cache/old_logs")
Guarantees — and honest limits¶
- Publishers are async — always
awaitthem. Callingtelemetry()withoutawaitjust builds a coroutine and publishes nothing. - At-most-once by default. An offline consumer misses messages. Use
durable=Truefor replay/recovery. - A typed payload that fails validation is dropped. On the live path the
SchemaValidationErroris logged (pub/sub has no reply channel). Leave the param untyped to accept anything. - Serializers must match. A JSON publisher and a MsgPack subscriber won't decode.
- The session must be running. Calling a
@publish/publish_oncebeforeapp.run()/run_async()raisesRuntimeError. Publish from a lifespan hook, a handler, or after the session is open.
Authorizing subscribers¶
@subscribe(..., authorizer=…) drops samples that fail the gate (no NACK —
pub/sub has nowhere to reply). Attach a token with publish_once(..., token=…)
or via the request envelope when publishing from an authorized handler. Details
in Authorization.