merge: Gateway 후속 프로필 큐 자동 복구를 main에 통합
This commit is contained in:
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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는 유지합니다.
|
||||
|
||||
@@ -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 유지 배포`는 현재 시즌을 계속 운영하면서 코드를 교체할 때 사용합니다.
|
||||
|
||||
Reference in New Issue
Block a user