From 58eddf5480869dc5f5ee038a4db3a3788f1c1fbd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 12 Jan 2026 20:38:15 +0000 Subject: [PATCH 1/3] Checkpoint before follow-up message Co-authored-by: amirbiron --- services/shared_theme_service.py | 48 ++++++++++++++++++++++++++++++-- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/services/shared_theme_service.py b/services/shared_theme_service.py index 5ea27d673..a2ded75a2 100644 --- a/services/shared_theme_service.py +++ b/services/shared_theme_service.py @@ -11,7 +11,7 @@ import logging import re -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from typing import Any, Dict, List, Optional, Tuple from services.theme_parser_service import ( @@ -55,6 +55,34 @@ def __init__(self, db): """ self.db = db self.collection = getattr(db, "shared_themes", None) + # ========================== + # In-memory cache (active themes list) + # ========================== + self._active_themes_cache: Optional[List[Dict[str, Any]]] = None + self._active_themes_expires_at: Optional[datetime] = None + self._cache_ttl_seconds = 300 # 5 minutes + + # Optional: best-effort index creation (safe if unsupported) + self.ensure_indexes() + + def invalidate_cache(self) -> None: + """איפוס cache (יש לקרוא אחרי create/update/delete).""" + self._active_themes_cache = None + self._active_themes_expires_at = None + + def ensure_indexes(self) -> None: + """יוצר אינדקסים בסיסיים לשיפור ביצועים (best-effort).""" + if self.collection is None: + return + try: + create_index = getattr(self.collection, "create_index", None) + if not callable(create_index): + return + # Compound index for is_active + order + created_at + create_index([("is_active", 1), ("order", 1), ("created_at", -1)]) + except Exception as e: + # לא שוברים את האפליקציה/טסטים בגלל אינדקסים + logger.debug("SharedThemeService.ensure_indexes failed: %s", e) # ============= Validation ============= @@ -119,6 +147,14 @@ def get_all_active(self) -> List[Dict[str, Any]]: """קבלת כל הערכות הפעילות (מטא-דאטה בלבד).""" if self.collection is None: return [] + now = datetime.now(timezone.utc) + # Cache hit + if ( + self._active_themes_cache is not None + and self._active_themes_expires_at is not None + and self._active_themes_expires_at > now + ): + return self._active_themes_cache try: cursor = self.collection.find( {"is_active": True}, @@ -135,9 +171,11 @@ def get_all_active(self) -> List[Dict[str, Any]]: themes: List[Dict[str, Any]] = [] for doc in cursor: created_at = doc.get("created_at") + theme_id = doc.get("_id") themes.append( { - "id": doc.get("_id"), + # ⚠️ JSON safety: ObjectId לא תמיד סיריאליזבילי, אז ממירים ל-str + "id": str(theme_id) if theme_id is not None else None, "name": doc.get("name"), "description": doc.get("description", ""), "is_featured": bool(doc.get("is_featured", False)), @@ -145,6 +183,9 @@ def get_all_active(self) -> List[Dict[str, Any]]: "type": "shared", } ) + # Save to cache + self._active_themes_cache = themes + self._active_themes_expires_at = now + timedelta(seconds=self._cache_ttl_seconds) return themes except Exception as e: logger.exception("SharedThemeService.get_all_active failed: %s", e) @@ -245,6 +286,7 @@ def create( try: self.collection.insert_one(doc) + self.invalidate_cache() logger.info("Created shared theme %s by user %s", slug, created_by) return True, slug except Exception as e: @@ -313,6 +355,7 @@ def update( result = self.collection.update_one({"_id": str(theme_id)}, {"$set": update_fields}) if getattr(result, "modified_count", 0) == 0: return False, "no_changes" + self.invalidate_cache() return True, "ok" except Exception as e: logger.exception("SharedThemeService.update failed: %s", e) @@ -326,6 +369,7 @@ def delete(self, theme_id: str) -> Tuple[bool, str]: result = self.collection.delete_one({"_id": str(theme_id)}) if getattr(result, "deleted_count", 0) == 0: return False, "theme_not_found" + self.invalidate_cache() return True, "ok" except Exception as e: logger.exception("SharedThemeService.delete failed: %s", e) From 5f83e2367c3e161051d195a5b2ad64e83383b0a1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 12 Jan 2026 20:54:48 +0000 Subject: [PATCH 2/3] fix: return safe copies from themes cache Prevent cached shared themes list from being mutated by callers by returning copies on both cache hits and misses. --- services/shared_theme_service.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/services/shared_theme_service.py b/services/shared_theme_service.py index a2ded75a2..ab78b9127 100644 --- a/services/shared_theme_service.py +++ b/services/shared_theme_service.py @@ -154,7 +154,8 @@ def get_all_active(self) -> List[Dict[str, Any]]: and self._active_themes_expires_at is not None and self._active_themes_expires_at > now ): - return self._active_themes_cache + # מחזירים עותק כדי למנוע "השחתה" של ה-cache ע"י קוראים שמשנים את הרשימה/מילונים + return [t.copy() for t in self._active_themes_cache] try: cursor = self.collection.find( {"is_active": True}, @@ -186,7 +187,8 @@ def get_all_active(self) -> List[Dict[str, Any]]: # Save to cache self._active_themes_cache = themes self._active_themes_expires_at = now + timedelta(seconds=self._cache_ttl_seconds) - return themes + # מחזירים עותק כדי לשמור על התנהגות עקבית (גם ב-cache miss) + return [t.copy() for t in themes] except Exception as e: logger.exception("SharedThemeService.get_all_active failed: %s", e) return [] From 3317c075e698a6dbf93b2bf45cf7f2dd5e781659 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 12 Jan 2026 21:12:23 +0000 Subject: [PATCH 3/3] fix: harden shared themes cache against races Use local snapshots for cache reads and a cache version counter to prevent invalidate_cache() from being overwritten by concurrent refreshes. --- services/shared_theme_service.py | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/services/shared_theme_service.py b/services/shared_theme_service.py index ab78b9127..9173c1d69 100644 --- a/services/shared_theme_service.py +++ b/services/shared_theme_service.py @@ -61,12 +61,16 @@ def __init__(self, db): self._active_themes_cache: Optional[List[Dict[str, Any]]] = None self._active_themes_expires_at: Optional[datetime] = None self._cache_ttl_seconds = 300 # 5 minutes + # מונה "גרסה" כדי למנוע דריסת invalidate ע"י כתיבה מאוחרת מ-thread אחר + self._active_themes_cache_version = 0 # Optional: best-effort index creation (safe if unsupported) self.ensure_indexes() def invalidate_cache(self) -> None: """איפוס cache (יש לקרוא אחרי create/update/delete).""" + # קודם מעלים גרסה כדי שכל fetch שמתקדם במקביל לא יכתוב cache "ישן" + self._active_themes_cache_version += 1 self._active_themes_cache = None self._active_themes_expires_at = None @@ -148,14 +152,20 @@ def get_all_active(self) -> List[Dict[str, Any]]: if self.collection is None: return [] now = datetime.now(timezone.utc) + # העתקה למשתנים מקומיים כדי להימנע מקריסה אם invalidate מתרחש באמצע (race) + cached_themes = self._active_themes_cache + cached_expires_at = self._active_themes_expires_at # Cache hit if ( - self._active_themes_cache is not None - and self._active_themes_expires_at is not None - and self._active_themes_expires_at > now + cached_themes is not None + and cached_expires_at is not None + and cached_expires_at > now ): # מחזירים עותק כדי למנוע "השחתה" של ה-cache ע"י קוראים שמשנים את הרשימה/מילונים - return [t.copy() for t in self._active_themes_cache] + return [t.copy() for t in cached_themes] + + # Cache miss: זוכרים את הגרסה הנוכחית כדי לא לדרוס invalidate שהתרחש בזמן ה-fetch + version_at_start = self._active_themes_cache_version try: cursor = self.collection.find( {"is_active": True}, @@ -185,8 +195,9 @@ def get_all_active(self) -> List[Dict[str, Any]]: } ) # Save to cache - self._active_themes_cache = themes - self._active_themes_expires_at = now + timedelta(seconds=self._cache_ttl_seconds) + if version_at_start == self._active_themes_cache_version: + self._active_themes_cache = themes + self._active_themes_expires_at = now + timedelta(seconds=self._cache_ttl_seconds) # מחזירים עותק כדי לשמור על התנהגות עקבית (גם ב-cache miss) return [t.copy() for t in themes] except Exception as e: