> ## Documentation Index
> Fetch the complete documentation index at: https://praison.ai/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# 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.

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph LR
    subgraph "Cluster-Wide Turn Lock"
        R1[📥 Replica A<br/>msg #1] --> Lock{🔒 Turn Lease<br/>session:alice}
        R2[📥 Replica B<br/>msg #2] --> Lock
        Lock -->|holds| Run1[🤖 Turn 1]
        Run1 --> Release[🔓 release]
        Release --> Run2[🤖 Turn 2]
    end

    classDef inbound fill:#8B0000,stroke:#7C90A0,color:#fff
    classDef lock fill:#F59E0B,stroke:#7C90A0,color:#fff
    classDef run fill:#189AB4,stroke:#7C90A0,color:#fff
    classDef done fill:#10B981,stroke:#7C90A0,color:#fff

    class R1,R2 inbound
    class Lock,Release lock
    class Run1,Run2 run
```

## Quick Start

The `redis` backend needs the async Redis client:

```bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
pip install "praisonai-bot[redis]"   # or: pip install redis
```

**1. Single replica — already correct, no config**

One gateway process serialises turns with the in-process default. Nothing to set.

```python theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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"
```

**2. 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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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()
```

**3. Or configure it in gateway.yaml**

```yaml theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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.

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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.

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph LR
    Turn[📥 Inbound turn] --> Try{Redis<br/>reachable?}
    Try -->|yes| Cluster[🔒 Cluster-wide lease<br/>SET NX PX]
    Try -->|no| Local[🔒 Local asyncio.Lock<br/>+ record degraded capability]
    Cluster --> Run[🤖 Turn runs]
    Local --> Run

    classDef inbound fill:#8B0000,stroke:#7C90A0,color:#fff
    classDef q fill:#6366F1,stroke:#7C90A0,color:#fff
    classDef ok fill:#189AB4,stroke:#7C90A0,color:#fff
    classDef degraded fill:#F59E0B,stroke:#7C90A0,color:#fff
    classDef run fill:#10B981,stroke:#7C90A0,color:#fff

    class Turn inbound
    class Try q
    class Cluster ok
    class Local degraded
    class Run run
```

> [!WARNING]
>
> A live turn is never blocked by Redis being down; you find out via `praisonai gateway doctor`.

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 the `capability` / `turn_lock:redis` degraded 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.

> [card] [Extensible Doctor](https://praison.ai/docs/features/extensible-doctor.md)
>
> How `praisonai gateway doctor` reports degraded capabilities.

> [card] [Gateway Liveness](https://praison.ai/docs/features/gateway-liveness.md)
>
> 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"`).

> [!NOTE]
>
> Lease keys are namespaced as `{RedisConfig.prefix}turnlock:{session_id}`, defaulting to `praison:turnlock:` when no prefix is set. Operators sharing one Redis across environments should set distinct `RedisConfig.prefix` values so leases never collide.

> [card] [TurnLockConfig Reference](https://praison.ai/docs/sdk/reference/praisonaiagents/classes/TurnLockConfig.md)
>
> Full field, type, and default reference for `TurnLockConfig`.

> [card] [GatewayConfig Reference](https://praison.ai/docs/sdk/reference/praisonaiagents/classes/GatewayConfig.md)
>
> 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.

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph TB
    Start{More than one<br/>gateway process<br/>sharing a session?} -->|no| Local[backend='local'<br/>default, no config]
    Start -->|yes — replicas &gt; 1<br/>or multi-instance| Redis[backend='redis'<br/>cluster-wide lease]

    classDef q fill:#6366F1,stroke:#7C90A0,color:#fff
    classDef off fill:#7C90A0,stroke:#7C90A0,color:#fff
    classDef opt fill:#10B981,stroke:#7C90A0,color:#fff

    class Start q
    class Local off
    class Redis opt
```

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

> [accordion] 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.

> [accordion] 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.

> [accordion] 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.

> [accordion] 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

> [card] [Gateway Durability](https://praison.ai/docs/features/gateway-durability.md)
>
> The wider "never lose or double-run a turn" story this fits into.

> [card] [Gateway Degraded Channels](https://praison.ai/docs/features/gateway-degraded-channels.md)
>
> The symmetric fail-open pattern for a degraded delivery channel.

> [card] [Cross-Platform Sessions](https://praison.ai/docs/features/cross-platform-mirror.md)
>
> The in-process turn lock this extends across replicas.

> [card] [Helm Chart (Gateway)](https://praison.ai/docs/features/helm-chart-gateway.md)
>
> Scale the gateway on Kubernetes with `replicaCount`.

> [card] [Gateway Admission Control](https://praison.ai/docs/features/gateway-admission-control.md)
>
> Sibling robustness knob — concurrency ceiling and backpressure.

> [card] [Gateway Turn Executor](https://praison.ai/docs/features/gateway-turn-executor.md)
>
> Sibling robustness knob — place a session's turn on its own worker.

> [!NOTE]
>
> **See also — idempotency `store_backend: redis`.** The inbound idempotency store now mirrors the turn lock: setting `store_backend: redis` gives cluster-wide exactly-once webhook admission via `RedisIdempotencyStore` ([Issue #5154](https://github.com/MervinPraison/PraisonAI/issues/5154)), sharing the gateway's push `RedisConfig` — the same wiring you use for the turn lock. Enable both together for a multi-replica gateway. See [Cross-Replica Idempotency](https://praison.ai/docs/features/gateway-redis-idempotency.md).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.