Skip to content

Address TTL Cascade (#277) review comments + NOSCRIPT recovery fix - #278

Open
yedidyakfir wants to merge 7 commits into
developfrom
gsd/cascade-ttl-review-fixes
Open

Address TTL Cascade (#277) review comments + NOSCRIPT recovery fix#278
yedidyakfir wants to merge 7 commits into
developfrom
gsd/cascade-ttl-review-fixes

Conversation

@yedidyakfir

@yedidyakfir yedidyakfir commented Jul 12, 2026

Copy link
Copy Markdown
Collaborator

Addresses the review comments left on #277, plus one correctness fix found along the way.

Review comments addressed

# Comment Change
config.py "all config should be frozen after init, not just ttl" _ttl_frozen_frozen; __setattr__ blocks every public Meta field once frozen; MetaTtlFrozenErrorMetaFrozenError; global autouse test fixture unfreezes between tests
base.py refresh_ttl "always use the sha" dropped the _has_cascade branch — refresh_ttl always runs the cascade EVALSHA (no edges ⇒ just re-arms own keys)
planner.py "use dataclasses everywhere" _classify_edge returns an EdgeClassification dataclass instead of a tuple
planner.py "docstrings start on a new line unless one-liner; keep them short" reworded multi-line docstrings across the cascade code/tests
planner.py "no -> None" removed -> None return annotations
planner.py "why not use contains_sf_field?" now uses contains_sf_field() (guarded by safe_issubclass) instead of the manual hasattr check
planner.py "don't include WR-02 / GSD notation in comments" stripped all internal workflow tags (D-0x/WR-0x/IN-0x/RESEARCH.md/Pitfall) from comments across code and tests

Bonus correctness fix (not from a comment)

  • NOSCRIPT recovery (context.py): the self-heal replayed the full pipeline on NOSCRIPT, double-applying already-committed non-idempotent native ops (JSON.NUMINCRBY/JSON.ARRAPPEND). Now replays EVALSHA-only (matching _apipeline), and _apipeline shares that one helper. Kept because it's a real data-corruption bug on the write path; happy to split it out if you'd rather.

Verification

  • 825 unit tests (fakeredis) pass; black --check and ruff check clean.
  • Integration suite (real Redis Stack :6370) not run locally — relies on CI.

🤖 Generated with Claude Code

YedidyaHKfir and others added 3 commits July 12, 2026 17:59
…on NOSCRIPT, converge recovery paths

execute_pipeline_with_noscript_recovery replayed the FULL command stack on
NOSCRIPT. In a transactional MULTI/EXEC a NOSCRIPT surfaces at EXEC time after
the non-EVALSHA commands already committed (Redis does not roll back a
transaction on a mid-execution command error), so a full replay double-applies
non-idempotent native ops (JSON.NUMINCRBY, JSON.ARRAPPEND, SF ops). This
affected every write routed through ensure_pipeline/pipeline_with_execution,
not just cascade, and only on a script-cache flush/failover.

- context.py: replay only the EVALSHA entries (matching the already-correct
  _apipeline pattern); add an ignore_redis_error param so the two write paths
  share one recovery implementation (WR-03, IN-01).
- base.py: _apipeline now delegates to the shared helper; drop the duplicated
  backup/replay block. Also document the non-positive-ttl root-delete asymmetry
  in aset_ttl(cascade=True) (IN-04) and restore valid indentation on
  contains_sf_field (stray uncommitted corruption).
- test_context.py: rewrite the full-stack-replay test to assert EVALSHA-only
  replay (it had locked in the buggy behavior).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…freeze lifecycle robust

- init.py (WR-01): wrap the unfreeze -> configure -> bake -> refreeze sequence
  in try/finally so a failed init (e.g. validate_cascade_ttl_targets on a
  mis-configured graph) still refreezes every model instead of leaving them
  unfrozen with silently-mutable, half-baked Meta.ttl/cascade_ttl.
- init.py (WR-02): teardown_rapyer now clears _ttl_frozen so a torn-down model
  doesn't leak MetaTtlFrozenError into a later init-less path.
- config.py (WR-04): freeze cascade_ttl too, not just ttl — both are baked into
  the per-class Lua plan / _has_cascade gate, so mutating cascade_ttl post-freeze
  would silently desync the runtime cascade from the baked plan.
- cascade/conftest.py: autouse fixture resets _ttl_frozen around each cascade
  test so the process-global freeze can't make tests order-dependent (WR-02).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…g and correct dead-branch comment

- registry.py (IN-03): _lua_literal now also escapes newlines/CR so a stray
  control char in an injected literal yields valid Lua at SCRIPT LOAD instead
  of a silently broken script body.
- apply.lua (IN-02): correct the recurse=false comment — the branch is a
  not-yet-exercised seam (every emitted edge has recurse=true), and a
  non-recursing target can still be reached via its override edges since
  next_hop ignores budget for overrides.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 12, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR centralizes Redis pipeline recovery, replays only queued EVALSHA commands after NOSCRIPT errors, expands TTL configuration freezing, guarantees freeze cleanup, escapes additional Lua characters, and updates related tests and documentation.

Changes

Pipeline recovery

Layer / File(s) Summary
Centralized pipeline recovery
rapyer/base.py, rapyer/context.py, tests/unit/test_context.py
Pipeline execution delegates NOSCRIPT and Redis error handling to the shared helper, which can swallow ResponseError and retries only queued EVALSHA commands.

TTL freeze lifecycle

Layer / File(s) Summary
TTL freeze lifecycle
rapyer/config.py, rapyer/init.py, tests/unit/cascade/conftest.py, rapyer/base.py
Both TTL fields are frozen after initialization, refrozen on initialization failure, cleared during teardown and cascade tests, and documented for non-positive TTL behavior.

Lua serialization and cascade documentation

Layer / File(s) Summary
Lua serialization and cascade behavior
rapyer/scripts/registry.py, rapyer/scripts/lua/cascade/apply.lua
Lua literals now escape newline and carriage-return characters, and cascade traversal comments describe target refresh and override-edge handling.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Pipeline
  participant RecoveryHelper
  participant Redis
  Pipeline->>RecoveryHelper: execute pipeline
  RecoveryHelper->>Redis: submit queued commands
  Redis-->>RecoveryHelper: NoScriptError or ResponseError
  RecoveryHelper->>Redis: re-register scripts and replay EVALSHA commands
  Redis-->>RecoveryHelper: retry results
Loading

Possibly related issues

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% 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
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main changes: TTL cascade review fixes and the NOSCRIPT recovery correction.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch gsd/cascade-ttl-review-fixes

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@github-actions

github-actions Bot commented Jul 12, 2026

Copy link
Copy Markdown

Coverage report

Total coverage: 99%

Full report
Name                                                     Stmts   Miss  Cover
----------------------------------------------------------------------------
rapyer/__init__.py                                           5      0   100%
rapyer/actions.py                                          213      0   100%
rapyer/base.py                                             834      0   100%
rapyer/cascade/__init__.py                                   3      0   100%
rapyer/cascade/planner.py                                  121      5    96%
rapyer/cascade/spec.py                                      13      0   100%
rapyer/cascade/ttl.py                                        5      0   100%
rapyer/config.py                                            44      0   100%
rapyer/context.py                                           65      0   100%
rapyer/errors/__init__.py                                   11      0   100%
rapyer/errors/base.py                                       23      0   100%
rapyer/errors/cascade.py                                     7      0   100%
rapyer/errors/delete.py                                      3      0   100%
rapyer/errors/find.py                                       15      0   100%
rapyer/fields/__init__.py                                    4      0   100%
rapyer/fields/expression.py                                108      0   100%
rapyer/fields/index.py                                      20      0   100%
rapyer/fields/key.py                                        19      0   100%
rapyer/fields/safe_load.py                                  14      0   100%
rapyer/init.py                                              64      0   100%
rapyer/links.py                                              2      0   100%
rapyer/result.py                                            31      0   100%
rapyer/scripts/__init__.py                                   5      0   100%
rapyer/scripts/constants.py                                 16      0   100%
rapyer/scripts/loader.py                                    27      0   100%
rapyer/scripts/lua/__init__.py                               0      0   100%
rapyer/scripts/lua/atomic/__init__.py                        0      0   100%
rapyer/scripts/lua/cascade/__init__.py                       0      0   100%
rapyer/scripts/lua/datetime/__init__.py                      0      0   100%
rapyer/scripts/lua/dict/__init__.py                          0      0   100%
rapyer/scripts/lua/list/__init__.py                          0      0   100%
rapyer/scripts/lua/numeric/__init__.py                       0      0   100%
rapyer/scripts/lua/sf/__init__.py                            0      0   100%
rapyer/scripts/lua/sf/redis_priority_queue/__init__.py       0      0   100%
rapyer/scripts/lua/sf/redis_set/__init__.py                  0      0   100%
rapyer/scripts/lua/string/__init__.py                        0      0   100%
rapyer/scripts/registry.py                                  80      1    99%
rapyer/types/__init__.py                                    13      0   100%
rapyer/types/base.py                                       103      0   100%
rapyer/types/byte.py                                        33      0   100%
rapyer/types/convert.py                                     53      0   100%
rapyer/types/datetime.py                                    77      0   100%
rapyer/types/dct.py                                        116      0   100%
rapyer/types/float.py                                       65      0   100%
rapyer/types/foreign_key.py                                 68      0   100%
rapyer/types/generic.py                                     83      0   100%
rapyer/types/init.py                                        10      0   100%
rapyer/types/integer.py                                     58      0   100%
rapyer/types/lst.py                                        129      0   100%
rapyer/types/priority_queue.py                             102      0   100%
rapyer/types/redis_set.py                                  196      0   100%
rapyer/types/relational.py                                  24      0   100%
rapyer/types/special.py                                     39      0   100%
rapyer/types/string.py                                      22      0   100%
rapyer/typing_support.py                                     3      0   100%
rapyer/utils/__init__.py                                     0      0   100%
rapyer/utils/annotation.py                                  69      1    99%
rapyer/utils/fields.py                                      43      0   100%
rapyer/utils/pythonic.py                                    21      0   100%
rapyer/utils/redis.py                                       77      0   100%
----------------------------------------------------------------------------
TOTAL                                                     3156      7    99%

@codspeed-hq

codspeed-hq Bot commented Jul 12, 2026

Copy link
Copy Markdown

Merging this PR will degrade performance by 18.45%

⚡ 1 improved benchmark
❌ 36 regressed benchmarks
✅ 131 untouched benchmarks

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Mode Benchmark BASE HEAD Efficiency
WallTime test_benchmark_with_ttl 4.5 ms 7 ms -34.7%
WallTime test_benchmark_with_ttl 9.6 ms 14.5 ms -34.18%
WallTime test_benchmark_with_ttl 9.7 ms 14.7 ms -33.84%
WallTime test_benchmark_with_ttl 4.9 ms 7.4 ms -33.19%
WallTime test_benchmark_with_ttl 5 ms 7.4 ms -32.97%
WallTime test_benchmark_with_ttl 113.7 ms 160.2 ms -29.03%
WallTime test_benchmark_with_ttl 132.4 ms 179.2 ms -26.14%
WallTime test_benchmark_with_ttl 3.9 ms 5.3 ms -26.11%
WallTime test_benchmark_with_ttl 11 ms 14.6 ms -24.36%
WallTime test_benchmark_with_ttl 2.1 ms 2.8 ms -24.34%
WallTime test_benchmark_with_ttl 3.5 ms 4.7 ms -24.16%
WallTime test_benchmark_with_ttl 3.7 ms 4.7 ms -22.94%
WallTime test_benchmark_with_ttl 187.3 ms 238.4 ms -21.45%
WallTime test_benchmark_with_ttl 4.1 ms 5.1 ms -19.34%
WallTime test_benchmark_with_ttl 2.5 ms 3 ms -15.34%
WallTime test_benchmark_with_ttl 1.8 ms 2.1 ms -15.24%
WallTime test_benchmark_with_ttl 2.4 ms 2.8 ms -15.09%
WallTime test_benchmark_with_ttl 2.3 ms 2.7 ms -14.59%
WallTime test_benchmark_with_ttl 2.6 ms 3.1 ms -14.52%
WallTime test_benchmark_with_ttl 1.8 ms 2.2 ms -14.29%
... ... ... ... ... ...

ℹ️ Only the first 20 benchmarks are displayed. Go to the app to view all benchmarks.

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing gsd/cascade-ttl-review-fixes (920d7a9) with develop (cb8089e)

Open in CodSpeed

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
rapyer/init.py (1)

107-117: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Keep _ttl_frozen reset in a finally block

await model.Meta.redis.aclose() can abort the loop before later models are unfrozen, leaving shared Meta state frozen after teardown. Move the reset into a finally (or a second loop) so every model is cleared even if one close fails.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rapyer/init.py` around lines 107 - 117, Update teardown_rapyer so each
model’s Meta._ttl_frozen is reset in a finally block around its Redis client
close, ensuring the reset runs even when aclose() raises and all models are
unfrozen.

Source: Path instructions

🧹 Nitpick comments (1)
tests/unit/test_context.py (1)

50-83: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Good coverage of the EVALSHA-only replay fix; consider adding edge-case tests.

The updated test correctly asserts the ride-along JSON.SET is not replayed and only the EVALSHA result comes back. As per path instructions to focus on test coverage completeness and edge cases for tests/**, consider adding:

  • A case with two different EVALSHA entries (different SHAs) in command_stack to pin down current behavior for the multi-script scenario discussed in rapyer/context.py.
  • A case for ignore_redis_error=True swallowing a non-NOSCRIPT ResponseError and returning [].
  • A case for a persistent NOSCRIPT after retry raising PersistentNoScriptError.

These may already exist elsewhere in the file — worth checking before adding.

#!/bin/bash
rg -n "ignore_redis_error|PersistentNoScriptError|def test_" tests/unit/test_context.py
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit/test_context.py` around lines 50 - 83, Expand coverage around
execute_pipeline_with_noscript_recovery by first checking existing tests, then
add missing cases for two distinct EVALSHA commands, ignore_redis_error=True
swallowing a non-NOSCRIPT ResponseError and returning [], and a retry that still
raises NOSCRIPT producing PersistentNoScriptError. Reuse the existing pipe,
retry-pipe, and metadata helpers and preserve the current EVALSHA-only replay
behavior.

Source: Path instructions

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@rapyer/scripts/lua/cascade/apply.lua`:
- Around line 209-214: Update the IN-02 comment near the non-recursing edge
branch to state that reaching the target conditionally refreshes it only when
edge.ttl is true; clarify that special-only edges do not call queue_refresh,
while preserving the existing traversal-budget and OVERRIDE behavior
description.

---

Outside diff comments:
In `@rapyer/init.py`:
- Around line 107-117: Update teardown_rapyer so each model’s Meta._ttl_frozen
is reset in a finally block around its Redis client close, ensuring the reset
runs even when aclose() raises and all models are unfrozen.

---

Nitpick comments:
In `@tests/unit/test_context.py`:
- Around line 50-83: Expand coverage around
execute_pipeline_with_noscript_recovery by first checking existing tests, then
add missing cases for two distinct EVALSHA commands, ignore_redis_error=True
swallowing a non-NOSCRIPT ResponseError and returning [], and a retry that still
raises NOSCRIPT producing PersistentNoScriptError. Reuse the existing pipe,
retry-pipe, and metadata helpers and preserve the current EVALSHA-only replay
behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: c224ac26-d39c-41fa-8074-06c932f158c4

📥 Commits

Reviewing files that changed from the base of the PR and between cb8089e and 13f1263.

📒 Files selected for processing (8)
  • rapyer/base.py
  • rapyer/config.py
  • rapyer/context.py
  • rapyer/init.py
  • rapyer/scripts/lua/cascade/apply.lua
  • rapyer/scripts/registry.py
  • tests/unit/cascade/conftest.py
  • tests/unit/test_context.py

Comment thread rapyer/scripts/lua/cascade/apply.lua Outdated
Comment on lines +209 to +214
-- IN-02: not-yet-exercised seam. Every edge the planner
-- currently emits has recurse=true, so this branch is dead
-- today. A non-recursing edge reaches (and refreshes) its
-- target and yields it zero traversal budget; note that
-- the target's own OVERRIDE edges can still be followed,
-- since next_hop ignores budget for overrides.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Clarify that non-recursing edges refresh conditionally.

This branch reaches the target, but it only refreshes it when edge.ttl is true; special-only edges do not call queue_refresh. Please avoid documenting unconditional refresh behavior.

Suggested wording
-                    -- target and yields it zero traversal budget; note that
+                    -- target, conditionally refreshes it when edge.ttl is set,
+                    -- and yields it zero traversal budget; note that
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
-- IN-02: not-yet-exercised seam. Every edge the planner
-- currently emits has recurse=true, so this branch is dead
-- today. A non-recursing edge reaches (and refreshes) its
-- target and yields it zero traversal budget; note that
-- the target's own OVERRIDE edges can still be followed,
-- since next_hop ignores budget for overrides.
-- IN-02: not-yet-exercised seam. Every edge the planner
-- currently emits has recurse=true, so this branch is dead
-- today. A non-recursing edge reaches its target,
-- conditionally refreshes it when edge.ttl is set, and
-- yields it zero traversal budget; note that the target's
-- own OVERRIDE edges can still be followed, since
-- next_hop ignores budget for overrides.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rapyer/scripts/lua/cascade/apply.lua` around lines 209 - 214, Update the
IN-02 comment near the non-recursing edge branch to state that reaching the
target conditionally refreshes it only when edge.ttl is true; clarify that
special-only edges do not call queue_refresh, while preserving the existing
traversal-budget and OVERRIDE behavior description.

YedidyaHKfir and others added 4 commits July 12, 2026 18:38
…ig after init, not just ttl

Per review: the freeze should cover all config, not only ttl. Rename the
_ttl_frozen flag to _frozen, block every public Meta field in __setattr__ once
frozen (private attrs stay writable so init/teardown can toggle it), and rename
MetaTtlFrozenError -> MetaFrozenError. A global autouse test fixture unfreezes
models around each test so the process-global freeze can't leak across tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e cascade script

Per review: drop the _has_cascade branch in refresh_ttl and always run the
cascade EVALSHA. With no outgoing edges it simply re-arms the model's own keys,
so every TTL refresh goes through one path. Tests updated to assert the script
call instead of the per-key EXPIRE loop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…contains_sf_field, shorter docstrings

Per review, in rapyer/cascade/planner.py:
- _classify_edge returns an EdgeClassification dataclass instead of a tuple.
- _static_walk_special_suffixes uses contains_sf_field() instead of a manual
  hasattr(_special_field_names) check.
- multi-line docstrings start on their own line and are trimmed to short
  summaries; drop -> None return annotations.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… notation from comments

Per review: drop the internal review/plan tags (D-0x, WR-0x, IN-0x, etc.) and
RESEARCH.md/Pitfall references from comments and docstrings across the cascade
code and tests; keep the actual explanations. Also reword multi-line docstrings
to start on their own line. Renamed a few tests that embedded those tags.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@yedidyakfir yedidyakfir changed the title Post-merge review fixes for TTL Cascade (CR-01 data-corruption + freeze lifecycle + hardening) Address TTL Cascade (#277) review comments + NOSCRIPT recovery fix Jul 12, 2026
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