merge: Gateway 후속 프로필 큐 자동 복구를 main에 통합
This commit is contained in:
@@ -8,29 +8,44 @@ import {
|
|||||||
type ProcessDefinition,
|
type ProcessDefinition,
|
||||||
} from './processManager.js';
|
} 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 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 withTimeout = <T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> =>
|
||||||
const pm2 = loadPm2();
|
new Promise<T>((resolve, reject) => {
|
||||||
await new Promise<void>((resolve, reject) => {
|
const timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms.`)), timeoutMs);
|
||||||
pm2.connect((error) => {
|
timer.unref();
|
||||||
if (error) {
|
promise.then(
|
||||||
|
(value) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve(value);
|
||||||
|
},
|
||||||
|
(error: unknown) => {
|
||||||
|
clearTimeout(timer);
|
||||||
reject(error);
|
reject(error);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
resolve();
|
);
|
||||||
});
|
});
|
||||||
});
|
|
||||||
try {
|
|
||||||
return await handler(pm2);
|
|
||||||
} finally {
|
|
||||||
pm2.disconnect();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const buildPm2StartOptions = (definition: ProcessDefinition) => ({
|
export const buildPm2StartOptions = (definition: ProcessDefinition) => ({
|
||||||
name: definition.name,
|
name: definition.name,
|
||||||
@@ -47,8 +62,52 @@ export const buildPm2StartOptions = (definition: ProcessDefinition) => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export class Pm2ProcessManager implements ProcessManager {
|
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[]> {
|
async list(): Promise<ManagedProcessInfo[]> {
|
||||||
return withPm2(
|
return this.withPm2(
|
||||||
|
'PM2 list',
|
||||||
|
this.listTimeoutMs,
|
||||||
(pm2) =>
|
(pm2) =>
|
||||||
new Promise<ManagedProcessInfo[]>((resolve, reject) => {
|
new Promise<ManagedProcessInfo[]>((resolve, reject) => {
|
||||||
pm2.list((error, list) => {
|
pm2.list((error, list) => {
|
||||||
@@ -72,7 +131,9 @@ export class Pm2ProcessManager implements ProcessManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async start(definition: ProcessDefinition): Promise<void> {
|
async start(definition: ProcessDefinition): Promise<void> {
|
||||||
await withPm2(
|
await this.withPm2(
|
||||||
|
`PM2 start ${definition.name}`,
|
||||||
|
this.mutationTimeoutMs,
|
||||||
(pm2) =>
|
(pm2) =>
|
||||||
new Promise<void>((resolve, reject) => {
|
new Promise<void>((resolve, reject) => {
|
||||||
pm2.list((listError, list) => {
|
pm2.list((listError, list) => {
|
||||||
@@ -100,7 +161,9 @@ export class Pm2ProcessManager implements ProcessManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async stop(name: string): Promise<void> {
|
async stop(name: string): Promise<void> {
|
||||||
await withPm2(
|
await this.withPm2(
|
||||||
|
`PM2 stop ${name}`,
|
||||||
|
this.mutationTimeoutMs,
|
||||||
(pm2) =>
|
(pm2) =>
|
||||||
new Promise<void>((resolve, reject) => {
|
new Promise<void>((resolve, reject) => {
|
||||||
pm2.stop(name, (error) => {
|
pm2.stop(name, (error) => {
|
||||||
@@ -115,7 +178,9 @@ export class Pm2ProcessManager implements ProcessManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async delete(name: string): Promise<void> {
|
async delete(name: string): Promise<void> {
|
||||||
await withPm2(
|
await this.withPm2(
|
||||||
|
`PM2 delete ${name}`,
|
||||||
|
this.mutationTimeoutMs,
|
||||||
(pm2) =>
|
(pm2) =>
|
||||||
new Promise<void>((resolve, reject) => {
|
new Promise<void>((resolve, reject) => {
|
||||||
pm2.delete(name, (error) => {
|
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', () => {
|
describe('buildPm2StartOptions', () => {
|
||||||
it('enforces bounded restart policy and strips inherited PM2 identity at the PM2 boundary', () => {
|
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');
|
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 검사를 우회하지
|
기능이 어긋날 수 있으므로, 일반 배포의 manifest protocol 검사를 우회하지
|
||||||
마세요. Self-upgrade CLI만 다음 protocol을 허용하며 schema head와 component는
|
마세요. 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`는 같은 보호
|
제거됩니다. Profile 관리자 API의 `admin.profiles.cleanupWorkspaces`는 같은 보호
|
||||||
규칙을 사용하므로 진행 중인 build/operation이 있으면 전체 정리를 보류합니다.
|
규칙을 사용하므로 진행 중인 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 배포
|
||||||
|
|
||||||
버전 업데이트 화면에서 profile의 branch 또는 commit을 선택합니다. Branch는 worker가
|
버전 업데이트 화면에서 profile의 branch 또는 commit을 선택합니다. Branch는 worker가
|
||||||
@@ -103,6 +112,14 @@ queue에서 기다리고, Gateway 릴리스가 실행 중이면 새 profile 작
|
|||||||
Gateway process 전환이 진행 중인 profile migration·seed 실행자를 중단하지 않는
|
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 유지 배포
|
||||||
|
|
||||||
`DB 유지 배포`는 현재 시즌을 계속 운영하면서 코드를 교체할 때 사용합니다.
|
`DB 유지 배포`는 현재 시즌을 계속 운영하면서 코드를 교체할 때 사용합니다.
|
||||||
|
|||||||
Reference in New Issue
Block a user