perf(schedule): 월별 일정 Redis 캐시 + 쓰기 시 무효화
캘린더(최다 호출 공개 엔드포인트) getMonthlySchedules를 getOrSet으로 캐시(TTL 60s). 무효화는 모든 쓰기가 수렴하는 3개 meili 동기화 함수 (addOrUpdateSchedule/syncScheduleById/deleteSchedule)에 redis 전달 시 schedule:monthly:* 무효화. 관리자 라우트·봇 경로에서 redis 전달(즉시 반영), festival/누락 경로는 60s TTL로 자동 치유. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
f50463f394
commit
54e014c833
10 changed files with 52 additions and 28 deletions
|
|
@ -339,7 +339,7 @@ export default async function concertRoutes(fastify) {
|
||||||
category_name: category.name || '',
|
category_name: category.name || '',
|
||||||
category_color: category.color || '',
|
category_color: category.color || '',
|
||||||
member_names: memberNames,
|
member_names: memberNames,
|
||||||
});
|
}, fastify.redis);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -540,7 +540,7 @@ export default async function concertRoutes(fastify) {
|
||||||
category_name: category.name || '',
|
category_name: category.name || '',
|
||||||
category_color: category.color || '',
|
category_color: category.color || '',
|
||||||
member_names: memberNames,
|
member_names: memberNames,
|
||||||
});
|
}, fastify.redis);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -206,8 +206,8 @@ export default async function eventsRoutes(fastify) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Meilisearch 동기화
|
// Meilisearch 동기화 + 월별 캐시 무효화
|
||||||
await syncScheduleById(meilisearch, db, scheduleId);
|
await syncScheduleById(meilisearch, db, scheduleId, fastify.redis);
|
||||||
|
|
||||||
logActivity(db, {
|
logActivity(db, {
|
||||||
actor: 'admin', action: 'create', category: 'schedule',
|
actor: 'admin', action: 'create', category: 'schedule',
|
||||||
|
|
@ -291,7 +291,7 @@ export default async function eventsRoutes(fastify) {
|
||||||
[finalIds.length > 0 ? JSON.stringify(finalIds) : null, id]
|
[finalIds.length > 0 ? JSON.stringify(finalIds) : null, id]
|
||||||
);
|
);
|
||||||
|
|
||||||
await syncScheduleById(meilisearch, db, parseInt(id));
|
await syncScheduleById(meilisearch, db, parseInt(id), fastify.redis);
|
||||||
|
|
||||||
logActivity(db, {
|
logActivity(db, {
|
||||||
actor: 'admin', action: 'update', category: 'schedule',
|
actor: 'admin', action: 'update', category: 'schedule',
|
||||||
|
|
|
||||||
|
|
@ -125,7 +125,7 @@ export default async function varietyRoutes(fastify) {
|
||||||
category_name: category.name || '',
|
category_name: category.name || '',
|
||||||
category_color: category.color || '',
|
category_color: category.color || '',
|
||||||
member_names: memberNames,
|
member_names: memberNames,
|
||||||
});
|
}, redis);
|
||||||
|
|
||||||
// 방송사 캐시 무효화
|
// 방송사 캐시 무효화
|
||||||
await redis.del(BROADCASTER_KEY);
|
await redis.del(BROADCASTER_KEY);
|
||||||
|
|
@ -214,7 +214,7 @@ export default async function varietyRoutes(fastify) {
|
||||||
await db.query('INSERT INTO schedule_members (schedule_id, member_id) VALUES ?', [values]);
|
await db.query('INSERT INTO schedule_members (schedule_id, member_id) VALUES ?', [values]);
|
||||||
}
|
}
|
||||||
|
|
||||||
await syncScheduleById(meilisearch, db, parseInt(id));
|
await syncScheduleById(meilisearch, db, parseInt(id), redis);
|
||||||
await redis.del(BROADCASTER_KEY);
|
await redis.del(BROADCASTER_KEY);
|
||||||
logActivity(db, { actor: 'admin', action: 'update', category: 'schedule', targetType: 'variety_schedule', targetId: parseInt(id), summary: `예능 일정 수정: ${title.trim()}` });
|
logActivity(db, { actor: 'admin', action: 'update', category: 'schedule', targetType: 'variety_schedule', targetId: parseInt(id), summary: `예능 일정 수정: ${title.trim()}` });
|
||||||
return { success: true };
|
return { success: true };
|
||||||
|
|
|
||||||
|
|
@ -152,7 +152,7 @@ export default async function xRoutes(fastify) {
|
||||||
category_name: category.name || '',
|
category_name: category.name || '',
|
||||||
category_color: category.color || '',
|
category_color: category.color || '',
|
||||||
source_name: '',
|
source_name: '',
|
||||||
});
|
}, fastify.redis);
|
||||||
|
|
||||||
logActivity(db, { actor: 'admin', action: 'create', category: 'schedule', targetType: 'x_schedule', targetId: scheduleId, summary: `X 일정 생성: ${title}` });
|
logActivity(db, { actor: 'admin', action: 'create', category: 'schedule', targetType: 'x_schedule', targetId: scheduleId, summary: `X 일정 생성: ${title}` });
|
||||||
return { success: true, scheduleId };
|
return { success: true, scheduleId };
|
||||||
|
|
@ -251,8 +251,8 @@ export default async function xRoutes(fastify) {
|
||||||
[fetchUsername, newContent, newImageUrls, row.schedule_id]
|
[fetchUsername, newContent, newImageUrls, row.schedule_id]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Meilisearch 동기화
|
// Meilisearch 동기화 + 월별 캐시 무효화
|
||||||
await syncScheduleById(meilisearch, db, row.schedule_id);
|
await syncScheduleById(meilisearch, db, row.schedule_id, fastify.redis);
|
||||||
|
|
||||||
updated++;
|
updated++;
|
||||||
fastify.log.info(`리트윗 재수집 완료: schedule_id=${row.schedule_id}, post_id=${row.post_id}`);
|
fastify.log.info(`리트윗 재수집 완료: schedule_id=${row.schedule_id}, post_id=${row.post_id}`);
|
||||||
|
|
|
||||||
|
|
@ -148,7 +148,7 @@ export default async function youtubeRoutes(fastify) {
|
||||||
category_name: category.name || '',
|
category_name: category.name || '',
|
||||||
category_color: category.color || '',
|
category_color: category.color || '',
|
||||||
source_name: channelName || '',
|
source_name: channelName || '',
|
||||||
});
|
}, fastify.redis);
|
||||||
|
|
||||||
logActivity(db, { actor: 'admin', action: 'create', category: 'schedule', targetType: 'youtube_schedule', targetId: scheduleId, summary: `YouTube 일정 생성: ${title}` });
|
logActivity(db, { actor: 'admin', action: 'create', category: 'schedule', targetType: 'youtube_schedule', targetId: scheduleId, summary: `YouTube 일정 생성: ${title}` });
|
||||||
return { success: true, scheduleId };
|
return { success: true, scheduleId };
|
||||||
|
|
@ -252,7 +252,7 @@ export default async function youtubeRoutes(fastify) {
|
||||||
category_color: category.color || '',
|
category_color: category.color || '',
|
||||||
member_names: memberNames,
|
member_names: memberNames,
|
||||||
source_name: channelName,
|
source_name: channelName,
|
||||||
});
|
}, fastify.redis);
|
||||||
|
|
||||||
logActivity(db, { actor: 'admin', action: 'update', category: 'schedule', targetType: 'youtube_schedule', targetId: parseInt(id), summary: `YouTube 일정 수정: ${schedules[0].title}` });
|
logActivity(db, { actor: 'admin', action: 'update', category: 'schedule', targetType: 'youtube_schedule', targetId: parseInt(id), summary: `YouTube 일정 수정: ${schedules[0].title}` });
|
||||||
return { success: true };
|
return { success: true };
|
||||||
|
|
|
||||||
|
|
@ -142,7 +142,7 @@ export default async function schedulesRoutes(fastify) {
|
||||||
return badRequest(reply, 'search, startDate, 또는 year/month는 필수입니다.');
|
return badRequest(reply, 'search, startDate, 또는 year/month는 필수입니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return await getMonthlySchedules(db, parseInt(year), parseInt(month));
|
return await getMonthlySchedules(db, parseInt(year), parseInt(month), redis);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
fastify.log.error(err);
|
fastify.log.error(err);
|
||||||
return serverError(reply, '일정 조회 실패');
|
return serverError(reply, '일정 조회 실패');
|
||||||
|
|
@ -271,8 +271,8 @@ export default async function schedulesRoutes(fastify) {
|
||||||
await connection.query('DELETE FROM schedules WHERE id = ?', [id]);
|
await connection.query('DELETE FROM schedules WHERE id = ?', [id]);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Meilisearch에서도 삭제 (트랜잭션 외부, 실패해도 무시)
|
// Meilisearch에서도 삭제 + 월별 캐시 무효화 (트랜잭션 외부, 실패해도 무시)
|
||||||
await deleteSchedule(meilisearch, id);
|
await deleteSchedule(meilisearch, id, redis);
|
||||||
|
|
||||||
logActivity(db, { actor: 'admin', action: 'delete', category: 'schedule', targetType: null, targetId: parseInt(id), summary: `일정 삭제: ${id}` });
|
logActivity(db, { actor: 'admin', action: 'delete', category: 'schedule', targetType: null, targetId: parseInt(id), summary: `일정 삭제: ${id}` });
|
||||||
return { success: true };
|
return { success: true };
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,17 @@
|
||||||
import Inko from 'inko';
|
import Inko from 'inko';
|
||||||
import config, { CATEGORY_IDS } from '../../config/index.js';
|
import config, { CATEGORY_IDS } from '../../config/index.js';
|
||||||
import { createLogger } from '../../utils/logger.js';
|
import { createLogger } from '../../utils/logger.js';
|
||||||
|
import { invalidatePattern } from '../../utils/cache.js';
|
||||||
|
|
||||||
|
// 일정 쓰기 시 월별 일정 캐시 무효화 (redis가 주어진 호출에 한함)
|
||||||
|
async function invalidateMonthlyCache(redis) {
|
||||||
|
if (!redis) return;
|
||||||
|
try {
|
||||||
|
await invalidatePattern(redis, 'schedule:monthly:*');
|
||||||
|
} catch {
|
||||||
|
// 캐시 무효화 실패는 치명적이지 않음 (TTL로 자동 만료)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const inko = new Inko();
|
const inko = new Inko();
|
||||||
const logger = createLogger('Meilisearch');
|
const logger = createLogger('Meilisearch');
|
||||||
|
|
@ -202,7 +213,7 @@ function formatScheduleResponse(hit) {
|
||||||
/**
|
/**
|
||||||
* 일정 추가/업데이트 (데이터 직접 전달)
|
* 일정 추가/업데이트 (데이터 직접 전달)
|
||||||
*/
|
*/
|
||||||
export async function addOrUpdateSchedule(meilisearch, schedule) {
|
export async function addOrUpdateSchedule(meilisearch, schedule, redis = null) {
|
||||||
try {
|
try {
|
||||||
const index = meilisearch.index(INDEX_NAME);
|
const index = meilisearch.index(INDEX_NAME);
|
||||||
|
|
||||||
|
|
@ -223,12 +234,13 @@ export async function addOrUpdateSchedule(meilisearch, schedule) {
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error(`문서 추가 오류: ${err.message}`);
|
logger.error(`문서 추가 오류: ${err.message}`);
|
||||||
}
|
}
|
||||||
|
await invalidateMonthlyCache(redis);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 일정 ID로 DB에서 조회 후 Meilisearch에 동기화
|
* 일정 ID로 DB에서 조회 후 Meilisearch에 동기화
|
||||||
*/
|
*/
|
||||||
export async function syncScheduleById(meilisearch, db, scheduleId) {
|
export async function syncScheduleById(meilisearch, db, scheduleId, redis = null) {
|
||||||
try {
|
try {
|
||||||
const [rows] = await db.query(`
|
const [rows] = await db.query(`
|
||||||
SELECT
|
SELECT
|
||||||
|
|
@ -273,9 +285,11 @@ export async function syncScheduleById(meilisearch, db, scheduleId) {
|
||||||
const index = meilisearch.index(INDEX_NAME);
|
const index = meilisearch.index(INDEX_NAME);
|
||||||
await index.addDocuments([document]);
|
await index.addDocuments([document]);
|
||||||
logger.info(`일정 동기화: ${scheduleId}`);
|
logger.info(`일정 동기화: ${scheduleId}`);
|
||||||
|
await invalidateMonthlyCache(redis);
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error(`일정 동기화 오류 (${scheduleId}): ${err.message}`);
|
logger.error(`일정 동기화 오류 (${scheduleId}): ${err.message}`);
|
||||||
|
await invalidateMonthlyCache(redis);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -283,7 +297,7 @@ export async function syncScheduleById(meilisearch, db, scheduleId) {
|
||||||
/**
|
/**
|
||||||
* 일정 삭제
|
* 일정 삭제
|
||||||
*/
|
*/
|
||||||
export async function deleteSchedule(meilisearch, scheduleId) {
|
export async function deleteSchedule(meilisearch, scheduleId, redis = null) {
|
||||||
try {
|
try {
|
||||||
const index = meilisearch.index(INDEX_NAME);
|
const index = meilisearch.index(INDEX_NAME);
|
||||||
await index.deleteDocument(scheduleId);
|
await index.deleteDocument(scheduleId);
|
||||||
|
|
@ -291,6 +305,7 @@ export async function deleteSchedule(meilisearch, scheduleId) {
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error(`문서 삭제 오류: ${err.message}`);
|
logger.error(`문서 삭제 오류: ${err.message}`);
|
||||||
}
|
}
|
||||||
|
await invalidateMonthlyCache(redis);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -500,7 +500,8 @@ const SCHEDULE_LIST_SQL = `
|
||||||
* @param {number} month - 월
|
* @param {number} month - 월
|
||||||
* @returns {object} { schedules: [] }
|
* @returns {object} { schedules: [] }
|
||||||
*/
|
*/
|
||||||
export async function getMonthlySchedules(db, year, month) {
|
export async function getMonthlySchedules(db, year, month, redis = null) {
|
||||||
|
const run = async () => {
|
||||||
const startDate = `${year}-${String(month).padStart(2, '0')}-01`;
|
const startDate = `${year}-${String(month).padStart(2, '0')}-01`;
|
||||||
const endDate = new Date(year, month, 0).toISOString().split('T')[0];
|
const endDate = new Date(year, month, 0).toISOString().split('T')[0];
|
||||||
|
|
||||||
|
|
@ -620,6 +621,14 @@ export async function getMonthlySchedules(db, year, month) {
|
||||||
});
|
});
|
||||||
|
|
||||||
return { schedules };
|
return { schedules };
|
||||||
|
};
|
||||||
|
|
||||||
|
// 캘린더(최다 호출 공개 엔드포인트) 캐시. 쓰기 시 schedule:monthly:* 무효화 +
|
||||||
|
// 짧은 TTL 안전망으로 혹시 빠진 경로도 자동 치유.
|
||||||
|
if (redis) {
|
||||||
|
return getOrSet(redis, cacheKeys.scheduleMonthly(year, month), run, TTL.SHORT);
|
||||||
|
}
|
||||||
|
return run();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -212,7 +212,7 @@ async function xBotPlugin(fastify, opts) {
|
||||||
const scheduleId = await saveYoutubeFromTweet(video);
|
const scheduleId = await saveYoutubeFromTweet(video);
|
||||||
if (scheduleId) {
|
if (scheduleId) {
|
||||||
// Meilisearch 동기화
|
// Meilisearch 동기화
|
||||||
await syncScheduleById(fastify.meilisearch, fastify.db, scheduleId);
|
await syncScheduleById(fastify.meilisearch, fastify.db, scheduleId, fastify.redis);
|
||||||
addedCount++;
|
addedCount++;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -254,7 +254,7 @@ async function xBotPlugin(fastify, opts) {
|
||||||
const scheduleId = await saveTweet(tweet, bot.username);
|
const scheduleId = await saveTweet(tweet, bot.username);
|
||||||
if (scheduleId) {
|
if (scheduleId) {
|
||||||
// Meilisearch 동기화
|
// Meilisearch 동기화
|
||||||
await syncScheduleById(fastify.meilisearch, fastify.db, scheduleId);
|
await syncScheduleById(fastify.meilisearch, fastify.db, scheduleId, fastify.redis);
|
||||||
const title = extractTitle(tweet.text);
|
const title = extractTitle(tweet.text);
|
||||||
logActivity(fastify.db, {
|
logActivity(fastify.db, {
|
||||||
actor: bot.id,
|
actor: bot.id,
|
||||||
|
|
@ -296,7 +296,7 @@ async function xBotPlugin(fastify, opts) {
|
||||||
const scheduleId = await saveTweet(tweet, bot.username);
|
const scheduleId = await saveTweet(tweet, bot.username);
|
||||||
if (scheduleId) {
|
if (scheduleId) {
|
||||||
// Meilisearch 동기화
|
// Meilisearch 동기화
|
||||||
await syncScheduleById(fastify.meilisearch, fastify.db, scheduleId);
|
await syncScheduleById(fastify.meilisearch, fastify.db, scheduleId, fastify.redis);
|
||||||
addedCount++;
|
addedCount++;
|
||||||
// YouTube 링크 처리 (옵션이 켜져 있을 때만)
|
// YouTube 링크 처리 (옵션이 켜져 있을 때만)
|
||||||
if (bot.extractYoutube === true) {
|
if (bot.extractYoutube === true) {
|
||||||
|
|
|
||||||
|
|
@ -115,7 +115,7 @@ async function youtubeBotPlugin(fastify) {
|
||||||
|
|
||||||
// Meilisearch 동기화
|
// Meilisearch 동기화
|
||||||
if (scheduleId) {
|
if (scheduleId) {
|
||||||
await syncScheduleById(fastify.meilisearch, fastify.db, scheduleId);
|
await syncScheduleById(fastify.meilisearch, fastify.db, scheduleId, fastify.redis);
|
||||||
fastify.log.info(`[${bot.id}] 다음 주 예정 일정 생성: ${nextDate} - ${title}`);
|
fastify.log.info(`[${bot.id}] 다음 주 예정 일정 생성: ${nextDate} - ${title}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -141,7 +141,7 @@ async function youtubeBotPlugin(fastify) {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Meilisearch 동기화
|
// Meilisearch 동기화
|
||||||
await syncScheduleById(fastify.meilisearch, fastify.db, scheduledEntry.schedule_id);
|
await syncScheduleById(fastify.meilisearch, fastify.db, scheduledEntry.schedule_id, fastify.redis);
|
||||||
fastify.log.info(`[${bot.id}] 예정 일정 업데이트: ${video.title}`);
|
fastify.log.info(`[${bot.id}] 예정 일정 업데이트: ${video.title}`);
|
||||||
|
|
||||||
return scheduledEntry.schedule_id;
|
return scheduledEntry.schedule_id;
|
||||||
|
|
@ -159,7 +159,7 @@ async function youtubeBotPlugin(fastify) {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Meilisearch에서도 삭제
|
// Meilisearch에서도 삭제
|
||||||
await deleteSchedule(fastify.meilisearch, scheduleId);
|
await deleteSchedule(fastify.meilisearch, scheduleId, fastify.redis);
|
||||||
fastify.log.info(`[${bot.id}] 예정 일정 삭제 (영상 미업로드)`);
|
fastify.log.info(`[${bot.id}] 예정 일정 삭제 (영상 미업로드)`);
|
||||||
|
|
||||||
// 다음 주 예정 일정 생성
|
// 다음 주 예정 일정 생성
|
||||||
|
|
@ -367,7 +367,7 @@ async function youtubeBotPlugin(fastify) {
|
||||||
|
|
||||||
const scheduleId = await saveVideo(video, bot);
|
const scheduleId = await saveVideo(video, bot);
|
||||||
if (scheduleId) {
|
if (scheduleId) {
|
||||||
await syncScheduleById(fastify.meilisearch, fastify.db, scheduleId);
|
await syncScheduleById(fastify.meilisearch, fastify.db, scheduleId, fastify.redis);
|
||||||
logActivity(fastify.db, {
|
logActivity(fastify.db, {
|
||||||
actor: bot.id,
|
actor: bot.id,
|
||||||
action: 'create',
|
action: 'create',
|
||||||
|
|
@ -394,7 +394,7 @@ async function youtubeBotPlugin(fastify) {
|
||||||
const scheduleId = await saveVideo(video, bot);
|
const scheduleId = await saveVideo(video, bot);
|
||||||
if (scheduleId) {
|
if (scheduleId) {
|
||||||
// Meilisearch 동기화
|
// Meilisearch 동기화
|
||||||
await syncScheduleById(fastify.meilisearch, fastify.db, scheduleId);
|
await syncScheduleById(fastify.meilisearch, fastify.db, scheduleId, fastify.redis);
|
||||||
addedCount++;
|
addedCount++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue