From 1ced06fa2d20dd32e10b2e6174275772f51d02df Mon Sep 17 00:00:00 2001 From: Aleksei Pavlov Date: Sun, 21 Jun 2026 21:30:06 +0300 Subject: [PATCH] fix(autogen): pg_advisory_lock + catch-up + double-check after lock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Race condition (дубли статей) устранён окончательно: 1. pg_advisory_lock по ключу категории в начале runAutogenForCategory — если два процесса запускаются одновременно, второй ждёт первого. pg_advisory_unlock в finally — освобождается всегда, даже при ошибке. 2. После получения lock — повторная проверка 'уже генерировали сегодня' (SELECT articles WHERE category AND created_at >= CURRENT_DATE). Если да — skip без генерации. Это защита от случая когда первый процесс завершился пока второй ждал lock. 3. draftAutoApprove catch-up при старте: если engine стартовал после 07:00 и есть непрогнанные вчерашние черновики — одобряет их сразу. Раньше deploy в 07:27 приводил к тому что черновики зависали навсегда. --- draftAutoApprove.js | 21 +++++++++++++++++++++ src/services/autogen.js | 19 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/draftAutoApprove.js b/draftAutoApprove.js index a531515..f628c2d 100644 --- a/draftAutoApprove.js +++ b/draftAutoApprove.js @@ -113,6 +113,27 @@ async function runDraftAutoApprove() { function startDraftAutoApproveScheduler() { console.log('[DraftApprove] scheduler started (auto-approve at 07:00 MSK, только вчерашние черновики)'); +// Catch-up при старте: если сейчас уже после AUTO_APPROVE_HOUR_MSK — +// проверить есть ли вчерашние черновики которые пропустили тик. +(async () => { + const nowMsk = new Date(Date.now() + 3 * 60 * 60 * 1000); + const hourMsk = nowMsk.getUTCHours(); + if (hourMsk >= AUTO_APPROVE_HOUR_MSK) { + try { + const { rows: missed } = await query(` + SELECT COUNT(*) AS cnt FROM articles + WHERE status='draft' + AND created_at AT TIME ZONE 'Europe/Moscow' >= (CURRENT_DATE - INTERVAL '1 day')::date + AND created_at AT TIME ZONE 'Europe/Moscow' < CURRENT_DATE::date + `); + if (parseInt(missed[0]?.cnt, 10) > 0) { + console.log('[DraftApprove] catch-up при старте: найдены пропущенные вчерашние черновики (' + missed[0].cnt + ' шт)'); + await runAutoApprove(); + } + } catch (err) { console.error('[DraftApprove] catch-up error:', err.message); } + } +})(); + setInterval(() => { const now = new Date(); const msk = new Date(now.getTime() + 3 * 60 * 60 * 1000); diff --git a/src/services/autogen.js b/src/services/autogen.js index c2e45a8..09828d2 100644 --- a/src/services/autogen.js +++ b/src/services/autogen.js @@ -116,6 +116,23 @@ async function getNextTopic(category) { * Запускает генерацию одной статьи для категории. */ async function runAutogenForCategory(category) { + // pg_advisory_lock: транзакционный lock по ключу категории. + // Гарантирует что только один процесс генерирует статью для данной категории. + // Устраняет race condition когда несколько тиков/запросов запускаются одновременно. + const lockKey = Math.abs(category.split('').reduce((h, c) => (Math.imul(31, h) + c.charCodeAt(0)) | 0, 0)); + await query('SELECT pg_advisory_lock($1)', [lockKey]); + + // После получения lock — проверяем ещё раз что за сегодня ещё не генерировали + const { rows: alreadyToday } = await query( + `SELECT id FROM articles WHERE category=$1 AND status='draft' AND created_at >= CURRENT_DATE LIMIT 1`, + [category] + ); + if (alreadyToday.length) { + await query('SELECT pg_advisory_unlock($1)', [lockKey]).catch(() => {}); + console.log(`[Autogen] category=${category}: already generated today, skipping`); + return { ok: false, skipped: true, reason: 'already generated today' }; + } + const { id: queueId, topic, tags, keywords } = await getNextTopic(category); console.log(`[Autogen] category=${category} topic="${topic.slice(0, 60)}"`); @@ -152,6 +169,8 @@ async function runAutogenForCategory(category) { await query(`UPDATE content_queue SET status='failed' WHERE id=$1`, [queueId]); } return { ok: false, error: err.message }; + } finally { + await query('SELECT pg_advisory_unlock($1)', [lockKey]).catch(() => {}); } }