fix(test): close v9 db fixture pools per URL and seed via admin role
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user