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:
2
.env
2
.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 ###
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
19
docker/pgsql/pgbouncer-dev.ini
Normal file
19
docker/pgsql/pgbouncer-dev.ini
Normal 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
|
||||
1
docker/pgsql/userlist-dev.txt
Normal file
1
docker/pgsql/userlist-dev.txt
Normal file
@@ -0,0 +1 @@
|
||||
"app" "md56a422f785c9e20873908ce25d1736ae2"
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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) %}
|
||||
|
||||
Reference in New Issue
Block a user