From c920d3bcd5c3a08cbe8c30c327ff778db9d37445 Mon Sep 17 00:00:00 2001 From: Aleksei Pavlov Date: Sun, 21 Jun 2026 16:47:49 +0300 Subject: [PATCH] =?UTF-8?q?fix(autogen):=20=D0=B0=D1=82=D0=BE=D0=BC=D0=B0?= =?UTF-8?q?=D1=80=D0=BD=D1=8B=D0=B9=20=D0=B7=D0=B0=D1=85=D0=B2=D0=B0=D1=82?= =?UTF-8?q?=20=D1=82=D0=B5=D0=BC=D1=8B=20=E2=80=94=20=D0=B8=D1=81=D0=BA?= =?UTF-8?q?=D0=BB=D1=8E=D1=87=D0=B0=D0=B5=D1=82=20=D0=B4=D1=83=D0=B1=D0=BB?= =?UTF-8?q?=D0=B8=20=D1=81=D1=82=D0=B0=D1=82=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Проблема: одна и та же тема из 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() на весь массив) — более равномерное распределение. --- src/services/autogen.js | 36 ++++++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/src/services/autogen.js b/src/services/autogen.js index 35e9d73..6631544 100644 --- a/src/services/autogen.js +++ b/src/services/autogen.js @@ -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: [] }; }