Passe critique sur les zones non vérifiées. BUG — la correction manuelle d'un lieu n'atteignait pas la moitié du site bands.lat/lon/geom est une dénormalisation du lieu d'origine, maintenue par sync_bands_primary() dans geocoder/worker.py. La saisie manuelle de coordonnées (PATCH /admin/api/locations/:id, ajoutée récemment) écrivait band_locations sans jamais la déclencher : la correction apparaissait sur la carte — qui lit band_locations — mais jamais dans la liste, les statistiques ni la heatmap, qui lisent bands. Le groupe restait affiché au mauvais endroit indéfiniment. La synchronisation est répliquée en SQL dans la même transaction, avec la même règle de tri (step_order ASC, id ASC) que le worker Python. BUG — course entre réplicas au démarrage (migrate.js) Le script s'exécute au démarrage de CHAQUE conteneur API. Deux réplicas démarrant ensemble lisaient tous deux schema_migrations vide et appliquaient les mêmes fichiers en parallèle : au mieux une violation de clé primaire qui faisait échouer le démarrage, au pire deux ALTER concurrents. Verrou consultatif pg_advisory_lock, relâché explicitement. migrate.js n'exécute plus au chargement s'il est importé (nécessaire pour le tester). CODE MORT — redis Signalé au tout début, jamais retiré : un conteneur redis + un volume persistant dans les DEUX composes, sans une seule référence dans le code. TESTS AJOUTÉS - Intégration migrations (7 tests) : application sur base vierge, rejouabilité, ordre lexicographique, relâchement du verrou, échec bruyant sur migration invalide, et surtout DEUX MIGRATIONS SIMULTANÉES sur une base vierge — le cas qui motivait le verrou. - Tests par PROPRIÉTÉS (fast-check, 21 tests) : batterie qui manquait. Les tests par l'exemple ne couvrent que les cas auxquels on a pensé. L'invariant central : toute entrée arbitraire produit soit une valeur normalisée valide, soit une ValidationError — jamais une autre exception, jamais NaN. C'est ce qui garantit un 400 plutôt qu'un 500. Vérifie aussi la cohérence offset = (page-1) × pageSize, le domaine des coordonnées, et qu'aucun caractère de contrôle ne survit à la validation. Tests : 601 JS + 61 intégration, 63 Python, 89 Playwright Co-Authored-By: Claude <noreply@anthropic.com>
99 lines
3.4 KiB
JavaScript
99 lines
3.4 KiB
JavaScript
import pg from 'pg';
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
import { fileURLToPath } from 'url';
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const MIGRATIONS_DIR = path.join(__dirname, '../migrations');
|
|
|
|
async function connectWithRetry(connStr, maxAttempts = 12) {
|
|
for (let i = 1; i <= maxAttempts; i++) {
|
|
const client = new pg.Client({ connectionString: connStr });
|
|
try {
|
|
await client.connect();
|
|
return client;
|
|
} catch (err) {
|
|
await client.end().catch(() => {});
|
|
if (i === maxAttempts) throw err;
|
|
const delay = Math.min(2000 * i, 30000);
|
|
console.log(`[migrate] DB not ready (attempt ${i}/${maxAttempts}), retry in ${delay}ms — ${err.message}`);
|
|
await new Promise(r => setTimeout(r, delay));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Identifiant arbitraire mais STABLE du verrou consultatif : deux processus qui
|
|
// migrent la même base doivent choisir le même nombre.
|
|
const MIGRATION_LOCK_ID = 4_073_219_001;
|
|
|
|
async function run() {
|
|
const client = await connectWithRetry(process.env.DATABASE_URL);
|
|
|
|
// Verrou consultatif : le conteneur API exécute ce script à CHAQUE démarrage.
|
|
// Deux réplicas qui démarrent ensemble lisaient tous deux schema_migrations
|
|
// vide et appliquaient le même fichier en parallèle — au mieux une erreur au
|
|
// second (clé primaire dupliquée) qui faisait échouer le démarrage, au pire
|
|
// deux ALTER concurrents. Le verrou sérialise ; il est relâché à la
|
|
// fermeture de la connexion, y compris si le process est tué.
|
|
console.log('[migrate] acquisition du verrou…');
|
|
await client.query('SELECT pg_advisory_lock($1)', [MIGRATION_LOCK_ID]);
|
|
|
|
await client.query(`
|
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
version TEXT PRIMARY KEY,
|
|
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
)
|
|
`);
|
|
|
|
const { rows } = await client.query(
|
|
'SELECT version FROM schema_migrations ORDER BY version'
|
|
);
|
|
const applied = new Set(rows.map(r => r.version));
|
|
|
|
const files = fs.readdirSync(MIGRATIONS_DIR)
|
|
.filter(f => f.endsWith('.sql'))
|
|
.sort();
|
|
|
|
let count = 0;
|
|
for (const file of files) {
|
|
if (applied.has(file)) continue;
|
|
|
|
console.log(`[migrate] applying ${file}`);
|
|
const sql = fs.readFileSync(path.join(MIGRATIONS_DIR, file), 'utf8');
|
|
|
|
await client.query('BEGIN');
|
|
try {
|
|
await client.query(sql);
|
|
await client.query(
|
|
'INSERT INTO schema_migrations (version) VALUES ($1)', [file]
|
|
);
|
|
await client.query('COMMIT');
|
|
console.log(`[migrate] ok ${file}`);
|
|
count++;
|
|
} catch (err) {
|
|
await client.query('ROLLBACK');
|
|
console.error(`[migrate] FAILED ${file}: ${err.message}`);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
if (count === 0) console.log('[migrate] nothing to apply');
|
|
// Relâché explicitement : la fermeture suffirait, mais l'expliciter rend le
|
|
// verrou visible dans les logs et évite de le garder si end() traîne.
|
|
await client.query('SELECT pg_advisory_unlock($1)', [MIGRATION_LOCK_ID]);
|
|
await client.end();
|
|
}
|
|
|
|
export { MIGRATION_LOCK_ID, run };
|
|
|
|
// N'exécute la migration que si le fichier est lancé directement : importé par
|
|
// un test, il ne doit pas migrer la base au chargement.
|
|
const invokedDirectly = process.argv[1]
|
|
&& path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url));
|
|
|
|
if (invokedDirectly) {
|
|
run().catch(err => {
|
|
console.error('[migrate] fatal:', err.message);
|
|
process.exit(1);
|
|
});
|
|
}
|