Skip to content

Commit 711b7b7

Browse files
committed
缩短网关崩溃后的锁接管时间
1 parent 853c842 commit 711b7b7

6 files changed

Lines changed: 44 additions & 5 deletions

File tree

docs/architecture.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ Dashboard ─────┘ ↑ ↑
4848
Gateway 启动时按以下顺序工作:
4949

5050
1. 初始化数据库,自动执行 `drizzle/` migrations,开启外键并运行 `PRAGMA foreign_key_check`;有孤立记录时直接拒绝启动。
51-
2.`BEGIN IMMEDIATE` 更新 `gateway_lock`。锁每 10 秒心跳30 秒未更新才允许新实例接管;此时 `ready_at` 仍为空。
51+
2.`BEGIN IMMEDIATE` 更新 `gateway_lock`。锁每 10 秒心跳;记录的 PID 已不存在时可立即接管,否则要等心跳 30 秒未更新;此时 `ready_at` 仍为空。
5252
3. 加载并校验配置;非法配置直接失败,不静默回退。
5353
4. 启动 Worker、Watchdog、Scheduler,最后按配置启动内嵌 Dashboard;全部成功后才写入 `ready_at`
5454
5. 收到 `SIGINT``SIGTERM` 时先停止接单,等待 `worker.shutdownGracePeriodMs`;宽限期内完成的任务正常落库,只有剩余子进程才会终止、重置为 `pending`,对应执行记录标为失败。

src/core/process-control.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,16 @@ function isSafePid(pid: number): boolean {
1010
return Number.isInteger(pid) && pid > 1 && pid !== process.pid;
1111
}
1212

13+
export function isProcessAlive(pid: number): boolean {
14+
if (!Number.isInteger(pid) || pid <= 0) return false;
15+
try {
16+
process.kill(pid, 0);
17+
return true;
18+
} catch (error) {
19+
return error instanceof Error && 'code' in error && error.code === 'EPERM';
20+
}
21+
}
22+
1323
function inspectUnixProcess(pid: number): ProcessInfo | null {
1424
const result = spawnSync('ps', ['-o', 'pgid=', '-o', 'command=', '-p', String(pid)], {
1525
encoding: 'utf8',

src/gateway/index.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,13 @@ import { closeDb } from '@core/db';
77
import { TaskService } from '@core/services/task.service';
88
import { TaskRunService } from '@core/services/task-run.service';
99
import { initializeGatewayHealth, resetGatewayHealth } from './health';
10+
import { isProcessAlive } from '@core/process-control';
1011

1112
// gateway_lock.heartbeat_at / acquired_at 单位:毫秒(Date.now())
1213
// 超过此阈值未心跳则视为锁持有者已死亡
1314
const STALE_THRESHOLD_MS = 30_000;
1415

15-
function acquireLock(): boolean {
16+
export function acquireLock(): boolean {
1617
const now = Date.now();
1718
const pid = process.pid;
1819

@@ -26,7 +27,8 @@ function acquireLock(): boolean {
2627
} | undefined;
2728

2829
if (existing) {
29-
if (now - existing.heartbeat_at < STALE_THRESHOLD_MS) {
30+
const lockHolderAlive = existing.pid !== pid && isProcessAlive(existing.pid);
31+
if (now - existing.heartbeat_at < STALE_THRESHOLD_MS && lockHolderAlive) {
3032
sqlite.exec('ROLLBACK');
3133
console.error(JSON.stringify({
3234
ts: new Date().toISOString(),
@@ -58,7 +60,7 @@ function acquireLock(): boolean {
5860
}
5961
}
6062

61-
function releaseLock() {
63+
export function releaseLock() {
6264
try {
6365
sqlite.exec('DELETE FROM gateway_lock WHERE pid = ?', [process.pid]);
6466
} catch {}

tests/gateway-lock.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { describe, expect, test } from 'bun:test';
2+
import { setupTestDb } from './helpers/mock-db';
3+
4+
describe('Gateway 单实例锁', () => {
5+
test('新鲜锁记录的 PID 已不存在时立即接管', async () => {
6+
setupTestDb();
7+
const { sqlite } = await import('../src/core/db');
8+
const missingPid = 2_147_483_647;
9+
sqlite.exec(
10+
'INSERT INTO gateway_lock (id, pid, acquired_at, heartbeat_at, ready_at) VALUES (1, ?, ?, ?, ?)',
11+
[missingPid, Date.now(), Date.now(), Date.now()],
12+
);
13+
const { acquireLock, releaseLock } = await import('../src/gateway/index');
14+
15+
expect(acquireLock()).toBe(true);
16+
expect(sqlite.prepare('SELECT pid FROM gateway_lock WHERE id = 1').get()).toEqual({
17+
pid: process.pid,
18+
});
19+
releaseLock();
20+
});
21+
});

tests/helpers/mock-db.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ export function setupTestDb() {
9696

9797
mock.module('../../src/core/db', () => ({
9898
db: testDb,
99+
sqlite,
99100
schema,
100101
getDb: () => testDb,
101102
getSqlite: () => sqlite,

tests/process-control.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync
33
import { basename, join } from 'path';
44
import { tmpdir } from 'os';
55
import { spawn, type ChildProcess } from 'child_process';
6-
import { signalRecordedProcessTree, signalSpawnedProcessTree } from '../src/core/process-control';
6+
import { isProcessAlive, signalRecordedProcessTree, signalSpawnedProcessTree } from '../src/core/process-control';
77

88
const dirs: string[] = [];
99
const children: ChildProcess[] = [];
@@ -57,6 +57,11 @@ setInterval(() => {}, 1000);
5757
}
5858

5959
describe('进程树终止', () => {
60+
test('能区分当前进程与不存在的 PID', () => {
61+
expect(isProcessAlive(process.pid)).toBe(true);
62+
expect(isProcessAlive(2_147_483_647)).toBe(false);
63+
});
64+
6065
test('命令不匹配时拒绝终止记录的 PID', async () => {
6166
const tree = createProcessTree();
6267
await waitForFile(tree.childPidFile);

0 commit comments

Comments
 (0)