Going distributed¶
An AsyncNode does not have to keep its
chunks in the process that created it. Give two nodes Redis-backed stores and
two programs can use them as a small, durable chat: Alice writes one stream and
listens to the other, while Bob does the reverse.
This guide runs the same chat.py on two imaginary machines. Both machines can
reach the same Redis deployment, but they do not need a direct connection to
each other.
| Redis-backed node | Writer | Reader |
|---|---|---|
alice_to_bob_messages |
Alice | Bob |
bob_to_alice_messages |
Bob | Alice |
1. Point both machines at Redis¶
Set the same Redis URL and chunk-store key prefix on Alice's and Bob's machines:
export A11_REDIS_URL=redis://chat-redis.internal:6379/0
export A11_REDIS_CHUNK_STORE_KEY_PREFIX=a11:chat-demo-001:
The default Redis client reads this configuration. The key prefix gives this conversation its own namespace; use a new prefix for another run. Redis chunk stores are persistent, and graceful shutdown permanently seals both message streams in this example.
2. Build every store with the default client¶
Both deployments define the same
ChunkStoreFactory. It receives a
node ID and returns a
RedisChunkStore composed with
the process-global client:
def redis_chunk_store_factory(node_id: str) -> a11.ChunkStore:
return a11.RedisChunkStore(
node_id,
client=a11.default_redis_client(),
)
default_redis_client() returns the same client on every call within one
process, so the two stores share its command and Pub/Sub connections. Alice and
Bob still have separate process-local clients; the environment makes both
clients connect to the same Redis deployment.
3. Create both directional nodes¶
Each process creates both named nodes with that factory:
alice_to_bob_messages = a11.AsyncNode.create(
"alice_to_bob_messages",
chunk_store_factory=redis_chunk_store_factory,
)
bob_to_alice_messages = a11.AsyncNode.create(
"bob_to_alice_messages",
chunk_store_factory=redis_chunk_store_factory,
)
The AsyncNode objects themselves are local. Their IDs select the same Redis
streams on both machines, which is what joins the two deployments. Alice uses
the first node as her outgoing stream and the second as her incoming stream;
Bob swaps those roles.
4. Listen without blocking the sender¶
The listener consumes the other party's node with async for. When that node
receives its final marker, iteration ends and the listener announces that its
owner has left:
async def listen(incoming: a11.AsyncNode, sender: str) -> None:
async for message in incoming:
print(f"\n{sender}> {message}", flush=True)
print(f"\n[{sender}] has quit the chat", flush=True)
Start it with asyncio.create_task() before entering the input loop. Terminal
input is moved to a worker thread with asyncio.to_thread(), leaving the event
loop free to print incoming messages immediately:
listener_task = asyncio.create_task(listen(incoming, peer_name))
while True:
try:
message = await asyncio.to_thread(input, f"{local_name}> ")
except EOFError:
break
if message.strip() == "/quit":
break
if not message:
continue
await outgoing.put(message)
The await admits the value to the node's bounded writer queue, applying
backpressure instead of accumulating an unbounded list of unsent chat
messages. The returned store confirmation does not need to be awaited here:
the node is drained when this producer finishes.
5. Finalize, drain, and close¶
Leaving the input loop—by typing /quit, sending end-of-file, or unwinding
through an error—runs this cleanup in a finally block:
async def finish(outgoing: a11.AsyncNode) -> None:
await outgoing.put_null_final()
await outgoing.drain_and_close()
put_null_final() appends a final marker without adding a visible chat
message. The peer's async for sees that marker as end-of-stream and reaches
its quit announcement. drain_and_close() then flushes anything still queued,
closes the store with an OK status, and prevents later writes. Keeping the
final marker and the close in this order guarantees that the peer reads every
accepted message before it reports the departure.
6. Put the program together¶
Save this as chat.py on both machines. The command-line role changes only
which node is incoming and which is outgoing; both processes construct both
nodes.
import argparse
import asyncio
from contextlib import suppress
import a11
def redis_chunk_store_factory(node_id: str) -> a11.ChunkStore:
return a11.RedisChunkStore(
node_id,
client=a11.default_redis_client(),
)
async def listen(incoming: a11.AsyncNode, sender: str) -> None:
async for message in incoming:
print(f"\n{sender}> {message}", flush=True)
print(f"\n[{sender}] has quit the chat", flush=True)
async def send(outgoing: a11.AsyncNode, sender: str) -> None:
while True:
try:
message = await asyncio.to_thread(input, f"{sender}> ")
except EOFError:
return
if message.strip() == "/quit":
return
if not message:
continue
await outgoing.put(message)
async def finish(outgoing: a11.AsyncNode) -> None:
await outgoing.put_null_final()
await outgoing.drain_and_close()
async def chat(role: str) -> None:
redis_client = a11.default_redis_client()
await redis_client.ready()
alice_to_bob_messages = a11.AsyncNode.create(
"alice_to_bob_messages",
chunk_store_factory=redis_chunk_store_factory,
)
bob_to_alice_messages = a11.AsyncNode.create(
"bob_to_alice_messages",
chunk_store_factory=redis_chunk_store_factory,
)
if role == "alice":
local_name = "Alice"
peer_name = "Bob"
outgoing = alice_to_bob_messages
incoming = bob_to_alice_messages
else:
local_name = "Bob"
peer_name = "Alice"
outgoing = bob_to_alice_messages
incoming = alice_to_bob_messages
print(f"[{local_name}] connected; type /quit to leave", flush=True)
listener_task = asyncio.create_task(
listen(incoming, peer_name),
name=f"listen-for-{peer_name.lower()}",
)
try:
await send(outgoing, local_name)
finally:
try:
await finish(outgoing)
finally:
listener_task.cancel()
try:
with suppress(asyncio.CancelledError):
await listener_task
finally:
redis_client.close()
def parse_args() -> str:
parser = argparse.ArgumentParser()
parser.add_argument("role", choices=("alice", "bob"))
return parser.parse_args().role
if __name__ == "__main__":
asyncio.run(chat(parse_args()))
Alice starts her copy with:
Bob starts his with:
Either side may start first. A listener waiting on an empty Redis-backed node sleeps until data arrives, and messages written before the other process starts remain available when it connects.
7. See the conversation from both terminals¶
Suppose Alice starts first, they exchange three messages, and Alice types
/quit. Her terminal could look like this (prompts are shown without incidental
redraws from concurrent output):
$ python chat.py alice
[Alice] connected; type /quit to leave
Alice> Hi Bob—did the deployment finish?
Bob> Yes. The new worker is healthy.
Alice> Great. I will check the traces.
Bob> Let me know if anything looks odd.
Alice> /quit
On Bob's machine the same conversation arrives through
alice_to_bob_messages. Alice's invisible final marker ends his listener and
produces the last line:
$ python chat.py bob
[Bob] connected; type /quit to leave
Alice> Hi Bob—did the deployment finish?
Bob> Yes. The new worker is healthy.
Alice> Great. I will check the traces.
Bob> Let me know if anything looks odd.
[Alice] has quit the chat
Bob> /quit
If Bob leaves first, Alice sees the symmetric notification:
There is no chat-specific network protocol here: the two stable node IDs, the
shared Redis-backed factory, and normal AsyncNode streaming and lifecycle
operations are the whole distributed boundary.