diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 4ee154bb..e1fd4f33 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -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-11(Gitea 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 测试失败。 +- 触发条件:在 CI(Docker fixture)中运行 `rectification-v9-database.test.ts`。 +- 根因(脱敏): + 1. 两个测试创建了 `createLocalPostgresDataClient` 的 service 客户端后没有关闭其全局按 URL 缓存的连接池;`fixture.stop()` 先销毁 PostgreSQL,池的异步终止随后触发 57P01(terminating 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 对触碰文件无新错误。本机无 Docker,Docker 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) diff --git a/frontend/src/lib/db/local-postgres-client-core.ts b/frontend/src/lib/db/local-postgres-client-core.ts index f2d220c2..f1091ab3 100644 --- a/frontend/src/lib/db/local-postgres-client-core.ts +++ b/frontend/src/lib/db/local-postgres-client-core.ts @@ -74,6 +74,14 @@ function localDataPool(connectionString: string): Pool { return pool; } +export async function closeLocalPostgresDataPool(connectionString: string): Promise { + const pools = poolGlobal.jyotishaLocalDataPools; + const pool = pools?.get(connectionString); + if (!pool) return; + pools?.delete(connectionString); + await pool.end(); +} + export async function closeLocalPostgresDataPools(): Promise { const pools = poolGlobal.jyotishaLocalDataPools; poolGlobal.jyotishaLocalDataPools = new Map(); diff --git a/frontend/tests/local-postgres-pool-close.test.ts b/frontend/tests/local-postgres-pool-close.test.ts new file mode 100644 index 00000000..9d9aa375 --- /dev/null +++ b/frontend/tests/local-postgres-pool-close.test.ts @@ -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)); +}); diff --git a/frontend/tests/rectification-v9-database.test.ts b/frontend/tests/rectification-v9-database.test.ts index f29851d2..61bc49ec 100644 --- a/frontend/tests/rectification-v9-database.test.ts +++ b/frontend/tests/rectification-v9-database.test.ts @@ -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).case_id, caseId); assert.equal((sessionOpen.data as Record).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).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(); + } } });