fix(autogen): атомарный захват темы — исключает дубли статей

Проблема: одна и та же тема из blog_topics использовалась несколько раз
(см. 7 дублей в БД). Причина: getNextTopic читал is_used=false, возвращал
тему, но помечал её использованной только ПОСЛЕ INSERT статьи (через 30-60 сек
пока AI генерирует текст). За это время повторный запуск видел ту же is_used=false
и выбирал её снова.

Фикс: атомарная операция UPDATE...RETURNING с FOR UPDATE SKIP LOCKED —
тема помечается is_used=true В МОМЕНТ ВЫБОРА, до вызова AI.
Параллельные или повторные запуски видят is_used=true и пропускают тему.

Дополнительно: fallback TOPIC_BANK теперь перемешивает пул случайно
(вместо Math.random() на весь массив) — более равномерное распределение.
This commit is contained in:
Aleksei Pavlov
2026-06-21 16:47:49 +03:00
parent 214bf307c7
commit c920d3bcd5
+24 -12
View File
@@ -70,24 +70,33 @@ async function getNextTopic(category) {
return { id: rows[0].id, topic: rows[0].topic, tags: rows[0].tags || [], keywords: rows[0].keywords || [] };
}
// 2. DB-банк тем — неиспользованные
// 2. DB-банк тем — атомарно захватываем следующую свободную тему.
// FOR UPDATE SKIP LOCKED + немедленный UPDATE is_used=true устраняет race condition:
// параллельные генерации не могут выбрать одну и ту же тему.
const { rows: dbTopics } = await query(`
SELECT bt.id, bt.topic FROM blog_topics bt
WHERE bt.category = $1
AND bt.is_used = false
AND NOT EXISTS (
SELECT 1 FROM articles a
WHERE a.source_topic = bt.topic AND a.category = $1
)
ORDER BY bt.priority DESC, bt.created_at ASC
LIMIT 1
UPDATE blog_topics
SET is_used=true, used_at=NOW()
WHERE id = (
SELECT bt.id FROM blog_topics bt
WHERE bt.category = $1
AND bt.is_used = false
AND NOT EXISTS (
SELECT 1 FROM articles a
WHERE a.source_topic = bt.topic AND a.category = $1
)
ORDER BY bt.priority DESC, bt.created_at ASC
LIMIT 1
FOR UPDATE SKIP LOCKED
)
RETURNING id, topic
`, [category]);
if (dbTopics.length) {
return { id: null, topic: dbTopics[0].topic, tags: [], keywords: [], blog_topic_id: dbTopics[0].id };
}
// 3. Fallback: хардкод если DB пустой
// 3. Fallback: хардкод если DB-банк пустой или все темы использованы.
// Проверяем использованные темы (всё время) чтобы не повторяться.
const bank = TOPIC_BANK[category] || TOPIC_BANK['ai-tools'];
const { rows: usedTopics } = await query(
`SELECT source_topic FROM articles WHERE category=$1 AND source_topic IS NOT NULL`,
@@ -95,8 +104,11 @@ async function getNextTopic(category) {
);
const usedSet = new Set(usedTopics.map(r => r.source_topic?.toLowerCase().trim()).filter(Boolean));
const unused = bank.filter(t => !usedSet.has(t.toLowerCase().trim()));
// Если все темы уже использованы — берём рандомную (лучше повтор чем пустой контент)
const pool = unused.length > 0 ? unused : bank;
const topic = pool[Math.floor(Math.random() * pool.length)];
// Перемешиваем и берём первую (вместо случайного — детерминированно для одного запуска)
const shuffled = [...pool].sort(() => Math.random() - 0.5);
const topic = shuffled[0];
return { id: null, topic, tags: [], keywords: [] };
}