feat(perf): implement performance optimizations for session handling
- Introduced a new configuration file `config.yaml` for specifying project context and artifact rules. - Added `.openspec.yaml` files for tracking changes related to performance improvements. - Created design documents outlining the context, goals, decisions, and migration plans for optimizing session performance. - Proposed changes include batching database queries, debouncing event refreshes, purging old events, and implementing loading states for better user experience. - Added tasks and specifications to ensure proper implementation and validation of the new features. These enhancements aim to improve the scalability and responsiveness of the application during collaborative sessions.
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-03-09
|
||||
@@ -0,0 +1,57 @@
|
||||
## Context
|
||||
|
||||
L'application charge les collaborateurs de session via `resolveCollaborator` appelé en séquence dans une boucle (N+1). Le hook `useLive` déclenche `router.refresh()` sur chaque événement SSE reçu, sans groupement, causant des re-renders en cascade si plusieurs événements arrivent simultanément. La fonction `cleanupOldEvents` existe dans `session-share-events.ts` mais n'est jamais appelée, laissant les événements s'accumuler indéfiniment. L'absence de `loading.tsx` sur les routes principales empêche le streaming App Router de s'activer. Les modals (`ShareModal`) sont inclus dans le bundle initial alors qu'ils sont rarement utilisés.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Éliminer le N+1 sur `resolveCollaborator` avec un fetch batché
|
||||
- Grouper les refreshes SSE consécutifs avec un debounce
|
||||
- Purger les événements SSE au fil de l'eau (après chaque `createEvent`)
|
||||
- Activer le streaming de navigation avec `loading.tsx` sur les routes à chargement lent
|
||||
- Réduire le bundle JS initial en lazy-loadant les modals
|
||||
|
||||
**Non-Goals:**
|
||||
- Refactorer l'architecture SSE (sujet Phase 2)
|
||||
- Changer la stratégie de cache/revalidation (sujet Phase 2)
|
||||
- Optimiser les requêtes Prisma profondes (sujet Phase 3)
|
||||
- Modifier le comportement fonctionnel existant
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. Batch resolveCollaborator par collect + single query
|
||||
|
||||
**Décision** : Dans `session-queries.ts`, collecter tous les `userId` des collaborateurs d'une liste de sessions, puis faire un seul `prisma.user.findMany({ where: { id: { in: [...ids] } } })`, et mapper les résultats en mémoire.
|
||||
|
||||
**Alternatives** : Garder le N+1 mais ajouter un cache mémoire par requête → rejeté car ne résout pas le problème structurellement.
|
||||
|
||||
### 2. Debounce via useRef + setTimeout natif
|
||||
|
||||
**Décision** : Dans `useLive.ts`, utiliser `useRef` pour stocker un timer et `setTimeout` / `clearTimeout` pour debounce à 300ms. Pas de dépendance externe.
|
||||
|
||||
**Alternatives** : Bibliothèque `lodash.debounce` → rejeté pour éviter une dépendance pour 5 lignes.
|
||||
|
||||
### 3. cleanupOldEvents inline dans createEvent
|
||||
|
||||
**Décision** : Appeler `cleanupOldEvents` à la fin de chaque `createEvent` (fire-and-forget, pas d'await bloquant). La purge garde les 50 derniers événements par session (seuil actuel).
|
||||
|
||||
**Alternatives** : Cron externe → trop complexe pour un quick win ; interval côté API SSE → couplage non souhaité.
|
||||
|
||||
### 4. loading.tsx avec skeleton minimaliste
|
||||
|
||||
**Décision** : Créer un `loading.tsx` par route principale (`/sessions`, `/weather`, `/users`) avec un skeleton générique (barres grises animées). Le composant est statique et ultra-léger.
|
||||
|
||||
### 5. next/dynamic avec ssr: false sur les modals
|
||||
|
||||
**Décision** : Wrapper `ShareModal` (et `CollaborationToolbar` si pertinent) avec `next/dynamic({ ssr: false })`. Le composant parent gère le loading state.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **Debounce 300ms** → légère latence perçue sur les mises à jour collaboratives. Mitigation : valeur configurable via constante.
|
||||
- **cleanupOldEvents fire-and-forget** → si la purge échoue, les erreurs sont silencieuses. Mitigation : logger l'erreur sans bloquer.
|
||||
- **Batch resolveCollaborator** → si la liste de sessions est très grande (>500), la requête `IN` peut être lente. Mitigation : acceptable pour les volumes actuels ; paginer si nécessaire (Phase 3).
|
||||
- **next/dynamic ssr: false** → les modals ne sont pas rendus côté serveur. Acceptable car ils sont interactifs uniquement.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
Chaque optimisation est indépendante et déployable séparément. Pas de migration de données. Rollback : revert du commit concerné. L'ordre recommandé : (1) batch resolveCollaborator, (2) cleanupOldEvents, (3) debounce useLive, (4) loading.tsx, (5) next/dynamic.
|
||||
@@ -0,0 +1,29 @@
|
||||
## Why
|
||||
|
||||
Les routes principales souffrent de plusieurs problèmes de performance facilement corrigeables : N+1 sur `resolveCollaborator`, re-renders en cascade dans `useLive`, accumulation illimitée d'événements SSE, et absence de feedback visuel pendant les navigations. Ces quick wins peuvent être adressés indépendamment, sans refactoring architectural.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **Batch resolveCollaborator** : remplacer les appels séquentiels par un batch unique dans `session-queries.ts` (élimination N+1)
|
||||
- **Debounce router.refresh()** : ajouter un debounce ~300ms dans `useLive.ts` pour grouper les événements SSE simultanés
|
||||
- **Appel de cleanupOldEvents** : intégrer l'appel à `cleanupOldEvents` dans `createEvent` pour purger les vieux événements au fil de l'eau
|
||||
- **Ajout de `loading.tsx`** : ajouter des fichiers `loading.tsx` sur les routes `/sessions`, `/weather`, `/users` pour activer le streaming App Router
|
||||
- **Lazy-load des modals** : utiliser `next/dynamic` sur `ShareModal` et autres modals lourds pour réduire le bundle JS initial
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `perf-loading-states`: Feedback visuel de chargement sur les routes principales via `loading.tsx`
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- Aucune modification de spec existante — les changements sont purement implémentation/performance
|
||||
|
||||
## Impact
|
||||
|
||||
- `src/services/session-queries.ts` — refactoring batch resolveCollaborator
|
||||
- `src/hooks/useLive.ts` — ajout debounce sur router.refresh
|
||||
- `src/services/session-share-events.ts` — appel cleanupOldEvents dans createEvent
|
||||
- `src/app/sessions/loading.tsx`, `src/app/weather/loading.tsx`, `src/app/users/loading.tsx` — nouveaux fichiers
|
||||
- Composants qui importent `ShareModal` — passage à import dynamique
|
||||
@@ -0,0 +1,24 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Loading skeleton on main routes
|
||||
The application SHALL display a skeleton loading state during navigation to `/sessions`, `/weather`, and `/users` routes, activated by Next.js App Router streaming via `loading.tsx` files.
|
||||
|
||||
#### Scenario: Navigation to sessions page shows skeleton
|
||||
- **WHEN** a user navigates to `/sessions`
|
||||
- **THEN** a loading skeleton SHALL be displayed immediately while the page data loads
|
||||
|
||||
#### Scenario: Navigation to weather page shows skeleton
|
||||
- **WHEN** a user navigates to `/weather`
|
||||
- **THEN** a loading skeleton SHALL be displayed immediately while the page data loads
|
||||
|
||||
#### Scenario: Navigation to users page shows skeleton
|
||||
- **WHEN** a user navigates to `/users`
|
||||
- **THEN** a loading skeleton SHALL be displayed immediately while the page data loads
|
||||
|
||||
### Requirement: Modal lazy loading
|
||||
Heavy modal components (ShareModal) SHALL be loaded lazily via `next/dynamic` to reduce the initial JS bundle size.
|
||||
|
||||
#### Scenario: ShareModal not in initial bundle
|
||||
- **WHEN** a page loads that contains a ShareModal trigger
|
||||
- **THEN** the ShareModal component code SHALL NOT be included in the initial JS bundle
|
||||
- **THEN** the ShareModal code SHALL be fetched only when first needed
|
||||
33
openspec/changes/archive/2026-03-09-perf-quick-wins/tasks.md
Normal file
33
openspec/changes/archive/2026-03-09-perf-quick-wins/tasks.md
Normal file
@@ -0,0 +1,33 @@
|
||||
## 1. Batch resolveCollaborator (N+1 fix)
|
||||
|
||||
- [x] 1.1 Lire `src/services/session-queries.ts` et identifier toutes les occurrences de `resolveCollaborator` appelées en boucle
|
||||
- [x] 1.2 Créer une fonction `batchResolveCollaborators(userIds: string[])` qui fait un seul `prisma.user.findMany({ where: { id: { in: userIds } } })`
|
||||
- [x] 1.3 Remplacer les boucles N+1 par collect des IDs → batch query → mapping en mémoire
|
||||
- [x] 1.4 Vérifier que les pages sessions/weather/etc. chargent correctement
|
||||
|
||||
## 2. Debounce router.refresh() dans useLive
|
||||
|
||||
- [x] 2.1 Lire `src/hooks/useLive.ts` et localiser l'appel à `router.refresh()`
|
||||
- [x] 2.2 Ajouter un `useRef<ReturnType<typeof setTimeout>>` pour le timer de debounce
|
||||
- [x] 2.3 Wrapper l'appel `router.refresh()` avec `clearTimeout` + `setTimeout` à 300ms
|
||||
- [x] 2.4 Ajouter un `clearTimeout` dans le cleanup de l'effet pour éviter les leaks mémoire
|
||||
|
||||
## 3. Purge automatique des événements SSE
|
||||
|
||||
- [x] 3.1 Lire `src/services/session-share-events.ts` et localiser `createEvent` et `cleanupOldEvents`
|
||||
- [x] 3.2 Ajouter un appel fire-and-forget à `cleanupOldEvents` à la fin de `createEvent` (après l'insert)
|
||||
- [x] 3.3 Wrapper l'appel dans un try/catch pour logger l'erreur sans bloquer
|
||||
|
||||
## 4. Ajout des loading.tsx sur les routes principales
|
||||
|
||||
- [x] 4.1 Créer `src/app/sessions/loading.tsx` avec un skeleton de liste de sessions
|
||||
- [x] 4.2 Créer `src/app/weather/loading.tsx` avec un skeleton de tableau météo
|
||||
- [x] 4.3 Créer `src/app/users/loading.tsx` avec un skeleton de liste utilisateurs
|
||||
- [ ] 4.4 Vérifier que le skeleton s'affiche bien à la navigation (ralentir le réseau dans DevTools)
|
||||
|
||||
## 5. Lazy-load des modals avec next/dynamic
|
||||
|
||||
- [x] 5.1 Identifier tous les composants qui importent `ShareModal` directement
|
||||
- [x] 5.2 Remplacer chaque import statique par `next/dynamic(() => import(...), { ssr: false })`
|
||||
- [ ] 5.3 Vérifier que les modals s'ouvrent correctement après lazy-load
|
||||
- [ ] 5.4 Vérifier dans les DevTools Network que le chunk modal n'est pas dans le bundle initial
|
||||
2
openspec/changes/perf-data-optimization/.openspec.yaml
Normal file
2
openspec/changes/perf-data-optimization/.openspec.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-03-09
|
||||
59
openspec/changes/perf-data-optimization/design.md
Normal file
59
openspec/changes/perf-data-optimization/design.md
Normal file
@@ -0,0 +1,59 @@
|
||||
## Context
|
||||
|
||||
`src/services/weather.ts` utilise `findMany` sans `take` ni `orderBy`, chargeant potentiellement des centaines d'entrées pour calculer des tendances qui n'utilisent que les 30-90 derniers points. Les services de sessions utilisent `include: { items: true, shares: true, events: true }` pour construire les listes, alors que l'affichage carte n'a besoin que du titre, de la date, du comptage d'items et du statut de partage. `User.name` est filtré dans les recherches admin mais sans index SQLite. Les pages les plus visitées (`/sessions`, `/users`) recalculent leurs données à chaque requête.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Borner le chargement historique weather à une constante configurable
|
||||
- Réduire la taille des objets retournés par les queries de liste (select vs include)
|
||||
- Ajouter un index SQLite sur `User.name`
|
||||
- Introduire un cache Next.js sur les queries de liste avec invalidation ciblée
|
||||
|
||||
**Non-Goals:**
|
||||
- Changer la structure des modèles Prisma
|
||||
- Modifier le rendu des pages (les sélections couvrent tous les champs affichés)
|
||||
- Introduire un cache externe (Redis, Memcached)
|
||||
- Optimiser les pages de détail session (hors scope)
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. Constante WEATHER_HISTORY_LIMIT dans lib/types.ts
|
||||
|
||||
**Décision** : Définir `WEATHER_HISTORY_LIMIT = 90` dans `src/lib/types.ts` (cohérent avec les autres constantes de config). La query devient : `findMany({ orderBy: { createdAt: 'desc' }, take: WEATHER_HISTORY_LIMIT })`.
|
||||
|
||||
**Alternatives** : Paramètre d'URL ou env var → sur-ingénierie pour un seuil rarement modifié.
|
||||
|
||||
### 2. Select minimal pour les listes — interface ListItem dédiée
|
||||
|
||||
**Décision** : Pour chaque service de liste, définir un type `XxxListItem` dans `types.ts` avec uniquement les champs de la carte (id, title, createdAt, _count.items, shares.length). Utiliser `select` Prisma pour matcher exactement ce type.
|
||||
|
||||
**Alternatives** : Garder `include` et filtrer côté TypeScript → charge DB identique, gain nul.
|
||||
|
||||
### 3. Index @@index([name]) sur User
|
||||
|
||||
**Décision** : Ajouter `@@index([name])` dans le modèle `User` de `schema.prisma`. Créer une migration nommée `add_user_name_index`. Impact : SQLite crée un B-tree index, recherches `LIKE 'x%'` bénéficient de l'index (prefix match).
|
||||
|
||||
**Note** : `LIKE '%x%'` (contains) n'utilise pas l'index en SQLite — acceptable, le use case principal est la recherche par préfixe.
|
||||
|
||||
### 4. unstable_cache avec tags sur requêtes de liste
|
||||
|
||||
**Décision** : Wrapper les fonctions de service de liste (ex: `getSessionsForUser`, `getUserStats`) avec `unstable_cache(fn, [cacheKey], { tags: ['sessions-list:userId'] })`. Les Server Actions appellent `revalidateTag` correspondant après mutation.
|
||||
|
||||
Durée de cache : `revalidate: 60` secondes en fallback, mais invalidation explicite prioritaire.
|
||||
|
||||
**Alternatives** : `React.cache` → par-requête uniquement, pas de persistance entre navigations ; `fetch` avec cache → ne s'applique pas aux queries Prisma.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **select strict** → si un composant accède à un champ non sélectionné, erreur TypeScript au build (bonne chose — détecté tôt).
|
||||
- **unstable_cache** → API Next.js marquée unstable. Mitigation : isoler dans les services, wrapper facilement remplaçable.
|
||||
- **Index User.name** → légère augmentation de la taille du fichier SQLite et du temps d'écriture. Négligeable pour les volumes actuels.
|
||||
- **WEATHER_HISTORY_LIMIT** → les calculs de tendance doivent fonctionner avec N entrées ou moins. Vérifier que l'algorithme est robuste avec un historique partiel.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. Migration Prisma `add_user_name_index` (non-destructif, peut être appliqué à tout moment)
|
||||
2. Ajout `WEATHER_HISTORY_LIMIT` + update query weather (indépendant)
|
||||
3. Refactoring select par service (vérifier TypeScript au build à chaque service)
|
||||
4. Ajout cache layer en dernier (dépend des tags définis en Phase 2 si applicable, sinon définir localement)
|
||||
29
openspec/changes/perf-data-optimization/proposal.md
Normal file
29
openspec/changes/perf-data-optimization/proposal.md
Normal file
@@ -0,0 +1,29 @@
|
||||
## Why
|
||||
|
||||
Les requêtes Prisma des pages les plus fréquentées chargent trop de données : `weather.ts` ramène tout l'historique sans borne, les queries de la sessions page incluent des relations profondes inutiles pour l'affichage liste, et aucun cache n'est appliqué sur les requêtes répétées à chaque navigation. Ces optimisations réduisent la taille des payloads et le temps de réponse DB sans changer le comportement.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **Weather historique borné** : ajouter `take` + `orderBy createdAt DESC` dans `src/services/weather.ts`, configurable via constante (défaut : 90 entrées)
|
||||
- **Select fields sur sessions list** : remplacer les `include` profonds par des `select` avec uniquement les champs affichés dans les cards de liste
|
||||
- **Index `User.name`** : ajouter `@@index([name])` dans `prisma/schema.prisma` + migration
|
||||
- **Cache sur requêtes fréquentes** : wraper les queries de liste sessions et stats utilisateurs avec `unstable_cache` + tags, invalidés lors des mutations
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `query-cache-layer`: Cache Next.js sur les requêtes de liste fréquentes avec invalidation par tags
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- Aucune modification de spec comportementale — optimisations internes transparentes
|
||||
|
||||
## Impact
|
||||
|
||||
- `src/services/weather.ts` — ajout limite + orderBy
|
||||
- `src/services/` (tous les services de liste) — `include` → `select`
|
||||
- `prisma/schema.prisma` — ajout `@@index([name])` sur `User`
|
||||
- `prisma/migrations/` — nouvelle migration pour l'index
|
||||
- `src/services/` — wrapping `unstable_cache` sur queries fréquentes
|
||||
- `src/actions/` — ajout `revalidateTag` correspondants (complément Phase 2)
|
||||
@@ -0,0 +1,30 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Cached session list queries
|
||||
Frequently-called session list queries SHALL be cached using Next.js `unstable_cache` with user-scoped tags, avoiding redundant DB reads on repeated navigations.
|
||||
|
||||
#### Scenario: Session list served from cache on repeated navigation
|
||||
- **WHEN** a user navigates to the sessions page multiple times within the cache window
|
||||
- **THEN** the session list data SHALL be served from cache on subsequent requests
|
||||
- **THEN** no additional Prisma query SHALL be executed for cached data
|
||||
|
||||
#### Scenario: Cache invalidated after mutation
|
||||
- **WHEN** a Server Action creates, updates, or deletes a session
|
||||
- **THEN** the corresponding cache tag SHALL be invalidated via `revalidateTag`
|
||||
- **THEN** the next request SHALL fetch fresh data from the DB
|
||||
|
||||
### Requirement: Weather history bounded query
|
||||
The weather service SHALL limit historical data loading to a configurable maximum number of entries (default: 90), ordered by most recent first.
|
||||
|
||||
#### Scenario: Weather history respects limit
|
||||
- **WHEN** the weather service fetches historical entries
|
||||
- **THEN** at most `WEATHER_HISTORY_LIMIT` entries SHALL be returned
|
||||
- **THEN** entries SHALL be ordered by `createdAt` DESC (most recent first)
|
||||
|
||||
### Requirement: Minimal field selection on list queries
|
||||
Service functions returning lists for display purposes SHALL use Prisma `select` with only the fields required for the list UI, not full `include` of related models.
|
||||
|
||||
#### Scenario: Sessions list query returns only display fields
|
||||
- **WHEN** the sessions list service function is called
|
||||
- **THEN** the returned objects SHALL contain only fields needed for card display (id, title, createdAt, item count, share status)
|
||||
- **THEN** full related model objects (items array, events array) SHALL NOT be included
|
||||
30
openspec/changes/perf-data-optimization/tasks.md
Normal file
30
openspec/changes/perf-data-optimization/tasks.md
Normal file
@@ -0,0 +1,30 @@
|
||||
## 1. Index User.name (migration Prisma)
|
||||
|
||||
- [ ] 1.1 Lire `prisma/schema.prisma` et localiser le modèle `User`
|
||||
- [ ] 1.2 Ajouter `@@index([name])` au modèle `User`
|
||||
- [ ] 1.3 Exécuter `pnpm prisma migrate dev --name add_user_name_index`
|
||||
- [ ] 1.4 Vérifier que la migration s'applique sans erreur et que `prisma studio` montre l'index
|
||||
|
||||
## 2. Weather: limiter le chargement historique
|
||||
|
||||
- [ ] 2.1 Ajouter la constante `WEATHER_HISTORY_LIMIT = 90` dans `src/lib/types.ts`
|
||||
- [ ] 2.2 Lire `src/services/weather.ts` et localiser la query `findMany` des entrées historiques
|
||||
- [ ] 2.3 Ajouter `take: WEATHER_HISTORY_LIMIT` et `orderBy: { createdAt: 'desc' }` à la query
|
||||
- [ ] 2.4 Vérifier que les calculs de tendances fonctionnent avec un historique partiel
|
||||
|
||||
## 3. Select fields sur les queries de liste
|
||||
|
||||
- [ ] 3.1 Lire les services de liste : `src/services/swot.ts`, `motivators.ts`, `year-review.ts`, `weekly-checkin.ts`, `weather.ts`
|
||||
- [ ] 3.2 Identifier les `include` utilisés dans les fonctions de liste (pas de détail session)
|
||||
- [ ] 3.3 Définir des types `XxxListItem` dans `src/lib/types.ts` avec uniquement les champs affichés en carte
|
||||
- [ ] 3.4 Remplacer les `include` profonds par `select` correspondant aux types `XxxListItem` dans chaque service
|
||||
- [ ] 3.5 Mettre à jour les composants de liste qui utilisaient les champs supprimés (vérifier les erreurs TypeScript)
|
||||
- [ ] 3.6 Vérifier `pnpm build` sans erreurs TypeScript
|
||||
|
||||
## 4. Cache layer sur requêtes fréquentes
|
||||
|
||||
- [ ] 4.1 Créer `src/lib/cache-tags.ts` si pas encore fait (sinon compléter) avec les helpers de tags : `sessionTag(id)`, `sessionsListTag(userId)`, `userStatsTag(userId)`
|
||||
- [ ] 4.2 Wrapper la fonction de liste sessions dans chaque service avec `unstable_cache(fn, [key], { tags: [sessionsListTag(userId)], revalidate: 60 })`
|
||||
- [ ] 4.3 Wrapper la fonction de stats utilisateurs (`getUserStats` ou équivalent) avec `unstable_cache`
|
||||
- [ ] 4.4 Vérifier que les Server Actions de création/suppression de session appellent `revalidateTag(sessionsListTag(userId))`
|
||||
- [ ] 4.5 Tester l'invalidation : créer une session → vérifier qu'elle apparaît immédiatement dans la liste
|
||||
2
openspec/changes/perf-realtime-scale/.openspec.yaml
Normal file
2
openspec/changes/perf-realtime-scale/.openspec.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-03-09
|
||||
65
openspec/changes/perf-realtime-scale/design.md
Normal file
65
openspec/changes/perf-realtime-scale/design.md
Normal file
@@ -0,0 +1,65 @@
|
||||
## Context
|
||||
|
||||
Chaque route `/api/*/subscribe` crée un `setInterval` à 1s qui poll la DB pour les événements. Si 10 utilisateurs ont le même workshop ouvert, c'est 10 requêtes/seconde sur la même table. Le pattern weather utilise déjà une `Map` de subscribers in-process pour broadcaster les événements sans re-poll, mais ce pattern n'est pas généralisé. Les Server Actions appellent `revalidatePath('/sessions')` qui invalide tous les sous-segments, forçant Next.js à re-render des pages entières même pour une mutation mineure.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Réduire le nombre de requêtes DB de polling proportionnellement au nombre de clients connectés
|
||||
- Fournir un module de broadcast réutilisable pour tous les workshops
|
||||
- Réduire la surface d'invalidation du cache Next.js avec des tags granulaires
|
||||
- Limiter le volume de données chargées sur la page sessions avec pagination
|
||||
|
||||
**Non-Goals:**
|
||||
- Passer à WebSockets ou un serveur temps-réel externe (Redis, Pusher)
|
||||
- Modifier le modèle de données Prisma pour les événements
|
||||
- Implémenter du SSE multi-process / multi-instance (déploiement standalone single-process)
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. Module broadcast.ts : Map<sessionId, Set<subscriber>>
|
||||
|
||||
**Décision** : Créer `src/lib/broadcast.ts` qui expose :
|
||||
- `subscribe(sessionId, callback)` → retourne `unsubscribe()`
|
||||
- `broadcast(sessionId, event)` → notifie tous les subscribers
|
||||
|
||||
Les routes SSE s'abonnent au lieu de poller. Les Server Actions appellent `broadcast()` après mutation.
|
||||
|
||||
**Alternatives** : EventEmitter Node.js → rejeté car moins typé ; BroadcastChannel → rejeté car limité à same-origin workers, pas adapté aux route handlers Next.js.
|
||||
|
||||
### 2. Polling de fallback maintenu mais mutualisé
|
||||
|
||||
**Décision** : Garder un seul polling par session active (le premier subscriber démarre l'interval, le dernier le stoppe). Le broadcast natif est prioritaire (appelé depuis Server Actions), le polling est le fallback pour les clients qui rejoignent en cours de route.
|
||||
|
||||
### 3. revalidateTag avec convention de nommage
|
||||
|
||||
**Décision** : Convention de tags :
|
||||
- `session:<id>` — pour une session spécifique
|
||||
- `sessions-list:<userId>` — pour la liste des sessions d'un user
|
||||
- `workshop:<type>` — pour tout le workshop
|
||||
|
||||
Chaque query Prisma dans les services est wrappée avec `unstable_cache` ou utilise `cacheTag` (Next.js 15+).
|
||||
|
||||
**Alternatives** : Garder `revalidatePath` mais avec des paths plus précis → moins efficace que les tags.
|
||||
|
||||
### 4. Pagination cursor-based sur sessions page
|
||||
|
||||
**Décision** : Pagination par cursor (basée sur `createdAt` DESC) plutôt qu'offset, pour la stabilité des listes en insertion fréquente. Taille de page initiale : 20 sessions par type de workshop. UI : bouton "Charger plus" (pas de pagination numérotée).
|
||||
|
||||
**Alternatives** : Virtual scroll → plus complexe, dépendance JS côté client ; offset pagination → instable si nouvelles sessions insérées entre deux pages.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **Broadcast in-process** → ne fonctionne qu'en déploiement single-process. Acceptable pour le cas d'usage actuel (standalone Next.js). Documenter la limitation.
|
||||
- **unstable_cache** → API marquée unstable dans Next.js, peut changer. Mitigation : isoler dans les services, pas dans les composants.
|
||||
- **Pagination** → change l'UX de la page sessions (actuellement tout visible). Mitigation : conserver le total affiché et un indicateur "X sur Y".
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. Créer `src/lib/broadcast.ts` sans toucher aux routes existantes
|
||||
2. Migrer les routes SSE une par une (commencer par `weather` qui a déjà le pattern)
|
||||
3. Mettre à jour les Server Actions pour appeler `broadcast()` + `revalidateTag()`
|
||||
4. Ajouter `cacheTag` aux queries services
|
||||
5. Ajouter pagination sur sessions page en dernier (changement UI visible)
|
||||
|
||||
Rollback : chaque étape est indépendante — revert par feature.
|
||||
29
openspec/changes/perf-realtime-scale/proposal.md
Normal file
29
openspec/changes/perf-realtime-scale/proposal.md
Normal file
@@ -0,0 +1,29 @@
|
||||
## Why
|
||||
|
||||
La couche temps-réel actuelle (SSE + polling DB à 1s) multiplie les connexions et les requêtes dès que plusieurs utilisateurs collaborent. Chaque onglet ouvert sur une session déclenche son propre polling, et les Server Actions invalident des segments de route entiers avec `revalidatePath`. Ces problèmes de scalabilité deviennent visibles dès 5-10 utilisateurs simultanés.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **Polling SSE partagé** : un seul interval actif par session côté serveur, partagé entre tous les clients connectés à cette session
|
||||
- **Broadcast unifié** : généraliser le pattern de broadcast in-process (déjà présent dans `weather`) à tous les workshops via un module `src/lib/broadcast.ts`
|
||||
- **`revalidateTag` granulaire** : remplacer `revalidatePath` dans tous les Server Actions par des tags ciblés (`session:<id>`, `sessions-list`, etc.)
|
||||
- **Pagination sessions page** : limiter le chargement initial à N sessions par type avec pagination ou chargement progressif
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `sse-shared-polling`: Polling SSE mutualisé par session (un seul interval par session active)
|
||||
- `unified-broadcast`: Module de broadcast in-process réutilisable par tous les workshops
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `sessions-list`: Ajout de pagination/limite sur le chargement des sessions
|
||||
|
||||
## Impact
|
||||
|
||||
- `src/app/api/*/subscribe/route.ts` — refactoring du polling vers le module broadcast partagé
|
||||
- `src/lib/broadcast.ts` — nouveau module (Map de sessions actives + subscribers)
|
||||
- `src/actions/*.ts` — remplacement de `revalidatePath` par `revalidateTag` + `unstable_cache`
|
||||
- `src/app/sessions/page.tsx` — ajout pagination
|
||||
- `src/services/` — ajout de `cache` tags sur les requêtes Prisma fréquentes
|
||||
@@ -0,0 +1,15 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Paginated sessions list
|
||||
The sessions page SHALL load sessions in pages rather than fetching all sessions at once, with a default page size of 20 per workshop type.
|
||||
|
||||
#### Scenario: Initial load shows first page
|
||||
- **WHEN** a user visits the sessions page
|
||||
- **THEN** at most 20 sessions per workshop type SHALL be loaded
|
||||
- **THEN** a total count SHALL be displayed (e.g., "Showing 20 of 47")
|
||||
|
||||
#### Scenario: Load more sessions on demand
|
||||
- **WHEN** there are more sessions beyond the current page
|
||||
- **THEN** a "Charger plus" button SHALL be displayed
|
||||
- **WHEN** the user clicks "Charger plus"
|
||||
- **THEN** the next page of sessions SHALL be appended to the list
|
||||
@@ -0,0 +1,17 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Single polling interval per active session
|
||||
The SSE infrastructure SHALL maintain at most one active DB polling interval per session, regardless of the number of connected clients.
|
||||
|
||||
#### Scenario: First client connects starts polling
|
||||
- **WHEN** the first client connects to a session's SSE endpoint
|
||||
- **THEN** a single polling interval SHALL be started for that session
|
||||
|
||||
#### Scenario: Additional clients share existing polling
|
||||
- **WHEN** a second or subsequent client connects to the same session's SSE endpoint
|
||||
- **THEN** no additional polling interval SHALL be created
|
||||
- **THEN** the new client SHALL receive events from the shared poll
|
||||
|
||||
#### Scenario: Last client disconnect stops polling
|
||||
- **WHEN** all clients disconnect from a session's SSE endpoint
|
||||
- **THEN** the polling interval for that session SHALL be stopped and cleaned up
|
||||
@@ -0,0 +1,22 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Centralized broadcast module
|
||||
The system SHALL provide a centralized `src/lib/broadcast.ts` module used by all workshop SSE routes to push events to connected clients.
|
||||
|
||||
#### Scenario: Server Action triggers broadcast
|
||||
- **WHEN** a Server Action mutates session data and calls `broadcast(sessionId, event)`
|
||||
- **THEN** all clients subscribed to that session SHALL receive the event immediately without waiting for the next poll cycle
|
||||
|
||||
#### Scenario: Broadcast module subscribe/unsubscribe
|
||||
- **WHEN** an SSE route calls `subscribe(sessionId, callback)`
|
||||
- **THEN** the callback SHALL be invoked on every subsequent `broadcast(sessionId, ...)` call
|
||||
- **WHEN** the returned `unsubscribe()` function is called
|
||||
- **THEN** the callback SHALL no longer receive events
|
||||
|
||||
### Requirement: Granular cache invalidation via revalidateTag
|
||||
Server Actions SHALL use `revalidateTag` with session-scoped tags instead of `revalidatePath` to limit cache invalidation scope.
|
||||
|
||||
#### Scenario: Session mutation invalidates only that session's cache
|
||||
- **WHEN** a Server Action mutates a specific session (e.g., adds an item)
|
||||
- **THEN** only the cache tagged `session:<id>` SHALL be invalidated
|
||||
- **THEN** other sessions' cached data SHALL NOT be invalidated
|
||||
36
openspec/changes/perf-realtime-scale/tasks.md
Normal file
36
openspec/changes/perf-realtime-scale/tasks.md
Normal file
@@ -0,0 +1,36 @@
|
||||
## 1. Module broadcast.ts
|
||||
|
||||
- [ ] 1.1 Créer `src/lib/broadcast.ts` avec une `Map<string, Set<(event: unknown) => void>>` et les fonctions `subscribe(sessionId, cb)` et `broadcast(sessionId, event)`
|
||||
- [ ] 1.2 Ajouter la logique de polling mutualisé : `startPolling(sessionId)` / `stopPolling(sessionId)` avec compteur de subscribers
|
||||
- [ ] 1.3 Écrire un test manuel : ouvrir 2 onglets sur la même session, vérifier qu'un seul interval tourne (log côté serveur)
|
||||
|
||||
## 2. Migration des routes SSE
|
||||
|
||||
- [ ] 2.1 Lire toutes les routes `src/app/api/*/subscribe/route.ts` pour inventorier le pattern actuel
|
||||
- [ ] 2.2 Migrer la route weather en premier (elle a déjà un pattern partiel) pour valider l'approche
|
||||
- [ ] 2.3 Migrer les routes swot, motivators, year-review, weekly-checkin une par une
|
||||
- [ ] 2.4 Vérifier que le cleanup SSE (abort signal) appelle bien `unsubscribe()` dans chaque route migrée
|
||||
|
||||
## 3. revalidateTag dans les Server Actions
|
||||
|
||||
- [ ] 3.1 Définir la convention de tags dans `src/lib/cache-tags.ts` (ex: `session(id)`, `sessionsList(userId)`)
|
||||
- [ ] 3.2 Ajouter `cacheTag` / `unstable_cache` aux queries de services correspondantes
|
||||
- [ ] 3.3 Remplacer `revalidatePath` par `revalidateTag` dans `src/actions/swot.ts`
|
||||
- [ ] 3.4 Remplacer `revalidatePath` par `revalidateTag` dans `src/actions/motivators.ts`
|
||||
- [ ] 3.5 Remplacer `revalidatePath` par `revalidateTag` dans `src/actions/year-review.ts`
|
||||
- [ ] 3.6 Remplacer `revalidatePath` par `revalidateTag` dans `src/actions/weekly-checkin.ts`
|
||||
- [ ] 3.7 Remplacer `revalidatePath` par `revalidateTag` dans `src/actions/weather.ts`
|
||||
- [ ] 3.8 Vérifier que les mutations se reflètent correctement dans l'UI après revalidation
|
||||
|
||||
## 4. Broadcast depuis les Server Actions
|
||||
|
||||
- [ ] 4.1 Ajouter l'appel `broadcast(sessionId, { type: 'update' })` dans chaque Server Action de mutation (après revalidateTag)
|
||||
- [ ] 4.2 Vérifier que les mises à jour collaboratives fonctionnent (ouvrir 2 onglets, muter depuis l'un, voir la mise à jour dans l'autre)
|
||||
|
||||
## 5. Pagination sessions page
|
||||
|
||||
- [ ] 5.1 Modifier les queries dans `src/services/` pour accepter `cursor` et `limit` (défaut: 20)
|
||||
- [ ] 5.2 Mettre à jour `src/app/sessions/page.tsx` pour charger la première page + afficher le total
|
||||
- [ ] 5.3 Créer un Server Action `loadMoreSessions(type, cursor)` pour la pagination
|
||||
- [ ] 5.4 Ajouter le bouton "Charger plus" avec état loading dans le composant sessions list
|
||||
- [ ] 5.5 Vérifier l'affichage "X sur Y sessions" pour chaque type de workshop
|
||||
Reference in New Issue
Block a user