Skip to content

State adapters: add ioredis-compatible lock token prefix for cross-runtime Redis sharing #71

Description

@patrick-chinchill

Interop gap — can't share a Redis between TS and Python Chat SDK deployments.

Current state

src/chat_sdk/state/redis.py generates lock tokens with a fixed prefix: redis_<ms>_<hex> (see redis.py:30 and the _generate_token() helper).

Upstream Vercel Chat TS ships its Redis state adapter on top of the ioredis npm package and generates tokens with the prefix ioredis_<ms>_<hex>. This is the prefix that any existing TS Chat SDK deployment will have written into Redis.

Today, if a consumer wants to migrate from a TS Chat SDK deployment to this Python port — or run both side-by-side against a shared Redis during a migration — the two sides can't identify each other's tokens. Release semantics still work (tokens compare by full string equality), but:

  1. There's no way for our port to reject a release attempt that targets a TS-issued lock using a Python-issued token, because we only compare opaque strings — that part happens to be safe.
  2. Observability suffers: looking at KEYS chat:lock:* in Redis and seeing a mix of redis_... and ioredis_... tokens gives no signal about which runtime issued each lock.
  3. Migrations can't do the graceful "let TS drain, then flip Python on" dance with certainty that every in-flight TS lock has a matching TS-prefixed token.

Proposed fix

Parameterize the prefix on RedisStateAdapter, and ship a thin IoRedisStateAdapter subclass for the ioredis interop case:

# src/chat_sdk/state/redis.py
class RedisStateAdapter:
    def __init__(self, *, token_prefix: str = "redis", ...) -> None:
        self._token_prefix = token_prefix
        ...

    def _generate_token(self) -> str:
        return f"{self._token_prefix}_{int(time.time() * 1000)}_{secrets.token_hex(16)}"


class IoRedisStateAdapter(RedisStateAdapter):
    """Use when sharing a Redis with a TypeScript Chat SDK deployment that uses ioredis.

    Identical to RedisStateAdapter except lock tokens carry the ``ioredis_`` prefix,
    matching the TS port byte-for-byte so tokens are origin-identifiable at a glance.
    """

    def __init__(self, **kwargs: Any) -> None:
        kwargs.setdefault("token_prefix", "ioredis")
        super().__init__(**kwargs)

Everything else is unchanged — same Lua scripts, same key layout ({prefix}:lock:{thread_id}, {prefix}:queue:{thread_id}, …), same SET NX PX acquisition, same PEXPIRE extend, same cache semantics.

Estimate: 10–15 lines of net new code plus the subclass. Zero behavior change for anyone not opting into IoRedisStateAdapter.

What this does not solve

  • Wire format of queue entries is still "message serialized via to_json()", which differs from TS's JSON.stringify(entry) — tracked at docs/UPSTREAM_SYNC.md under Serialization differences. Cross-runtime queue sharing needs a separate effort. This issue is scoped to lock-token prefix only.
  • Cross-runtime subscription storage uses the same Redis set membership on both sides and is already compatible.

Acceptance

  • RedisStateAdapter(..., token_prefix="ioredis") works
  • IoRedisStateAdapter subclass re-exports from chat_sdk.state
  • Regression test: acquire + release + extend using both prefixes
  • docs/ARCHITECTURE.md (or a new section) documents when to pick IoRedisStateAdapter
  • Entry in docs/UPSTREAM_SYNC.md noting the compat variant exists

Activity

  1. added a commit that references this issue on Apr 24, 2026
    e30020b
  2. added a commit that references this issue on Apr 24, 2026
    b3129d6
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions