Merge the tagging deadlock fix
All checks were successful
build-and-push / docker (push) Successful in 22s
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:
commit
099e6d24b4
2 changed files with 34 additions and 14 deletions
16
lib/db.js
16
lib/db.js
|
|
@ -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 };
|
||||||
|
|
|
||||||
32
lib/store.js
32
lib/store.js
|
|
@ -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) {
|
||||||
const { rows } = await client.query(
|
for (let attempt = 0; attempt < 3; attempt++) {
|
||||||
`INSERT INTO tags (name) VALUES ($1)
|
const { rows: found } = await client.query('SELECT id FROM tags WHERE name = $1', [name]);
|
||||||
ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name RETURNING id`, [name]);
|
if (found.length) return found[0].id;
|
||||||
return rows[0].id;
|
const { rows } = await client.query(
|
||||||
|
'INSERT INTO tags (name) VALUES ($1) ON CONFLICT (name) DO NOTHING RETURNING id', [name]);
|
||||||
|
if (rows.length) 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]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue