Skip to content

Add user stats caching and invalidation functionality#807

Open
tenkus47 wants to merge 2 commits into
developfrom
update_stats_cache
Open

Add user stats caching and invalidation functionality#807
tenkus47 wants to merge 2 commits into
developfrom
update_stats_cache

Conversation

@tenkus47

Copy link
Copy Markdown
Member
  • Introduced caching for user statistics with a new timeout configuration.
  • Implemented functions to set, get, and invalidate user stats cache in the daily log cache service.
  • Updated daily log service to utilize the new caching mechanism for user stats.
  • Modified accumulator service to invalidate user stats cache upon updates.
  • Enhanced tests to cover new caching behavior and ensure proper functionality of user stats retrieval and invalidation.

- Introduced caching for user statistics with a new timeout configuration.
- Implemented functions to set, get, and invalidate user stats cache in the daily log cache service.
- Updated daily log service to utilize the new caching mechanism for user stats.
- Modified accumulator service to invalidate user stats cache upon updates.
- Enhanced tests to cover new caching behavior and ensure proper functionality of user stats retrieval and invalidation.
@greptile-apps

greptile-apps Bot commented Jun 24, 2026

Copy link
Copy Markdown

Confidence Score: 3/5

Needs one fix before merging: cache invalidation in delete_task_service fires before the DB transaction commits, which can leave stale stats cached for up to 5 minutes.

The cache invalidation in delete_task_service is placed inside the with SessionLocal() block. Because delete_task_service is a sync function that runs in a FastAPI thread-pool worker, asyncio.run() is called synchronously there — meaning the Redis DELETE completes before the surrounding DB transaction commits. A concurrent call to get_user_stats_service can race into that window, read pre-commit data from the DB, and cache it as the authoritative result. The rest of the caching logic (timer service, accumulator service, daily-log service, series-day-sync) is correctly ordered with invalidation happening after commits.

pecha_api/plans/users/plan_users_service.pydelete_task_service and check_day_completion both call schedule_invalidate_user_stats_cache inside an open DB session.

Reviews (2): Last reviewed commit: "Remove rate limiting functionality and r..." | Re-trigger Greptile

Comment on lines +45 to +50
async def get_user_stats_cache(user_id: UUID) -> Optional[UserStatsResponse]:
cache_key = _build_user_stats_cache_key(user_id=user_id)
cache_data = await get_cache_data(hash_key=cache_key)
if cache_data and isinstance(cache_data, dict):
return UserStatsResponse(**cache_data)
return None

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 UserStatsResponse(**cache_data) will raise a Pydantic ValidationError if the cached dict doesn't conform to the current model schema — for example, after a deployment that adds a new required field while Redis still holds stale entries from the previous schema. The exception is not caught here, so it propagates through get_user_stats_service and surfaces as a 500 for every user whose stats are cached until all entries expire (up to 5 minutes). Returning None on validation failure causes a graceful cache miss and fresh DB computation instead.

Suggested change
async def get_user_stats_cache(user_id: UUID) -> Optional[UserStatsResponse]:
cache_key = _build_user_stats_cache_key(user_id=user_id)
cache_data = await get_cache_data(hash_key=cache_key)
if cache_data and isinstance(cache_data, dict):
return UserStatsResponse(**cache_data)
return None
async def get_user_stats_cache(user_id: UUID) -> Optional[UserStatsResponse]:
cache_key = _build_user_stats_cache_key(user_id=user_id)
cache_data = await get_cache_data(hash_key=cache_key)
if cache_data and isinstance(cache_data, dict):
try:
return UserStatsResponse(**cache_data)
except Exception:
logging.warning("Failed to deserialize user stats cache; treating as cache miss", exc_info=True)
return None
return None

Comment on lines +64 to +70
def schedule_invalidate_user_stats_cache(user_id: UUID) -> None:
"""Invalidate stats cache from sync callers without blocking."""
try:
loop = asyncio.get_running_loop()
loop.create_task(invalidate_user_stats_cache(user_id))
except RuntimeError:
asyncio.run(invalidate_user_stats_cache(user_id))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 loop.create_task() returns a Task but the reference is immediately discarded. When the garbage collector eventually collects the task and it raised an exception, Python emits a "Task exception was never retrieved" warning rather than surfacing the error. Storing the returned task (or attaching a done-callback) ensures unhandled exceptions are at minimum logged.

Suggested change
def schedule_invalidate_user_stats_cache(user_id: UUID) -> None:
"""Invalidate stats cache from sync callers without blocking."""
try:
loop = asyncio.get_running_loop()
loop.create_task(invalidate_user_stats_cache(user_id))
except RuntimeError:
asyncio.run(invalidate_user_stats_cache(user_id))
def schedule_invalidate_user_stats_cache(user_id: UUID) -> None:
"""Invalidate stats cache from sync callers without blocking."""
try:
loop = asyncio.get_running_loop()
task = loop.create_task(invalidate_user_stats_cache(user_id))
task.add_done_callback(
lambda t: logging.error("Failed to invalidate user stats cache", exc_info=t.exception())
if not t.cancelled() and t.exception() is not None
else None
)
except RuntimeError:
asyncio.run(invalidate_user_stats_cache(user_id))

- Deleted the rate limiting middleware and its associated configuration from the application.
- Removed references to rate limiting in the configuration file and application code.
- Eliminated related tests and fixtures to ensure a clean removal of rate limiting features.
- Updated dependencies by removing the `slowapi` package from the project.
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.

1 participant