Recover only active groups after restart
Limit restart interruption snapshots, announcements, and recovery queueing to groups that were actively processing. Waiting groups remain compatible in stored context but no longer receive restart recovery signals.
This commit is contained in:
@@ -34,6 +34,7 @@ import {
|
|||||||
buildRestartAnnouncement,
|
buildRestartAnnouncement,
|
||||||
buildInterruptedRestartAnnouncement,
|
buildInterruptedRestartAnnouncement,
|
||||||
consumeRestartContext,
|
consumeRestartContext,
|
||||||
|
getRecoverableInterruptedGroups,
|
||||||
getInterruptedRecoveryCandidates,
|
getInterruptedRecoveryCandidates,
|
||||||
inferRecentRestartContext,
|
inferRecentRestartContext,
|
||||||
type RestartContext,
|
type RestartContext,
|
||||||
@@ -196,7 +197,9 @@ async function announceRestartRecovery(
|
|||||||
'Sent explicit restart recovery announcement',
|
'Sent explicit restart recovery announcement',
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const interrupted of explicitContext.interruptedGroups ?? []) {
|
for (const interrupted of getRecoverableInterruptedGroups(
|
||||||
|
explicitContext,
|
||||||
|
)) {
|
||||||
if (interrupted.chatJid === explicitContext.chatJid) continue;
|
if (interrupted.chatJid === explicitContext.chatJid) continue;
|
||||||
if (hasRecentRestartAnnouncement(interrupted.chatJid, dedupeSince)) {
|
if (hasRecentRestartAnnouncement(interrupted.chatJid, dedupeSince)) {
|
||||||
continue;
|
continue;
|
||||||
@@ -265,8 +268,8 @@ async function main(): Promise<void> {
|
|||||||
(
|
(
|
||||||
status,
|
status,
|
||||||
): status is typeof status & {
|
): status is typeof status & {
|
||||||
status: 'processing' | 'waiting';
|
status: 'processing';
|
||||||
} => status.status !== 'inactive',
|
} => status.status === 'processing',
|
||||||
)
|
)
|
||||||
.map((status) => ({
|
.map((status) => ({
|
||||||
chatJid: status.jid,
|
chatJid: status.jid,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
buildRestartAnnouncement,
|
||||||
|
getRecoverableInterruptedGroups,
|
||||||
getInterruptedRecoveryCandidates,
|
getInterruptedRecoveryCandidates,
|
||||||
type RestartContext,
|
type RestartContext,
|
||||||
} from './restart-context.js';
|
} from './restart-context.js';
|
||||||
@@ -16,8 +18,8 @@ function makeGroup(folder: string): RegisteredGroup {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('getInterruptedRecoveryCandidates', () => {
|
describe('restart recovery context', () => {
|
||||||
it('returns only registered interrupted groups and deduplicates by chatJid', () => {
|
it('returns only registered processing groups and deduplicates by chatJid', () => {
|
||||||
const roomBindings: Record<string, RegisteredGroup> = {
|
const roomBindings: Record<string, RegisteredGroup> = {
|
||||||
'dc:1': makeGroup('group-one'),
|
'dc:1': makeGroup('group-one'),
|
||||||
'dc:2': makeGroup('group-two'),
|
'dc:2': makeGroup('group-two'),
|
||||||
@@ -38,8 +40,8 @@ describe('getInterruptedRecoveryCandidates', () => {
|
|||||||
pendingTasks: 0,
|
pendingTasks: 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
chatJid: 'dc:1',
|
chatJid: 'dc:2',
|
||||||
groupName: 'one-duplicate',
|
groupName: 'two-waiting',
|
||||||
status: 'waiting',
|
status: 'waiting',
|
||||||
elapsedMs: null,
|
elapsedMs: null,
|
||||||
pendingMessages: false,
|
pendingMessages: false,
|
||||||
@@ -47,7 +49,7 @@ describe('getInterruptedRecoveryCandidates', () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
chatJid: 'dc:2',
|
chatJid: 'dc:2',
|
||||||
groupName: 'two',
|
groupName: 'two-idle',
|
||||||
status: 'idle',
|
status: 'idle',
|
||||||
elapsedMs: null,
|
elapsedMs: null,
|
||||||
pendingMessages: false,
|
pendingMessages: false,
|
||||||
@@ -61,6 +63,14 @@ describe('getInterruptedRecoveryCandidates', () => {
|
|||||||
pendingMessages: true,
|
pendingMessages: true,
|
||||||
pendingTasks: 0,
|
pendingTasks: 0,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
chatJid: 'dc:1',
|
||||||
|
groupName: 'one-duplicate',
|
||||||
|
status: 'processing',
|
||||||
|
elapsedMs: 2000,
|
||||||
|
pendingMessages: false,
|
||||||
|
pendingTasks: 1,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -72,17 +82,40 @@ describe('getInterruptedRecoveryCandidates', () => {
|
|||||||
pendingMessages: true,
|
pendingMessages: true,
|
||||||
pendingTasks: 0,
|
pendingTasks: 0,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
chatJid: 'dc:2',
|
|
||||||
groupFolder: 'group-two',
|
|
||||||
status: 'idle',
|
|
||||||
pendingMessages: false,
|
|
||||||
pendingTasks: 0,
|
|
||||||
},
|
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns empty when there is no explicit restart context', () => {
|
it('returns empty when there is no explicit restart context', () => {
|
||||||
expect(getInterruptedRecoveryCandidates(null, {})).toEqual([]);
|
expect(getInterruptedRecoveryCandidates(null, {})).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses only processing groups for restart announcements', () => {
|
||||||
|
const context: RestartContext = {
|
||||||
|
chatJid: 'dc:main',
|
||||||
|
summary: 'restart',
|
||||||
|
verify: [],
|
||||||
|
writtenAt: '2026-05-21T00:00:00.000Z',
|
||||||
|
interruptedGroups: [
|
||||||
|
{
|
||||||
|
chatJid: 'dc:processing',
|
||||||
|
groupName: 'processing',
|
||||||
|
status: 'processing',
|
||||||
|
elapsedMs: 1000,
|
||||||
|
pendingMessages: false,
|
||||||
|
pendingTasks: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
chatJid: 'dc:waiting',
|
||||||
|
groupName: 'waiting',
|
||||||
|
status: 'waiting',
|
||||||
|
elapsedMs: null,
|
||||||
|
pendingMessages: true,
|
||||||
|
pendingTasks: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(getRecoverableInterruptedGroups(context)).toHaveLength(1);
|
||||||
|
expect(buildRestartAnnouncement(context)).toContain('중단 작업 감지: 1개');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -72,6 +72,20 @@ function readRestartContextFile(filePath: string): RestartContext | null {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isRecoverableInterruptedGroup(
|
||||||
|
group: RestartInterruptedGroup,
|
||||||
|
): boolean {
|
||||||
|
return group.status === 'processing';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRecoverableInterruptedGroups(
|
||||||
|
context: Pick<RestartContext, 'interruptedGroups'> | null,
|
||||||
|
): RestartInterruptedGroup[] {
|
||||||
|
return (context?.interruptedGroups ?? []).filter(
|
||||||
|
isRecoverableInterruptedGroup,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function getLatestDistBuildTime(): number | null {
|
function getLatestDistBuildTime(): number | null {
|
||||||
const distDir = path.join(process.cwd(), 'dist');
|
const distDir = path.join(process.cwd(), 'dist');
|
||||||
if (!fs.existsSync(distDir)) return null;
|
if (!fs.existsSync(distDir)) return null;
|
||||||
@@ -145,7 +159,10 @@ export function writeShutdownRestartContext(
|
|||||||
signal: string,
|
signal: string,
|
||||||
serviceIds: string[] = [SERVICE_ID],
|
serviceIds: string[] = [SERVICE_ID],
|
||||||
): string[] {
|
): string[] {
|
||||||
if (interruptedGroups.length === 0) return [];
|
const recoverableInterruptedGroups = interruptedGroups.filter(
|
||||||
|
isRecoverableInterruptedGroup,
|
||||||
|
);
|
||||||
|
if (recoverableInterruptedGroups.length === 0) return [];
|
||||||
|
|
||||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||||
const mainChatJid =
|
const mainChatJid =
|
||||||
@@ -156,8 +173,8 @@ export function writeShutdownRestartContext(
|
|||||||
const filePath = getRestartContextPath(serviceId);
|
const filePath = getRestartContextPath(serviceId);
|
||||||
const existing = readRestartContextFile(filePath);
|
const existing = readRestartContextFile(filePath);
|
||||||
const mergedInterrupted = [
|
const mergedInterrupted = [
|
||||||
...(existing?.interruptedGroups ?? []),
|
...getRecoverableInterruptedGroups(existing),
|
||||||
...interruptedGroups,
|
...recoverableInterruptedGroups,
|
||||||
].filter(
|
].filter(
|
||||||
(group, index, all) =>
|
(group, index, all) =>
|
||||||
all.findIndex((candidate) => candidate.chatJid === group.chatJid) ===
|
all.findIndex((candidate) => candidate.chatJid === group.chatJid) ===
|
||||||
@@ -221,8 +238,9 @@ export function consumeRestartContext(): RestartContext | null {
|
|||||||
|
|
||||||
export function buildRestartAnnouncement(context: RestartContext): string {
|
export function buildRestartAnnouncement(context: RestartContext): string {
|
||||||
const lines = ['재시작 완료.', `- 변경: ${context.summary}`];
|
const lines = ['재시작 완료.', `- 변경: ${context.summary}`];
|
||||||
if (context.interruptedGroups && context.interruptedGroups.length > 0) {
|
const recoverableInterruptedGroups = getRecoverableInterruptedGroups(context);
|
||||||
lines.push(`- 중단 작업 감지: ${context.interruptedGroups.length}개`);
|
if (recoverableInterruptedGroups.length > 0) {
|
||||||
|
lines.push(`- 중단 작업 감지: ${recoverableInterruptedGroups.length}개`);
|
||||||
}
|
}
|
||||||
if (context.verify.length > 0) {
|
if (context.verify.length > 0) {
|
||||||
lines.push(`- 검증: ${context.verify.join(', ')}`);
|
lines.push(`- 검증: ${context.verify.join(', ')}`);
|
||||||
@@ -240,7 +258,7 @@ export function getInterruptedRecoveryCandidates(
|
|||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const candidates: RestartRecoveryCandidate[] = [];
|
const candidates: RestartRecoveryCandidate[] = [];
|
||||||
|
|
||||||
for (const interrupted of context.interruptedGroups) {
|
for (const interrupted of getRecoverableInterruptedGroups(context)) {
|
||||||
if (seen.has(interrupted.chatJid)) continue;
|
if (seen.has(interrupted.chatJid)) continue;
|
||||||
const group = roomBindings[interrupted.chatJid];
|
const group = roomBindings[interrupted.chatJid];
|
||||||
if (!group) continue;
|
if (!group) continue;
|
||||||
|
|||||||
Reference in New Issue
Block a user