# Vue Activité : affichage instantané sur les grosses bases (PR #2647)

**URL:** <https://community.gladysassistant.com/t/vue-activite-affichage-instantane-sur-les-grosses-bases-pr-2647/10340>\
**Category:** Développement\
**Created:** [Juillet 12, 2026, 3:28 UTC](https://community.gladysassistant.com/t/vue-activite-affichage-instantane-sur-les-grosses-bases-pr-2647/10340 "2026-07-12T15:28:22Z")\
**Posts on this page:** 3\
**Page:** 1

<div class="post-metadata">

**Author:** ![Terdious](https://gladys-assistant-community.b-cdn.net/user_avatar/community.gladysassistant.com/terdious/32/6316_2.png) [@Terdious](https://community.gladysassistant.com/u/Terdious)\
**Post date:** [Juillet 12, 2026, 3:28 UTC](https://community.gladysassistant.com/t/vue-activite-affichage-instantane-sur-les-grosses-bases-pr-2647/10340/1 "2026-07-12T15:28:22Z")

</div>

Suite à la sortie de la 4.82 et aux échanges avec @pierre-gilles sur les perfs de la nouvelle vue Activité sur des installations un peu lourdes (tout du moins comme la mienne), voici le bilan de la PR [#2647](https://github.com/GladysAssistant/Gladys/pull/2647), testée sur ma base de **448 millions d’états**.

## Le problème

Sur une grosse base, filtrer l’Activité sur une catégorie _clairsemée_ (ex. « Ouvertures » : peu d’états, anciens) obligeait le serveur à scanner une énorme partie de l’historique avant de répondre : le serveur ne répondait qu’une fois sa page de 80 états remplie, quelle que soit la profondeur à parcourir. Résultat : un spinner figé pendant 20 à 50 secondes, parfois 3 minutes.

On a mesuré méthodiquement toutes les pistes « pur serveur » (fenêtres progressives, ancrage sur la dernière activité — merci @pierre-gilles pour la #2642 —, tranches disjointes, compaction physique de la table) : chacune améliore, aucune ne supprime le mur — le coût plancher, c’est le débit de scan de DuckDB multiplié par le volume à parcourir.

## La solution : laisser le client piloter la recherche

- **Serveur** : `getDeviceStatesHistory` accepte une borne `since` → une requête = une fenêtre temporelle bornée, qui rend ce qu’elle contient (même moins que 80) et ne s’élargit jamais. Une fenêtre bornée répond en millisecondes grâce aux zone maps DuckDB. Sans `since`, comportement strictement inchangé (rétro-compatible).
- **Front** : la vue Activité sonde 1 → 2 → 4 → 8 → 16 → 32 mois puis une dernière requête illimitée, **affiche chaque lot dès son arrivée** , avec un bandeau _« Recherche d’activités — mars 2025… »_ pendant que les fenêtres plus anciennes sont sondées en arrière-plan.

## Les mesures (448 M d’états)

| Cas | Avant | Après |
| --- | --- | --- |
| Filtre « Ouvertures » (peu d’états, anciens) | 20 à 33 s de spinner figé | **Premiers états en ~100 ms** , la page se complète en fond |
| Filtre « Boutons » (états à 17 mois) | 22 à 51 s | Bandeau immédiat indiquant le mois scanné, états dès qu’ils sont trouvés |
| Vue « Tout » / live / capteurs bavards | ~100 ms | Inchangé (~100 ms) |

Le travail total du pire cas ne change pas — mais il se fait **derrière une page déjà utilisable** au lieu de bloquer le premier affichage. C’est le principe du logbook Home Assistant, adapté à l’API Gladys.

 ![image](https://gladys-assistant-community.b-cdn.net/uploads/default/original/3X/1/7/17cb600eb7cbc4b0dea9e44ea261379cd1bf4701.png)  
 ![image](https://gladys-assistant-community.b-cdn.net/uploads/default/original/3X/0/c/0c717120f04348780415ccf199cac902f1f13aab.png)

La PR est prête pour review. Elle ne touche ni au modèle de données, ni aux agrégats — c’est du pur découpage de requêtes.

> <https://github.com/GladysAssistant/Gladys/pull/2647>
>
> \### Pull Request check-list
> 
> To ensure your Pull Request can be accepted as fast… as possible, make sure to review and check all of these items:
> 
> \- \[x\] If your changes affect the code, did you write the tests? \*(4 new server tests: bounded probe semantics, inclusive lower bound, empty window, invalid \`since\`, null-room mapping — the touched file is at 100% statements/branches/functions/lines)\*
> \- \[x\] Are server tests passing with coverage? (\`npm run coverage\` — full suite green locally)
> \- \[x\] Did Cypress E2E tests pass? \*(no dedicated spec for the Activity/history route)\*
> \- \[x\] Is the linter passing? (\`npm run eslint\` on front and server)
> \- \[x\] Did you run prettier? (front and server)
> \- \[x\] If you are adding a new feature/service, did you run the integration comparator? (\`npm run compare-translations\` — new \`history.searchingOlder\` key added to en/fr/de)
> \- \[x\] Did you test this pull request in real life? With real devices? \*(validated on a 448M-state installation, timings below — please also test on a regular-size install to confirm no regression on common usage)\*
> \- \[x\] If your changes modify the API (REST or Node.js), did you modify the API documentation? \*(JSDoc on \`getDeviceStatesHistory\` documents the new optional \`since\` query parameter; API is backward compatible)\*
> \- ~~If you are adding a new features/services which needs explanation, did you modify the user documentation?~~ \*(UX improvement of an existing screen)\*
> \- ~~Did you add fake requests data for the demo mode?~~ \*(same endpoint, \`since\` only narrows it)\*
> 
> \`npm run build\` (Vite) passes locally.
> 
> \### Description of change
> 
> \#### Problem
> 
> On large databases, filtering the Activity feed on a sparse category is very slow: the server keeps widening its internal time windows until it has collected \`take\` states, however deep that requires scanning. On a 448M-state installation, "Openings" took 20-33s and "Buttons & remotes" 22-51s of frozen spinner — and every deeper "Load more" paid the same price. Anchoring (#2642) and window strategies cannot beat this: the cost floor is DuckDB's scan throughput times the volume to scan before the quota is filled.
> 
> \#### Solution — let the client drive the widening
> 
> \*\*Server\*\* — \`getDeviceStatesHistory\` accepts an optional \`since\` lower bound. When provided, the query is a \*\*single bounded \`\[since, before)\` probe\*\*: it returns what the window contains, even fewer than \`take\`, and never widens. Bounded probes are answered in milliseconds thanks to DuckDB zone-map pruning. Without \`since\`, behavior is strictly unchanged (backward compatible).
> 
> \*\*Front\*\* — the Activity feed probes windows of 1, 2, 4, 8, 16, 32 months back from the cursor, then one final unbounded request. Each batch is rendered \*\*as soon as it arrives\*\*, with a "Searching activity — {month}…" banner while older windows are probed in the background. A generation counter cancels in-flight probes when the filter, date or search changes.
> 
> \#### Measured on a 448M-state installation
> 
> | Case | Before | After |
> |---|---|---|
> | Sparse category ("Openings") | 20-33s frozen spinner | \*\*first states at ~100ms\*\*, page completes in background |
> | Very old sparse category ("Buttons", last states 17 months back) | 22-51s frozen spinner | instant banner showing scanned months, states stream in |
> | Live feed / "All" / chatty categories | ~100ms | unchanged (~100ms) |
> 
> The total server work for the worst case is unchanged (the final unbounded request still scans deep history), but it happens in the background of an already-usable page instead of blocking the first paint.
> 
> 🤖 Generated with \[Claude Code\](https://claude.com/claude-code)
> 
> 
> 
> \## Summary by CodeRabbit
> 
> \* \*\*New Features\*\*
> \* History now progressively searches older activity when needed, with a localized loading indicator showing the date being searched.
> \* Infinite scrolling maintains bounded results and handles changing filters or pagination more reliably.
> 
> \* \*\*Bug Fixes\*\*
> \* Prevented the empty-history state from appearing while history is still loading.
> \* Improved handling of device history within date ranges, including devices without assigned rooms.

---

<div class="post-metadata">

**Author:** ![pierre-gilles](https://gladys-assistant-community.b-cdn.net/user_avatar/community.gladysassistant.com/pierre-gilles/32/5886_2.png) [@pierre-gilles](https://community.gladysassistant.com/u/pierre-gilles)\
**Post date:** [Juillet 13, 2026, 8:01 UTC](https://community.gladysassistant.com/t/vue-activite-affichage-instantane-sur-les-grosses-bases-pr-2647/10340/2 "2026-07-13T08:01:10Z")

</div>

Salut @Terdious, merci pour cette analyse ! 🙏

Je suis quand même surpris que DuckDB ne gère pas déjà ce cas correctement. C’est assez fou de devoir attendre 30 secondes juste pour un `SELECT [...] ORDER BY created_at DESC`, alors que c’était justement une des grandes promesses de DuckDB.

À mon avis, on ne l’utilise pas de la bonne manière. Soit il nous manque un index (en gardant en tête que des index multi-colonnes complexes peuvent vite coûter cher en espace disque), soit notre requête n’est pas optimale. 🙂

---

<div class="post-metadata">

**Author:** ![Terdious](https://gladys-assistant-community.b-cdn.net/user_avatar/community.gladysassistant.com/terdious/32/6316_2.png) [@Terdious](https://community.gladysassistant.com/u/Terdious)\
**Post date:** [Juillet 13, 2026, 8:20 UTC](https://community.gladysassistant.com/t/vue-activite-affichage-instantane-sur-les-grosses-bases-pr-2647/10340/3 "2026-07-13T08:20:50Z")

</div>

Salut @pierre-gilles,

On s’est posé exactement la même, et on a testé les deux hypothèses (requête et structure) avant d’écrire la PR. Bon tu te doute, pour te répondre le mieux possible, j’ai posé mes point à l’IA en fonction des essais et réflexion qu’on a eu pendant le dev, j’espère qu’il sera plus clair que ce que je pourrais faire ^^ :

Réponse courte : **la requête n’est pas « juste » un ORDER BY, et l’index qui la sauverait n’existe pas dans DuckDB — par design.**

**1. La vraie requête.** Ce n’est pas `SELECT … ORDER BY created_at DESC LIMIT 80` seul — ça, DuckDB le fait très bien (Top-N vectorisé). C’est `WHERE device_feature_id IN (les features de la catégorie) ORDER BY created_at DESC LIMIT 80`. Sur une catégorie _rare_, les 80 lignes qui matchent sont dispersées loin dans le passé — et sans index secondaire, le moteur n’a aucun moyen de savoir _où_ : il scanne jusqu’à les trouver. Pire cas = toute la table.

**2. Pourquoi pas un index ?** Le seul index de DuckDB est l’ART, conçu pour les clés primaires et les point lookups : il n’est pas utilisé pour ordonner (pas de parcours ordonné à la B-tree), la doc déconseille d’en créer au-delà de ce cas, et sur 448 M de lignes il coûterait des Go en plus d’un surcoût sur chacune de nos dizaines d’écritures/seconde. Le « vrai » index de DuckDB, ce sont les **zonemaps** (min/max par bloc de ~120 k lignes) — efficaces uniquement quand le filtre est corrélé à l’ordre physique des données.

**3. « On structure mal nos données ? » — testé aussi, ca à pris environ 30 minutes.** J’ai réécrit la table entière en `ORDER BY created_at` sur ma copie (le layout optimal pour les zonemaps temporelles) : les fenêtres bornées passent à quelques ms ✅… mais la catégorie rare ne gagne que ×2 (33 s → 15 s). Le problème n’est pas le rangement : il faut de toute façon _lire_ un an de données denses pour y trouver 80 états d’une catégorie qui n’en produit presque pas. Et clusteriser par feature à la place casserait la vue « Tout » et se déferait en continu (les inserts live arrivent en ordre temporel). Ton ancrage `last_value_changed` (#2642) et une variante à tranches disjointes ont aussi été mesurés : mêmes ordres de grandeur.

**4. La promesse de DuckDB est ailleurs — et elle est tenue.** Colonne + OLAP = scans, agrégations massives ultra rapides (nos graphes énergie sur des millions de lignes !) et légèreté de la DB, en échange de l’abandon des index secondaires. « Les 80 derniers états d’une entité » est la requête OLTP par excellence — c’est exactement pourquoi Home Assistant la sert avec un index composite (entité, temps) sur un row-store, en payant ce que ça coûte : espace disque et écritures plus lourdes. Même arbitrage, autre versant.

D’où le choix de la #2647 : des **fenêtres temporelles bornées** (là où DuckDB excelle, quelques ms par requête) pilotées par le client, qui affiche au fil de l’eau — le pire cas ne bloque plus jamais le premier rendu. Si un jour on veut aussi éliminer le coût _total_ du pire cas, la piste « propre » serait une mini table-index (1 ligne par feature et par mois) interrogée d’abord — mais c’est un ajout au modèle de données.
