merge: Gateway 후속 프로필 큐 자동 복구를 main에 통합

This commit is contained in:
2026-08-21 02:05:31 +00:00
4 changed files with 233 additions and 22 deletions
@@ -8,29 +8,44 @@ import {
type ProcessDefinition,
} from './processManager.js';
type Pm2Module = typeof Pm2;
export interface Pm2Client {
connect(callback: (error?: Error) => void): void;
disconnect(): void;
list(callback: (error: Error | null, list?: Pm2.ProcessDescription[]) => void): void;
start(options: Pm2.StartOptions, callback: (error?: Error) => void): void;
stop(name: string, callback: (error?: Error) => void): void;
delete(name: string, callback: (error?: Error) => void): void;
}
export interface Pm2ProcessManagerOptions {
loadPm2?: () => Pm2Client;
connectTimeoutMs?: number;
listTimeoutMs?: number;
mutationTimeoutMs?: number;
}
const require = createRequire(import.meta.url);
const loadPm2 = (): Pm2Module => require('pm2') as Pm2Module;
const loadPm2 = (): Pm2Client => require('pm2') as Pm2Client;
const DEFAULT_PM2_CONNECT_TIMEOUT_MS = 5_000;
const DEFAULT_PM2_LIST_TIMEOUT_MS = 5_000;
const DEFAULT_PM2_MUTATION_TIMEOUT_MS = 30_000;
const withPm2 = async <T>(handler: (pm2: Pm2Module) => Promise<T>): Promise<T> => {
const pm2 = loadPm2();
await new Promise<void>((resolve, reject) => {
pm2.connect((error) => {
if (error) {
const withTimeout = <T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> =>
new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms.`)), timeoutMs);
timer.unref();
promise.then(
(value) => {
clearTimeout(timer);
resolve(value);
},
(error: unknown) => {
clearTimeout(timer);
reject(error);
return;
}
resolve();
});
);
});
try {
return await handler(pm2);
} finally {
pm2.disconnect();
}
};
export const buildPm2StartOptions = (definition: ProcessDefinition) => ({
name: definition.name,
@@ -47,8 +62,52 @@ export const buildPm2StartOptions = (definition: ProcessDefinition) => ({
});
export class Pm2ProcessManager implements ProcessManager {
private readonly loadPm2: () => Pm2Client;
private readonly connectTimeoutMs: number;
private readonly listTimeoutMs: number;
private readonly mutationTimeoutMs: number;
private sessionTail: Promise<void> = Promise.resolve();
constructor(options: Pm2ProcessManagerOptions = {}) {
this.loadPm2 = options.loadPm2 ?? loadPm2;
this.connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_PM2_CONNECT_TIMEOUT_MS;
this.listTimeoutMs = options.listTimeoutMs ?? DEFAULT_PM2_LIST_TIMEOUT_MS;
this.mutationTimeoutMs = options.mutationTimeoutMs ?? DEFAULT_PM2_MUTATION_TIMEOUT_MS;
}
private withPm2<T>(label: string, timeoutMs: number, handler: (pm2: Pm2Client) => Promise<T>): Promise<T> {
const task = this.sessionTail.then(async () => {
const pm2 = this.loadPm2();
try {
await withTimeout(
new Promise<void>((resolve, reject) => {
pm2.connect((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
}),
this.connectTimeoutMs,
'PM2 connect'
);
return await withTimeout(handler(pm2), timeoutMs, label);
} finally {
pm2.disconnect();
}
});
this.sessionTail = task.then(
() => undefined,
() => undefined
);
return task;
}
async list(): Promise<ManagedProcessInfo[]> {
return withPm2(
return this.withPm2(
'PM2 list',
this.listTimeoutMs,
(pm2) =>
new Promise<ManagedProcessInfo[]>((resolve, reject) => {
pm2.list((error, list) => {
@@ -72,7 +131,9 @@ export class Pm2ProcessManager implements ProcessManager {
}
async start(definition: ProcessDefinition): Promise<void> {
await withPm2(
await this.withPm2(
`PM2 start ${definition.name}`,
this.mutationTimeoutMs,
(pm2) =>
new Promise<void>((resolve, reject) => {
pm2.list((listError, list) => {
@@ -100,7 +161,9 @@ export class Pm2ProcessManager implements ProcessManager {
}
async stop(name: string): Promise<void> {
await withPm2(
await this.withPm2(
`PM2 stop ${name}`,
this.mutationTimeoutMs,
(pm2) =>
new Promise<void>((resolve, reject) => {
pm2.stop(name, (error) => {
@@ -115,7 +178,9 @@ export class Pm2ProcessManager implements ProcessManager {
}
async delete(name: string): Promise<void> {
await withPm2(
await this.withPm2(
`PM2 delete ${name}`,
this.mutationTimeoutMs,
(pm2) =>
new Promise<void>((resolve, reject) => {
pm2.delete(name, (error) => {
+121 -2
View File
@@ -1,6 +1,10 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { buildPm2StartOptions } from '../src/orchestrator/pm2ProcessManager.js';
import {
buildPm2StartOptions,
Pm2ProcessManager,
type Pm2Client,
} from '../src/orchestrator/pm2ProcessManager.js';
describe('buildPm2StartOptions', () => {
it('enforces bounded restart policy and strips inherited PM2 identity at the PM2 boundary', () => {
@@ -56,3 +60,118 @@ describe('buildPm2StartOptions', () => {
expect(options.env).not.toHaveProperty('args');
});
});
describe('Pm2ProcessManager session recovery', () => {
it('serializes concurrent PM2 sessions so one disconnect cannot interrupt another request', async () => {
const events: string[] = [];
let listCall = 0;
let releaseFirstList: (() => void) | undefined;
const pm2 = {
connect(callback: Parameters<Pm2Client['connect']>[0]) {
events.push('connect');
callback();
},
disconnect() {
events.push('disconnect');
},
list(callback: Parameters<Pm2Client['list']>[0]) {
listCall += 1;
const currentCall = listCall;
events.push(`list:${currentCall}`);
if (currentCall === 1) {
releaseFirstList = () => callback(null, []);
return;
}
callback(null, []);
},
start() {
throw new Error('unused');
},
stop() {
throw new Error('unused');
},
delete() {
throw new Error('unused');
},
} satisfies Pm2Client;
const manager = new Pm2ProcessManager({ loadPm2: () => pm2 });
const first = manager.list();
const second = manager.list();
await vi.waitFor(() => expect(events).toEqual(['connect', 'list:1']));
releaseFirstList?.();
await expect(Promise.all([first, second])).resolves.toEqual([[], []]);
expect(events).toEqual(['connect', 'list:1', 'disconnect', 'connect', 'list:2', 'disconnect']);
});
it('times out a lost PM2 callback and lets the next queued session proceed', async () => {
let listCall = 0;
const pm2 = {
connect(callback: Parameters<Pm2Client['connect']>[0]) {
callback();
},
disconnect() {},
list(callback: Parameters<Pm2Client['list']>[0]) {
listCall += 1;
if (listCall === 1) {
return;
}
callback(null, []);
},
start() {
throw new Error('unused');
},
stop() {
throw new Error('unused');
},
delete() {
throw new Error('unused');
},
} satisfies Pm2Client;
const manager = new Pm2ProcessManager({
loadPm2: () => pm2,
listTimeoutMs: 10,
});
await expect(manager.list()).rejects.toThrow('PM2 list timed out after 10ms.');
await expect(manager.list()).resolves.toEqual([]);
});
it('disconnects a timed-out PM2 connection before releasing the serialized session', async () => {
let connectCall = 0;
let disconnectCall = 0;
const pm2 = {
connect(callback: Parameters<Pm2Client['connect']>[0]) {
connectCall += 1;
if (connectCall > 1) {
callback();
}
},
disconnect() {
disconnectCall += 1;
},
list(callback: Parameters<Pm2Client['list']>[0]) {
callback(null, []);
},
start() {
throw new Error('unused');
},
stop() {
throw new Error('unused');
},
delete() {
throw new Error('unused');
},
} satisfies Pm2Client;
const manager = new Pm2ProcessManager({
loadPm2: () => pm2,
connectTimeoutMs: 10,
});
await expect(manager.list()).rejects.toThrow('PM2 connect timed out after 10ms.');
expect(disconnectCall).toBe(1);
await expect(manager.list()).resolves.toEqual([]);
expect(disconnectCall).toBe(2);
});
});
+10
View File
@@ -128,3 +128,13 @@ README에 정의된 경로에서 다음 순서로 확인하며, `down --volumes`
기능이 어긋날 수 있으므로, 일반 배포의 manifest protocol 검사를 우회하지
마세요. Self-upgrade CLI만 다음 protocol을 허용하며 schema head와 component는
동일하게 검증합니다.
Gateway 릴리스가 terminal인데 후속 profile 작업이 `QUEUED`, `attempts=0`에서
두 poll 주기 이상 움직이지 않으면 build process를 종료할 문제가 아니라 profile
orchestrator poll 자체를 조사합니다. Gateway 전환 직후 reconcile과 worktree cleanup이
겹쳐도 PM2 Node client session은 직렬화되며, `connect/list` callback이 5초 안에 오지
않으면 해당 scheduled task를 실패시켜 다음 poll로 자동 복구합니다. 이 timeout 전후에
PM2 mutation을 수동 반복하지 말고 active Gateway release row가 없는지, orchestrator
started 로그와 profile operation attempts가 그대로인지 먼저 확인합니다. 제한 복구가
필요하면 현재 PM2 definition의 `sammo:gateway-orchestrator`만 재시작하고 Gateway
API/frontend, release-controller, profile daemon과 container는 유지합니다.
+17
View File
@@ -92,6 +92,15 @@ Profile orchestrator와 Gateway release-controller는 서로 다른 worktree roo
제거됩니다. Profile 관리자 API의 `admin.profiles.cleanupWorkspaces`는 같은 보호
규칙을 사용하므로 진행 중인 build/operation이 있으면 전체 정리를 보류합니다.
PM2의 Node client는 한 process 안에서 공유 connection을 사용합니다. Profile
orchestrator가 시작될 때 reconcile과 worktree 정리가 동시에 `connect/list/disconnect`
호출해 한 callback이 사라지면, PM2에는 orchestrator가 `online`으로 보이면서도 정리
flag가 풀리지 않아 profile operation poll이 계속 대기할 수 있습니다. 제품 경계에서는
PM2 session을 직렬화하고 `connect/list`를 5초, start/stop/delete를 30초로 제한합니다.
Timeout은 scheduled task 오류로 끝나 정리 flag를 해제하고 다음 5초 operation poll이
queue를 다시 claim하게 합니다. PM2 mutation이 timeout된 경우에는 같은 mutation을 즉시
직접 반복하지 않고 실제 process 목록과 operation terminal 상태를 먼저 재조회합니다.
## Profile 배포
버전 업데이트 화면에서 profile의 branch 또는 commit을 선택합니다. Branch는 worker가
@@ -103,6 +112,14 @@ queue에서 기다리고, Gateway 릴리스가 실행 중이면 새 profile 작
Gateway process 전환이 진행 중인 profile migration·seed 실행자를 중단하지 않는
운영 계약입니다.
상대 Gateway 릴리스가 terminal인데도 profile 작업이 두 번의 poll 주기 이상
`QUEUED`, `attempts=0`이면 단순 build 지연이 아닙니다. Gateway orchestrator의 PM2
상태뿐 아니라 최근 started 로그, 활성 release row와 profile operation row를 함께
확인합니다. DB에 활성 release가 없고 API/frontend/profile runtime이 정상인 경우에만
현재 definition의 `sammo:gateway-orchestrator` 한 process를 재시작해 queue poll을
복구할 수 있습니다. Container, release-controller와 game daemon은 함께 재시작하지
않습니다.
### DB 유지 배포
`DB 유지 배포`는 현재 시즌을 계속 운영하면서 코드를 교체할 때 사용합니다.