Skip to content

fix: use redis hset mapping for scoped state - #265

Open
LiMelson wants to merge 3 commits into
trpc-group:mainfrom
LiMelson:codex/fix-redis-state-hset
Open

fix: use redis hset mapping for scoped state#265
LiMelson wants to merge 3 commits into
trpc-group:mainfrom
LiMelson:codex/fix-redis-state-hset

Conversation

@LiMelson

@LiMelson LiMelson commented Jul 30, 2026

Copy link
Copy Markdown

背景

这是 #89 的后续优化。上一次 PR #176 关闭后,主线已经合入了新的 replay harness 实现;本 PR 不再重复提交完整 harness,而是针对后续 Redis 集成验证中暴露出的 scoped state 写入问题做小范围修复。

同时也回应本 PR 中的 review/comment:RedisClusterStorage.__init__ 默认 decode_responses=True,但普通 RedisStorage 不默认;因此 replay backend 对 standalone Redis 显式设置 decode_responses=True,用于保证真实 Redis 返回字符串,避免跨后端比较时出现 bytes / str 非业务差异。

改进内容

  • RedisSessionService 中 app/user scoped state 的 Redis hash 写入从位置参数形式改为 hset(..., mapping=...),兼容真实 redis-py 的标准调用语义。
  • 保留 command.args=(key,),因此 EXPIRE_METHOD 中依赖 command.args[0] 的 TTL 刷新逻辑不受影响。
  • 更新 test_redis_session_service.py 中的 Redis mock,支持 mapping 写入路径。
  • 按 review 建议增强 mock 保真度:模拟 Redis hash value 编码行为,int/float/str 写入后按字符串读回;bool 与真实 redis-py 一样抛 DataError
  • 补充 scoped state 测试,覆盖 numeric value 编码和 bool value 拒绝行为。
  • tests/sessions/replay/backends.py 的 standalone Redis replay backend 中显式启用 decode_responses=True,降低 Redis 集成测试中的序列化噪声。

测试

  • pytest tests/sessions/test_redis_session_service.py -q
  • pytest tests/sessions/test_replay_consistency.py tests/sessions/test_replay_injections.py tests/sessions/test_replay_unit.py -q
  • TRPC_REPLAY_REDIS_URL=redis://localhost:6379/0 pytest tests/sessions/test_replay_consistency.py tests/sessions/test_replay_injections.py tests/sessions/test_replay_unit.py -q

@codecov

codecov Bot commented Jul 30, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
⚠️ Please upload report for BASE (main@86b5f7d). Learn more about missing BASE report.

Additional details and impacted files
@@            Coverage Diff             @@
##             main        #265   +/-   ##
==========================================
  Coverage        ?   87.85411%           
==========================================
  Files           ?         482           
  Lines           ?       45184           
  Branches        ?           0           
==========================================
  Hits            ?       39696           
  Misses          ?        5488           
  Partials        ?           0           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@helloopenworld

Copy link
Copy Markdown
Contributor

AI Code Review

我已对整个 PR 有完整理解。RedisClusterStorage.__init__ 默认 decode_responses=True,但普通 RedisStorage 不默认。replay backends 给非 cluster 的 RedisSessionService 显式加了 decode_responses=True,这是为了和 injectors 的 redis 客户端保持 str 一致——这是测试侧的合理修复。

现在评估核心改动。生产 execute_commandgetattr(conn, 'hset'),调用 conn.hset(key, mapping=app_state),这是 redis-py 标准签名,正确。command.args=(key,) 保证 expire key 提取正常。mock 同步更新支持 mapping。

唯一要核实的风险点:app_state 可能含非 str 值(bool/int),conn.hset(key, mapping=...) 会对每个 value 调用编码器转成 bytes/str。这在改动前后行为一致,非本次引入。但有一个值得提的点:mock 的 mapping 分支用 dict.update 直接存原值,不会模拟真实 redis 的字符串编码,因此单测无法发现类型回归。不过这是测试保真度的既有局限,不算本次新增缺陷。

我已有结论,输出 review。

发现的问题

未发现明显阻塞问题。本次 PR 将 hset 由展开的 args=[key, k1, v1, ...] 改为 kwargs={"mapping": state},与生产侧 RedisStorage.execute_commandgetattr(conn, 'hset') 调用 conn.hset(key, mapping=...) 的 redis-py 标准签名一致;command.args=(key,) 仍非空,EXPIRE_METHOD 中基于 command.args[0] 的 TTL 刷新逻辑不受影响。测试侧 mock 与 replay backend 同步适配了 mapping 路径与 decode_responses=True,改动闭环。

⚠️ Warning

  • tests/sessions/test_redis_session_service.py:89-91:mock 的 mapping 分支直接 dict.update 原值,未模拟真实 Redis 对 hash value 的字符串编码
    • 生产 conn.hset(key, mapping=app_state) 会将 bool/int 等非字符串值编码为字符串,而 mock 仍以原始 Python 类型存储,导致 test_append_with_state_delta 等用例无法捕获类型相关回归(如 flag: true 在真实 Redis 读回后变为 "True")。建议 mock 对 mapping 的 value 做一次 str() 转换,使单测与真实行为一致。

💡 Suggestion

  • tests/sessions/replay/backends.py:78-83decode_responses=True 仅显式加在非 cluster 的 RedisSessionService/RedisMemoryService 上,而 RedisClusterStorage 已在 __init__setdefault("decode_responses", True)
    • 若未来 replay 也覆盖 cluster backend,易遗漏此处显式参数;可考虑在普通 RedisStorage 层统一默认 decode_responses=True,或在 replay backend 注释说明仅 standalone 需要显式设置,降低后续维护时的认知负担。

总结

整体风险低,核心改动正确且与 redis-py 签名及 TTL 刷新路径兼容,无必须修复问题。唯一值得跟进的是 mock 未模拟 Redis 的字符串编码,削弱了对非字符串 state 值的回归保护。

测试建议

建议补充一条针对真实 Redis(或更高保真 mock)的用例:写入包含 bool/int/嵌套类型的 app/user state,读回后断言值经字符串往返后的实际表现,以覆盖 hset(mapping=...) 的编码行为。

@LiMelson

Copy link
Copy Markdown
Author

已按 review 建议更新:

  • test_redis_session_service.py 中的 Redis mock 现在会模拟 Redis hash value 编码行为:int/float/str 写入后按字符串读回。
  • 进一步确认了真实 redis-pybool 会抛 DataError,所以 mock 也同步该行为,并新增 bool scoped state 的拒绝用例,避免 mock 比真实 Redis 更宽松。
  • 补充了 numeric scoped state 用例,验证 session state 保持原类型,app/user scoped state 按 Redis hash 编码语义读回为字符串。

重新验证:

  • pytest tests/sessions/test_redis_session_service.py -q
  • pytest tests/sessions/test_replay_consistency.py tests/sessions/test_replay_injections.py tests/sessions/test_replay_unit.py -q

关于 replay backend 的 decode_responses=True:当前 PR 保持在 standalone Redis replay 接入层显式设置,避免扩大到全局 RedisStorage 默认行为,降低本次修复范围。

@helloopenworld

Copy link
Copy Markdown
Contributor

AI Code Review

我已经了解足够的信息。让我来整理一下审查结果。

发现的问题

🚨 Critical

未发现 Critical 级别问题。

⚠️ Warning

  • trpc_agent_sdk/sessions/_redis_session_service.py:288-292hset 改用 mapping 后,非 Cluster 的 RedisSessionService 默认不会设置 decode_responses=True(仅 RedisClusterStorage_redis_cluster.py:65 做了 setdefault)。当用户未显式传入 decode_responses 时,hgetall 返回的是 bytes,而 _update_app_state/_update_user_state 直接 app_state.update(state_delta) 后再整体 mapping 写回,读到的旧值会以 bytes 形式再次作为 hash value 写回,redis-py 不会拒绝 bytes,但这会导致数值/字符串状态在第二次更新后变成不可读的 b"..." 串并持续累积,破坏状态读取语义。建议在 RedisStorage.__init__(或 RedisSessionService 默认 kwargs)中也对 decode_responsessetdefault(True),与非 Cluster 一致;或在读回时统一 decode。

  • tests/sessions/test_redis_session_service.py:305-313test_append_with_bool_scoped_state_matches_redis_rejection 期望 append_eventDataError,但 _MockRedisStorage._encode_hash_valuebool 抛错能覆盖 mock 路径;真实 redis-py 的 hset(mapping=...) 同样会因 boolDataError,逻辑一致。风险在于该测试仅断言异常而未校验副作用:bool 写入失败后,本应已成功的 session JSON 是否被回滚/部分写入未验证,真实 Redis 下 hset 失败但 session 已 set 写入会造成 session 与 state 不一致。建议补一条断言确认失败后 session 仍可读且 state 为空,以覆盖回滚/一致性风险路径。

💡 Suggestion

  • tests/sessions/test_redis_session_service.py:96-103:mock 对 mapping 与位置参数两条分支都做了 _encode_hash_value,但生产代码(diff 后)只会走 mapping 分支,位置参数分支已无对应生产调用路径。可保留以兼容旧 mock 用法,但若想精确反映真实行为,可在注释中标注该分支仅为兼容,避免误导后续维护者以为生产仍走位置参数。

总结

本 PR 将 scoped state 的 hset 从位置参数改为 mapping 形式并补齐了对应测试,方向正确且测试较好地还原了 redis-py 的编码/拒绝行为;主要风险在于非 Cluster 的 RedisSessionService 未默认 decode_responses,可能在真实 Redis 下导致状态经多次更新后被 bytes 污染,建议修复后再合入。

测试建议

  • 补充一条测试:用真实/接近真实的 storage(或显式不传 decode_responses 的 mock)验证同一 app/user state 经两次 append_event 更新后,读回的值仍为字符串而非 bytes,覆盖 decode_responses 缺省场景。
  • 补充一条测试:bool state 写入抛错后,确认 session JSON 已落库而 scoped state 为空(或确认整体一致性策略),覆盖失败路径的副作用。

@LiMelson

Copy link
Copy Markdown
Author

已按最新 review 继续修改:

  • RedisStorage.__init__ 中默认设置 decode_responses=True,与 RedisClusterStorage 的默认行为对齐,避免 standalone Redis 在未显式配置时读回 bytes 污染 scoped state。
  • 移除了 replay backend 中重复的 decode_responses=True 显式参数,统一由 RedisStorage 默认值兜底。
  • 补充 RedisStorage 默认解码和显式关闭解码的单测。
  • bool scoped state 写入失败后,新增断言确认 session 仍可读、state 为空、events 为空,覆盖失败路径副作用。
  • mock 的位置参数 hset 分支已加注释,说明仅保留用于兼容旧 mock 调用。

验证:

  • pytest tests/storage/test_redis.py tests/storage/test_redis_cluster_storage.py -q
  • pytest tests/sessions/test_redis_session_service.py -q
  • pytest tests/sessions/test_replay_consistency.py tests/sessions/test_replay_injections.py tests/sessions/test_replay_unit.py -q
  • TRPC_REPLAY_REDIS_URL=redis://localhost:6379/0 pytest tests/sessions/test_replay_consistency.py tests/sessions/test_replay_injections.py tests/sessions/test_replay_unit.py -q

@helloopenworld

Copy link
Copy Markdown
Contributor

AI Code Review

发现的问题

⚠️ Warning

  • trpc_agent_sdk/storage/_redis.py:138decode_responses=True 默认值使非集群 RedisStorage._deserialize_valuequery() 的字节处理分支失效

    • 该 PR 把 decode_responses 默认改为 True_redis.py:101),但非集群的 _deserialize_value 仍只处理 bytes/None:对 str 调用 value.decode('utf-8') 会抛 AttributeErrorquery()isinstance(key_type, bytes) 分支(_redis.py:190)也变为死代码。集群侧 _redis_cluster.py:198-211 已同时兼容 str/bytes,非集群侧未同步,行为不一致。建议参照集群实现让 _deserialize_value 兼容 str,或在该路径上不强制开启 decode。
    ...
    value_str = value.decode('utf-8')  # value 现在是 str,会 AttributeError
    ...
  • tests/sessions/test_redis_session_service.py:70:Mock 的 _encode_hash_value 对非 bool 值统一 str(value),与真实 redis-py 行为不一致

    • 真实 hset(mapping=...) 对 dict/list 等非 (str/int/float/bytes) 值会抛 DataError,而 mock 会静默存成 "{'x': 1}" 字符串;test_append_with_numeric_scoped_state_matches_redis_encodingtest_redis_session_service.py:293)因此只对原始类型成立,对容器值给出虚假信心。同时 test_append_with_bool_scoped_state_matches_redis_rejectiontest_redis_session_service.py:310)把"抛异常、整个 append_event 中止且不回滚"固化为预期行为——生产中一旦 scoped state 含 bool 或容器值,_set_session 不会执行,事件与会话持久化整体丢失。建议 mock 对非原始类型也抛 DataError 以对齐 redis,并在 _update_app_state/_update_user_state 写入前对值做校验或类型转换。

总结

该 PR 用 hset(mapping=...) 存储 scoped state 并将 decode_responses 默认为 True,方向正确,但没有必须修复的阻塞性问题;主要风险是默认 decode 与非集群 _deserialize_value/query 的字节假设不一致,以及 mock 对容器值/bool 的处理与真实 redis 偏离、进而把"异常即中止且不回滚"的脆弱行为写进测试。

测试建议

  • 补充一条用例:scoped state 含容器值(dict/list)时 append_event 的真实表现(真实 redis 应抛 DataError),并验证 mock 与之一致;当前 mock 会掩盖此路径。
  • 补充一条 RedisStorage.query() 对 string 类型 key 的用例(默认 decode 下),覆盖 _deserialize_value 收到 str 的路径,以暴露上述 AttributeError

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.

2 participants