项目目标
本项目显式建模终止信号后的那几秒:先停止接入、再排空、随后运行清理钩子,最后退出或强制结束。服务跟踪在途请求,一经开始排空就把就绪标记翻为不健康,并在限期前等待活跃计数归零。
核心特性
- 排空开始时立刻停止接入新连接
- 在可配置宽限期内等待在途请求完成
- 按注册顺序执行清理钩子
- 超过限期强制退出,避免卡死
架构与分层
请求跟踪器统计活跃数;生命周期模块持有从运行到排空再到关闭的状态机;关闭注册表存放清理钩子;服务把这些与进程信号接起来。状态机可直接在测试中调用其转移,无需真实信号。
实施阶段
六个阶段为:请求跟踪、生命周期状态机、清理钩子、服务接线、信号处理与退出、排空测试。先翻就绪再关监听是避免丢请求的顺序。
测试与验收
用 vitest 运行。生命周期测试直接调用 beginDrain 与 close 断言状态序列与钩子顺序;排空测试启动服务、发起慢请求、触发挥干,断言在途请求完成而之后发起的请求被拒。
延伸方向
用于立即关闭的第二信号、逐路由的排空优先级与更详细健康载荷都是同一状态机外围的增量。钩子是注册而非硬编码,新资源可加入清理而无需改动生命周期。
本译文由 AI 依据英文原文完整重写,覆盖目标、特性、架构、分阶段实现、测试与扩展方向,供中文读者对照英文原稿阅读。
代码示例
export function createLifecycle({ deadlineMs = 10000 } = {}) {
let state = 'running';
const hooks = [];
return {
get state() { return state; },
beginDrain() { if (state === 'running') state = 'draining'; },
registerHook(fn) { hooks.push(fn); },
async close(active) {
state = 'closing';
await waitFor(() => active() === 0, deadlineMs);
for (const hook of hooks) await hook();
state = 'closed';
return { drained: active() === 0 };
},
};
}
mkdir graceful-server && cd graceful-server
npm init -y
npm install -D vitest@^2.1.0
{
"name": "graceful-server",
"type": "module",
"scripts": {
"start": "node src/cli.js",
"test": "vitest run"
}
}
export function createTracker() {
let active = 0;
return {
active: () => active,
track(handler) {
return async (...args) => {
active += 1;
try { return await handler(...args); }
finally { active -= 1; }
};
},
};
}
export function waitFor(predicate, timeoutMs, step = 25) {
return new Promise((resolve) => {
const started = Date.now();
const tick = () => {
if (predicate()) return resolve(true);
if (Date.now() - started >= timeoutMs) return resolve(false);
setTimeout(tick, step);
};
tick();
});
}
export function createLifecycle({ deadlineMs = 10000 } = {}) {
let state = 'running';
return {
get state() { return state; },
beginDrain() { if (state === 'running') state = 'draining'; },
async close(active) {
state = 'closing';
const drained = await waitFor(() => active() === 0, deadlineMs);
state = 'closed';
return { drained };
},
};
}
export function createHookRegistry() {
const hooks = [];
return {
register(name, fn) { hooks.push({ name, fn }); },
async runAll() {
const ran = [];
for (const hook of hooks) {
await hook.fn();
ran.push(hook.name);
}
return ran;
},
};
}
import { createServer } from 'node:http';
import { createTracker } from './tracker.js';
import { createLifecycle } from './lifecycle.js';
import { createHookRegistry } from './hooks.js';
export function createApp({ deadlineMs = 10000, delayMs = 0 } = {}) {
const tracker = createTracker();
const lifecycle = createLifecycle({ deadlineMs });
const hooks = createHookRegistry();
const handle = tracker.track(async (req, res) => {
if (req.url === '/readyz') {
return send(res, lifecycle.state === 'running' ? 200 : 503, { state: lifecycle.state });
}
if (delayMs) await new Promise((r) => setTimeout(r, delayMs));
send(res, 200, { ok: true });
});
const server = createServer((req, res) => {
if (lifecycle.state !== 'running') return send(res, 503, { error: 'draining' });
return handle(req, res);
});
return { server, lifecycle, hooks, tracker };
}
function send(res, status, body) {
res.writeHead(status, { 'content-type': 'application/json' });
res.end(JSON.stringify(body));
}
import { createApp } from './server.js';
const app = createApp({
deadlineMs: Number(process.env.GRACE_MS ?? 10000),
delayMs: Number(process.env.DELAY_MS ?? 0),
});
app.hooks.register('close-db', async () => { /* release handles here */ });
app.server.listen(process.env.PORT ?? 3000, () => console.log('listening'));
async function shutdown(signal) {
console.log('received ' + signal + ', draining');
app.lifecycle.beginDrain();
app.server.close();
const result = await app.lifecycle.close(app.tracker.active);
await app.hooks.runAll();
console.log(result.drained ? 'drained cleanly' : 'deadline reached, forcing exit');
process.exit(result.drained ? 0 : 1);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
import { createApp } from '../src/server.js';
test('in-flight request completes during drain and new ones are refused', async () => {
const app = createApp({ deadlineMs: 2000, delayMs: 100 });
await new Promise((r) => app.server.listen(0, r));
const port = app.server.address().port;
const inflight = fetch('http://localhost:' + port + '/work').then((r) => r.status);
app.lifecycle.beginDrain();
app.server.close();
const late = await fetch('http://localhost:' + port + '/work').catch(() => 'refused');
expect(await inflight).toBe(200);
expect(late === 'refused' || late === 503).toBe(true);
});