diff --git a/app/gateway-api/src/orchestrator/pm2ProcessManager.ts b/app/gateway-api/src/orchestrator/pm2ProcessManager.ts index f94edcdb..0f45ac2e 100644 --- a/app/gateway-api/src/orchestrator/pm2ProcessManager.ts +++ b/app/gateway-api/src/orchestrator/pm2ProcessManager.ts @@ -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 (handler: (pm2: Pm2Module) => Promise): Promise => { - const pm2 = loadPm2(); - await new Promise((resolve, reject) => { - pm2.connect((error) => { - if (error) { +const withTimeout = (promise: Promise, timeoutMs: number, label: string): Promise => + new Promise((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 = 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(label: string, timeoutMs: number, handler: (pm2: Pm2Client) => Promise): Promise { + const task = this.sessionTail.then(async () => { + const pm2 = this.loadPm2(); + try { + await withTimeout( + new Promise((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 { - return withPm2( + return this.withPm2( + 'PM2 list', + this.listTimeoutMs, (pm2) => new Promise((resolve, reject) => { pm2.list((error, list) => { @@ -72,7 +131,9 @@ export class Pm2ProcessManager implements ProcessManager { } async start(definition: ProcessDefinition): Promise { - await withPm2( + await this.withPm2( + `PM2 start ${definition.name}`, + this.mutationTimeoutMs, (pm2) => new Promise((resolve, reject) => { pm2.list((listError, list) => { @@ -100,7 +161,9 @@ export class Pm2ProcessManager implements ProcessManager { } async stop(name: string): Promise { - await withPm2( + await this.withPm2( + `PM2 stop ${name}`, + this.mutationTimeoutMs, (pm2) => new Promise((resolve, reject) => { pm2.stop(name, (error) => { @@ -115,7 +178,9 @@ export class Pm2ProcessManager implements ProcessManager { } async delete(name: string): Promise { - await withPm2( + await this.withPm2( + `PM2 delete ${name}`, + this.mutationTimeoutMs, (pm2) => new Promise((resolve, reject) => { pm2.delete(name, (error) => { diff --git a/app/gateway-api/test/pm2ProcessManager.test.ts b/app/gateway-api/test/pm2ProcessManager.test.ts index b6074766..ca47eb24 100644 --- a/app/gateway-api/test/pm2ProcessManager.test.ts +++ b/app/gateway-api/test/pm2ProcessManager.test.ts @@ -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[0]) { + events.push('connect'); + callback(); + }, + disconnect() { + events.push('disconnect'); + }, + list(callback: Parameters[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[0]) { + callback(); + }, + disconnect() {}, + list(callback: Parameters[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[0]) { + connectCall += 1; + if (connectCall > 1) { + callback(); + } + }, + disconnect() { + disconnectCall += 1; + }, + list(callback: Parameters[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); + }); +}); diff --git a/app/release-controller/README.md b/app/release-controller/README.md index d319933e..c048ca79 100644 --- a/app/release-controller/README.md +++ b/app/release-controller/README.md @@ -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는 유지합니다. diff --git a/docs/release-operations.md b/docs/release-operations.md index 5e5fd86b..ae0dba6a 100644 --- a/docs/release-operations.md +++ b/docs/release-operations.md @@ -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 유지 배포`는 현재 시즌을 계속 운영하면서 코드를 교체할 때 사용합니다.