Gateway Turn Lock
Serialise per-session turns across replicas so a multi-replica gateway cannot corrupt a shared transcript
The turn lock keeps only one turn running against a session at a time, and its redis backend extends that guarantee across every replica.
Quick Start
The redis backend needs the async Redis client:
pip install "praisonai-bot[redis]" # or: pip install redis- Single replica — already correct, no config
One gateway process serialises turns with the in-process default. Nothing to set.
python from praisonaiagents import Agent from praisonai_bot.gateway import WebSocketGateway agent = Agent(name="Support", instructions="Help users") WebSocketGateway(agent=agent).start() # turn_lock backend defaults to "local" - Multiple replicas — switch the backend to redis
Two or more gateway processes sharing a session need a distributed lease so two pods can’t run one session’s turns at once.
python from praisonaiagents import Agent from praisonaiagents.gateway import GatewayConfig, TurnLockConfig from praisonai_bot.gateway import WebSocketGateway agent = Agent(name="Support", instructions="Help users") gateway = WebSocketGateway( agent=agent, config=GatewayConfig( turn_lock=TurnLockConfig(backend="redis", ttl=60.0), ), ) gateway.start() - Or configure it in gateway.yaml
yaml gateway: turn_lock: backend: redis # local | redis ttl: 60 # lease TTL in seconds # url: redis://... # optional; overrides the gateway's RedisConfig
How It Works
The lock is LockMap-compatible: one .get(session_id) call returns an async context manager that holds a cluster-wide lease for the whole async with block, and a crashed holder’s lease expires after ttl so a healthy session is never wedged.
sequenceDiagram
participant A as Replica A
participant B as Replica B
participant Redis as Redis<br/>(lease: session:alice)
A->>Redis: SET NX PX (owner=A, ttl)
Redis-->>A: OK — lease acquired
Note over A: Turn 1 runs; lease renews at ~ttl/3
B->>Redis: SET NX PX (owner=B, ttl)
Redis-->>B: nil — held by A (polls until free)
A->>Redis: compare-and-del(owner=A)
Redis-->>A: released
B->>Redis: SET NX PX (owner=B, ttl)
Redis-->>B: OK — lease acquired
Note over B: Turn 2 runs in order
Note over A,Redis: A crashes mid-turn?<br/>renewal stops → lease expires after ttl → B proceeds (fail-open)| Piece | Owner | Role |
|---|---|---|
TurnLockConfig | Core (praisonaiagents.gateway) | Selects local or redis; wired as turn_lock=… on GatewayConfig |
build_turn_lock | Wrapper (praisonai_bot.bots._redis_turn_lock) | Factory that picks the backend from TurnLockConfig + the gateway’s RedisConfig; fails open to a local lock (and records a degraded entry) if Redis is unreachable |
RedisTurnLock | Wrapper (praisonai_bot.bots._redis_turn_lock) | Distributed, LockMap-compatible lock — .get(key) returns an async context manager holding a cluster-wide lease keyed on the resolved session id. Auto-renews at ~ttl/3; releases with an owner-checked compare-and-del |
LockMap (default) | Wrapper (praisonai_bot._lockmap) | In-process default — today’s asyncio.Lock, byte-for-byte |
Release is owner-checked and idempotent: only the exact owner token that claimed the lease may release it, so an expired or reclaimed lease is a harmless no-op, never another replica’s lease.
Fail-open on Redis outage
A live turn is never blocked by Redis being down — the lock degrades to a local asyncio.Lock for that turn and records a degraded capability you find later.
While degraded, turns serialise per-process only (cross-replica ordering pauses until Redis recovers). The lock records a degraded capability entry (owner_kind="capability", owner_id="turn_lock:redis") and clears it automatically on the first successful reconnect.
Health & observability
The fail-open behaviour surfaces through the same tools that report every other degraded gateway capability.
praisonai gateway doctor— lists thecapability/turn_lock:redisdegraded owner with a redacted reason.- Gateway
health()//health— the same degraded entry, machine-readable. - Logs — a
WARNING: Distributed turn lock degraded to local-only (Redis unavailable): …on the first outage.
How praisonai gateway doctor reports degraded capabilities.
Companion health signal — reap half-open connections.
Configuration Options
TurnLockConfig from praisonaiagents/gateway/config.py.
| Option | Type | Default | Description |
|---|---|---|---|
backend | str | "local" | "local" (in-process asyncio.Lock) or "redis" (distributed lease, cluster-wide serialisation). Any other value raises ValueError. |
ttl | float | 60.0 | Lease TTL in seconds for the "redis" backend — how long a crashed holder’s lease survives before it is reclaimable. The lease auto-renews at ~ttl/3 while a turn runs. Inert for "local". Rejects <= 0. |
url | Optional[str] | None | Optional Redis URL for the "redis" backend. When set, overrides RedisConfig; when omitted, reuses the gateway’s RedisConfig. Redacted ("***") in to_dict(). |
The enabled property returns True only when a distributed backend is selected (backend != "local").
Full field, type, and default reference for TurnLockConfig.
The parent config that wires turn_lock into the gateway.
When to Enable
Enable redis whenever more than one gateway process can share a session — replica count alone decides it.
You need backend: 'redis' whenever more than one gateway process shares a session — with or without an identity resolver. A single-adapter deployment with replicas > 1 still wires the distributed backend.
Backward Compatibility
local is the default and reproduces today’s in-process asyncio.Lock byte-for-byte — single-replica deployments are unchanged and add no new dependency.
Best Practices
Size ttl to survive one Redis blip; renewal covers long turns
The lease auto-renews at ~ttl/3 while a turn runs, so a turn longer than ttl never lets another replica claim the same session. Size ttl to survive a brief Redis blip, not your slowest turn — the default 60.0 suits most deployments.
Reuse the gateway's RedisConfig unless you need a separate store
Leave url unset and the distributed lock reuses the gateway’s configured RedisConfig. Set url only when the lock lives on a different Redis than push/delivery — when both are set, url wins.
Watch the doctor for a degraded turn lock
On a Redis outage the lock fails open and records a turn_lock:redis degraded capability. Check praisonai gateway doctor or /health — a persistent entry means cross-replica ordering is paused until Redis recovers.
Enable it before you scale, not after
Flip backend='redis' before raising replicaCount. Scaling first leaves a window where two pods run concurrent turns on one session.
Related
The wider “never lose or double-run a turn” story this fits into.
Gateway Degraded ChannelsThe symmetric fail-open pattern for a degraded delivery channel.
Cross-Platform SessionsThe in-process turn lock this extends across replicas.
Helm Chart (Gateway)Scale the gateway on Kubernetes with replicaCount.
Sibling robustness knob — concurrency ceiling and backpressure.
Gateway Turn ExecutorSibling robustness knob — place a session’s turn on its own worker.