From 8091f4ac67bf25c5bcebb3b7cffcc877e5ea6641 Mon Sep 17 00:00:00 2001 From: caadiq Date: Fri, 23 Jan 2026 21:41:05 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Meilisearch=20=EB=B2=84=EC=A0=84=20?= =?UTF-8?q?=EC=B2=B4=ED=81=AC=20=EA=B8=B0=EB=B0=98=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EB=8F=99=EA=B8=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 4시~4시 5분간 1분 간격으로 Meilisearch 버전 체크 - watchtower 업데이트로 버전 변경 감지 시 즉시 동기화 - 동기화 오류 시 인덱스 삭제 후 재생성하여 재시도 - 기존 고정 시간(4:05) cron 방식에서 버전 감지 방식으로 변경 Co-Authored-By: Claude Opus 4.5 --- backend/src/config/bots.js | 2 +- backend/src/plugins/scheduler.js | 113 +++++++++++++++++++++- backend/src/services/meilisearch/index.js | 73 ++++++++++++++ 3 files changed, 185 insertions(+), 3 deletions(-) diff --git a/backend/src/config/bots.js b/backend/src/config/bots.js index 579c6a2..48445e9 100644 --- a/backend/src/config/bots.js +++ b/backend/src/config/bots.js @@ -3,7 +3,7 @@ export default [ id: 'meilisearch-sync', type: 'meilisearch', name: 'Meilisearch 동기화', - cron: '5 4 * * *', + cron: '0 4 * * *', // 4시부터 5분간 버전 체크, 변경 시 동기화 enabled: true, }, { diff --git a/backend/src/plugins/scheduler.js b/backend/src/plugins/scheduler.js index a6f5052..553eebc 100644 --- a/backend/src/plugins/scheduler.js +++ b/backend/src/plugins/scheduler.js @@ -1,7 +1,7 @@ import fp from 'fastify-plugin'; import cron from 'node-cron'; import bots from '../config/bots.js'; -import { syncAllSchedules } from '../services/meilisearch/index.js'; +import { syncWithRetry, getVersion } from '../services/meilisearch/index.js'; const REDIS_PREFIX = 'bot:status:'; @@ -45,13 +45,116 @@ async function schedulerPlugin(fastify, opts) { return fastify.xBot.syncNewTweets; } else if (bot.type === 'meilisearch') { return async () => { - const count = await syncAllSchedules(fastify.meilisearch, fastify.db); + const count = await syncWithRetry(fastify.meilisearch, fastify.db); return { addedCount: count, total: count }; }; } return null; } + /** + * Meilisearch 버전 체크 및 동기화 (업데이트 감지용) + */ + async function startMeilisearchVersionCheck(botId, bot) { + const REDIS_VERSION_KEY = 'meilisearch:version'; + const CHECK_INTERVAL = 60 * 1000; // 1분 + const CHECK_DURATION = 5 * 60 * 1000; // 5분간 체크 + + // 체크 시작 cron (매일 4시) + const task = cron.schedule(bot.cron, async () => { + fastify.log.info(`[${botId}] 버전 체크 시작 (5분간 1분 간격)`); + await updateStatus(botId, { status: 'running' }); + + const startTime = Date.now(); + let synced = false; + let checkCount = 0; + + // 초기 버전 저장 + const initialVersion = await getVersion(fastify.meilisearch); + if (!initialVersion) { + fastify.log.error(`[${botId}] Meilisearch 연결 실패`); + await updateStatus(botId, { status: 'error', errorMessage: 'Meilisearch 연결 실패' }); + return; + } + + const savedVersion = await fastify.redis.get(REDIS_VERSION_KEY); + fastify.log.info(`[${botId}] 현재 버전: ${initialVersion}, 저장된 버전: ${savedVersion || '없음'}`); + + // 버전이 이미 다르면 즉시 동기화 + if (savedVersion && savedVersion !== initialVersion) { + fastify.log.info(`[${botId}] 버전 변경 감지! ${savedVersion} → ${initialVersion}`); + await performSync(botId, initialVersion, REDIS_VERSION_KEY); + return; + } + + // 5분간 1분 간격으로 체크 + const intervalId = setInterval(async () => { + checkCount++; + const elapsed = Date.now() - startTime; + + if (synced || elapsed >= CHECK_DURATION) { + clearInterval(intervalId); + if (!synced) { + fastify.log.info(`[${botId}] 버전 변경 없음, 체크 종료`); + await updateStatus(botId, { + status: 'running', + lastCheckAt: new Date().toISOString(), + }); + } + return; + } + + const currentVersion = await getVersion(fastify.meilisearch); + fastify.log.info(`[${botId}] 체크 #${checkCount}: 버전 ${currentVersion}`); + + if (currentVersion && currentVersion !== initialVersion) { + synced = true; + clearInterval(intervalId); + fastify.log.info(`[${botId}] 버전 변경 감지! ${initialVersion} → ${currentVersion}`); + await performSync(botId, currentVersion, REDIS_VERSION_KEY); + } + }, CHECK_INTERVAL); + }); + + tasks.set(botId, task); + await updateStatus(botId, { status: 'running' }); + fastify.log.info(`[${botId}] 버전 체크 스케줄 시작 (cron: ${bot.cron})`); + + // 초기 버전 저장 (최초 실행 시) + const currentVersion = await getVersion(fastify.meilisearch); + if (currentVersion) { + const savedVersion = await fastify.redis.get(REDIS_VERSION_KEY); + if (!savedVersion) { + await fastify.redis.set(REDIS_VERSION_KEY, currentVersion); + fastify.log.info(`[${botId}] 초기 버전 저장: ${currentVersion}`); + } + } + } + + /** + * 동기화 실행 및 상태 업데이트 + */ + async function performSync(botId, newVersion, versionKey) { + try { + const count = await syncWithRetry(fastify.meilisearch, fastify.db); + await fastify.redis.set(versionKey, newVersion); + await updateStatus(botId, { + status: 'running', + lastCheckAt: new Date().toISOString(), + lastAddedCount: count, + errorMessage: null, + }); + fastify.log.info(`[${botId}] 동기화 완료: ${count}개, 새 버전: ${newVersion}`); + } catch (err) { + await updateStatus(botId, { + status: 'error', + lastCheckAt: new Date().toISOString(), + errorMessage: err.message, + }); + fastify.log.error(`[${botId}] 동기화 오류: ${err.message}`); + } + } + /** * 동기화 결과 처리 (중복 코드 제거) */ @@ -88,6 +191,12 @@ async function schedulerPlugin(fastify, opts) { tasks.delete(botId); } + // Meilisearch는 버전 체크 방식 사용 + if (bot.type === 'meilisearch') { + await startMeilisearchVersionCheck(botId, bot); + return; + } + const syncFn = getSyncFunction(bot); if (!syncFn) { throw new Error(`지원하지 않는 봇 타입: ${bot.type}`); diff --git a/backend/src/services/meilisearch/index.js b/backend/src/services/meilisearch/index.js index 54a2068..fc601f6 100644 --- a/backend/src/services/meilisearch/index.js +++ b/backend/src/services/meilisearch/index.js @@ -275,3 +275,76 @@ export async function syncAllSchedules(meilisearch, db) { return 0; } } + +/** + * Meilisearch 버전 조회 + */ +export async function getVersion(meilisearch) { + try { + const version = await meilisearch.getVersion(); + return version.pkgVersion; + } catch (err) { + logger.error(`버전 조회 오류: ${err.message}`); + return null; + } +} + +/** + * 인덱스 삭제 후 재생성 + */ +async function recreateIndex(meilisearch) { + const index = meilisearch.index(INDEX_NAME); + + try { + // 인덱스 삭제 + const deleteTask = await meilisearch.deleteIndex(INDEX_NAME); + await meilisearch.waitForTask(deleteTask.taskUid); + logger.info('기존 인덱스 삭제 완료'); + } catch (err) { + // 인덱스가 없으면 무시 + } + + // 인덱스 재생성 + const createTask = await meilisearch.createIndex(INDEX_NAME, { primaryKey: 'id' }); + await meilisearch.waitForTask(createTask.taskUid); + + // 설정 복원 + await index.updateSearchableAttributes([ + 'title', 'member_names', 'description', 'source_name', 'category_name', + ]); + await index.updateFilterableAttributes(['category_id', 'date']); + await index.updateSortableAttributes(['date', 'time']); + await index.updateRankingRules([ + 'words', 'typo', 'proximity', 'attribute', 'exactness', 'date:desc', + ]); + await index.updateTypoTolerance({ + enabled: true, + minWordSizeForTypos: { oneTypo: 2, twoTypos: 4 }, + }); + await index.updatePagination({ maxTotalHits: 10000 }); + + logger.info('인덱스 재생성 완료'); +} + +/** + * 동기화 (오류 시 인덱스 재생성 후 재시도) + */ +export async function syncWithRetry(meilisearch, db) { + try { + const count = await syncAllSchedules(meilisearch, db); + if (count > 0) return count; + + // 0개면 오류일 수 있으므로 재시도 + throw new Error('동기화 결과 0개'); + } catch (err) { + logger.warn(`동기화 실패, 인덱스 재생성 후 재시도: ${err.message}`); + + try { + await recreateIndex(meilisearch); + return await syncAllSchedules(meilisearch, db); + } catch (retryErr) { + logger.error(`재시도 실패: ${retryErr.message}`); + return 0; + } + } +}