Merge the tagging deadlock fix
All checks were successful
build-and-push / docker (push) Successful in 22s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
dlawler489 2026-09-16 14:56:28 +10:00
commit 099e6d24b4
2 changed files with 34 additions and 14 deletions

View file

@ -21,7 +21,12 @@ function query(text, params) {
} }
// Run a function inside a transaction, passing it a dedicated client. // Run a function inside a transaction, passing it a dedicated client.
async function transaction(fn) { // 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(); const client = await pool.connect();
try { try {
await client.query('BEGIN'); await client.query('BEGIN');
@ -29,11 +34,14 @@ async function transaction(fn) {
await client.query('COMMIT'); await client.query('COMMIT');
return result; return result;
} catch (err) { } catch (err) {
await client.query('ROLLBACK'); await client.query('ROLLBACK').catch(() => {}); // the connection may be gone
throw err; if (!RETRY_CODES.has(err.code) || attempt >= 3) throw err;
console.warn(`Database ${err.code}, retrying the transaction (attempt ${attempt + 2}).`);
} finally { } finally {
client.release(); 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 }; module.exports = { pool, query, transaction, init };

View file

@ -301,11 +301,25 @@ async function insertFile(client, modelId, file) {
} }
// ---------------------------------------------------------------- tags // ---------------------------------------------------------------- tags
// Always tag in the same order. Two transactions touching the same tags in
// opposite orders each hold a row the other wants, which Postgres resolves by
// killing one of them ("deadlock detected").
const tagOrder = (names) => [...new Set((names || []).map((n) => String(n).trim()).filter(Boolean))].sort();
// The tag's id, creating it if it's new. Deliberately not ON CONFLICT DO UPDATE:
// that rewrites the row and so write-locks it for the rest of the transaction,
// even though the name never changes — which is what made tagging deadlock.
async function tagId(client, name) { async function tagId(client, name) {
for (let attempt = 0; attempt < 3; attempt++) {
const { rows: found } = await client.query('SELECT id FROM tags WHERE name = $1', [name]);
if (found.length) return found[0].id;
const { rows } = await client.query( const { rows } = await client.query(
`INSERT INTO tags (name) VALUES ($1) 'INSERT INTO tags (name) VALUES ($1) ON CONFLICT (name) DO NOTHING RETURNING id', [name]);
ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name RETURNING id`, [name]); if (rows.length) return rows[0].id;
return rows[0].id; // Someone else is inserting this very tag and hasn't committed yet.
await new Promise((r) => setTimeout(r, 20 * (attempt + 1)));
}
throw new Error(`Could not get an id for the tag “${name}”`);
} }
// Sets a model's tags from an edit. A tag that stays keeps where it came from // Sets a model's tags from an edit. A tag that stays keeps where it came from
@ -314,9 +328,7 @@ async function setTags(client, modelId, tagNames) {
const { rows: before } = await client.query('SELECT tag_id, source FROM model_tags WHERE model_id = $1', [modelId]); const { rows: before } = await client.query('SELECT tag_id, source FROM model_tags WHERE model_id = $1', [modelId]);
const sourceOf = new Map(before.map((r) => [r.tag_id, r.source])); const sourceOf = new Map(before.map((r) => [r.tag_id, r.source]));
await client.query('DELETE FROM model_tags WHERE model_id = $1', [modelId]); await client.query('DELETE FROM model_tags WHERE model_id = $1', [modelId]);
for (const raw of tagNames) { for (const name of tagOrder(tagNames)) {
const name = String(raw).trim();
if (!name) continue;
const id = await tagId(client, name); const id = await tagId(client, name);
await client.query( await client.query(
'INSERT INTO model_tags (model_id, tag_id, source) VALUES ($1,$2,$3) ON CONFLICT DO NOTHING', 'INSERT INTO model_tags (model_id, tag_id, source) VALUES ($1,$2,$3) ON CONFLICT DO NOTHING',
@ -329,7 +341,7 @@ async function setTags(client, modelId, tagNames) {
async function replaceRuleTags(modelId, tagNames) { async function replaceRuleTags(modelId, tagNames) {
await db.transaction(async (client) => { await db.transaction(async (client) => {
const ids = []; const ids = [];
for (const name of tagNames) ids.push(await tagId(client, name)); for (const name of tagOrder(tagNames)) ids.push(await tagId(client, name));
await client.query( await client.query(
`DELETE FROM model_tags WHERE model_id = $1 AND source = 'rule' AND NOT (tag_id = ANY($2::int[]))`, [modelId, ids]); `DELETE FROM model_tags WHERE model_id = $1 AND source = 'rule' AND NOT (tag_id = ANY($2::int[]))`, [modelId, ids]);
for (const id of ids) { for (const id of ids) {
@ -350,8 +362,8 @@ async function removeTagsFrom(source) {
// markTagged records that the model has had AI tags and a description. // markTagged records that the model has had AI tags and a description.
async function applyAiResult(modelId, { tags, description, overwriteDescription, name = null, markTagged = true }) { async function applyAiResult(modelId, { tags, description, overwriteDescription, name = null, markTagged = true }) {
await db.transaction(async (client) => { await db.transaction(async (client) => {
for (const name of tags) { for (const tagName of tagOrder(tags)) { // not `name`: that's this function's rename parameter
const id = await tagId(client, name); const id = await tagId(client, tagName);
await client.query(`INSERT INTO model_tags (model_id, tag_id, source) VALUES ($1,$2,'ai') ON CONFLICT DO NOTHING`, await client.query(`INSERT INTO model_tags (model_id, tag_id, source) VALUES ($1,$2,'ai') ON CONFLICT DO NOTHING`,
[modelId, id]); [modelId, id]);
} }