Skip to content

perf: pipeline redis set_state writes, cache required-state classes#6745

Open
Alek99 wants to merge 2 commits into
mainfrom
claude/reflex-perf-optimizations-21kg8y-redis
Open

perf: pipeline redis set_state writes, cache required-state classes#6745
Alek99 wants to merge 2 commits into
mainfrom
claude/reflex-perf-optimizations-21kg8y-redis

Conversation

@Alek99

@Alek99 Alek99 commented Jul 10, 2026

Copy link
Copy Markdown
Member

All Submissions:

  • Have you followed the guidelines stated in CONTRIBUTING.md file?
  • Have you checked to ensure there aren't any other open Pull Requests for the desired changed?

Type of change

  • Bug fix (non-breaking change which fixes an issue)

Changes To Core Features:

  • Have you added an explanation of what your changes do and why you'd like us to include them?
  • Have you written new tests for your core changes, as applicable?

StateManagerRedis.set_state recursed over the substate tree spawning a task per substate; every recursive call re-GETed the same lock key and re-PTTLed it, and each touched state got its own unpipelined SET — roughly 2N+1 redis round trips per event flush for N states. get_state already pipelines its reads; set_state now does the same for writes (ENG-10097):

  • Verify the lock once, write once: the tree is walked iteratively, the lock GET + PTTL happen a single time, and all touched states' payloads go out in one pipeline. 3 round trips per flush regardless of tree size.
  • Off-loop pickling: states whose previous payload exceeded 64 KiB are serialized via asyncio.to_thread instead of stalling the event loop (safe: the token's lock is held, so nothing mutates the tree mid-pickle — the old code awaited between pickles too).
  • Cached _get_required_state_classes: the recursive class-level computation ran on every get_state. It's now an lru_cached module function keyed on (target class, registration context, state hierarchy generation); BaseState.__init_subclass__ and reload_state_module bump the generation, so dynamically created classes and hot reload invalidate cleanly (including same-name class redefinition, where registry sizes don't change).
  • _was_touched reset after write (mirrors the disk manager's existing get_and_reset_touched_state): previously the flag was never cleared, so a once-touched state kept alive by the oplock cache was re-pickled and re-SET on every subsequent flush. Reset only happens after a successful pipeline execute, so failed writes retry.

Round trips per event flush (modeled on the exact before/after command flows; "3" = lock GET + PTTL + one pipelined write):

state tree before after worst-case serialized latency @ 0.25 ms RTT
root + 3 substates 12 3 3.00 ms → 0.75 ms (4x)
root + 9 substates 30 3 7.50 ms → 0.75 ms (10x)
3 levels × 3 wide (13 states) 39 3 9.75 ms → 0.75 ms (13x)

New unit tests (run against both mock and real redis in CI): test_set_state_verifies_lock_once_and_pipelines_writes counts actual GET/PTTL commands during a 3-substate modify (asserts exactly one of each); test_set_state_resets_touched_flag asserts a second flush of an unchanged instance creates no write pipeline at all; test_set_state_offloads_large_pickles asserts the to_thread path engages above the threshold; test_required_state_classes_cache_invalidation asserts a subclass defined after cache warm-up appears in the cached result via the generation bump. Existing modify_state/oplock/expiration tests cover end-to-end behavior.

Linear: ENG-10097

🤖 Generated with Claude Code

https://claude.ai/code/session_01Sns7EuBPYvfGCxRSRZ1bUx


Generated by Claude Code

set_state recursed over the substate tree spawning a task per substate;
every recursive call re-GETed the same lock key and re-PTTLed it, and
each touched state got its own unpipelined SET -- roughly 2N+1 round
trips per event flush for N states. The tree is now walked once, the
lock is verified once, and all touched states are written in a single
pipeline: 3 round trips per flush regardless of tree size (12->3 for a
root with 3 substates, 39->3 for a 13-state tree).

Also:
- Pickling runs off the event loop (asyncio.to_thread) for states whose
  previous payload exceeded 64KiB, instead of stalling the loop.
- The recursive required-state-classes computation (pure class-level
  data) is now lru-cached per registration context and state hierarchy
  generation; subclass creation and module hot reload bump the
  generation, so dynamic classes invalidate cleanly.
- _was_touched is reset after a successful write (mirroring the disk
  manager), so a once-touched state held by the oplock cache is no
  longer re-pickled and re-SET on every subsequent flush.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sns7EuBPYvfGCxRSRZ1bUx
@Alek99 Alek99 requested a review from a team as a code owner July 10, 2026 20:49
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sns7EuBPYvfGCxRSRZ1bUx
@greptile-apps

greptile-apps Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR optimizes Redis-backed state reads and writes. The main changes are:

  • Batched touched-state writes into one Redis pipeline.
  • Added off-loop serialization for large state payloads.
  • Cached required-state class discovery with generation invalidation.
  • Reset touched flags after successful Redis writes.
  • Added unit coverage for the new Redis manager behavior.

Confidence Score: 4/5

The Redis write path needs a fix for large-state serialization before merging.

  • Most batching and cache invalidation behavior matches the old contracts.
  • The large-payload path now serializes a live state object in another thread.
  • A concurrent mutation can be missed after _was_touched is cleared.

reflex/istate/manager/redis.py

Important Files Changed

Filename Overview
reflex/istate/manager/redis.py Batches Redis state writes, adds large-payload offload, resets touched flags after writes, and caches required-state discovery; the off-thread serialization path can race with live state mutation.
reflex/state.py Adds a state hierarchy generation counter and bumps it on subclass creation and reload to invalidate cached required-state results.
tests/units/istate/manager/test_redis.py Adds focused tests for pipelined writes, lock checks, touched-flag reset, serialization offload, and cache invalidation.
news/6745.performance.md Adds a performance news entry for the Redis state manager optimizations.

Reviews (1): Last reviewed commit: "chore: add news fragment for #6745" | Re-trigger Greptile

> _OFFLOAD_SERIALIZE_THRESHOLD
):
# Pickling large states would stall the event loop.
pickle_state = await asyncio.to_thread(substate._serialize)

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.

P1 Live State Thread Race

When a state's previous pickle is larger than the threshold, this sends the live substate object to a worker thread and yields the event loop. If a background task mutates the same instance while _serialize() is running, Redis can receive a stale or partial snapshot and _was_touched is then cleared, so the interleaved change may not be written on the next flush.

@codspeed-hq

codspeed-hq Bot commented Jul 10, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 26 untouched benchmarks
⏩ 8 skipped benchmarks1


Comparing claude/reflex-perf-optimizations-21kg8y-redis (c4b7b46) with main (9a5c4d3)

Open in CodSpeed

Footnotes

  1. 8 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9cd59d2f22

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

# Reset the touched flag so an unchanged state is not re-pickled
# and re-written by subsequent flushes of the same instance.
for substate in written_states:
substate._was_touched = False

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Keep Redis state TTLs refreshed after writes

Resetting _was_touched here means a state that was persisted once is skipped by every later flush until that same state changes again, so its Redis key never receives another SET ... ex=self.token_expiration. In an active session that keeps modifying only a sibling/substate, the unchanged root or sibling key can expire after redis_token_expiration while the session is still in use; the next fetch after an oplock/cache miss or on another worker recreates that part of the tree with defaults and loses the previously persisted values. The optimization needs a separate TTL refresh path for already-persisted states before clearing the touched marker.

Useful? React with 👍 / 👎.

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