From 74c10a60f5be7828194c4f97d0b5b7b0a57368d6 Mon Sep 17 00:00:00 2001 From: Serreau Jovann Date: Thu, 26 Mar 2026 10:33:51 +0100 Subject: [PATCH] Add PgBouncer to dev and PgBouncer stats to admin Infra page - Add pgbouncer service to docker-compose-dev.yml with dev config - Route DATABASE_URL through pgbouncer:6432 in dev (matches prod) - Add PgBouncer pools and stats tables to /admin/infra with color-coded avg query/xact times and client waiting indicators - php, messenger, cron now depend on pgbouncer instead of database directly Co-Authored-By: Claude Opus 4.6 (1M context) --- .env | 2 +- .env.test | 2 +- docker-compose-dev.yml | 23 +++++++- docker/pgsql/pgbouncer-dev.ini | 19 ++++++ docker/pgsql/userlist-dev.txt | 1 + src/Controller/AdminController.php | 68 +++++++++++++++++++++ templates/admin/infra.html.twig | 94 ++++++++++++++++++++++++++++++ 7 files changed, 204 insertions(+), 5 deletions(-) create mode 100644 docker/pgsql/pgbouncer-dev.ini create mode 100644 docker/pgsql/userlist-dev.txt diff --git a/.env b/.env index 47f11fa..d1ce30c 100644 --- a/.env +++ b/.env @@ -25,7 +25,7 @@ DEFAULT_URI=https://esyweb.local ###< symfony/routing ### ###> doctrine/doctrine-bundle ### -DATABASE_URL="postgresql://app:secret@database:5432/ecosplay?serverVersion=16&charset=utf8" +DATABASE_URL="postgresql://app:secret@pgbouncer:6432/e_ticket?serverVersion=16&charset=utf8" ###< doctrine/doctrine-bundle ### ###> symfony/messenger ### diff --git a/.env.test b/.env.test index 83c5f1d..5bb2f14 100644 --- a/.env.test +++ b/.env.test @@ -1,7 +1,7 @@ # define your env variables for the test env here KERNEL_CLASS='App\Kernel' APP_SECRET='$ecretf0rt3st' -DATABASE_URL="postgresql://app:secret@database:5432/e_ticket?serverVersion=16&charset=utf8" +DATABASE_URL="postgresql://app:secret@pgbouncer:6432/e_ticket?serverVersion=16&charset=utf8" MEILISEARCH_URL=http://meilisearch:7700 MEILISEARCH_API_KEY=e_ticket SONARQUBE_URL=https://sn.esy-web.dev diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml index 2c02486..8359f0a 100644 --- a/docker-compose-dev.yml +++ b/docker-compose-dev.yml @@ -10,7 +10,7 @@ services: ports: - "9000:9000" depends_on: - database: + pgbouncer: condition: service_healthy redis: condition: service_healthy @@ -32,6 +32,23 @@ services: timeout: 5s retries: 5 + pgbouncer: + image: edoburu/pgbouncer + container_name: e_ticket_pgbouncer + volumes: + - ./docker/pgsql/pgbouncer-dev.ini:/etc/pgbouncer/pgbouncer.ini:ro + - ./docker/pgsql/userlist-dev.txt:/etc/pgbouncer/userlist.txt:ro + ports: + - "6432:6432" + depends_on: + database: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -p 6432 -U app"] + interval: 5s + timeout: 5s + retries: 5 + redis: image: redis:7-alpine container_name: e_ticket_redis @@ -67,7 +84,7 @@ services: volumes: - .:/app depends_on: - database: + pgbouncer: condition: service_healthy redis: condition: service_healthy @@ -162,7 +179,7 @@ services: volumes: - .:/app depends_on: - database: + pgbouncer: condition: service_healthy redis: condition: service_healthy diff --git a/docker/pgsql/pgbouncer-dev.ini b/docker/pgsql/pgbouncer-dev.ini new file mode 100644 index 0000000..aabecb1 --- /dev/null +++ b/docker/pgsql/pgbouncer-dev.ini @@ -0,0 +1,19 @@ +[databases] +e_ticket = host=database port=5432 dbname=e_ticket + +[pgbouncer] +listen_addr = 0.0.0.0 +listen_port = 6432 +auth_type = md5 +auth_file = /etc/pgbouncer/userlist.txt +pool_mode = transaction +max_client_conn = 200 +default_pool_size = 20 +min_pool_size = 5 +reserve_pool_size = 5 +reserve_pool_timeout = 3 +server_lifetime = 3600 +server_idle_timeout = 600 +log_connections = 0 +log_disconnections = 0 +ignore_startup_parameters = extra_float_digits diff --git a/docker/pgsql/userlist-dev.txt b/docker/pgsql/userlist-dev.txt new file mode 100644 index 0000000..8a81737 --- /dev/null +++ b/docker/pgsql/userlist-dev.txt @@ -0,0 +1 @@ +"app" "md56a422f785c9e20873908ce25d1736ae2" diff --git a/src/Controller/AdminController.php b/src/Controller/AdminController.php index 81aab4f..6fcb491 100644 --- a/src/Controller/AdminController.php +++ b/src/Controller/AdminController.php @@ -654,17 +654,20 @@ class AdminController extends AbstractController #[Autowire(env: 'MESSENGER_TRANSPORT_DSN')] string $messengerDsn, #[Autowire(env: 'SESSION_HANDLER_DSN')] string $sessionDsn, #[Autowire(env: 'REDIS_CACHE_DSN')] string $cacheDsn, + #[Autowire(env: 'DATABASE_URL')] string $databaseUrl, ): Response { $redisGlobal = $this->getRedisGlobalInfo($messengerDsn); $redisMessenger = $this->getRedisDbInfo($messengerDsn, 'Messenger'); $redisSession = $this->getRedisDbInfo($sessionDsn, 'Sessions'); $redisCache = $this->getRedisDbInfo($cacheDsn, 'Cache'); $pgInfo = $this->getPostgresInfo($em); + $pgbouncerInfo = $this->getPgBouncerInfo($databaseUrl); return $this->render('admin/infra.html.twig', [ 'redis_global' => $redisGlobal, 'redis_dbs' => [$redisMessenger, $redisSession, $redisCache], 'postgres' => $pgInfo, + 'pgbouncer' => $pgbouncerInfo, ]); } @@ -826,6 +829,71 @@ class AdminController extends AbstractController } } + /** + * @return array + */ + private function getPgBouncerInfo(string $databaseUrl): array + { + try { + $parsed = parse_url($databaseUrl); + $host = $parsed['host'] ?? 'pgbouncer'; + $port = $parsed['port'] ?? 6432; + $user = $parsed['user'] ?? 'app'; + $pass = $parsed['pass'] ?? ''; + + $pdo = new \PDO( + "pgsql:host={$host};port={$port};dbname=pgbouncer", + $user, + $pass, + [\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION], + ); + + $pools = $pdo->query('SHOW POOLS')->fetchAll(\PDO::FETCH_ASSOC); + $stats = $pdo->query('SHOW STATS')->fetchAll(\PDO::FETCH_ASSOC); + + $poolData = []; + foreach ($pools as $pool) { + if ('pgbouncer' === ($pool['database'] ?? '')) { + continue; + } + $poolData[] = [ + 'database' => $pool['database'] ?? '?', + 'user' => $pool['user'] ?? '?', + 'cl_active' => $pool['cl_active'] ?? 0, + 'cl_waiting' => $pool['cl_waiting'] ?? 0, + 'sv_active' => $pool['sv_active'] ?? 0, + 'sv_idle' => $pool['sv_idle'] ?? 0, + 'sv_used' => $pool['sv_used'] ?? 0, + 'pool_mode' => $pool['pool_mode'] ?? '?', + ]; + } + + $statsData = []; + foreach ($stats as $stat) { + if ('pgbouncer' === ($stat['database'] ?? '')) { + continue; + } + $statsData[] = [ + 'database' => $stat['database'] ?? '?', + 'total_xact_count' => $stat['total_xact_count'] ?? 0, + 'total_query_count' => $stat['total_query_count'] ?? 0, + 'total_received' => $stat['total_received'] ?? 0, + 'total_sent' => $stat['total_sent'] ?? 0, + 'avg_xact_time' => $stat['avg_xact_time'] ?? 0, + 'avg_query_time' => $stat['avg_query_time'] ?? 0, + ]; + } + + return [ + 'connected' => true, + 'pools' => $poolData, + 'stats' => $statsData, + ]; + } catch (\Throwable $e) { + return ['connected' => false, 'error' => $e->getMessage()]; + } + } + #[Route('/organisateurs/inviter', name: 'app_admin_invite_organizer', methods: ['GET', 'POST'])] public function inviteOrganizer(Request $request, EntityManagerInterface $em, MailerService $mailerService): Response { diff --git a/templates/admin/infra.html.twig b/templates/admin/infra.html.twig index f63204b..ed80df9 100644 --- a/templates/admin/infra.html.twig +++ b/templates/admin/infra.html.twig @@ -109,6 +109,100 @@ {% endfor %} + + {# PgBouncer #} +

PgBouncer

+
+ {% if pgbouncer.connected %} + {# Pools #} +
+
+

Pools

+ {% if pgbouncer.pools|length > 0 %} +
+ + + + + + + + + + + + + + {% for pool in pgbouncer.pools %} + + + + + + + + + + {% endfor %} + +
DatabaseUserModeClients actifsClients en attenteServeurs actifsServeurs idle
{{ pool.database }}{{ pool.user }}{{ pool.pool_mode }}{{ pool.cl_active }}{{ pool.cl_waiting }}{{ pool.sv_active }}{{ pool.sv_idle }}
+
+ {% else %} +

Aucun pool actif.

+ {% endif %} +
+
+ + {# Stats #} +
+
+

Stats

+ {% if pgbouncer.stats|length > 0 %} +
+ + + + + + + + + + + + + + {% for stat in pgbouncer.stats %} + + + + + + + + + + {% endfor %} + +
DatabaseTransactionsRequetesAvg xact (us)Avg query (us)RecuEnvoye
{{ stat.database }}{{ stat.total_xact_count|number_format(0, '.', ' ') }}{{ stat.total_query_count|number_format(0, '.', ' ') }}{{ stat.avg_xact_time|number_format(0, '.', ' ') }}{{ stat.avg_query_time|number_format(0, '.', ' ') }}{{ (stat.total_received / 1048576)|number_format(1) }} MB{{ (stat.total_sent / 1048576)|number_format(1) }} MB
+
+ {% else %} +

Aucune stat disponible.

+ {% endif %} +
+
+ {% else %} +
+
+
+

PgBouncer

+ Deconnecte +
+

{{ pgbouncer.error }}

+
+
+ {% endif %} +
{% endblock %} {% macro row(label, value) %}