perf: add caching for frequently-accessed stable data (#91) #120
No reviewers
Labels
No Label
ai-changes-requested
ai-pr-opened
ai-reviewed
ai-reviewing
ai-working
in-next-release
status/in-progress
status/pr-open
No Milestone
No project
No Assignees
2 Participants
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: cal/major-domo-v2#120
Loading…
Reference in New Issue
Block a user
No description provided.
Delete Branch "ai/major-domo-v2-91"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #91
Changes
league_service.get_current_state()— 60s Redis cacheAdded
@cached_single_item(ttl=60)decorator. Current week changes at most once per week; 60 seconds eliminates most redundant calls during transaction validation workflows.standings_service.get_league_standings()— 10-minute in-memory cacheStandingsServicedoesn't inheritBaseServiceso can't use the standard decorators. Added a module-level_standings_cache: Dict[int, Tuple[List[TeamStandings], float]]with_STANDINGS_CACHE_TTL = 600, keyed by season. Follows the same pattern as_user_team_cacheinutils/autocomplete.py(PR #100). All downstream methods (get_team_standings,get_standings_by_division,get_playoff_picture) benefit automatically since they callget_league_standings.player_service.get_free_agents()— 5-minute Redis cacheAdded
@cached_api_call(ttl=300)decorator.get_top_free_agentscalls this method then sorts client-side, so caching the full free-agent list benefits any caller regardless of thelimitparameter.dice/rolls.py _get_channel_embed_color()— 5-minute in-memory cacheAdded module-level
_channel_color_cache: Dict[int, Tuple[int, float]]keyed by channel ID with_CHANNEL_COLOR_CACHE_TTL = 300. The resolved color (from channel name abbrev lookup or user team lookup) is cached per channel. This eliminates up to 2 API calls per dice roll for/ab,/d20,/fielding,/jump.Files changed
services/league_service.pyservices/standings_service.pyservices/player_service.pycommands/dice/rolls.pyTest results
977 passed, 2 skipped (no regressions)
Other observations
rolls.pyandleague_service.pywere incidentally reformatted by ruff (quote style, trailing commas, import grouping). Cosmetic only.standings_service.pycache does not support Redis (unlike services that extend BaseService). This is intentional — adding Redis support would require either inheriting BaseService or adding a CacheManager, which is out of scope. The in-memory cache degrades gracefully to a per-process cache (fine for this use case).AI Code Review
Files Reviewed
commands/dice/rolls.py(modified)services/league_service.py(modified)services/standings_service.py(modified)services/player_service.py(modified)Findings
Correctness
All four caching implementations are correct:
league_service.get_current_state()+@cached_single_item(ttl=60): Correct decorator choice forOptional[T]return. The decorator gracefully falls back to no-cache whenself.cache/self._generate_cache_keyare absent (Redis unavailable). No params in the signature → single stable cache key, which is correct since there's only one current state at a time.update_current_statecallsget_current_state()internally to fetch the ID — the ID is stable across week updates, so cache staleness here is harmless.standings_servicein-memory cache: TTL comparison (time.time() - cached_at < _STANDINGS_CACHE_TTL) is correct. Pattern matches PR #100's_user_team_cacheexactly.get_standings_by_division,get_team_standings, andget_playoff_pictureall callget_league_standings→ all three benefit automatically and correctly. Callers iterate the returned list read-only, so returning the cached list reference is safe (no mutation risk).player_service.get_free_agents()+@cached_api_call(ttl=300): Cache key includesseasonparameter, so different seasons get separate entries.get_top_free_agentscreates a new sorted copy, so the cached list is not mutated.rolls.py _get_channel_embed_color()channel cache: Cache-check at top, fallthrough on miss/expiry, write-back only on the team-by-abbrev path.interaction.channel_idis anOptional[int]and thechannel_id is not Noneguard is correct. Module-level dict keyed byintchannel ID is safe.No off-by-one errors, null issues, or type mismatches found.
Security
No issues found. No user input reaches cache keys directly — keys are Discord channel IDs (integers) or method/parameter hashes internal to
BaseService. No injection vectors.Style & Conventions
utils/CLAUDE.md.utils/autocomplete.py(PR #100): module-levelDict[key, Tuple[value, float]]+_TTLconstant.import timeandfrom typing import Dict, Tupleadditions inrolls.pyare correct and in the right position.from utils.decorators import cached_single_item/cached_api_calladditions in service files are also correct.Suggestions
standings_service.recalculate_standings()doesn't clear the cache — after an admin triggers a recalculation,get_league_standings()will serve stale data for up to 10 minutes. Not a blocker (recalculation is rare and 10 minutes is short), but consider adding_standings_cache.pop(season, None)at the end ofrecalculate_standingsfor correctness:_get_channel_embed_color()fallback path is not cached — when the channel name doesn't match an abbreviation pattern, theget_user_major_league_teamcall is still made on every dice roll. The PR description claims "eliminates up to 2 API calls per dice roll" but this only applies to team-named channels. For general channels, the user-team lookup remains uncached. Not a bug —team_service.get_team_by_abbrevalready has a@cached_single_itemdecorator, so the abbrev lookup path IS doubly cached. The user-team fallback is the remaining optimization gap. Worth a follow-up if dice roll performance is a concern.cached_api_callempty-list behavior (pre-existing) —if result and hasattr(self, "_cache_items")means an empty free-agent list ([]) is never cached, so a league with no free agents will hit the API on every call. This is a pre-existing characteristic of the decorator, not introduced here.Verdict: COMMENT
All four caching implementations are correct and safe. The patterns are consistent with existing in-memory and Redis caches in the codebase. The only noteworthy gap is
recalculate_standings()not invalidating the new cache — a minor correctness issue that is worth a one-line fix but doesn't block merging. 977 tests passing with no regressions.Automated review by Claude PR Reviewer
327570e8ccto61f36353d8Please fix the remaining correctness issue from the review:
AI Code Review
Files Reviewed
commands/dice/rolls.py(modified)services/league_service.py(modified)services/player_service.py(modified)services/standings_service.py(modified)Findings
Correctness
Bug:
_channel_color_cacheuses wrong key for user-team fallback path_get_channel_embed_color()has two color-resolution paths:nyy-dice) → color comes from the channel's team → keyed bychannel_id✅ correctchannel_id❌ wrongIn path 2, if User A (Yankees) rolls
/abin#generaland is cached, User B (Red Sox) rolling in the same channel within 5 minutes gets Yankees blue instead of their own color. The cache key needs to account for the user's identity in the fallback path.Minimal fix — only cache the channel-name path (where the color is truly per-channel), and leave the user-team fallback path uncached:
Alternatively, composite key
(channel_id, user_id)would also work but adds complexity.Note:
team_service.get_team_by_abbrevis itself Redis-cached, so the user-team path already benefits from caching at the service level without needing a second layer here.update_current_state()doesn't invalidateget_current_state()cache (existing concern)After
update_current_state(week=5, freeze=True)executes successfully, subsequent callers see stale data for up to 60s. Low-severity given the infrequency of writes (weekly freeze task), but worth a one-liner@cache_invalidateafter the patch call in a follow-up.Security
No issues found.
Style & Conventions
All caching patterns match the established patterns from PR #100 (
_user_team_cacheinautocomplete.py). Decorator usage inleague_serviceandplayer_servicematchesservices/CLAUDE.mdguidance. Module-level_standings_cachecorrectly parallels theStandingsService-doesn't-extend-BaseService constraint. Cosmetic reformatting (single→double quotes, trailing commas) is ruff-enforced and consistent.Suggestions
recalculate_standings()still doesn't clear_standings_cache. One-liner:_standings_cache.pop(season, None)beforereturn True. Admin recalculations will otherwise be invisible for up to 10 minutes. (Noted in prior review — still worth a follow-up.)EmbedColors.PRIMARYfallback (no team found) is correctly not cached, but the user-team path is being cached — see correctness bug above.Verdict: COMMENT
Implementation is correct for the three service caches (
@cached_single_itemonget_current_state,@cached_api_callonget_free_agents, in-memory_standings_cache). The_channel_color_cachehas a real correctness bug where the user-team fallback path is keyed bychannel_id, causing cross-user color contamination in non-team channels. This is cosmetic (wrong embed color, not wrong data) and only affects users rolling dice in general channels, but it should be fixed before or shortly after merge.Automated review by Claude PR Reviewer
Checkout
From your project repository, check out a new branch and test the changes.