Spaces:
Sleeping
Sleeping
| # Quran App β Caching Architecture | |
| --- | |
| ## 1. Overview | |
| The platform uses a two-layer caching strategy to reduce database load and eliminate unnecessary loading states for end users. | |
| | Layer | Technology | Scope | | |
| |---|---|---| | |
| | Server-side | Caffeine (in-process) via Spring Cache | Backend JVM | | |
| | Client-side | React Query (`TanStack Query`) | Browser memory | | |
| These two layers are independent but complementary. The server cache cuts repeated DB queries across all users. The client cache eliminates the loading spinner when a user navigates away and returns to a page they have already visited. | |
| --- | |
| ## 2. Server-Side Caching (Caffeine) | |
| ### 2.1 Why Caffeine | |
| Caffeine is a high-performance in-process cache for the JVM. It was chosen over Redis because the application runs as a single instance and does not require distributed cache synchronization. This keeps the infrastructure simple β no additional service to deploy or monitor. | |
| If the application is ever scaled horizontally across multiple instances, the caches would need to be externalized to Redis. | |
| ### 2.2 Configuration | |
| **File:** `quran-app-backend/src/main/java/com/yourcompany/quranapp/quran_app_backend/config/CacheConfig.java` | |
| All caches share a single default spec: | |
| - **TTL:** 10 minutes (expire after write) | |
| - **Max size:** 500 entries per cache | |
| - **Stats recording:** enabled β metrics are exposed at `/actuator/metrics/cache.gets` | |
| ```java | |
| Caffeine.newBuilder() | |
| .expireAfterWrite(10, TimeUnit.MINUTES) | |
| .maximumSize(500) | |
| .recordStats() | |
| ``` | |
| Caches are pre-declared by name as constants on `CacheConfig` so service classes import the constant rather than hardcoding strings. | |
| ### 2.3 Cache Inventory | |
| #### Sheikh Caches | |
| | Cache name | Key | Cached method | Evicted by | | |
| |---|---|---|---| | |
| | `sheikhAvailability` | `sheikhId` | `SheikhAvailabilityService.getAvailability()` | `setAvailability()` | | |
| | `sheikhAvailableDates` | `sheikhId_from_days` | `getAvailableDatesForRange()` | `setAvailability()` | | |
| | `allActiveSheikhs` | `page_size` | `SheikhServiceFind.getAllActiveSheikhs()` | `updatePrice()`, sheikh approve/reject | | |
| | `sheikhProfile` | `sheikhId` | `SheikhServiceFind.getSheikhProfile()` | `updatePrice()` | | |
| | `sheikhTopReviews` | `sheikhId` | `SheikhServiceFind.getTopReviews()` | β (TTL only) | | |
| | `sheikhDashboardStats` | `sheikhId` | `sheikhDashboardService.getDashboardStats()` | `updatePrice()` | | |
| #### Student Caches | |
| | Cache name | Key | Cached method | Evicted by | | |
| |---|---|---|---| | |
| | `studentDashboard` | `userId` | `DashboardService.getStudentDashboard()` | `createRecitationFromAudio()` | | |
| | `studentRecitationStats` | `userId` | `StudentPracticeService.getRecitationStats()`, `StudentRecitationService.getRecitationSummaryStats()` | `createRecitationFromAudio()` | | |
| | `studentTopRecitations` | `userId` | `StudentRecitationService.getTop5RecentRecitations()` | `createRecitationFromAudio()` | | |
| | `studentStreak` | `userId` | `StudentStreakService.getUserStreakDto()` | `updateStreak()` | | |
| #### Admin Caches | |
| | Cache name | Key | Cached method | Evicted by | | |
| |---|---|---|---| | |
| | `adminDashboardStats` | β (single entry) | `mainDashbaordAdmin.getDashboardStatsAndPlatformAnalytics()` | `approveSheikh()`, `rejectSheikh()` | | |
| | `adminPendingSheikhs` | β (single entry) | `mainDashbaordAdmin.getDashboardPendingSheikhs()` | `approveSheikh()`, `rejectSheikh()`, `scheduleInterview()` | | |
| | `adminSheikhList` | β (single entry) | `sheikhService.getNotApprovedSheikhsByStatus()`, `UserManagementService.getSheikhs()` | `approveSheikh()`, `rejectSheikh()`, `scheduleInterview()` | | |
| | `adminStudentList` | β (single entry) | `UserManagementService.getStudents()` | `blockUser()`, `unblockUser()` | | |
| | `adminUserMgmtStats` | β (single entry) | `UserManagementService.getUserManagementStats()` | `blockUser()`, `unblockUser()` | | |
| ### 2.4 Cache Annotations Used | |
| | Annotation | Purpose | | |
| |---|---| | |
| | `@Cacheable` | Returns cached value if present; calls the method and stores the result if not | | |
| | `@CacheEvict` | Removes one or all entries from a named cache after a method executes | | |
| | `@Caching` | Groups multiple `@CacheEvict` (or mixed) annotations on a single method | | |
| ### 2.5 What Is Not Cached | |
| The following are deliberately left uncached: | |
| - **`getUpcomingClasses(sheikhId)`** β session state changes frequently (booking, cancellation, completion). Stale data here directly misleads the sheikh. | |
| - **`getAllSessionsForStudent()`** / **`getSessionStatus()`** β session lists are time-sensitive and mutation-heavy. | |
| - **`bookSession()`** β a write operation, never cached. | |
| - **Auth and JWT operations** β security-critical, must always hit the DB. | |
| --- | |
| ## 3. Client-Side Caching (React Query) | |
| ### 3.1 Why React Query | |
| React Query manages server state in the browser. Without it, every component mount triggers a fresh API call and shows a loading spinner. With it, previously fetched data is served instantly from an in-memory cache while a background refetch runs silently. | |
| ### 3.2 Query Key Convention | |
| Each major data domain has its own query key: | |
| | Query key | Component | API call | | |
| |---|---|---| | |
| | `['dashboard']` | `StudentDashboard` | `fetchDashboardData()` | | |
| | `['sheikhDashboard']` | `SheikhDashboard` | `fetchSheikhDashboardData()` | | |
| ### 3.3 Cache Settings Per Dashboard | |
| #### Student Dashboard | |
| ```ts | |
| useQuery({ | |
| queryKey: ['dashboard'], | |
| queryFn: fetchDashboardData, | |
| staleTime: 0, // always considered stale β refetches in background | |
| gcTime: 10 * 60 * 1000, // keep in memory for 10 min after last use | |
| refetchOnWindowFocus: true, | |
| refetchOnMount: true, | |
| }) | |
| ``` | |
| `staleTime: 0` means the student always gets a background refresh when they revisit the dashboard, but they never see a blank loading state because the cached data is shown immediately while the refresh runs. | |
| #### Sheikh Dashboard | |
| ```ts | |
| useQuery({ | |
| queryKey: ['sheikhDashboard'], | |
| queryFn: fetchSheikhDashboardData, | |
| staleTime: 5 * 60 * 1000, // treat as fresh for 5 minutes β no refetch on revisit | |
| gcTime: 10 * 60 * 1000, | |
| refetchOnWindowFocus: true, | |
| }) | |
| ``` | |
| `staleTime: 5 minutes` is appropriate here because the sheikh dashboard shows aggregated stats (earnings, session counts, ratings) that do not change second-by-second. A 5-minute window eliminates the loading spinner entirely on revisit while still keeping data reasonably fresh. | |
| ### 3.4 Optimistic Updates | |
| When the sheikh updates their hourly rate, the new price is written directly into the React Query cache without waiting for a refetch. This gives instant UI feedback. | |
| ```ts | |
| queryClient.setQueryData(['sheikhDashboard'], (old) => ({ | |
| ...old, | |
| data: { | |
| ...old.data, | |
| stats: { ...old.data.stats, hourlyRate: newPrice }, | |
| }, | |
| })); | |
| ``` | |
| --- | |
| ## 4. Cache Invalidation Flow | |
| The diagram below shows how a write operation propagates through both cache layers. | |
| ``` | |
| Sheikh updates price | |
| β | |
| βΌ | |
| PUT /api/sheikh/.../update-price | |
| β | |
| βββΊ @CacheEvict: sheikhDashboardStats (sheikhId) | |
| βββΊ @CacheEvict: sheikhProfile (sheikhId) | |
| βββΊ @CacheEvict: allActiveSheikhs (all entries) | |
| β | |
| βΌ | |
| Next GET request hits DB, | |
| repopulates cache with fresh data | |
| β | |
| Client side: | |
| queryClient.setQueryData(['sheikhDashboard'], ...) | |
| β | |
| βΌ | |
| UI updates instantly (optimistic) | |
| ``` | |
| For admin approval/rejection of a sheikh: | |
| ``` | |
| Admin approves sheikh | |
| β | |
| βΌ | |
| POST /api/admin/sheikhs/{id}/approve | |
| β | |
| βββΊ @CacheEvict: adminPendingSheikhs (all) | |
| βββΊ @CacheEvict: adminSheikhList (all) | |
| βββΊ @CacheEvict: adminDashboardStats (all) | |
| βββΊ @CacheEvict: allActiveSheikhs (all) | |
| ``` | |
| For a student submitting a new recitation: | |
| ``` | |
| Student submits recitation | |
| β | |
| βΌ | |
| POST /api/student/recitations | |
| β | |
| βββΊ @CacheEvict: studentRecitationStats (userId) | |
| βββΊ @CacheEvict: studentTopRecitations (userId) | |
| βββΊ @CacheEvict: studentDashboard (userId) | |
| ``` | |
| --- | |
| ## 5. Monitoring | |
| Cache hit/miss rates are available via Spring Boot Actuator without any additional configuration: | |
| ``` | |
| GET /actuator/metrics/cache.gets | |
| GET /actuator/metrics/cache.gets?tag=name:sheikhDashboardStats&tag=result:hit | |
| GET /actuator/metrics/cache.gets?tag=name:sheikhDashboardStats&tag=result:miss | |
| ``` | |
| The `recordStats()` setting on the Caffeine spec enables this. A high miss rate on a specific cache indicates the TTL is too short or the eviction strategy is too aggressive for its access pattern. | |
| --- | |
| ## 6. Design Decisions and Tradeoffs | |
| ### Single-entry admin caches use `allEntries = true` | |
| Admin caches for stats and lists do not use a per-entity key because the data aggregates the whole platform. `allEntries = true` is used on eviction because there is only one meaningful entry anyway. | |
| ### Student recitation stats are keyed by `userId` | |
| Each student's stats are independent, so per-user keying is correct. Evicting one student's cache on recitation submission does not affect any other student. | |
| ### Sheikh availability dates cache uses a compound key | |
| `sheikhId_from_days` captures the full context of the query. A sheikh with availability MondayβFriday will have different available dates depending on whether `from` is this week or next month, so `sheikhId` alone would be incorrect. | |
| ### `sheikhTopReviews` relies on TTL only | |
| Reviews are written infrequently and the top-3 display is decorative. Adding eviction on review creation would require wiring the review write path into the cache eviction chain. The 10-minute TTL is an acceptable tradeoff for this use case. | |
| ### Client `staleTime` differs between student and sheikh dashboards | |
| The student dashboard has `staleTime: 0` because it shows live practice data (streaks, recent recitations) that the student actively creates. The sheikh dashboard has `staleTime: 5 minutes` because its data is aggregated and less volatile. | |