Skip to content

Commit

Permalink
Remplace l'API WFS IGN par la BD TOPO (#677)
Browse files Browse the repository at this point in the history
* Géocode les voies nommées entières avec la BD TOPO

* Ajoute un ADR

* Add docs

* Add bdtopo_update script

* Update

* Tweak ogr2ogr options

* Allow database URL, add prompt

* Fix query param support

* Use gdal through Docker

* Add custom indexes

* Update docs

* Update tests

* Update data.md

* Handle accents and setup indexes

* Fix BdTopoRoadGeocoder autowiring

* Fix: transform to EPSG:4326 bc BD TOPO uses EPSG:2154

* Update ADR

* Import BD TOPO data using main DB user

* Remove --env-var, document upload speed

* Simplify: TRUNCATE before import. Add ANALYZE step. Rework custom index definitions

* TODO: need vacuum too

* Fix absolute path issue

* Update: use single dialog-bdtopo deployment, use migrations for indexes etc

* Add BDTOPO_DATABASE_URL in other CI

* Address feedback

* Undo comments

* Fix BDTOPO_DATABASE_URL binding

* Simplify bdtopo_migrate.yaml

* Update src/Infrastructure/Persistence/Doctrine/BdTopoMigrations/Version20240320122522.php

* Reproject as EPSG:4326 at import time

* Exclude BdTopoMigrations from coverage

* Run bdtopo_migrate CI job only when BdTopoMigrations changes
  • Loading branch information
florimondmanca authored Mar 21, 2024
1 parent 4e2f8ef commit cece772
Show file tree
Hide file tree
Showing 28 changed files with 1,051 additions and 369 deletions.
4 changes: 3 additions & 1 deletion .env
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,16 @@ APP_SERVER_TIMEZONE=UTC
APP_CLIENT_TIMEZONE=Europe/Paris
APP_SECRET=abc
APP_EUDONET_PARIS_BASE_URL=https://eudonet-partage.apps.paris.fr
APP_IGN_WFS_URL=https://data.geopf.fr/wfs/ows
APP_BAC_IDF_DECREES_FILE=data/bac_idf/decrees.json
APP_BAC_IDF_CITIES_FILE=data/bac_idf/cities.csv
DATABASE_URL="postgresql://dialog:dialog@database:5432/dialog"
REDIS_URL="redis://redis:6379"
API_ADRESSE_BASE_URL=https://api-adresse.data.gouv.fr
MATOMO_ENABLED=false
ADMIN_EMAIL=[email protected]
###> BD TOPO ###
# BDTOPO_DATABASE_URL=postgres://dialog_app:...
###< BD TOPO ###
###> symfony/messenger ###
# Choose one of the transports below
# MESSENGER_TRANSPORT_DSN=doctrine://default
Expand Down
40 changes: 40 additions & 0 deletions .github/workflows/bdtopo_migrate.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
name: BD TOPO Migrate

on:
workflow_dispatch:
push:
branches:
- main
paths:
- 'src/Infrastructure/Persistence/Doctrine/BdTopoMigrations/**'

jobs:
build:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v1

- name: Setup PHP with PECL extension
uses: shivammathur/setup-php@v2
with:
php-version: '8.2'

- name: Get Composer Cache Directory
id: composer-cache
run: |
echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- uses: actions/cache@v3
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
restore-keys: |
${{ runner.os }}-composer-
- name: Init environment variables
run: |
echo "BDTOPO_DATABASE_URL=${{ secrets.BDTOPO_MIGRATIONS_DATABASE_URL }}" >> .env
- name: CI
run: make ci_bdtopo_migrate BIN_CONSOLE="php bin/console"
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ jobs:
# .env directly.
run: |
echo "DATABASE_URL=postgresql://dialog:dialog@localhost:5432/dialog" >> .env
echo "BDTOPO_DATABASE_URL=${{ secrets.BDTOPO_DATABASE_URL }}" >> .env
echo "REDIS_URL=redis://localhost:6379" >> .env
- name: Install Symfony CLI
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/eudonet_paris_import.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ jobs:
- name: Init environment variables
run: |
echo "DATABASE_URL=${{ secrets.EUDONET_PARIS_IMPORT_DATABASE_URL }}" >> .env.local
echo "BDTOPO_DATABASE_URL=${{ secrets.BDTOPO_DATABASE_URL }}" >> .env.local
# Deal with JSON quotes
printf "APP_EUDONET_PARIS_CREDENTIALS='%s'\n" '${{ secrets.EUDONET_PARIS_IMPORT_CREDENTIALS }}' >> .env.local
echo "APP_EUDONET_PARIS_ORG_ID=${{ vars.EUDONET_PARIS_IMPORT_ORG_ID }}" >> .env.local
Expand Down
10 changes: 10 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,12 @@ dbmigration: ## Generate new db migration
dbmigrate: ## Run db migration
${BIN_CONSOLE} doctrine:migrations:migrate -n --all-or-nothing ${ARGS}

bdtopo_migration: ## Generate new db migration for bdtopo
${BIN_CONSOLE} doctrine:migrations:generate --configuration ./config/packages/bdtopo/doctrine_migrations.yaml

bdtopo_migrate: ## Run db migrations for bdtopo
${BIN_CONSOLE} doctrine:migrations:migrate -n --all-or-nothing --configuration ./config/packages/bdtopo/doctrine_migrations.yaml ${ARGS}

dbshell: ## Connect to the database
docker-compose exec database psql postgresql://dialog:dialog@database:5432/dialog

Expand Down Expand Up @@ -273,6 +279,10 @@ ci_eudonet_paris_import: ## Run CI steps for Eudonet Paris Import workflow
./tools/scalingodbtunnel ${EUDONET_PARIS_IMPORT_APP} --host-url --port 10000 & ./tools/wait-for-it.sh 127.0.0.1:10000
make console CMD="app:eudonet_paris:import"

ci_bdtopo_migrate: ## Run CI steps for BD TOPO Migrate workflow
make composer CMD="install -n --prefer-dist"
make bdtopo-migrate

##
## ----------------
## Prod
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ Digitaliser, diagnostiquer, diffuser l’information réglementaire de logistiqu

ℹ️ Vous devez avoir **[Docker](https://www.docker.com/)** et **[Docker Compose](https://docs.docker.com/compose/)** d'installés sur votre machine.

Tout d'abord, demandez à un membre de l'équipe la valeur de la variable d'environnement `BDTOPO_DATABASE_URL`, et ajoutez-la à `.env.local` (créez le fichier si besoin) :

```bash
# .env.local
BDTOPO_DATABASE_URL=postgres://dialog_app:...
```

Pour démarrer l'application (http://localhost:8000) :

```bash
Expand Down
3 changes: 3 additions & 0 deletions config/packages/bdtopo/doctrine_migrations.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
migrations_paths:
App\Infrastructure\Persistence\Doctrine\BdTopoMigrations: 'src/Infrastructure/Persistence/Doctrine/BdTopoMigrations'
em: bdtopo
41 changes: 26 additions & 15 deletions config/packages/doctrine.yaml
Original file line number Diff line number Diff line change
@@ -1,22 +1,33 @@
doctrine:
dbal:
url: '%env(resolve:DATABASE_URL)%'
platform_service: App\Infrastructure\Persistence\Doctrine\DBAL\CustomPostgreSQLPlatformService
schema_manager_factory: 'doctrine.dbal.default_schema_manager_factory'
default_connection: default
connections:
default:
url: '%env(resolve:DATABASE_URL)%'
platform_service: App\Infrastructure\Persistence\Doctrine\DBAL\CustomPostgreSQLPlatformService
schema_manager_factory: 'doctrine.dbal.default_schema_manager_factory'
bdtopo:
url: '%env(BDTOPO_DATABASE_URL)%'
orm:
auto_generate_proxy_classes: true
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
auto_mapping: true
dql:
string_functions:
FIRST: App\Infrastructure\Persistence\Doctrine\DBAL\FirstFunction
mappings:
Domain:
is_bundle: false
type: xml
dir: '%kernel.project_dir%/src/Infrastructure/Persistence/Doctrine/Mapping'
prefix: 'App\Domain'
alias: 'App\Domain'
default_entity_manager: default
entity_managers:
default:
connection: default
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
auto_mapping: true
dql:
string_functions:
FIRST: App\Infrastructure\Persistence\Doctrine\DBAL\FirstFunction
mappings:
Domain:
is_bundle: false
type: xml
dir: '%kernel.project_dir%/src/Infrastructure/Persistence/Doctrine/Mapping'
prefix: 'App\Domain'
alias: 'App\Domain'
bdtopo:
connection: bdtopo

when@test:
doctrine:
Expand Down
2 changes: 0 additions & 2 deletions config/packages/framework.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,6 @@ framework:
base_uri: '%env(API_ADRESSE_BASE_URL)%'
eudonet_paris.http.client:
base_uri: '%env(APP_EUDONET_PARIS_BASE_URL)%'
ign.wfs.client:
base_uri: '%env(APP_IGN_WFS_URL)%'

when@test:
framework:
Expand Down
6 changes: 2 additions & 4 deletions config/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ services:
$adminEmail: '%admin_email%'
$eudonetParisOrgId: '%env(APP_EUDONET_PARIS_ORG_ID)%'
$dialogOrgId: '%env(DIALOG_ORG_ID)%'
$ignWfsUrl: '%env(APP_IGN_WFS_URL)%'
$bacIdfDecreesFile: '%env(APP_BAC_IDF_DECREES_FILE)%'
$bacIdfCitiesFile: '%env(APP_BAC_IDF_CITIES_FILE)%'
$featureMap: '%features%'
Expand Down Expand Up @@ -106,9 +105,8 @@ when@test:
# See: https://symfony.com/doc/current/service_container/service_decoration.html
decorates: 'api.adresse.client'
decoration_inner_name: 'App\Tests\Mock\APIAdresseMockClient::api.adresse.client'
App\Tests\Mock\IgnWfsMockClient:
decorates: 'ign.wfs.client'
decoration_inner_name: 'App\Tests\Mock\IgnWfsMockClient::ign.wfs.client'
App\Tests\Mock\BdTopoRoadGeocoderMock:
decorates: 'App\Infrastructure\Adapter\BdTopoRoadGeocoder'
App\Tests\Mock\EudonetParis\EudonetParisMockHttpClient:
decorates: 'eudonet_paris.http.client'
decoration_inner_name: 'App\Tests\Mock\EudonetParis\EudonetParisMockHttpClient::eudonet_paris.http.client'
Expand Down
10 changes: 10 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,13 @@ services:
ports:
- 6379:6379

gdal:
# https://github.com/OSGeo/gdal/tree/master/docker
image: ghcr.io/osgeo/gdal:alpine-small-latest
container_name: gdal
profiles: [gdal]
init: true # Forward signals such as Ctrl+C, see: https://docs.docker.com/compose/compose-file/05-services/#init
extra_hosts:
# Allow accessing host ports from this container via host.docker.internal on Linux
# https://stackoverflow.com/a/43541732
- host.docker.internal:host-gateway
153 changes: 153 additions & 0 deletions docs/adr/008_bdtopo.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
# 008 - Utilisation de la BD TOPO

* Création : 2024-03-12
* Personnes impliquées : Florimond Manca (auteur principal), Mathieu Marchois, équipe DiaLog
* Statut : Accepté

## Contexte

DiaLog utilise diverses sources de données pour le géocodage.

Actuellement, il s'agit de :

* [L'API Adresse](https://adresse.data.gouv.fr/api-doc), pour le géocodage des adresses (exemple : "3 Rue Donnée, Ville Untelle") ;
* [L'API WFS de l'IGN](https://geoservices.ign.fr/documentation/services/api-et-services-ogc/donnees-vecteur-wfs-ogc), pour l'obtention du linéaire des voies nommées entières (exemple : "Rue Donnée" dans la ville dont le code Insee est 01234).

Il a été jugé que la qualité de service de l'API de l'IGN est insufissante pour les besoins de DiaLog, notamment au point de vue temps de réponse et fiabilité. Bien que des mesures précises n'aient pas été effectuée, il a été constaté des requêtes prenant presque toujours au-delà de 500 ms et régulièrement plusieurs secondes. Cela occasionne des lenteurs notables dans l'application et dégrade l'expérience utilisateur. L'API de l'IGN a aussi pu être partiellement voire totalement indisponible de façon intermittente (temps de réponse démesurés, erreurs serveur occasionnelles), peut-être en lien avec la migration vers la Géoplateforme.

Même dans le cas où les comportements extrêmes possiblement liés à la migration Géoplateforme s'amélioraient, les temps de réponse élevés et les lenteurs occasionnées rendaient pertinents l'étude d'une solution alternative.

## Décision

Les tables de la BD TOPO nécessaires à DiaLog seront intégrées dans une base de données déployée dans une nouvelle application Scalingo, selon l'approche détaillée dans l'option 2.

Conséquences :

* Une application `dialog-bdtopo` sera créée sur Scalingo avec un add-on PostgreSQL sous le plan Starter 1G (1 GB RAM, 20 GB Disque).
* Cette base sera utilisée par tous les environnements (production, staging, branches, local...).
* Un utilisateur `dialog_app` avec accès "read-only" y sera créé. Il sera utilisé pour la connexion PostgreSQL entre DiaLog et notre hébergement de la BD TOPO. L'utilisateur principal créé par Scalingo sera conservé et servira à l'administration (mise à jour des données, etc).
* La base sera [ouverte à Internet](https://doc.scalingo.com/platform/databases/access#internet-accessibility) pour permettre l'accès en développement sans avoir besoin d'outillage Scalingo.
* Un script sera réalisé pour l'ingestion des tables souhaitées de la BD TOPO (création initiale ou mise à jour). Ce script permettra de configurer les indexes pertinents.
* De la documentation sera créée pour le fonctionnement de l'intégration BD TOPO et la mise à jour des données.

## Options envisagées

### Option 1 - Ne rien faire

Avantages

* Pas de travail supplémentaire

Inconvénients

* Les lenteurs et perturbations persistent, impactant à la fois l'expérience utilisateur et la productivité lors du développement.
* Divers cas d'erreurs à gérer : ruptures réseau, timeouts, erreurs HTTP inattendues, changement de format...

### Option 2 - Hébergement de certaines tables de la BD TOPO

Cette option consisterait à héberger nous-mêmes une instance PostgreSQL contenant les tables de la [BD TOPO](https://geoservices.ign.fr/bdtopo#telechargementtransportter) utilisées par DiaLog.

Avantages

* Maîtrise complète des données
* Permet d'atteindre des temps de réponse inférieurs à 100 ms et de façon beaucoup plus fiable
* Permet l'optimisation des requêtes faites spécifiquement par DiaLog, notamment par la création d'indexes (impossible avec l'API)
* Moins de cas d'erreurs possibles

Inconvénients

* Coût opérationnel pour la gestion des tables de la BD TOPO : hébergement, mise à jour (~ annuelle), utilisation en développement, documentation...
* Coût financier d'hébergement : 14,40€ / mois pour le plan Start 1G.

### Approche détaillée

#### Mise en place

Il s'agirait de **télécharger le thème "Transports" de la BD TOPO** (environ 4.5 Go) et d'**ingérer dans la base les tables qui nous intéressent** telles que `voie_nommee` (1.8 Go), `route_numerotee_ou_nommee` (400 Mo) ou encore `troncon_de_route`.

Les tables sont fournies au format GeoPackage et peuvent être ingérées avec l'outil [**`ogr2ogr`**](https://gdal.org/programs/ogr2ogr.html) fourni par la librairie de référence [GDAL](https://gdal.org/index.html), laquelle [supporte PostgreSQL / PostGIS](https://gdal.org/drivers/vector/pg.html#driver-capabilities) :

```bash
ogr2ogr -f PostgreSQL "PG:postgresql://dialog_bdtopo:password@localhost:5432/dialog" /path/to/voie_nommee.gpkg
```

Pour assurer une **portabilité** maximum parmi l'équipe de développement (Linux, Windows...), on pourra appeler ogr2ogr via l'[image Docker de GDAL](https://github.com/OSGeo/gdal/pkgs/container/gdal).

Suite à une ingestion, des **indexes** judicieux seront créés pour accélérer l'exécution des requêtes.

#### Hébergement

Les tables de la BD TOPO seront hébergées sur une instance PostgreSQL dédiée.

Cette séparation entre données applicatives et BD TOPO qui s'accompagne d'identifiants distincts facilite la maintenance différenciée et participe des bonnes pratiques de sécurité (par ex, principe de moindre privilège).

#### Performance attendue

Des premiers tests via la [PR #677](https://github.com/MTES-MCT/dialog/pull/677) suggèrent les résultats suivants :

| Métrique | Avant | Après | Évolution |
|---|---|---|---|
| Temps de réponse, latence comprise (min, typique, max) | ~500ms, ~ 1-2s, > 10s (estimations) | Avec indexes : ~20ms, ~ 100ms, < 200 ms (estimations) ; Sans indexes : ~300ms, ~1s, < 2s (estimations) | > 20x plus rapide, moindre variabilité |
| Disponibilité (timeouts compris) | < 90% (estimation) | > 98% (garanti par le [SLA Scalingo](https://scalingo.com/service-level-agreement)) | Meilleure disponibilité |

D'une part le requêtage direct à PostgreSQL permet de bénéficier de l'excellente performance de ce SGBD, notamment combiné à des indexes conçus judicieusement (ce que l'API IGN ne permet pas de faire).

D'autre part, nous bénéficierons par extension de la qualité de service de l'hébergeur Scalingo.

#### Transformation des requêtes API WFS en requêtes aux tables BD TOPO

Les requêtes actuellement réalisées à l'API WFS peuvent être facilement traduites en SQL.

Par exemple, cette requête [GetFeature](https://docs.geoserver.org/stable/en/user/services/wfs/reference.html#wfs-getfeature) qui interroge la table `voie_nommee` ...

```http
GET https://data.geopf.fr/wfs/ows?SERVICE=WFS&REQUEST=GetFeature&Version=2.0.0&OUTPUTFORMAT=application/json&TYPENAME=BDTOPO_V3:voie_nommee&cql_filter=code_insee='01234' HTTP/1.1
```

... correspondra à une requête SQL de ce type :

```sql
SELECT * FROM voie_nommee WHERE code_insee = '01234';
```

Ces requêtes pourront être réalisées avec l'infrastructure existante, à savoir Symfony avec l'ORM Doctrine. Ce dernier permet notamment de faire des requêtes SQL directement et de configurer une connexion distincte pour la BD TOPO.

#### Sécurisation des accès

L'hébergement de la BD TOPO dans une application Scalingo séparée permet une meilleure sécurisation que si la BD TOPO était hébergée directement au sein de la base DiaLog de production (par exemple), notamment du fait de limitations de Scalingo (l'option "read only" donnant accès à la base de données entières et non pas à seulement certaines tables).

Bien que les données BD TOPO soient d'ordre public, les identifiants BD TOPO devront être considérés comme sensibles pour réduire par exemple les risques d'attaques DDoS (surcharge en lecture de la BD TOPO par un acteur malveillant en ayant acquis les identifiants).

#### Mise à jour

Une mise à jour semi-manuelle (déclenchement manuel, exécution automatique) est envisageable puisque la BD TOPO est mise à jour peu fréquemment (une publication par an environ).

La mise à jour des données BD TOPO pourrait se faire par l'équipe de développement comme suit :

* Télécharger en local la nouvelle version du thème Transports ;
* Exécuter un script utilitaire qui mettra à jour les tables au fur et à mesure avec `ogr2ogr`.

Cette approche a des avantages et inconvénients par rapport au chargement complet des tables avant de remplacer les données existantes :

* Avantages :
* Elle est plus simple, car le renommage d'une table n'est pas trivial (il faut penser à renommer ses indexes, séquences, et autres objets PostgreSQL associés).
* Elle permet une économise de stockage significative, car le serveur n'a pas besoin d'être capable de stocker temporairement les tables en double (et donc d'être surdimensionné en temps normal).
* Inconvénients :
* Cette approche n'est pas atomique. Si l'import d'un GeoPackage échoue, alors la table concernée n'aura que des données partielles. Cela peut produire des échecs de géocodage en production.

Les opérations de type VACUUM et/ou ANALYZE pertinentes seront effectuées après la mise à jour pour préparer le nouveau contenu à être requêté (mise à jour des statistiques utilisées par le planificateur de requête PostgreSQL).

**Vitesse de transfert**

La mise à jour prendra typiquement plusieurs minutes, en raison de l'upload du contenu des tables la BD TOPO vers Scalingo.

### Réversibilité

Si la qualité du service de l'API WFS de l'IGN s'améliore au point que le surcoût opérationnel (modeste mais non-nul) de gestion de notre hébergement BD TOPO n'est plus justifié, il sera toujours possible de récupérer le code de l'ancien géocodeur basé sur l'API WFS.

Les données étant les mêmes puisque toutes deux issues de la BD TOPO, on ne devrait pas observer d'incohérences.

## Références

* [BD TOPO](https://geoservices.ign.fr/bdtopo) (documentation, téléchargements)
* [GDAL](https://gdal.org/index.html), [Driver PostgreSQL / PostGIS pour GDAL](https://gdal.org/drivers/vector/pg.html), [ogr2ogr](https://gdal.org/programs/ogr2ogr.html)
1 change: 1 addition & 0 deletions docs/deployment/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ Chaque application peut être configurée avec les variables d'environnement sui
| Variable d'environnement | Description | Valeur par défaut | Notes |
|--------------------------|-------------|--------|-------|
| `API_ADRESSE_BASE_URL` | URL de l'instance API Adresse / Addok à utiliser | _(Obligatoire)_ | Exemple : `https://api-adresse.data.gouv.fr` |
| `BDTOPO_DATABASE_URL` | URL de connexion PostgreSQL à notre [hébergement BD TOPO](../tools/bdtopo.md) | _(Obligatoire)_ | En développement, à récupérer auprès d'un membre de l'équipe |
| `APP_EUDONET_PARIS_BASE_URL` | URL de l'API Eudonet Paris | https://eudonet-partage.apps.paris.fr | |
| `APP_EUDONET_PARIS_ORG_ID` | Utiliser l'UUID de l'organisation Ville de Paris | _Vide_ | |
| `APP_SECRET` | Correspond au paramètre Symfony [`secret`](https://symfony.com/doc/current/reference/configuration/framework.html#secret) | _(Obligatoire)_ | Longueur recommandée : 32 caractères. Exemple : générer avec `python3 -c 'import secrets; print(secrets.token_hex(16))'` |
Expand Down
Loading

0 comments on commit cece772

Please sign in to comment.