# Recherche Dayuse

## TLDR;
### Must have
- [API] Supprimer l'utilisation du DTO `HotelSearch` qui fait doublon avec `HotelSearchOutput`
- [API] Simplifier énormément la sérialisation d'HotelMarker (vu avec Thomas)
- [FRONT] Ne pas récupérer les marqueurs en SSR mais seulement côté client, la map et les marqueurs peuvent être chargés en asynchrone
- [FRONT] Ajouter des key sur les composants rendus dans des tableaux, notamment le composant `Card`
### Should have
- [ES] Introduction d'un index pour les offres (qui embarque les infos de l'hôtel) pour faire des requêtes simplifiées
- [ES] Index différents pour les offres par période de temps donné (mois / jour, dépend de la volumétrie)
- [API] Supprimer les doubles appels aux listener Legacy Dayuse
- [API] Désactiver le WriteListener d’APIP sur les endpoints de recherche (write: false dans la configuration des ressources)
- [ES] Utiliser des `terms ou term query` au lieu des `range query`, plus gourmandes, quand c'est possible
- [API] Retirer l'intégration avec Sentry qui fait trop d'appels
- [FRONT] Ne pas récupérer les menus en SSR (cross-linking et top cities) mais seulement côté client.
- [FRONT] Désactiver les re-fetch de react-query
### Nice to have
- [API] Utiliser le `$context`dans les DataProvider pour récupérer les filtres (et l'interface `ContextAwareCollectionDataProviderInterface`)
- [API] Utiliser un `input` pour peupler le DTO SearchHotelQueryDTO (https://api-platform.com/docs/core/dto/)
- [API] Laisser le ValidateListener d'Api Platform valider les critères de recherche **ou** désactiver le ValidateListener (`validate : false` dans la configuration des ressources)
- [ES] Analyser tous les champs traduits si on veut pouvoir faire de la recherche dessus un jour (actuellement en `keyword`)
- [ES] Utiliser une valeur de préférence (id de session ou username) pour optimiser le cache sur les requêtes faites par le même utilisateur (souvent les mêmes requêtes) (https://www.elastic.co/guide/en/elasticsearch/reference/6.8/search-request-preference.html)
- [ES] Identifier les critères souvent cherchés ensemble par les users (recherches types) pour identifier des presets de recherche et indexer des champs combinés (ex: wifi-9to5) (demande un travail d'analyse et des métriques fiables)
- [FRONT] (Re)mettre le lazy load sur les photos des hôtels dans le carousel
## Analyse macro
Api platform avec ES,
2 index : hôtel et POI
- Poi => autocomplétion (pas exploré)
- Hotel => recherche d’hôtels, d’offres et de marqueurs, multi-critères
Résultat transformé directement en DTO (transformer) puis sérialisé par APIP
Grosse volumétrie (chiffres exacts ?)
En prod, recherche d'hôtels simple (peu de critères) => environ 200ms, recherche de marqueurs (même critères) => environ 1900ms.
## API
### Démarche suivie
- Premier survol du code
- Onglet "Event" du profiler Symfony pour vérifier quels listeners sont appelés
- Premier aperçu des performances via le profiler de Symfony
- Exploration plus poussée du code aux endroits suspectés d'être gourmands en performance
- Profiling d’une recherche d’hôtels via Blackfire
- Profiling d'une recherche de marqueurs via Blackfire
### Analyse
#### Listeners
API Platform : appel d’une suite de listeners pas tous utiles en fonction des routes
- DayUse legacy validateUserAgent, validateRequestedMediaType, validateRequestedLanguage, validateRequestedCurrency) appelé deux fois
- WriteListener inutilement appelé sur la recherche
#### DataProvider
Un DataProvider par type de recherche :
- `HotelCollectionDataProvider`pour la recherche d'hôtels
- `HotelMarkerCollectionDataProvider` pour la recherche des marqueurs
Différentes opérations faites dans le DataProvider `HotelCollectionDataProvider` (le plus complexe) :
- HotelCollectionDataProvider => récupération et validation des filtres depuis la requête alors que déjà fait par Api Platform et disponible dans un paramètre `$context`
- SearchHotelQueryDTO peuplé dans le provider avec les filtres
- Une requête Doctrine pour récupérer la page de recherche éventuellement
- Une requête sur une agrégation en fonction du rayon de recherche pour trouver le rayon le plus approprié
- La requête principale pour récupérer la liste des hôtels
- Hydratation des résultats dans un DTO `Dayuse\Api\Elasticsearch\Model\HotelSearch`
- Requête pour récupérer les agrégations (pour afficher le nombre d'hôtels sur chaque critère)
- Hydratation des résultats de la recherche dans un DTO `HotelSearch`
- Requête pour récupérer le nombre d'hôtels en fonction de chaque critère
- Renvoie un `AggregatedPaginatorDTO` contenant les résultats et les agrégations
Pourquoi passer par 2 DTOs (`HotelSearch`dans le DataProvider puis `HotelSearchOutput` pour API Platform) ?
#### DTO `HotelSearchOutput`
Utilisé par API Platform.
Utilisation d’un DTO très bien (plus lisible, prévisible, typable).
Propriétés publiques, sans getter ni setter => très léger gain de perf (très probablement négligeable, à mettre en balance avec les avantages d'une bonne encapsulation)
Passage direct du DTO (pas de requête BDD) => :thumbsup:
#### Profiling d'une recherche d'hôtels
[Profile blackfire](https://blackfire.io/profiles/5dce5b7b-e31c-4d4c-8842-b2a7482c80be/graph)
Recherche d'hôtels simple (ville, date), le profiler de Symfony donne les valeurs suivantes :

*:warning: Valeurs mesurées en environnement de developpement et en local, seuls les rapports entre les valeurs sont à considérer ici et pas les valeurs elles-mêmes.*
80% du temps total de la requête dans le ReadListener (on exclut le Profiler Listener).
Avec Blackfire :
*Pour ce profiling, le mode debug a été désactivé, ce qui donne des valeurs plus fiables, même si elles restent toujours plus grandes que celles qu'on observeraient en environnement de production.*

L'EventDispatcher occupe la quasi-totalité du temps de requête => attendu au vu du fonctionnement d'Api Platform. Rien à optimiser de ce côté à priori. Le graphique se divise ensuite en deux branches :

La première branche concerne la sérialisation. À première vue rien de choquant à ce niveau, en supposant que toutes les données sérialisées soient utilisées.

La deuxième branche concerne l'`HotelCollectionDataProvider` (cohérent avec les données du profiler de Symfony puisque le DataProvider est appelé par le ReadListener). Les 3 méthodes au bout du graph sont celles qui nous intéressent.
- L'appel au ProxyGenerator de Doctrine pour l'unique requête qui est faite pour récupérer la SearchPage prend 6,5% du temps total de la requête. Ce n'est pas normal puisque les proxies Doctrine sont censés être mis en cache en environnement de production. Puisque le profiling a été lancé en local et en environnement de développement il s'agit probablement d'un faux positif, mais c'est à vérifier.
- Les 2 appels à `Elastica\Transport\Http:exec` correspondent aux 2 requêtes Elasticsearch qui sont faites : récupération de la liste des hôtels et des aggrégations. Ces requêtes représentent 14% du temps total de la requête. Dans le détail, la requête de recherche d'hôtels prend 129ms et celle des agrégations 21ms (sur un total de 748ms). On peut donc chercher à optimiser l'utilisation d'Elasticsearch sur ces requêtes, et particulièrement la recherche d'hôtels, c'est ce que nous verrons ensuite.
- Enfin la méthode `HotelElasticSearchService:hydrateResultSet` appelée une seule fois, consomme 5,8% du temps de la requête. Beaucoup trop quand on sait qu'un deuxième transformer sera appelé ensuite. On retrouve le problème du DTO en double.
#### Profiling de la recherche des marqueurs
[Profile blackfire](https://blackfire.io/profiles/f0ba784c-b367-4676-8357-d192d3929e7d/graph)

Presque 6 fois plus long que la recherche d'hôtels (environ 4s contre 700ms). Même dynamique sur la production : 197ms pour la recherche d'hôtels et 1,5s pour la recherche de marqueurs.
2 points durs : la requête Elasticsearch en elle-même (34% du temps), que nous analyserons ensuite, et la sérialisation, en particulier la méthode `HotelMarkerNormalizer:normalize` (52% du temps). Ces 52% sont équitablement réparties dans deux méthodes : d'un côté `HotelElasticSearchService:processPricingInformation` et de l'autre `OjectNormalizer:normalize`. Concernant le normalizer, le profiling nous permet juste de voir qu'on appelle cette méthode de manière récursive (normal dans le cas d'entités imbriquées), mais surtout que l'appel le plus profond est fait plus de 24 000 fois, ce qui donne plus de 50 appels pour chacun des 434 hôtels remontés. Pas normal étant données le peu de données renvoyées pour chaque hôtel. Il s'agit donc de vérifier comment est sérialisé le DTO `HotelMarker`.
Concernant la méthode `HotelElasticSearchService:processPricingInformation`, le profiling permet de remonter jusqu'à `MoneyConverter:convert` d'un côté et `FixedExchangeProvider:provideFixedRates` de l'autre.

### Pistes
#### Nettoyage des listeners
- Corriger les doubles appels aux listener Legacy Dayuse
- Désactiver le WriteListener d'APIP sur les endpoints de recherche (`write: false` dans la configuration des ressources)
#### Améliorations sur le DataProvider
- Utiliser le `$context`dans les DataProvider pour récupérer les filtres (et l'interface `ContextAwareCollectionDataProviderInterface`)
- Utiliser un `input` pour peupler le DTO SearchHotelQueryDTO (https://api-platform.com/docs/core/dto/)
- Laisser le ValidateListener d'Api Platform valider les critères de recherche **ou** désactiver le ValidateListener (`validate : false` dans la configuration des ressources)
#### Doublon sur le DTO
- Supprimer l'utilisation du DTO `HotelSearch` qui fait doublon avec `HotelSearchOutput`.
#### Sentry
L'integration avec Sentry ne semble pas être idéale (trop de calls faits). On dirait que Sentry intercepte des appels. Il serait plus judicieux de retirer l'intégration de Sentry, mais de garder uniquement le handler monolog qui pousse les logs dans Sentry.

#### Sérialisation d'HotelMarker
- Sérialisation trop gourmande pour très peu de champs, vérifier et alléger la sérialisation.
## Elasticsearch
### Démarche suivie
- Analyse du mapping et de la construction des requêtes
- Profiling d’une recherche d’hôtels via Kibana
- Profiling d'une recherche de marqueurs
- Profiling d'une recherche d'offres (staycation)
### Analyse
#### Index et mapping
- Index trop volumineux
- Champ nested pour offerPrices
- Uniquement les champs recherchés sont indexés => :thumbsup:
- Pas de champs dynamiques, y compris pour les traductions => :thumbsup:
- Champs traduits indexés en `keyword` => recherche impossible
#### Requêtes
Construction de la requête principale pour récupérer la liste des hôtels :
- Ajout de tous les filtres (principalement `term ` et `terms`)
- Ajout du tri decay le cas échéant
- Ajout du filtre nested pour les offres
- Si l'utilisateur fournit sa position, ajout d'un script pour calculer sa distance à l'hôtel
- Si on n'utilise pas le tri par défaut, ajout du tri demandé
- Pour une recherche staycation, ajout d'une agrégation
#### Profiling

Profiling avec Kibana de :
- une recherche d'hôtels
- la requête d'aggrégations correspondante
- la recherche de marquers correspondante
Premier poste de dépense pour la recherche d'hôtels => JoinQuery pour récupérer les documents nested
Idem pour la recherche de marqueurs
Pour les aggrégations, à nouveau les filtres sur les documents nested sont ce qui coûte le plus cher.
En seconde place on retrouve les aggrégations numériques (étoiles, équipements, addons) => facile à remplacer par des keywords.

### Pistes
#### Découpage des index
- Introduction d'un index pour les offres (qui embarque les infos de l'hôtel) pour faire des requêtes simplifiées.
- Index différents pour les offres par période de temps donné (mois / jour, dépend de la volumétrie)
#### Mapping
- Avoir des documents plus simples, sans nested => en faveur du découpage des index
- Analyser tous les champs traduits si on veut pouvoir faire de la recherche dessus un jour (actuellement en `keyword`)
#### Requêtes
- Utiliser des `terms ou term query` au lieu des `range query`, plus gourmandes, quand c'est possible.
- Par exemple, indexer `startHour`et `endHour` en tant que `keyword` et, pour chercher une offre qui commence à 8h, faire une query `terms` sur les valeurs `[6, 7, 8, 9]`(toutes les valeurs possibles avant 8h).
- Alternativement, en plus d'indexer les heures de début et de fin d'une offre, indexer chaque heure active de l'offre dans un champ avec un mapping `keyword`, pour pouvoir utiliser des ` term query` sur ce champ. Par exemple pour une offre de 12h à 16h indexer un champ `activeHours: [12,13,14,15,16]` et pour chercher une offre ouverte à 8h il suffira de faire `term: {activeHours: 8}`.
- Permet également d'utiliser un mapping `keyword` sur les champs qui sont actuellement en `numeric`.
- Utiliser une valeur de préférence (id de session ou username) pour optimiser le cache sur les requêtes faites par le même utilisateur (souvent les mêmes requêtes) (https://www.elastic.co/guide/en/elasticsearch/reference/6.8/search-request-preference.html)
- Identifier les critères souvent cherchés ensemble par les users (recherches types) pour identifier des presets de recherche et indexer des champs combinés (ex: wifi-9to5) (demande un travail d'analyse et des métriques fiables)
#### Pistes non explorées (intéressant seulement quand tout le reste aura été bien optimisé)
- Hardware
- Découpage du cluster
## Front
En React et TypeScript avec Next.js
Premier rendu côté serveur
Lag visible sur la page de résultats
En prod 1600ms pour avoir la liste des hôtels et quasiment 5 secondes pour avoir la map et les marqueurs
Même en prenant en compte la durée des requêtes API il y a matière à amélioration.
### Démarche
- Désactiver le SSR pour pouvoir profiler le premier rendu
- Profiling avec et sans SSR sur la page de résulltats
- Parcours du code de la recherche
### Analyse
Premier rendu côté serveur bien trop long (1600ms en prod)
Multiples re-rendus, même avec le SSR, pour le premier chargement (25 commits)
Les photos ne semblent pas lazy-loadées en prod (toutes les photos des carousels sont chargées immédiatement)
Re-rendus inutiles déclenchés par des changements dans les hooks custom => on peut quasi systématiquement remonter à react-query

Profiling du premier commit
Composant `SearchPage` à optimiser => principale dépense, traitements trop longs hors rendu des composants enfants.
Idem pour `HotelListPage`, `FiltersMedium` et `Availabilities`.
### Pistes
- Ne pas récupérer les marqueurs en SSR mais seulement côté client, la map et les marqueurs peuvent être chargés en asynchrone
- Idem pour les menus (cross linking et top cities)
- (Re)mettre le lazy load sur les photos des hôtels
- Ajouter des key sur les composants rendus dans des tableaux, notamment le composant `Card`
- Désactiver les re-fecth de react-query
## Requêtes profilées avec Blackfire
### Recherche d'hôtels
```
https://api-distribution.dayuse.test/website/search/hotels?page=1&stars[]=1&stars[]=2&stars[]=3&stars[]=4&stars[\]=5&checkinDate=2021-10-14&placeSlug=france%2File-de-france%2Fparis
```
### Recherche de marqueurs
```
https://api-distribution.dayuse.test/website/search/hotels/markers?page=1&stars[]=1&stars[]=2&stars[]=3&stars[]=4&stars[]=5&checkinDate=2021-10-14&userCurrentTime=11:17&placeSlug=france%2File-de-france%2Fparis&selectedAddress=Paris
```
## Notes
Ligne inutile ? `HotelCollectionDataProvider:97`
```php
$this->searchService->priceRangeFilter($userCurrency);
```
## Littérature
### Elasticsearch
- https://jolicode.com/blog/elasticsearch-the-right-way-in-symfony
- https://www.gojek.io/blog/elasticsearch-the-trouble-with-nested-documents
- https://www.elastic.co/guide/en/kibana/current/xpack-profiler.html
- https://www.elastic.co/guide/en/elasticsearch/reference/current/tune-for-search-speed.html
- https://www.elastic.co/guide/en/elasticsearch/reference/6.8/search-request-preference.html
### APIP
- https://api-platform.com/docs/core/dto/