Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
39 lines
3.3 KiB
JavaScript
39 lines
3.3 KiB
JavaScript
/**
|
|
* portal/scheduler.mjs — 포털 주기 작업 등록부.
|
|
*
|
|
* 각 영역은 register() 안에서 deps.scheduler.every(name, ms, fn) 으로 자기 주기 작업을 등록한다(인증 영역이 가장 먼저 create 로 등록부를 만든다).
|
|
* start(deps) 는 index.mjs 가 앱 조립 뒤 한 번 부른다. 작업은 겹쳐 실행되지 않고(이전 실행이 끝나야 다음), 예외는 기록만 하고 다음 주기에 다시 돈다.
|
|
* 기본 작업(인증 영역): 세션 정리, 이메일 토큰 정리, 로그인 잠금 정리, 만료 예약 자동 반환(§11.2 — 반환 알림 포함).
|
|
*/
|
|
import { purgeSessions } from '../lib/session.mjs';
|
|
import { expireReservations, RESOURCES } from '../lib/quota.mjs';
|
|
import { notify } from '../lib/notify.mjs';
|
|
import { nowIso } from '../lib/db.mjs';
|
|
import { safeError } from '../lib/redact.mjs';
|
|
|
|
export function create(deps) {
|
|
if (deps.scheduler) return deps.scheduler;
|
|
const tasks = new Map(); const timers = []; const log = deps.log || console.log;
|
|
const sched = {
|
|
/** 주기 작업 등록. fn 은 async 가능. 같은 이름은 덮어쓴다. */
|
|
every(name, ms, fn) { tasks.set(name, { ms, fn, running: false, lastRunAt: null, lastError: null, runs: 0 }); return sched; },
|
|
/** 즉시 한 번 실행(테스트·운영자 '지금 실행') */
|
|
async runNow(name) { const t = tasks.get(name); if (!t) throw new Error(`주기 작업 없음: ${name}`); if (t.running) return { skipped: true }; t.running = true; try { const r = await t.fn(); t.lastRunAt = nowIso(); t.lastError = null; t.runs++; return { ok: true, result: r }; } catch (e) { t.lastError = safeError(e); log(`[scheduler] ${name} 실패: ${t.lastError}`); return { ok: false, error: t.lastError }; } finally { t.running = false; } },
|
|
list: () => [...tasks.entries()].map(([name, t]) => ({ name, ms: t.ms, lastRunAt: t.lastRunAt, lastError: t.lastError, runs: t.runs })),
|
|
start() { for (const [name, t] of tasks) { const id = setInterval(() => sched.runNow(name), t.ms); if (id.unref) id.unref(); timers.push(id); } return sched; },
|
|
stop() { for (const id of timers) clearInterval(id); timers.length = 0; },
|
|
};
|
|
deps.scheduler = sched;
|
|
// 기본 작업
|
|
sched.every('sessions.purge', 6 * 3600 * 1000, () => purgeSessions(deps.store, 7));
|
|
sched.every('tokens.purge', 3600 * 1000, () => deps.store.run('DELETE FROM email_tokens WHERE expires_at<? OR (used_at IS NOT NULL AND used_at<?)', new Date(Date.now() - 3 * 86400000).toISOString(), new Date(Date.now() - 3 * 86400000).toISOString()).changes);
|
|
sched.every('login.unlock', 10 * 60 * 1000, () => deps.store.run('DELETE FROM login_attempts WHERE updated_at<?', new Date(Date.now() - 24 * 3600000).toISOString()).changes);
|
|
sched.every('quota.expire', 60 * 1000, () => {
|
|
const rows = expireReservations(deps.store);
|
|
for (const r of rows) notify(deps.store, { groupId: r.group_id, category: 'usage', severity: 'info', incidentKey: `reservation:${r.id}`, title: `${RESOURCES[r.resource]?.label || r.resource} 예약이 반환되었습니다`, body: '정리되지 않은 작업의 예약 기한이 지나 자동으로 반환했습니다. 필요하면 작업을 다시 시작하세요.', link: '/usage', subjectType: r.subject_type, subjectId: r.subject_id });
|
|
return rows.length;
|
|
});
|
|
return sched;
|
|
}
|
|
export function start(deps) { return create(deps).start(); }
|