Skip to content

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

@patrick-chinchill

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions