Two transactions tagging overlapping models could each hold a tag row the other wanted. tagId used ON CONFLICT DO UPDATE, which rewrites the row and therefore write-locks it even though the name never changes, and each caller iterated in whatever order it happened to have. That is what killed "Mushroom Clicker" during AI run 21, while the collection watcher was importing and rule-tagging the same tags. tagId no longer rewrites unchanged rows, and applyAiResult, replaceRuleTags and setTags all tag in sorted order, so locks are taken in the same sequence every time. db.transaction retries a deadlock victim up to three times as well, which covers the paths this doesn't — the victim rolled back and changed nothing, so running it again is safe. Measured: the old pattern deadlocks on demand (40P01), and the same collision run 48 times through the new code fails zero times. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
47 lines
1.6 KiB
JavaScript
47 lines
1.6 KiB
JavaScript
const fs = require('fs');
|
|
const path = require('path');
|
|
const { Pool } = require('pg');
|
|
|
|
const pool = new Pool({
|
|
connectionString: process.env.DATABASE_URL,
|
|
});
|
|
|
|
pool.on('error', (err) => {
|
|
console.error('Unexpected Postgres pool error:', err);
|
|
});
|
|
|
|
// Apply schema.sql on startup (idempotent — uses CREATE TABLE IF NOT EXISTS).
|
|
async function init() {
|
|
const schema = fs.readFileSync(path.join(__dirname, '..', 'db', 'schema.sql'), 'utf8');
|
|
await pool.query(schema);
|
|
}
|
|
|
|
function query(text, params) {
|
|
return pool.query(text, params);
|
|
}
|
|
|
|
// Run a function inside a transaction, passing it a dedicated client.
|
|
// Postgres breaks a deadlock by killing one of the transactions. The victim
|
|
// rolled back and changed nothing, so running it again is safe — and usually
|
|
// works, because the transaction it collided with has since finished.
|
|
const RETRY_CODES = new Set(['40P01', '40001']); // deadlock_detected, serialization_failure
|
|
|
|
async function transaction(fn, attempt = 0) {
|
|
const client = await pool.connect();
|
|
try {
|
|
await client.query('BEGIN');
|
|
const result = await fn(client);
|
|
await client.query('COMMIT');
|
|
return result;
|
|
} catch (err) {
|
|
await client.query('ROLLBACK').catch(() => {}); // the connection may be gone
|
|
if (!RETRY_CODES.has(err.code) || attempt >= 3) throw err;
|
|
console.warn(`Database ${err.code}, retrying the transaction (attempt ${attempt + 2}).`);
|
|
} finally {
|
|
client.release(); // before the pause below, so a retry doesn't hold a connection
|
|
}
|
|
await new Promise((r) => setTimeout(r, 50 * (attempt + 1) ** 2));
|
|
return transaction(fn, attempt + 1);
|
|
}
|
|
|
|
module.exports = { pool, query, transaction, init };
|