Skip to content

feat(state): IoRedisStateAdapter for cross-runtime interop (#71)#73

Merged
patrick-chinchill merged 4 commits into
mainfrom
feat/ioredis-prefix-71
Apr 24, 2026
Merged

feat(state): IoRedisStateAdapter for cross-runtime interop (#71)#73
patrick-chinchill merged 4 commits into
mainfrom
feat/ioredis-prefix-71

Conversation

@patrick-chinchill
Copy link
Copy Markdown
Collaborator

@patrick-chinchill patrick-chinchill commented Apr 24, 2026

Summary

  • Parameterizes RedisStateAdapter.token_prefix (default "redis"; back-compat preserved).
  • Ships IoRedisStateAdapter(RedisStateAdapter) defaulting to token_prefix="ioredis" so consumers migrating between TS (Vercel Chat on ioredis) and Python chat-sdk deployments can share a Redis with runtime-of-origin-identifiable lock tokens.
  • Exports IoRedisStateAdapter from chat_sdk.state (matches the existing RedisStateAdapter export pattern — root chat_sdk.__init__ only exposes MemoryStateAdapter, so nothing added there).
  • Lua scripts, key layout, SET NX PX acquisition, and PEXPIRE extend are unchanged; tokens still compare by full string equality.

Closes #71.

Intentional divergence from upstream (not byte-for-byte)

The ioredis_ prefix matches upstream, but the token suffix diverges by design:

  • Python: ioredis_{ms}_{secrets.token_hex(16)} — always 32 hex chars, CSPRNG.
  • Upstream (state-ioredis/state-redis): ioredis_${Date.now()}_${Math.random().toString(36).substring(2, 15)} — base36, ≤13 chars, not CSPRNG.

Cross-runtime lock-release still works because release_lock / extend compare the full token string and each runtime only releases tokens it issued on acquire. The divergence is observability-only — bytes in Redis and in log lines differ in shape. We intentionally do not regress secrets.token_hex to Math.random() for cosmetic byte-for-byte parity; CSPRNG quality matters more than a matching log shape. Documented as a row in docs/UPSTREAM_SYNC.md under Known Non-Parity.

Test plan

  • uv run ruff check src/ tests/ scripts/
  • uv run ruff format --check src/ tests/ scripts/
  • uv run pyrefly check — 0 errors
  • uv run python scripts/audit_test_quality.py — 0 hard failures
  • uv run python scripts/verify_test_fidelity.py — no new missing
  • uv run pytest tests/ --tb=short -q — 3556 passed, 2 skipped
  • Added 9 tests in TestRedisStateTokenPrefix:
    • default redis_ prefix preserved (back-compat)
    • custom token_prefix applied
    • IoRedisStateAdapter() default = ioredis_
    • IoRedisStateAdapter isa RedisStateAdapter
    • explicit token_prefix on the subclass still wins
    • test_ioredis_token_shape_python_diverges_from_upstream — documents the intentional CSPRNG / hex-vs-base36 divergence (test name and docstring call this out explicitly)
    • release/extend with custom prefix succeed on full-string match
    • forged redis_-prefixed token with the same suffix cannot release an ioredis_ lock
    • extend under custom prefix bumps TTL as expected

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added an IoRedis-backed adapter variant and a configurable token_prefix option so locks include a runtime-identifying prefix (defaults preserved for compatibility).
  • Documentation
    • Changelog and docs updated to describe token formats, cross-runtime compatibility, and guidance for choosing the ioredis-style prefix.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 24, 2026

Warning

Rate limit exceeded

@patrick-chinchill has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 1 minutes and 52 seconds before requesting another review.

Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 1 minutes and 52 seconds.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 6ae23637-492c-44d8-8797-6330f363b823

📥 Commits

Reviewing files that changed from the base of the PR and between 130064f and ac59151.

📒 Files selected for processing (5)
  • CHANGELOG.md
  • docs/UPSTREAM_SYNC.md
  • src/chat_sdk/state/__init__.py
  • src/chat_sdk/state/redis.py
  • tests/test_state_redis.py
📝 Walkthrough

Walkthrough

Adds a token prefix parameter to Redis lock tokens and introduces an IoRedisStateAdapter subclass that defaults to the ioredis prefix, enabling observable cross-runtime token identification while preserving existing lock semantics.

Changes

Cohort / File(s) Summary
Changelog
CHANGELOG.md
Added an Unreleased entry documenting IoRedisStateAdapter and the new token_prefix parameter on RedisStateAdapter, and noted Python vs TS token-format divergence after the prefix.
Public exports
src/chat_sdk/state/__init__.py
Exported IoRedisStateAdapter by adding it to imports and __all__.
Core adapter
src/chat_sdk/state/redis.py
Generalized _generate_token(prefix=...); added token_prefix: str = "redis" to RedisStateAdapter.__init__; added IoRedisStateAdapter subclass defaulting token_prefix to "ioredis"; create_redis_state now accepts and forwards token_prefix.
Tests
tests/test_state_redis.py
Added tests covering default redis_ prefix, custom token_prefix, IoRedisStateAdapter defaults/overrides, token structure, and lock acquire/release/extend behavior with matching vs. mismatched prefixes.
Docs
docs/UPSTREAM_SYNC.md
Documented Redis lock token generation differences (CSPRNG hex vs upstream base36) and noted compatibility of release/extend via full-string comparison.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Poem

🐇 I stitched a prefix, neat and small,
So Redis locks can show their all.
"ioredis" or "redis" — pick your tune,
We hop between runtimes, one and soon,
Locks hold true beneath the moon.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed Title accurately describes the main change: introducing IoRedisStateAdapter for cross-runtime interoperability between Python and TypeScript Chat SDK deployments.
Linked Issues check ✅ Passed All coding requirements from #71 are met: token_prefix parameterization [#71], IoRedisStateAdapter subclass [#71], re-export from chat_sdk.state [#71], and comprehensive regression tests [#71].
Out of Scope Changes check ✅ Passed All changes are in-scope and align with #71 objectives: redis.py refactoring, init.py exports, tests, CHANGELOG, and UPSTREAM_SYNC.md documentation are all directly related to cross-runtime interop.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/ioredis-prefix-71

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces the IoRedisStateAdapter and adds a token_prefix parameter to the RedisStateAdapter to facilitate cross-runtime Redis sharing with TypeScript-based deployments. The changes include updates to the lock token generation logic, documentation, and the addition of comprehensive unit tests. Feedback identifies an inaccuracy in the new docstring regarding Redis command behavior and suggests updating the create_redis_state factory function to support the new parameter.

Comment thread src/chat_sdk/state/redis.py Outdated
Comment on lines +370 to +372
Enables migrations like "drain TS, then flip Python on" with tokens
identifiable by runtime-of-origin at a glance when inspecting
``KEYS chat:lock:*`` in Redis.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The docstring mentions that tokens are identifiable when inspecting KEYS chat:lock:*. However, the KEYS command in Redis only returns the names of the keys, not their values (where the tokens reside). To see the tokens, one would need to use GET on those keys. Additionally, since the default key_prefix is chat-sdk, the example pattern would more accurately be chat-sdk:lock:*.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 130064f — docstring reworded to describe reading the lock value via GET chat-sdk:lock:<thread_id> plus mention of logs/trace IDs where tokens surface on release/extend failures. Thanks.

Comment on lines +375 to +376
def __init__(self, *, token_prefix: str = "ioredis", **kwargs: Any) -> None:
super().__init__(token_prefix=token_prefix, **kwargs)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The create_redis_state factory function (located at the end of this file) should be updated to accept and forward the new token_prefix parameter to the RedisStateAdapter constructor. This ensures consistency between the class and its factory method, allowing users of the factory to leverage the new parameterization.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 43f4f40create_redis_state() now accepts and forwards token_prefix (and tests cover default + override). Thanks.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
tests/test_state_redis.py (1)

675-688: Use non-blocking sleep in async test.

Line 686 uses time.sleep(...) inside an async test; use await asyncio.sleep(...) instead to avoid blocking the event loop.

Proposed fix
+import asyncio
 import time
@@
-            time.sleep(0.15)
+            await asyncio.sleep(0.15)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_state_redis.py` around lines 675 - 688, In
test_extend_lock_works_with_custom_prefix replace the blocking time.sleep call
with a non-blocking await asyncio.sleep to avoid blocking the event loop: update
the test to use await asyncio.sleep(0.15) (and add an import for asyncio at the
top of the test file if missing). This change keeps the test async while still
waiting past the original TTL tested by RedisStateAdapter.acquire_lock and
RedisStateAdapter.extend_lock so the subsequent acquire_lock call behaves
correctly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@tests/test_state_redis.py`:
- Around line 675-688: In test_extend_lock_works_with_custom_prefix replace the
blocking time.sleep call with a non-blocking await asyncio.sleep to avoid
blocking the event loop: update the test to use await asyncio.sleep(0.15) (and
add an import for asyncio at the top of the test file if missing). This change
keeps the test async while still waiting past the original TTL tested by
RedisStateAdapter.acquire_lock and RedisStateAdapter.extend_lock so the
subsequent acquire_lock call behaves correctly.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: cbb9cd53-0552-4611-a3b1-6d61a2ca3ddb

📥 Commits

Reviewing files that changed from the base of the PR and between d23b6d9 and f6dee27.

📒 Files selected for processing (4)
  • CHANGELOG.md
  • src/chat_sdk/state/__init__.py
  • src/chat_sdk/state/redis.py
  • tests/test_state_redis.py

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
tests/test_state_redis.py (1)

705-705: Use non-blocking sleep in async test code.

Line 705 uses time.sleep(0.15) inside async def, which blocks the event loop. Prefer await asyncio.sleep(0.15).

Proposed patch
 import time
+import asyncio
 
 import pytest
-            time.sleep(0.15)
+            await asyncio.sleep(0.15)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_state_redis.py` at line 705, Replace the blocking call
time.sleep(0.15) inside the async test with a non-blocking await
asyncio.sleep(0.15): locate the async test function that currently calls
time.sleep(0.15) and change it to await asyncio.sleep(0.15), and if asyncio is
not already imported in tests/test_state_redis.py add an import asyncio at the
top so the call resolves.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@tests/test_state_redis.py`:
- Line 705: Replace the blocking call time.sleep(0.15) inside the async test
with a non-blocking await asyncio.sleep(0.15): locate the async test function
that currently calls time.sleep(0.15) and change it to await
asyncio.sleep(0.15), and if asyncio is not already imported in
tests/test_state_redis.py add an import asyncio at the top so the call resolves.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 551b9646-e3a3-4629-ac35-c3ef5791cde4

📥 Commits

Reviewing files that changed from the base of the PR and between f6dee27 and 130064f.

📒 Files selected for processing (4)
  • CHANGELOG.md
  • docs/UPSTREAM_SYNC.md
  • src/chat_sdk/state/redis.py
  • tests/test_state_redis.py
✅ Files skipped from review due to trivial changes (2)
  • CHANGELOG.md
  • docs/UPSTREAM_SYNC.md

patrick-chinchill added a commit that referenced this pull request Apr 24, 2026
Synchronous time.sleep in an async test blocks the event loop.
Addresses coderabbit feedback on PR #73.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@patrick-chinchill
Copy link
Copy Markdown
Collaborator Author

Review verdict: LGTM. Refreshed and reviewed latest head 9ba45e3 against main. No issues found in Redis/IoRedis lock-token prefix interop changes. Verification: tests/test_state_redis.py passed (68 passed) and ruff passed on redis state/tests. Residual risk: tests use MockRedis rather than a live Redis/ioredis integration. Formal GitHub approval is blocked because the authenticated account owns this PR.

patrick-chinchill added a commit that referenced this pull request Apr 24, 2026
Sweep remaining sync time.sleep calls in async tests (test_memory_state.py + test_state_postgres.py). Followup to #73 which fixed the same hazard in test_state_redis.py.

Also removes one redundant sleep flagged by gemini (lock is force-expired on the next line).
patrick-chinchill and others added 4 commits April 24, 2026 02:37
Parameterizes RedisStateAdapter.token_prefix and ships IoRedisStateAdapter
subclass defaulting to "ioredis_". Enables cross-runtime Redis sharing
between TS and Python chat-sdk deployments — tokens compare by full string
equality, but now they carry runtime-of-origin identification for
observability and graceful migration.

Closes #71.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…redis_state

- Corrects the observability example in RedisStateAdapter docstring
  (KEYS only returns key names, not values)
- create_redis_state factory now accepts/forwards token_prefix to
  keep the factory and direct-instantiation paths aligned

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ivergence

Upstream uses Math.random().toString(36) (base36, not CSPRNG); we use
secrets.token_hex(16) (32 hex chars, CSPRNG). Interop still works via
full-string lock-release equality. Renames the shape-test, updates
CHANGELOG and UPSTREAM_SYNC.md Non-Parity table to reflect reality
instead of claiming byte-for-byte compatibility.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Synchronous time.sleep in an async test blocks the event loop.
Addresses coderabbit feedback on PR #73.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@patrick-chinchill patrick-chinchill merged commit b3129d6 into main Apr 24, 2026
10 checks passed
patrick-chinchill added a commit that referenced this pull request Apr 24, 2026
Parity catch-up release for upstream chat@4.26.0. Bundles 8 PRs (#64 #65 #66 #67 #73 #74 #75 #76) + small followup cleanups. See CHANGELOG.md for details.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant