Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions packages/backend/app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
init_request_context,
)
from flask_cors import CORS
from flask import g
import click
import os
import logging
Expand Down Expand Up @@ -48,6 +49,10 @@ def create_app(settings: Settings | None = None) -> Flask:
# CORS for local dev frontend
CORS(app, resources={r"*": {"origins": "*"}}, supports_credentials=True)

# In-memory cache (SimpleCache)
from .services.memory_cache import init_memory_cache
init_memory_cache(app)

# Redis (already global)
# Blueprint routes
register_routes(app)
Expand All @@ -62,6 +67,10 @@ def _before_request():

@app.after_request
def _after_request(response):
# Add X-Cache-Hit header for debugging
cache_hit = getattr(g, "cache_hit", None)
if cache_hit is not None:
response.headers["X-Cache-Hit"] = "true" if cache_hit else "false"
return finalize_request(response)

@app.get("/health")
Expand Down
3 changes: 3 additions & 0 deletions packages/backend/app/routes/bills.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from ..extensions import db
from ..models import Bill, BillCadence, User
from ..services.cache import cache_delete_patterns
from ..services.memory_cache import invalidate_user_cache
import logging

bp = Blueprint("bills", __name__)
Expand Down Expand Up @@ -62,6 +63,7 @@ def create_bill():
cache_delete_patterns(
[f"user:{uid}:upcoming_bills*", f"user:{uid}:dashboard_summary:*"]
)
invalidate_user_cache(uid)
return jsonify(id=b.id), 201


Expand All @@ -85,6 +87,7 @@ def mark_paid(bill_id: int):
cache_delete_patterns(
[f"user:{uid}:upcoming_bills*", f"user:{uid}:dashboard_summary:*"]
)
invalidate_user_cache(uid)
logger.info(
"Marked bill paid id=%s user=%s next_due_date=%s", b.id, uid, b.next_due_date
)
Expand Down
4 changes: 4 additions & 0 deletions packages/backend/app/routes/categories.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from flask_jwt_extended import jwt_required, get_jwt_identity
from ..extensions import db
from ..models import Category
from ..services.memory_cache import invalidate_user_cache

bp = Blueprint("categories", __name__)
logger = logging.getLogger("finmind.categories")
Expand Down Expand Up @@ -36,6 +37,7 @@ def create_category():
db.session.add(c)
db.session.commit()
logger.info("Created category id=%s user=%s", c.id, uid)
invalidate_user_cache(uid)
return jsonify(id=c.id, name=c.name), 201


Expand All @@ -53,6 +55,7 @@ def update_category(category_id: int):
c.name = name
db.session.commit()
logger.info("Updated category id=%s user=%s", c.id, uid)
invalidate_user_cache(uid)
return jsonify(id=c.id, name=c.name)


Expand All @@ -66,4 +69,5 @@ def delete_category(category_id: int):
db.session.delete(c)
db.session.commit()
logger.info("Deleted category id=%s user=%s", c.id, uid)
invalidate_user_cache(uid)
return jsonify(message="deleted")
16 changes: 15 additions & 1 deletion packages/backend/app/routes/dashboard.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
from datetime import date
from sqlalchemy import extract, func
from flask import Blueprint, jsonify, request
from flask import Blueprint, jsonify, request, g
from flask_jwt_extended import jwt_required, get_jwt_identity

from ..extensions import db
from ..models import Bill, Expense, Category
from ..services.cache import cache_get, cache_set, dashboard_summary_key
from ..services.memory_cache import memory_cache, DASHBOARD_TTL, _build_cache_key

bp = Blueprint("dashboard", __name__)

Expand All @@ -17,9 +18,20 @@ def dashboard_summary():
ym = (request.args.get("month") or date.today().strftime("%Y-%m")).strip()
if not _is_valid_month(ym):
return jsonify(error="invalid month, expected YYYY-MM"), 400

# Check in-memory cache first
mem_key = _build_cache_key("dashboard", uid)
mem_cached = memory_cache.get(mem_key)
if mem_cached is not None:
g.cache_hit = True
return mem_cached

g.cache_hit = False
key = dashboard_summary_key(uid, ym)
cached = cache_get(key)
if cached:
resp = jsonify(cached)
memory_cache.set(mem_key, resp, timeout=DASHBOARD_TTL)
return jsonify(cached)

payload = {
Expand Down Expand Up @@ -165,6 +177,8 @@ def dashboard_summary():
payload["errors"].append("category_breakdown_unavailable")

cache_set(key, payload, ttl_seconds=300)
resp = jsonify(payload)
memory_cache.set(mem_key, resp, timeout=DASHBOARD_TTL)
return jsonify(payload)


Expand Down
2 changes: 2 additions & 0 deletions packages/backend/app/routes/expenses.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from ..extensions import db
from ..models import Expense, RecurringCadence, RecurringExpense, User
from ..services.cache import cache_delete_patterns, monthly_summary_key
from ..services.memory_cache import invalidate_user_cache
from ..services import expense_import
import logging

Expand Down Expand Up @@ -393,3 +394,4 @@ def _invalidate_expense_cache(uid: int, at: str):
f"user:{uid}:dashboard_summary:*",
]
)
invalidate_user_cache(uid)
14 changes: 13 additions & 1 deletion packages/backend/app/routes/insights.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
from datetime import date
from flask import Blueprint, jsonify, request
from flask import Blueprint, jsonify, request, g
from flask_jwt_extended import jwt_required, get_jwt_identity
from ..services.ai import monthly_budget_suggestion
from ..services.memory_cache import memory_cache, ANALYTICS_TTL, _build_cache_key
import logging

bp = Blueprint("insights", __name__)
Expand All @@ -13,6 +14,15 @@
def budget_suggestion():
uid = int(get_jwt_identity())
ym = (request.args.get("month") or date.today().strftime("%Y-%m")).strip()

# Check in-memory cache (15 min TTL for analytics)
mem_key = _build_cache_key("insights", uid)
mem_cached = memory_cache.get(mem_key)
if mem_cached is not None:
g.cache_hit = True
return mem_cached

g.cache_hit = False
user_gemini_key = (request.headers.get("X-Gemini-Api-Key") or "").strip() or None
persona = (request.headers.get("X-Insight-Persona") or "").strip() or None
suggestion = monthly_budget_suggestion(
Expand All @@ -22,4 +32,6 @@ def budget_suggestion():
persona=persona,
)
logger.info("Budget suggestion served user=%s month=%s", uid, ym)
resp = jsonify(suggestion)
memory_cache.set(mem_key, resp, timeout=ANALYTICS_TTL)
return jsonify(suggestion)
76 changes: 76 additions & 0 deletions packages/backend/app/services/memory_cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
"""In-memory caching layer using Flask-Caching SimpleCache (Issue #127).

Provides smart caching for dashboard and analytics queries with:
- TTL of 5 minutes for dashboard queries
- TTL of 15 minutes for analytics queries
- Cache key based on user_id + query params
- Invalidation on expense/bill/category CRUD operations
"""

import hashlib
import logging
from functools import wraps
from typing import Callable

from flask import request, g
from flask_caching import Cache

logger = logging.getLogger("finmind.memory_cache")

# The Cache instance; initialized during app creation via init_memory_cache()
memory_cache = Cache()

DASHBOARD_TTL = 300 # 5 minutes
ANALYTICS_TTL = 900 # 15 minutes


def init_memory_cache(app):
"""Initialize the in-memory cache on the Flask app."""
app.config.setdefault("CACHE_TYPE", "SimpleCache")
app.config.setdefault("CACHE_DEFAULT_TIMEOUT", DASHBOARD_TTL)
memory_cache.init_app(app)
logger.info("In-memory cache initialized (SimpleCache)")


def _build_cache_key(prefix: str, user_id: int) -> str:
"""Build a deterministic cache key from prefix, user_id and query params."""
params = sorted(request.args.items())
raw = f"{prefix}:{user_id}:{params}"
h = hashlib.md5(raw.encode()).hexdigest()[:16]
return f"{prefix}:{user_id}:{h}"


def cached_endpoint(prefix: str, ttl: int) -> Callable:
"""Decorator for caching JWT-protected endpoint responses.

Sets g.cache_hit so the after_request hook can add the X-Cache-Hit header.
"""
def decorator(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
from flask_jwt_extended import get_jwt_identity
uid = int(get_jwt_identity())
key = _build_cache_key(prefix, uid)
cached = memory_cache.get(key)
if cached is not None:
g.cache_hit = True
logger.debug("Cache HIT key=%s", key)
return cached
g.cache_hit = False
result = fn(*args, **kwargs)
memory_cache.set(key, result, timeout=ttl)
logger.debug("Cache MISS key=%s ttl=%s", key, ttl)
return result
return wrapper
return decorator


def invalidate_user_cache(user_id: int) -> None:
"""Invalidate all in-memory cached entries for a user.

Since SimpleCache doesn't support pattern-based deletion, we clear
the entire cache. For production at scale, a tagged cache backend
(e.g. Redis) would be more appropriate.
"""
memory_cache.clear()
logger.info("In-memory cache cleared for user=%s (full clear)", user_id)
1 change: 1 addition & 0 deletions packages/backend/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,5 @@ pytest==8.2.2
black==24.8.0
flake8==7.0.0
bandit==1.7.9
flask-caching==2.3.0

Loading