添加gfilter专用worker (#103)
Project CI / Repository checks (push) Successful in 1m3s
Project CI / Native shell tests (push) Successful in 2m26s
Project CI / Frontend tests (push) Successful in 2m0s
Project CI / Backend tests (push) Successful in 3m1s

Co-authored-by: kdletters <kdletters@qq.com>
Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/103
Co-authored-by: Linghong <ink29535@proton.me>
Co-committed-by: Linghong <ink29535@proton.me>
This commit was merged in pull request #103.
This commit is contained in:
2026-07-23 18:06:29 +08:00
committed by 段舒康
parent 00da0dd1c8
commit a2ee879fc8
49 changed files with 10627 additions and 1107 deletions
File diff suppressed because it is too large Load Diff
+190
View File
@@ -0,0 +1,190 @@
import assert from 'node:assert/strict';
import http from 'node:http';
import { afterEach, describe, test } from 'node:test';
import {
buildIsolatedWorkerEnv,
createProviderGate,
createProviderSequenceBehavior,
SMOKE_PNG_BYTES,
startMockBgfilterProvider,
} from './bgfilter-worker-load-smoke.mjs';
const providers = [];
afterEach(async () => {
await Promise.all(providers.splice(0).map((provider) => provider.close()));
});
describe('bgfilter worker smoke harness', () => {
test('worker 环境不继承真实服务密钥并固定使用假 OSS 配置', () => {
const env = buildIsolatedWorkerEnv({
processEnv: {
ALIYUN_OSS_ACCESS_KEY_SECRET: 'real-oss-secret',
GENARRATIVE_BGFILTER_INTERNAL_TOKEN: 'real-internal-token',
GENARRATIVE_EDITOR_BGFILTER_TOKEN: 'real-provider-token',
PATH: '/safe/bin',
VECTOR_ENGINE_API_KEY: 'real-vector-secret',
},
providerBaseUrl: 'http://127.0.0.1:19001',
tempRoot: '/tmp/bgfilter-load-smoke-test',
token: 'ephemeral-test-token',
workerPort: 19002,
});
assert.equal(env.PATH, '/safe/bin');
assert.equal(
env.GENARRATIVE_BGFILTER_INTERNAL_TOKEN,
'ephemeral-test-token',
);
assert.equal(env.ALIYUN_OSS_ENDPOINT, 'oss-cn-shanghai.invalid');
assert.notEqual(env.ALIYUN_OSS_ACCESS_KEY_SECRET, 'real-oss-secret');
assert.equal(env.GENARRATIVE_EDITOR_BGFILTER_TOKEN, undefined);
assert.equal(env.VECTOR_ENGINE_API_KEY, undefined);
assert.ok(!Object.values(env).includes('real-internal-token'));
assert.ok(!Object.values(env).includes('real-provider-token'));
assert.ok(!Object.values(env).includes('real-vector-secret'));
});
test('loopback mock 完整读取 multipart 后记录并发并返回合法 PNG 字节', async () => {
const provider = await startMockBgfilterProvider({ delayMs: 25 });
providers.push(provider);
const request = multipartFixture();
const responses = await Promise.all([
postMultipart(provider.baseUrl, request),
postMultipart(provider.baseUrl, request),
]);
for (const response of responses) {
assert.equal(response.statusCode, 200);
assert.equal(response.contentType, 'image/png');
assert.ok(response.body.equals(SMOKE_PNG_BYTES));
}
const stats = provider.snapshot();
assert.equal(stats.active, 0);
assert.equal(stats.peak, 2);
assert.equal(stats.requests, 2);
assert.deepEqual(stats.violations, []);
assert.equal(stats.timeline.filter((event) => event.event === 'start').length, 2);
assert.equal(stats.timeline.filter((event) => event.event === 'finish').length, 2);
});
test('provider gate 与 sequence behavior 生成无重叠 timeline', async () => {
const gate = createProviderGate();
const provider = await startMockBgfilterProvider({
behavior: createProviderSequenceBehavior([503, 200]),
delayMs: 5,
gate,
});
providers.push(provider);
const request = multipartFixture();
let firstSettled = false;
const first = postMultipart(provider.baseUrl, request).finally(() => {
firstSettled = true;
});
await provider.waitFor((stats) => stats.active === 1, { timeoutMs: 1_000 });
await new Promise((resolve) => setTimeout(resolve, 20));
assert.equal(firstSettled, false);
gate.release();
assert.equal((await first).statusCode, 503);
assert.equal((await postMultipart(provider.baseUrl, request)).statusCode, 200);
const stats = provider.snapshot();
assert.equal(stats.peak, 1);
assert.deepEqual(
stats.timeline.map((event) => [
event.attempt,
event.event,
event.statusCode ?? null,
]),
[
[1, 'start', null],
[1, 'finish', 503],
[2, 'start', null],
[2, 'finish', 200],
],
);
});
test('provider 可在成功响应 body 中途 reset 并记录完成类型', async () => {
const provider = await startMockBgfilterProvider({
behavior: createProviderSequenceBehavior([
{ resetMidBody: true, statusCode: 200 },
]),
delayMs: 5,
});
providers.push(provider);
await assert.rejects(postMultipart(provider.baseUrl, multipartFixture()));
const stats = provider.snapshot();
assert.equal(stats.active, 0);
assert.equal(stats.peak, 1);
assert.equal(stats.requests, 1);
assert.deepEqual(stats.violations, []);
assert.equal(stats.timeline[1]?.completion, 'mid_body_reset');
});
});
function multipartFixture() {
const boundary = 'bgfilter-load-smoke-boundary';
const body = Buffer.from(
[
`--${boundary}`,
'Content-Disposition: form-data; name="image_url"',
'',
'https://example.invalid/source.png',
`--${boundary}`,
'Content-Disposition: form-data; name="seg_model"',
'',
'birefnet',
`--${boundary}`,
'Content-Disposition: form-data; name="background_mode"',
'',
'complex',
`--${boundary}`,
'Content-Disposition: form-data; name="cross_check"',
'',
'off',
`--${boundary}--`,
'',
].join('\r\n'),
'utf8',
);
return { body, boundary };
}
function postMultipart(baseUrl, { body, boundary }) {
return new Promise((resolve, reject) => {
const request = http.request(
`${baseUrl}/remove-background`,
{
agent: false,
headers: {
'Content-Length': String(body.length),
'Content-Type': `multipart/form-data; boundary=${boundary}`,
},
method: 'POST',
},
(response) => {
const chunks = [];
response.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
response.once('aborted', () => {
reject(new Error('mock provider 响应在 body 中途中止'));
});
response.once('error', reject);
response.once('end', () => {
resolve({
body: Buffer.concat(chunks),
contentType: String(response.headers['content-type'] ?? ''),
statusCode: response.statusCode ?? 0,
});
});
},
);
request.once('error', reject);
request.end(body);
});
}
File diff suppressed because it is too large Load Diff
+36
View File
@@ -258,6 +258,42 @@ function assertApiReleaseContainsPingoraDirectDependencies() {
),
'API release 必须包含外部生成 worker controller systemd 单元。',
);
assertFileExists(
path.join(
releaseDir,
'deploy/systemd/genarrative-bgfilter-worker.service',
),
'API release 必须包含唯一 BgFilter worker systemd 单元。',
);
assertFileExists(
path.join(releaseDir, 'deploy/env/bgfilter-worker.env.example'),
'API release 必须包含 BgFilter worker env 示例。',
);
const bgfilterUnit = readFileSync(
path.join(
releaseDir,
'deploy/systemd/genarrative-bgfilter-worker.service',
),
'utf8',
);
const sharedEnvIndex = bgfilterUnit.indexOf(
'EnvironmentFile=/etc/genarrative/api-server.env',
);
const dedicatedEnvIndex = bgfilterUnit.indexOf(
'EnvironmentFile=/etc/genarrative/bgfilter-worker.env',
);
if (
sharedEnvIndex < 0 ||
dedicatedEnvIndex < 0 ||
sharedEnvIndex > dedicatedEnvIndex
) {
failures.push('API release 的 BgFilter unit 必须按共享 env → 专属 env 加载。');
}
assertIncludes(
bgfilterUnit,
'TimeoutStopSec=900',
'API release 的 BgFilter unit 必须给取得 permit 后的公式化 callBudget 留足优雅排空时间。',
);
assertFileExists(
path.join(releaseDir, 'deploy/pingora/pingora-gateway.env.example'),
'API release 必须包含 Pingora env 示例。',
@@ -54,6 +54,11 @@ function assertPublicBaseUrlDefaultsToGatewayEntry() {
"process.env.GENARRATIVE_HEALTH_PATROL_PUBLIC_BASE_URL ||\n 'http://127.0.0.1'",
'publicBaseUrl 默认必须指向本机网关入口,不能回落到 API 直连端口。',
);
assertIncludes(
script,
"process.env.GENARRATIVE_HEALTH_PATROL_BGFILTER_BASE_URL ||\n 'http://127.0.0.1:8083'",
'BgFilter worker 巡检默认必须指向唯一实例的 loopback 端口。',
);
if (
script.includes(
'process.env.GENARRATIVE_HEALTH_PATROL_PUBLIC_BASE_URL ||\n process.env.GENARRATIVE_HEALTH_PATROL_API_BASE_URL',
@@ -85,6 +90,14 @@ async function assertNginxModeChecksNginxService() {
'systemctl is-active nginx.service',
'nginx gateway mode 必须检查 nginx.service。',
);
assertIncludes(
commandsLog,
'systemctl is-active genarrative-bgfilter-worker.service',
'生产巡检必须检查唯一 BgFilter worker service。',
);
if (!payload.checks.some((check) => check.name === 'bgfilter:/readyz')) {
failures.push('生产巡检必须探测 BgFilter worker /readyz。');
}
if (commandsLog.includes('genarrative-pingora-gateway.service')) {
failures.push(
'nginx gateway mode 不应要求 Pingora gateway service active。',
@@ -369,6 +382,8 @@ async function runPatrol(fixture, args) {
'scripts/ops/production-health-patrol.mjs',
'--api-base-url',
fixture.baseUrl,
'--bgfilter-base-url',
fixture.baseUrl,
'--spacetime-base-url',
fixture.baseUrl,
'--public-base-url',
+111
View File
@@ -233,6 +233,12 @@ const checks = [
reason:
'API readiness 单次请求必须有超时,避免端口已建立但服务尚未响应时绕过重试上限无限挂起。',
},
{
file: 'scripts/deploy/production-api-deploy.sh',
includes:
'on_exit() {\n local exit_code=$?\n cleanup_rendered_systemd_unit',
reason: 'API deploy 被中断时必须清理尚未安装完成的临时 systemd unit。',
},
{
file: 'jenkins/Jenkinsfile.production-api-deploy',
includes:
@@ -244,6 +250,54 @@ const checks = [
includes: 'maintenance_deploy_args+=(--keep-maintenance-mode)',
reason: 'API Deploy Job 必须把保持维护参数传给发布产物内的部署脚本。',
},
{
file: 'jenkins/Jenkinsfile.production-api-deploy',
includes:
"string(name: 'CONTROLLER_ENV_FILE', defaultValue: '/etc/genarrative/external-generation-controller.env'",
reason: 'API Deploy Job 必须暴露外部生成 controller env 路径。',
},
{
file: 'jenkins/Jenkinsfile.production-api-deploy',
includes:
"string(name: 'BGFILTER_WORKER_ENV_FILE', defaultValue: '/etc/genarrative/bgfilter-worker.env'",
reason: 'API Deploy Job 必须暴露 BgFilter worker env 路径。',
},
{
file: 'jenkins/Jenkinsfile.production-api-deploy',
includes:
'--controller-env-file "${CONTROLLER_ENV_FILE:-/etc/genarrative/external-generation-controller.env}"',
reason: 'API Deploy Job 必须把 controller env 路径传给发布脚本。',
},
{
file: 'jenkins/Jenkinsfile.production-api-deploy',
includes:
'--bgfilter-worker-env-file "${BGFILTER_WORKER_ENV_FILE:-/etc/genarrative/bgfilter-worker.env}"',
reason: 'API Deploy Job 必须把 BgFilter worker env 路径传给发布脚本。',
},
{
file: 'jenkins/Jenkinsfile.production-full-build-and-deploy',
includes:
"string(name: 'CONTROLLER_ENV_FILE', defaultValue: '/etc/genarrative/external-generation-controller.env'",
reason: 'Full Job 必须暴露外部生成 controller env 路径。',
},
{
file: 'jenkins/Jenkinsfile.production-full-build-and-deploy',
includes:
"string(name: 'BGFILTER_WORKER_ENV_FILE', defaultValue: '/etc/genarrative/bgfilter-worker.env'",
reason: 'Full Job 必须暴露 BgFilter worker env 路径。',
},
{
file: 'jenkins/Jenkinsfile.production-full-build-and-deploy',
includes:
"string(name: 'CONTROLLER_ENV_FILE', value: params.CONTROLLER_ENV_FILE ?: '/etc/genarrative/external-generation-controller.env')",
reason: 'Full Job 必须把 controller env 路径传给 API Deploy Job。',
},
{
file: 'jenkins/Jenkinsfile.production-full-build-and-deploy',
includes:
"string(name: 'BGFILTER_WORKER_ENV_FILE', value: params.BGFILTER_WORKER_ENV_FILE ?: '/etc/genarrative/bgfilter-worker.env')",
reason: 'Full Job 必须把 BgFilter worker env 路径传给 API Deploy Job。',
},
{
file: 'scripts/jenkins-server-provision.sh',
includes: 'ensure_runtime_bootstrap_secret_file_env',
@@ -1647,6 +1701,63 @@ const checks = [
includes: 'genarrative-external-generation-worker@1.service',
reason: 'Server-Provision 必须启用外部生成保底 worker 实例。',
},
{
file: 'deploy/systemd/genarrative-bgfilter-worker.service',
includes: 'TimeoutStopSec=900',
reason:
'BgFilter worker 必须给取得 permit 后的公式化 callBudget 留足优雅排空时间,不能沿用 systemd 默认停止窗口。',
},
{
file: 'scripts/jenkins-server-provision.sh',
includes: 'validate_no_bgfilter_internal_token_plaintext',
reason:
'Server-Provision 必须拒绝 API 或 BgFilter worker env 保存内部 Token 明文。',
},
{
file: 'scripts/jenkins-server-provision.sh',
includes:
'for env_file in "${API_ENV_FILE}" "${WORKER_ENV_FILE}" "${BGFILTER_WORKER_ENV_FILE}"; do',
reason:
'Server-Provision 必须同时拒绝 external-generation-worker.env 保存 BgFilter 内部 Token 明文。',
},
{
file: 'scripts/jenkins-server-provision.sh',
includes:
'validate_bgfilter_env_file_alignment "${WORKER_ENV_FILE}" "外部生成 worker env" "false"',
reason:
'Server-Provision 启动外部生成 worker 前必须拒绝 BgFilter URL、Token 文件、timeout 与 OSS 位置漂移。',
},
{
file: 'scripts/jenkins-server-provision.sh',
includes: 'validate_bgfilter_loopback_endpoint_alignment',
reason:
'Server-Provision 启动 BgFilter worker 前必须确认父 base URL 与子 listener 指向同一 loopback endpoint。',
},
{
file: 'scripts/jenkins-server-provision.sh',
includes: 'BgFilter 内部 Token 文件必须为不含空白字符的单段值',
reason:
'Server-Provision 必须拒绝纯空白、含内部空白或包含多个非空行的 BgFilter 内部 Token 文件。',
},
{
file: 'scripts/jenkins-server-provision.sh',
includes: 'current_value="$(read_effective_env_value "${file}" "${key}")"',
reason:
'Server-Provision 迁移历史默认值时必须读取最后一次有效赋值,不能覆盖后写的自定义运行态值。',
},
{
file: 'scripts/jenkins-server-provision.sh',
includes:
'ensure_env_value_migrates_old_default "${BGFILTER_WORKER_ENV_FILE}" "GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS" "300" "120"',
reason:
'Server-Provision 必须把 BgFilter 熔断 cooldown 历史模板默认 300 定向迁移为 120,并保留其它显式定制值。',
},
{
file: 'scripts/jenkins-server-provision.sh',
includes: "root:genarrative:440",
reason:
'Server-Provision 必须复核 BgFilter 内部 Token 文件的 owner、group 与 0440 权限。',
},
{
file: 'scripts/deploy/production-api-deploy.sh',
includes: 'ensure_default_worker_service',
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -55,8 +55,8 @@ export function parsePortRangeSpec(value) {
throw new Error(`端口段无效: ${spec},端口必须在 1024-65535 且起始不大于结束`);
}
if (end - start + 1 < 4) {
throw new Error(`端口段至少需要 4 个端口: ${spec}`);
if (end - start + 1 < 5) {
throw new Error(`端口段至少需要 5 个端口: ${spec}`);
}
return {start, end, label: `${start}-${end}`};
@@ -118,6 +118,7 @@ export function mapDevPortsToPortRange(portRange) {
apiPort: normalizedRange.start + 1,
spacetimePort: normalizedRange.start + 2,
adminWebPort: normalizedRange.start + 3,
bgfilterWorkerPort: normalizedRange.start + 4,
range: normalizedRange,
};
}
@@ -569,6 +570,7 @@ export async function resolveDevStackPorts(config) {
['api', config.api],
['web', config.web],
['adminWeb', config.adminWeb],
['bgfilterWorker', config.bgfilterWorker],
].filter(([, portConfig]) => Boolean(portConfig));
const result = {};
+7 -2
View File
@@ -47,7 +47,7 @@ async function reserveConsecutivePorts() {
}
describe('dev stack port utils', () => {
it('解析端口段并映射到个 dev 端口', () => {
it('解析端口段并映射到个 dev 端口', () => {
expect(parsePortRangeSpec('10000-10099')).toEqual({
start: 10000,
end: 10099,
@@ -58,7 +58,11 @@ describe('dev stack port utils', () => {
apiPort: 10001,
spacetimePort: 10002,
adminWebPort: 10003,
bgfilterWorkerPort: 10004,
});
expect(() => parsePortRangeSpec('10000-10003')).toThrow(
'端口段至少需要 5 个端口',
);
});
it('使用端口可用性检查为被占用端口寻找后续可用端口', async () => {
@@ -112,9 +116,10 @@ describe('dev stack port utils', () => {
api: {host: '127.0.0.1', preferredPort: 0},
web: {host: '127.0.0.1', preferredPort: 0},
adminWeb: {host: '127.0.0.1', preferredPort: 0},
bgfilterWorker: {host: '127.0.0.1', preferredPort: 0},
});
expect(new Set(Object.values(resolvedPorts)).size).toBe(4);
expect(new Set(Object.values(resolvedPorts)).size).toBe(5);
});
it('端口段内会一直漂移到段尾,不会被默认 200 次尝试截断', async () => {
+339 -23
View File
File diff suppressed because it is too large Load Diff
+173 -1
View File
@@ -19,6 +19,7 @@ import {
assertReusableSpacetimeProcessVersionMatchesWorkspace,
assertSpacetimeToolVersionMatchesWorkspace,
buildApiServerProcessEnv,
buildBgfilterWorkerProcessEnv,
buildDevStackSnapshot,
buildFrontendProcessEnv,
buildLocalRustProcessEnv,
@@ -86,6 +87,26 @@ describe('dev scheduler argument routing', () => {
expect(runner.resolveFrontendApiTarget()).toBe('http://127.0.0.1:8090');
});
test('独立 BgFilter worker 命令解析内部监听地址', () => {
const { command, explicitOptions, options } = parseArgs(
[
'bgfilter-worker',
'--bgfilter-worker-host',
'127.0.0.2',
'--bgfilter-worker-port',
'18083',
],
{},
);
expect(command).toBe('bgfilter-worker');
expect(explicitOptions).toEqual(
new Set(['bgfilterWorkerHost', 'bgfilterWorkerPort']),
);
expect(options.bgfilterWorkerHost).toBe('127.0.0.2');
expect(options.bgfilterWorkerPort).toBe(18083);
});
test('单独 dev:web 未显式指定 api 参数时沿用已有 Rust target', () => {
const testEnv = {
RUST_SERVER_TARGET: 'http://127.0.0.1:3100',
@@ -130,7 +151,7 @@ describe('dev scheduler argument routing', () => {
);
});
linuxTest('Linux 启动时按系统级端口段映射个 dev 端口', async () => {
linuxTest('Linux 启动时按系统级端口段映射个 dev 端口', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-dev-port-range-'));
try {
const { command, explicitOptions, options } = parseArgs([], {
@@ -157,7 +178,9 @@ describe('dev scheduler argument routing', () => {
expect(runner.options.apiPort).toBe(22001);
expect(runner.options.spacetimePort).toBe(22002);
expect(runner.options.adminWebPort).toBe(22003);
expect(runner.options.bgfilterWorkerPort).toBe(22004);
expect(runner.state.apiTarget).toBe('http://127.0.0.1:22001');
expect(runner.state.bgfilterWorkerTarget).toBe('http://127.0.0.1:22004');
expect(runner.state.spacetimeServer).toBe('http://127.0.0.1:22002');
} finally {
rmSync(tempDir, { recursive: true, force: true });
@@ -197,6 +220,7 @@ describe('dev scheduler argument routing', () => {
expect(runner.options.apiPort).toBe(22001);
expect(runner.options.spacetimePort).toBe(22002);
expect(runner.options.adminWebPort).toBe(22003);
expect(runner.options.bgfilterWorkerPort).toBe(22004);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
@@ -234,6 +258,7 @@ describe('dev scheduler argument routing', () => {
expect(runner.options.apiPort).toBe(8082);
expect(runner.options.spacetimePort).toBe(3101);
expect(runner.options.adminWebPort).toBe(3102);
expect(runner.options.bgfilterWorkerPort).toBe(8083);
} finally {
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform);
@@ -283,6 +308,46 @@ describe('dev scheduler api-server env', () => {
expect(env.GENARRATIVE_PROCESS_ROLE).toBe('api');
});
test('父 API 与独立 BgFilter worker 共享实际 URL 和内部 token', () => {
const { options } = parseArgs([], {});
options.bgfilterWorkerPort = 18083;
const state = {
spacetimeServer: 'http://127.0.0.1:3199',
bgfilterWorkerTarget: 'http://127.0.0.1:18083',
};
const internalToken = 'local-bgfilter-token';
const apiEnv = buildApiServerProcessEnv({
baseEnv: {},
options,
state,
bgfilterInternalToken: internalToken,
processRole: 'all',
});
const workerEnv = buildBgfilterWorkerProcessEnv({
baseEnv: {},
options,
state,
bgfilterInternalToken: internalToken,
});
expect(apiEnv.GENARRATIVE_PROCESS_ROLE).toBe('all');
expect(workerEnv.GENARRATIVE_PROCESS_ROLE).toBe('bgfilter-worker');
expect(apiEnv.GENARRATIVE_BGFILTER_WORKER_BASE_URL).toBe(
state.bgfilterWorkerTarget,
);
expect(workerEnv.GENARRATIVE_BGFILTER_WORKER_BASE_URL).toBe(
state.bgfilterWorkerTarget,
);
expect(apiEnv.GENARRATIVE_BGFILTER_INTERNAL_TOKEN).toBe(internalToken);
expect(workerEnv.GENARRATIVE_BGFILTER_INTERNAL_TOKEN).toBe(internalToken);
expect(workerEnv.GENARRATIVE_BGFILTER_WORKER_HOST).toBe('127.0.0.1');
expect(workerEnv.GENARRATIVE_BGFILTER_WORKER_PORT).toBe('18083');
expect(workerEnv.GENARRATIVE_BGFILTER_WORKER_CONCURRENCY).toBe('16');
expect(workerEnv.GENARRATIVE_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS).toBe('5000');
expect(workerEnv.GENARRATIVE_BGFILTER_WORKER_MAX_REQUESTS).toBe('2048');
});
test('Windows 本地 dev 自动注入已安装的 FFmpeg 路径', () => {
const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-ffmpeg-'));
try {
@@ -340,6 +405,100 @@ describe('dev scheduler api-server env', () => {
});
});
describe('dev scheduler Rust service orchestration', () => {
test('Rust 双进程重启时先全部停止,再先 ready BgFilter、后 ready API', async () => {
const { explicitOptions, options } = parseArgs([], {});
const runner = new DevRunner(options, {}, explicitOptions);
const events: string[] = [];
runner.command = 'all';
runner.windowsApiServerCleanupCompleted = true;
runner.services = new Map([
[
'api-server',
{
stop: async () => events.push('stop-api'),
start: async () => events.push('start-api'),
},
],
[
'bgfilter-worker',
{
stop: async () => events.push('stop-bgfilter'),
start: async () => events.push('start-bgfilter'),
},
],
]);
vi.spyOn(runner, 'waitForBgfilterWorker').mockImplementation(async () => {
events.push('ready-bgfilter');
});
vi.spyOn(runner, 'waitForApiServer').mockImplementation(async () => {
events.push('ready-api');
});
await runner.restartRustServicePair();
expect(events).toEqual([
'stop-api',
'stop-bgfilter',
'start-bgfilter',
'ready-bgfilter',
'start-api',
'ready-api',
]);
});
test('dev:api-server 安全自动带起同 runner 的 BgFilter worker', async () => {
const { explicitOptions, options } = parseArgs(['api-server'], {});
const runner = new DevRunner(options, {}, explicitOptions);
const startPair = vi
.spyOn(runner, 'startRustServicePair')
.mockResolvedValue(undefined);
const startWatchers = vi
.spyOn(runner, 'startWatchers')
.mockImplementation(() => {});
await runner.startCommand('api-server');
expect(startPair).toHaveBeenCalledOnce();
expect(startWatchers).toHaveBeenCalledWith([
'api-server',
'bgfilter-worker',
]);
});
test('完整栈只为两个 Rust 角色创建一套组合 watcher', () => {
const { explicitOptions, options } = parseArgs(['--watch'], {});
const runner = new DevRunner(options, {}, explicitOptions);
runner.command = 'all';
runner.registerServices();
try {
runner.startWatchers(['api-server', 'bgfilter-worker']);
expect(runner.watchers).toHaveLength(1);
} finally {
for (const watcher of runner.watchers) {
watcher.close();
}
runner.watchers = [];
}
});
test('BgFilter worker 在 readiness 前退出时立即失败', async () => {
const { explicitOptions, options } = parseArgs([], {});
const runner = new DevRunner(options, {}, explicitOptions);
runner.services = new Map([
['bgfilter-worker', { runtime: { status: 'failed' } }],
]);
globalThis.fetch = vi.fn(async () => ({
status: 503,
})) as unknown as typeof fetch;
await expect(runner.waitForBgfilterWorker()).rejects.toThrow(
'bgfilter-worker 在 readiness 前退出',
);
});
});
describe('dev scheduler local worker cleanup', () => {
const expected = {
expectedDatabase: 'xushi-p4wfr',
@@ -472,6 +631,8 @@ describe('dev scheduler stack state file', () => {
options: {
apiHost: '127.0.0.1',
apiPort: 8090,
bgfilterWorkerHost: '127.0.0.1',
bgfilterWorkerPort: 8091,
webHost: '0.0.0.0',
webPort: 3010,
adminWebHost: '127.0.0.1',
@@ -484,6 +645,7 @@ describe('dev scheduler stack state file', () => {
},
state: {
apiTarget: 'http://127.0.0.1:8090',
bgfilterWorkerTarget: 'http://127.0.0.1:8091',
adminWebTargetHost: '127.0.0.1',
spacetimeServer: 'http://127.0.0.1:3120',
},
@@ -528,6 +690,12 @@ describe('dev scheduler stack state file', () => {
port: 8090,
url: 'http://127.0.0.1:8090',
});
expect(snapshot.services['bgfilter-worker']).toMatchObject({
status: 'idle',
pid: null,
port: 8091,
url: 'http://127.0.0.1:8091',
});
});
});
@@ -1303,6 +1471,8 @@ spacetimedb tool version 2.7.0; spacetimedb-lib version 2.7.0;
'migration-secret-hash',
GENARRATIVE_SPACETIME_RUNTIME_SERVICE_BOOTSTRAP_SECRET:
'runtime-secret',
GENARRATIVE_BGFILTER_INTERNAL_TOKEN: 'bgfilter-token',
GENARRATIVE_BGFILTER_INTERNAL_TOKEN_FILE: 'bgfilter-token-file',
SAFE_VALUE: 'kept',
},
{ RUST_SERVER_TARGET: 'http://127.0.0.1:8082' },
@@ -1318,6 +1488,8 @@ spacetimedb tool version 2.7.0; spacetimedb-lib version 2.7.0;
expect(env).not.toHaveProperty(
'GENARRATIVE_SPACETIME_RUNTIME_SERVICE_BOOTSTRAP_SECRET',
);
expect(env).not.toHaveProperty('GENARRATIVE_BGFILTER_INTERNAL_TOKEN');
expect(env).not.toHaveProperty('GENARRATIVE_BGFILTER_INTERNAL_TOKEN_FILE');
expect(env.SAFE_VALUE).toBe('kept');
});
File diff suppressed because it is too large Load Diff
+16
View File
@@ -18,12 +18,14 @@ const DEFAULT_PUBLIC_PATHS = [
const DEFAULT_SERVICES = [
'genarrative-api.service',
'genarrative-bgfilter-worker.service',
'genarrative-external-generation-controller.service',
'spacetimedb.service',
'nginx.service',
];
const PINGORA_DIRECT_SERVICES = [
'genarrative-api.service',
'genarrative-bgfilter-worker.service',
'genarrative-external-generation-controller.service',
'spacetimedb.service',
'genarrative-pingora-gateway.service',
@@ -37,6 +39,7 @@ function usage() {
Options:
--api-base-url <url> API direct base URL, default http://127.0.0.1:8082
--bgfilter-base-url <url> BgFilter worker base URL, default http://127.0.0.1:8083
--spacetime-base-url <url> SpacetimeDB base URL, default http://127.0.0.1:3101
--public-base-url <url> Nginx/public base URL, default http://127.0.0.1
--public-host <host> Optional public Host header, useful when probing 127.0.0.1
@@ -88,6 +91,9 @@ function parseArgs(argv) {
apiBaseUrl:
process.env.GENARRATIVE_HEALTH_PATROL_API_BASE_URL ||
'http://127.0.0.1:8082',
bgfilterBaseUrl:
process.env.GENARRATIVE_HEALTH_PATROL_BGFILTER_BASE_URL ||
'http://127.0.0.1:8083',
spacetimeBaseUrl:
process.env.GENARRATIVE_HEALTH_PATROL_SPACETIME_BASE_URL ||
'http://127.0.0.1:3101',
@@ -131,6 +137,9 @@ function parseArgs(argv) {
case '--api-base-url':
config.apiBaseUrl = requireValue(argv, ++index, arg);
break;
case '--bgfilter-base-url':
config.bgfilterBaseUrl = requireValue(argv, ++index, arg);
break;
case '--spacetime-base-url':
config.spacetimeBaseUrl = requireValue(argv, ++index, arg);
break;
@@ -693,6 +702,13 @@ async function main() {
config,
),
);
checks.push(
await checkHttp(
'bgfilter:/readyz',
joinUrl(config.bgfilterBaseUrl, '/readyz'),
config,
),
);
checks.push(
await checkHttp(
'spacetimedb:/v1/ping',