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) <noreply@anthropic.com>
This commit is contained in:
Serreau Jovann
2026-03-26 10:33:51 +01:00
parent 1a336edac5
commit 74c10a60f5
7 changed files with 204 additions and 5 deletions

2
.env
View File

@@ -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 ###

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1 @@
"app" "md56a422f785c9e20873908ce25d1736ae2"

View File

@@ -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<string, mixed>
*/
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
{

View File

@@ -109,6 +109,100 @@
</div>
{% endfor %}
</div>
{# PgBouncer #}
<h2 class="text-xl font-black uppercase tracking-tighter italic heading-page mb-4">PgBouncer</h2>
<div class="flex flex-wrap gap-6 mb-8">
{% if pgbouncer.connected %}
{# Pools #}
<div class="flex-1 min-w-[500px]">
<div class="admin-card">
<h2 class="text-sm font-black uppercase tracking-widest mb-4">Pools</h2>
{% if pgbouncer.pools|length > 0 %}
<div class="overflow-x-auto">
<table class="admin-table w-full">
<thead>
<tr>
<th class="text-[10px] font-black uppercase tracking-widest text-gray-300">Database</th>
<th class="text-[10px] font-black uppercase tracking-widest text-gray-300">User</th>
<th class="text-[10px] font-black uppercase tracking-widest text-gray-300 text-center">Mode</th>
<th class="text-[10px] font-black uppercase tracking-widest text-gray-300 text-center">Clients actifs</th>
<th class="text-[10px] font-black uppercase tracking-widest text-gray-300 text-center">Clients en attente</th>
<th class="text-[10px] font-black uppercase tracking-widest text-gray-300 text-center">Serveurs actifs</th>
<th class="text-[10px] font-black uppercase tracking-widest text-gray-300 text-center">Serveurs idle</th>
</tr>
</thead>
<tbody>
{% for pool in pgbouncer.pools %}
<tr>
<td class="font-black text-sm">{{ pool.database }}</td>
<td class="text-sm">{{ pool.user }}</td>
<td class="text-center"><span class="admin-badge-green text-xs font-black uppercase">{{ pool.pool_mode }}</span></td>
<td class="text-center font-black">{{ pool.cl_active }}</td>
<td class="text-center font-black {% if pool.cl_waiting > 0 %}text-yellow-600{% endif %}">{{ pool.cl_waiting }}</td>
<td class="text-center font-black">{{ pool.sv_active }}</td>
<td class="text-center font-black text-gray-400">{{ pool.sv_idle }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-sm text-gray-400">Aucun pool actif.</p>
{% endif %}
</div>
</div>
{# Stats #}
<div class="flex-1 min-w-[500px]">
<div class="admin-card">
<h2 class="text-sm font-black uppercase tracking-widest mb-4">Stats</h2>
{% if pgbouncer.stats|length > 0 %}
<div class="overflow-x-auto">
<table class="admin-table w-full">
<thead>
<tr>
<th class="text-[10px] font-black uppercase tracking-widest text-gray-300">Database</th>
<th class="text-[10px] font-black uppercase tracking-widest text-gray-300 text-right">Transactions</th>
<th class="text-[10px] font-black uppercase tracking-widest text-gray-300 text-right">Requetes</th>
<th class="text-[10px] font-black uppercase tracking-widest text-gray-300 text-right">Avg xact (us)</th>
<th class="text-[10px] font-black uppercase tracking-widest text-gray-300 text-right">Avg query (us)</th>
<th class="text-[10px] font-black uppercase tracking-widest text-gray-300 text-right">Recu</th>
<th class="text-[10px] font-black uppercase tracking-widest text-gray-300 text-right">Envoye</th>
</tr>
</thead>
<tbody>
{% for stat in pgbouncer.stats %}
<tr>
<td class="font-black text-sm">{{ stat.database }}</td>
<td class="text-right text-sm font-bold">{{ stat.total_xact_count|number_format(0, '.', ' ') }}</td>
<td class="text-right text-sm font-bold">{{ stat.total_query_count|number_format(0, '.', ' ') }}</td>
<td class="text-right text-sm {% if stat.avg_xact_time > 100000 %}text-red-600{% elseif stat.avg_xact_time > 10000 %}text-yellow-600{% else %}text-green-600{% endif %} font-bold">{{ stat.avg_xact_time|number_format(0, '.', ' ') }}</td>
<td class="text-right text-sm {% if stat.avg_query_time > 50000 %}text-red-600{% elseif stat.avg_query_time > 5000 %}text-yellow-600{% else %}text-green-600{% endif %} font-bold">{{ stat.avg_query_time|number_format(0, '.', ' ') }}</td>
<td class="text-right text-sm text-gray-500">{{ (stat.total_received / 1048576)|number_format(1) }} MB</td>
<td class="text-right text-sm text-gray-500">{{ (stat.total_sent / 1048576)|number_format(1) }} MB</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-sm text-gray-400">Aucune stat disponible.</p>
{% endif %}
</div>
</div>
{% else %}
<div class="flex-1 min-w-[400px]">
<div class="admin-card">
<div class="flex items-center justify-between mb-4">
<h2 class="text-sm font-black uppercase tracking-widest">PgBouncer</h2>
<span class="admin-badge-red text-xs font-black uppercase">Deconnecte</span>
</div>
<p class="text-sm text-red-500 font-bold">{{ pgbouncer.error }}</p>
</div>
</div>
{% endif %}
</div>
{% endblock %}
{% macro row(label, value) %}