fix(test): close v9 db fixture pools per URL and seed via admin role

This commit is contained in:
Jesse
2026-08-11 19:46:11 +08:00
parent 5dd0ab66a9
commit bc64ac743d
4 changed files with 133 additions and 10 deletions
+21
View File
@@ -2848,3 +2848,24 @@
- 相关记录:BUG-162、BUG-163、BUG-164
- 复发自:无(并行 runtime 合并冲突)
- 修复版本:本次 staging 候选
## BUG-166 | V9 Docker DB 测试在 staging gate 失败:service 连接池未按 URL 关闭 + fixture 种子角色越权
- 状态:investigating(本地已修复 + 非 Docker 单测通过;Gitea Docker gate 复跑确认前不标记 resolved
- 首次发现:2026-08-11Gitea staging gate run 1732
- 最近更新:2026-08-11
- 影响面:`frontend/tests/rectification-v9-database.test.ts``frontend/src/lib/db/local-postgres-client-core.ts` 的按 URL 连接池生命周期
- 用户现象:staging gate 的 Docker DB 套件恰好三个 V9 测试失败。
- 触发条件:在 CIDocker fixture)中运行 `rectification-v9-database.test.ts`
- 根因(脱敏):
1. 两个测试创建了 `createLocalPostgresDataClient` 的 service 客户端后没有关闭其全局按 URL 缓存的连接池;`fixture.stop()` 先销毁 PostgreSQL,池的异步终止随后触发 57P01terminating connection)类报错。不能全局关闭所有池(node:test 的 DB 用例可能并发),必须按各自 service URL 关闭,且任何情况下 `fixture.stop()` 都要执行。
2. 最后一个 “v9 agent api migration…” 测试用 `identity_runtime` 角色向 `public.profiles/chat_sessions/agentic_rectification_cases/agentic_rectification_turns` 播种 fixture 行;生产最小权限正确拒绝了这些写入。
- 修复:
1. 审查并保留新增的 `closeLocalPostgresDataPool(connectionString)`:先按 key 从全局缓存删除再 `pool.end()`,未知 key 与重复关闭均为安全 no-op,删除后再注册同名 key 可创建新池,与全局 `closeLocalPostgresDataPools` 并发/先后调用无冲突(end 幂等)。
2. `rectification-v9-database.test.ts` 中所有创建 service 客户端的测试(open / profile gating / evidence / backfill / agent api,共 5 个)在 `finally` 中先 `await closeLocalPostgresDataPool(service_url)``fixture.stop()`,嵌套 try/finally 保证池关闭失败时 fixture 仍会停止;纯迁移测试无需关闭。
3. 最后一个测试的 fixture 行改为:先经合法的 `identity_runtime` 播种 `identity.users`(同步到 `auth.users` 以满足 profiles FK,与文件内其它测试一致),再通过 `fixture.psql`postgres admin)播种 `public.*` 行,保留生产最小权限,不改任何 grant、不改迁移。
4. 新增非 Docker 单测 `tests/local-postgres-pool-close.test.ts`4 项):未知 key no-op、按 key 关闭互不影响、关闭后同 key 可重建、全局关闭后再按 key 关闭 no-op。
- 验证(本地可执行部分):`local-postgres-pool-close.test.ts` 4/4 通过;目标 ESLint 0 error`git diff --check` 通过;tsc 对触碰文件无新错误。本机无 DockerDocker fixture 套件无法本地执行 —— 真实 Docker 复跑证据待 Gitea gate 下一次运行提供,故状态保持 investigating。
- 防复发:任何测试创建本地数据客户端必须在 `fixture.stop()` 之前按自身 service URL 关闭连接池;测试不得用 `identity_runtime``public.*` 播种 fixture 行(一律走 postgres admin 的 `fixture.psql`);禁止为测试放宽运行时 grant 或改迁移。
- 相关记录:BUG-163、BUG-164、BUG-165
- 修复版本:本地 staging 候选(未 push / deploy
@@ -74,6 +74,14 @@ function localDataPool(connectionString: string): Pool {
return pool;
}
export async function closeLocalPostgresDataPool(connectionString: string): Promise<void> {
const pools = poolGlobal.jyotishaLocalDataPools;
const pool = pools?.get(connectionString);
if (!pool) return;
pools?.delete(connectionString);
await pool.end();
}
export async function closeLocalPostgresDataPools(): Promise<void> {
const pools = poolGlobal.jyotishaLocalDataPools;
poolGlobal.jyotishaLocalDataPools = new Map();
@@ -0,0 +1,51 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
closeLocalPostgresDataPool,
closeLocalPostgresDataPools,
createLocalPostgresDataClient,
} from "../src/lib/db/local-postgres-client-core.ts";
/**
* Non-Docker unit tests for the per-key pool close helper. pg Pool is lazy:
* constructing a client only registers the pool in the per-URL cache and
* never opens a socket, so these tests run without PostgreSQL and never
* produce 57P01 teardown noise.
*/
test("closeLocalPostgresDataPool is a safe no-op for an unknown key", async () => {
await assert.doesNotReject(
closeLocalPostgresDataPool("postgresql://never-registered@127.0.0.1:1/nope"),
);
});
test("closeLocalPostgresDataPool closes only the requested per-key pool", async () => {
const urlA = "postgresql://unit-a@127.0.0.1:1/jyotisha-unit-a";
const urlB = "postgresql://unit-b@127.0.0.1:1/jyotisha-unit-b";
createLocalPostgresDataClient(urlA, null, "service_role");
createLocalPostgresDataClient(urlB, null, "service_role");
// Closing one key must never reject or affect the other key's close.
await assert.doesNotReject(closeLocalPostgresDataPool(urlA));
await assert.doesNotReject(closeLocalPostgresDataPool(urlB));
// Re-closing the same key is idempotent.
await assert.doesNotReject(closeLocalPostgresDataPool(urlA));
});
test("the per-key cache accepts a fresh pool for a closed key", async () => {
const url = "postgresql://unit-c@127.0.0.1:1/jyotisha-unit-c";
createLocalPostgresDataClient(url, null, "service_role");
await closeLocalPostgresDataPool(url);
// A later client for the same URL registers a new pool (the close deleted
// the entry before ending the old pool); close again must resolve.
createLocalPostgresDataClient(url, null, "service_role");
await assert.doesNotReject(closeLocalPostgresDataPool(url));
});
test("per-key close after a global close is a safe no-op", async () => {
const url = "postgresql://unit-d@127.0.0.1:1/jyotisha-unit-d";
createLocalPostgresDataClient(url, null, "service_role");
await closeLocalPostgresDataPools();
await assert.doesNotReject(closeLocalPostgresDataPool(url));
});
@@ -3,7 +3,7 @@ import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import test from "node:test";
import { createLocalPostgresDataClient } from "../src/lib/db/local-postgres-client-core.ts";
import { closeLocalPostgresDataPool, createLocalPostgresDataClient } from "../src/lib/db/local-postgres-client-core.ts";
import { startPostgresFixture } from "./helpers/postgres-fixture.ts";
const runnerPath = fileURLToPath(
@@ -222,7 +222,17 @@ test("v9 open is atomic, idempotent and resumes instead of duplicating", { skip:
assert.equal((sessionOpen.data as Record<string, unknown>).case_id, caseId);
assert.equal((sessionOpen.data as Record<string, unknown>).session_id, sessionId);
} finally {
fixture.stop();
// The service client registered a per-URL pool in the global cache; close
// only this test's own service URL before destroying PostgreSQL, otherwise
// the async pool teardown surfaces 57P01 after the fixture is gone. The
// fixture must still stop even if pool close fails.
try {
await closeLocalPostgresDataPool(
fixture.connectionUrl("service_runtime", "service-runtime-test-password"),
);
} finally {
fixture.stop();
}
}
});
@@ -374,7 +384,13 @@ test("v9 enforces profile gating, ownership and terminal read-only", { skip: ski
});
assert.match(rpcError(proposal.error), /agentic_rectification_case_terminal/);
} finally {
fixture.stop();
try {
await closeLocalPostgresDataPool(
fixture.connectionUrl("service_runtime", "service-runtime-test-password"),
);
} finally {
fixture.stop();
}
}
});
@@ -542,7 +558,13 @@ test("v9 evidence lifecycle: quote grounding, idempotency, confirm and revision
"2",
);
} finally {
fixture.stop();
try {
await closeLocalPostgresDataPool(
fixture.connectionUrl("service_runtime", "service-runtime-test-password"),
);
} finally {
fixture.stop();
}
}
});
@@ -756,7 +778,13 @@ test("v9 legacy backfill maps statuses, keeps one resumable per user and is idem
assert.equal(again.error, null, rpcError(again.error));
assert.equal((again.data as Record<string, unknown>).cases_created, 0);
} finally {
fixture.stop();
try {
await closeLocalPostgresDataPool(
fixture.connectionUrl("service_runtime", "service-runtime-test-password"),
);
} finally {
fixture.stop();
}
}
});
@@ -785,18 +813,27 @@ test("v9 agent api migration applies, seeds the runtime flag and guards consent"
);
// Set up a profile + case + pending turn, then exercise the turn
// finalize and run-phase receipt RPCs.
// finalize and run-phase receipt RPCs. The identity row is seeded through
// the legitimate identity_runtime role (syncs to auth.users, satisfying
// the profiles FK); the public.* fixture rows are seeded through the
// postgres admin connection because identity_runtime has no grants on
// these tables (production least privilege). Tests must never widen
// runtime grants or change migrations.
fixture.psqlAs("identity_runtime", "identity-runtime-test-password", `
insert into identity.users (id, name, email, email_verified, email_verified_at)
values ('66666666-6666-4666-8666-666666666666', 'V9 Agent API Fixture', 'v9-agent-api-fixture@example.com', true, now())
`);
fixture.psql(`
insert into public.profiles (id, birth_date, reported_birth_time, active_birth_time, birth_time_source,
uncertainty_before_minutes, uncertainty_after_minutes, latitude, longitude, timezone_offset)
values ('66666666-6666-4666-8666-666666666666', '1997-08-08', '05:00', null, 'family_exact',
10, 10, 36.420487, 114.209936, 8);
`);
fixture.psqlAs("identity_runtime", "identity-runtime-test-password", `
fixture.psql(`
insert into public.chat_sessions (id, user_id, title, theme, session_type, messages)
values ('22222222-2222-4222-8222-222222222222', '66666666-6666-4666-8666-666666666666', '生时校正', 'general', 'birth_time_rectification', '[]'::jsonb);
`);
fixture.psqlAs("identity_runtime", "identity-runtime-test-password", `
fixture.psql(`
insert into public.agentic_rectification_cases (
id, user_id, session_id, status, skill_name, skill_version,
baseline_profile_fingerprint, baseline_birth_snapshot, candidate_range
@@ -808,7 +845,7 @@ test("v9 agent api migration applies, seeds the runtime flag and guards consent"
'{"start_time":"04:50","end_time":"05:10"}'::jsonb
);
`);
fixture.psqlAs("identity_runtime", "identity-runtime-test-password", `
fixture.psql(`
insert into public.agentic_rectification_turns (id, case_id, user_message, status, model_name)
values ('33333333-3333-4333-8333-333333333333', '11111111-1111-4111-8111-111111111111',
'2016年9月离开家去北京开始工作', 'pending', 'gpt-4o-mini');
@@ -861,6 +898,12 @@ test("v9 agent api migration applies, seeds the runtime flag and guards consent"
const consentRow = consent.data as { error?: unknown };
assert.equal(consentRow.error, "agentic_rectification_candidate_not_found");
} finally {
fixture.stop();
try {
await closeLocalPostgresDataPool(
fixture.connectionUrl("service_runtime", "service-runtime-test-password"),
);
} finally {
fixture.stop();
}
}
});