Commit cb97a914 by luoqi

merge: perf/persona-recompute → test(画像重算吞吐优化,上测试服实测对照)

四项:worker pool 替代批次栅栏 / --concurrency 真正驱动连接池 / patient_facts 两查合一 /
审计日志 2 写改 1 写。往返 12.7 → 10.7。

本地 13,268 患者实测 93.50s → 76.89s(1.22×),指纹法证明产出与旧算法逐字节一致。
但本地延迟分布(avg 35.9ms / max 436ms)测不出 worker pool 的真实收益 ——
测试服是 avg 165ms / max 75,750ms,长尾会冻住整批。

上测试服的目的就是拿真实倍数:旧代码基线已录得 1,454 人/分 · avg 146ms(concurrency=8)。
parents a0d20f82 e5122154
...@@ -14,6 +14,7 @@ import { Logger } from '@nestjs/common'; ...@@ -14,6 +14,7 @@ import { Logger } from '@nestjs/common';
import { AppModule } from '../app.module'; import { AppModule } from '../app.module';
import { PersonaService } from '../modules/persona/persona.service'; import { PersonaService } from '../modules/persona/persona.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { runPool } from '../common/run-pool';
interface Args { interface Args {
host: string; host: string;
...@@ -49,6 +50,13 @@ function parseArgs(argv: string[]): Args { ...@@ -49,6 +50,13 @@ function parseArgs(argv: string[]): Args {
async function bootstrap() { async function bootstrap() {
const args = parseArgs(process.argv.slice(2)); const args = parseArgs(process.argv.slice(2));
const logger = new Logger('recompute-persona'); const logger = new Logger('recompute-persona');
// ⭐ 必须在 NestFactory 之前设:PrismaService 在容器初始化时就按这个值算 connection_limit。
// 不设的话池是 Prisma 默认的「核数×2+1」(4 核机 = 9),--concurrency 超过它就静默失效
// —— 多出来的 worker 全卡在连接池排队,提并发一点不快(2026-07-26 生产实测)。
// 只在用户显式给了 --concurrency 时设,不覆盖外部已有的环境变量。
if (args.concurrency > 1 && !process.env.PAC_DB_CONCURRENCY) {
process.env.PAC_DB_CONCURRENCY = String(args.concurrency);
}
const app = await NestFactory.createApplicationContext(AppModule, { const app = await NestFactory.createApplicationContext(AppModule, {
logger: ['log', 'warn', 'error'], logger: ['log', 'warn', 'error'],
}); });
...@@ -140,11 +148,9 @@ async function bootstrap() { ...@@ -140,11 +148,9 @@ async function bootstrap() {
if (!verbose && ++done % 2000 === 0) logger.log(` …进度 ${done}/${total} (failed=${failed})`); if (!verbose && ++done % 2000 === 0) logger.log(` …进度 ${done}/${total} (failed=${failed})`);
}; };
// 并发分块:每患者 recompute 含 ~15 次串行 DB 往返(I/O 等待为主), // ⭐ worker pool 替代原「每 N 个一批 await Promise.all」的栅栏(实测效率只有 55%,
// 单进程内并发可重叠这些等待;CPU 占比高的部分再靠多进程 --shard 吃多核。 // 缺口=等本批最慢那个)。口径与 runPool 注释同源。
for (let i = 0; i < patients.length; i += args.concurrency) { await runPool(patients, args.concurrency, (p) => runOne(p));
await Promise.all(patients.slice(i, i + args.concurrency).map(runOne));
}
logger.log('─────────────────────────────────'); logger.log('─────────────────────────────────');
logger.log( logger.log(
......
/**
* runPool —— 连续调度的 worker pool(替代「每 N 个一批 await Promise.all」的栅栏写法)。
*
* 为什么不用批次栅栏:每批都要等本批**最慢**那个,快的 worker 白等。
* 2026-07-26 在测试服实测 recompute-persona(12 万患者样本):
* 单患者 p50 139ms / p90 230ms / p99 386ms / max 75.7s,均值 165ms
* concurrency=8 理论上限 8÷0.165 = 2,909 人/分,实际只有 ~1,600 → **效率 55%**
* 缺口正好是 E[8 次抽样最大值](≈p90)与均值之比。极端例子:那个 75.7 秒的患者
* 把同批 7 个 worker 一起冻了 75 秒。
*
* 本实现:N 个 worker 各自从共享游标取下一个,谁空谁取 —— 慢任务只拖住自己那条线。
*
* 语义与 `for (i+=N) await Promise.all(slice)` 保持一致的地方:
* · 全部处理完才 resolve;
* · 并发上限严格 ≤ concurrency;
* · **不做错误吞咽** —— fn 抛出会让整个 runPool reject(调用方自己 try/catch 每项,
* 跟原来 `.map(runOne)` 里 runOne 自带 try/catch 的用法一致)。
*/
export async function runPool<T>(
items: readonly T[],
concurrency: number,
fn: (item: T, index: number) => Promise<void>,
): Promise<void> {
const n = Math.max(1, Math.min(Math.floor(concurrency) || 1, items.length));
if (items.length === 0) return;
let cursor = 0;
await Promise.all(
Array.from({ length: n }, async () => {
for (;;) {
const idx = cursor++;
if (idx >= items.length) return;
await fn(items[idx]!, idx);
}
}),
);
}
...@@ -230,25 +230,36 @@ export class PersonaService { ...@@ -230,25 +230,36 @@ export class PersonaService {
}; };
} }
const log = await this.prisma.personaRecomputeLog.create({ // ⭐ 审计日志改「一次终态写」—— 原来是 create(running) + update(终态) 两次往返。
data: { // startedAt 可显式赋值,所以耗时统计一字不差(见下方两处 create)。
// 代价:进程被硬杀(OOM / SIGKILL)时这一条不会留痕。评估过可接受 ——
// · try/catch 里的异常仍会写 'failed' 行,真正的算法错误照样有记录;
// · 唯一丢的是"算到一半进程没了",而那种情况原本留下的是一条**永远卡在 running**
// 的行(没人清理),本身就是噪声;批量回填另有 CLI 进度日志兜底。
// 读侧确认无依赖:daily-health-report 只用 findFirst(最近一条 startedAt/status)
// 与按 status 的 groupBy 日统计,都不需要 running 行。
// 全量回填每患者省 1 次往返(11.7 → 10.7),且少一半日志表写入(35.6 万 → 17.8 万行/轮)。
const logBase = {
hostId: patient.hostId, hostId: patient.hostId,
tenantId: patient.tenantId, tenantId: patient.tenantId,
patientId: patient.id, patientId: patient.id,
triggeredBy: input.source, triggeredBy: input.source,
status: 'running', startedAt,
}, };
});
try { try {
const facts = await this.prisma.patientFact.findMany({ // ⭐ 一次取,内存分两份 —— 原来对 patient_facts 查两遍(同一患者、同一索引、两次往返):
// ① status IN (active, fulfilled) → factsByType
// ② type=appointment_record AND status != superseded → appointmentsAll
// ② 是 ① 的超集在 appointment 这一类上的放宽(多了 cancelled / no_show 等状态),
// 所以取 `status != 'superseded'` 一把捞回来,再在内存里切成两份,语义逐字节等价。
// (全量回填时每患者省 1 次往返;12.7 → 11.7)
const allFacts = await this.prisma.patientFact.findMany({
where: { where: {
hostId: patient.hostId, hostId: patient.hostId,
tenantId: patient.tenantId, tenantId: patient.tenantId,
patientId: patient.id, patientId: patient.id,
// 跟 chain-composer / scenario SQL 对齐:actual treatment 完成后 status='fulfilled', status: { not: 'superseded' },
// 不能只取 'active' 否则已完成治疗在 persona 看不到 → treatment_chain_status 误算 gap
status: { in: ['active', 'fulfilled'] },
}, },
select: { select: {
id: true, id: true,
...@@ -263,12 +274,20 @@ export class PersonaService { ...@@ -263,12 +274,20 @@ export class PersonaService {
}, },
}); });
// factsByType 口径不变:只收 active / fulfilled。
// 跟 chain-composer / scenario SQL 对齐:actual treatment 完成后 status='fulfilled',
// 不能只取 'active' 否则已完成治疗在 persona 看不到 → treatment_chain_status 误算 gap
const factsByType = new Map<string, ActiveFact[]>(); const factsByType = new Map<string, ActiveFact[]>();
for (const f of facts) { // 全部 appointment(各状态,排 superseded)— 特别关注爽约/迟到用(no_show 预约 status≠active)
const appointmentsAll: ActiveFact[] = [];
for (const f of allFacts) {
if (f.status === 'active' || f.status === 'fulfilled') {
const arr = factsByType.get(f.type) ?? []; const arr = factsByType.get(f.type) ?? [];
arr.push(f as ActiveFact); arr.push(f as ActiveFact);
factsByType.set(f.type, arr); factsByType.set(f.type, arr);
} }
if (f.type === 'appointment_record') appointmentsAll.push(f as ActiveFact);
}
// 关系边(家庭构成等用)— relatedPatientId 是否解析不影响,只看 relationship 类型 // 关系边(家庭构成等用)— relatedPatientId 是否解析不影响,只看 relationship 类型
const relations = await this.prisma.patientRelation.findMany({ const relations = await this.prisma.patientRelation.findMany({
...@@ -276,21 +295,6 @@ export class PersonaService { ...@@ -276,21 +295,6 @@ export class PersonaService {
select: { relationship: true }, select: { relationship: true },
}); });
// 全部 appointment(各状态,排 superseded)— 特别关注爽约/迟到用(no_show 预约 status≠active)
const appointmentsAll = (await this.prisma.patientFact.findMany({
where: {
hostId: patient.hostId,
tenantId: patient.tenantId,
patientId: patient.id,
type: 'appointment_record',
status: { not: 'superseded' },
},
select: {
id: true, subjectId: true, type: true, kind: true, status: true,
occurredAt: true, plannedFor: true, clinicId: true, content: true,
},
})) as ActiveFact[];
// 潜在治疗 gap(诊断了/建议了但没启动对应治疗)— 复用召回 gap 核心,去时间门(常态属性)。 // 潜在治疗 gap(诊断了/建议了但没启动对应治疗)— 复用召回 gap 核心,去时间门(常态属性)。
// 按患者 active 诊断/建议码剪枝:无相关码 → 不查 SQL。 // 按患者 active 诊断/建议码剪枝:无相关码 → 不查 SQL。
const activeCodes = new Set<string>(); const activeCodes = new Set<string>();
...@@ -461,9 +465,9 @@ export class PersonaService { ...@@ -461,9 +465,9 @@ export class PersonaService {
status = failed === 0 ? 'success' : drafts.length > 0 ? 'partial' : 'failed'; status = failed === 0 ? 'success' : drafts.length > 0 ? 'partial' : 'failed';
} }
await this.prisma.personaRecomputeLog.update({ await this.prisma.personaRecomputeLog.create({
where: { id: log.id },
data: { data: {
...logBase,
status, status,
producedPersonaId: personaId, producedPersonaId: personaId,
featureCount: drafts.length, featureCount: drafts.length,
...@@ -485,9 +489,9 @@ export class PersonaService { ...@@ -485,9 +489,9 @@ export class PersonaService {
durationMs: Date.now() - startedAt.getTime(), durationMs: Date.now() - startedAt.getTime(),
}; };
} catch (err) { } catch (err) {
await this.prisma.personaRecomputeLog.update({ await this.prisma.personaRecomputeLog.create({
where: { id: log.id },
data: { data: {
...logBase,
status: 'failed', status: 'failed',
errorMessage: err instanceof Error ? err.message : String(err), errorMessage: err instanceof Error ? err.message : String(err),
endedAt: new Date(), endedAt: new Date(),
......
...@@ -3,16 +3,23 @@ import { PrismaClient } from '@prisma/client'; ...@@ -3,16 +3,23 @@ import { PrismaClient } from '@prisma/client';
import { tenantGuardExtension } from './tenant-guard.extension'; import { tenantGuardExtension } from './tenant-guard.extension';
/** /**
* 连接池随 cohort 并发自动放大 —— 免去手动给 DATABASE_URL 追加 `?connection_limit=`。 * 连接池随**本进程声明的并发**自动放大 —— 免去手动给 DATABASE_URL 追加 `?connection_limit=`。
* *
* 病根:并发旋钮是 PAC_COHORT_CONCURRENCY(N 个 cohort runner 共享一个池), * 病根:Prisma 默认池大小是 `核数×2+1`,跟业务并发旋钮完全无关 → 一调并发就得手动调池。
* 但 Prisma 默认池大小是 `核数×2+1`,与 N 无关 → 一调并发就得手动调池。这里把两者联动: * 这里把两者联动:
* - 未设 / =1(常驻 API 进程)→ 不改 URL,走 Prisma 默认池; * - 未设 / =1(常驻 API 进程)→ 不改 URL,走 Prisma 默认池;
* - >1(摄入是独立 process)→ connection_limit = N×6+5,封顶 40(守住 Postgres max_connections=100); * - >1(各 CLI 是独立 process)→ connection_limit = N×6+5,封顶 40(守住 Postgres max_connections=100);
* - URL 里已显式写了 connection_limit → 尊重,不覆盖(逃生口)。 * - URL 里已显式写了 connection_limit → 尊重,不覆盖(逃生口)。
* 摄入是独立进程,只有它拿大池,常驻服务仍是小池,天然隔离。 * CLI 是独立进程,只有它拿大池,常驻服务仍是小池,天然隔离。
*
* ⭐ 2026-07-26 修:原来只认 `PAC_COHORT_CONCURRENCY`(摄入的旋钮),于是
* `recompute-persona --concurrency=N` 这类 CLI 的旋钮**根本没放大池** —— 生产实测
* 跑 `--concurrency=8` 时进程恰好只有 9 条连接(= 4核×2+1 的 Prisma 默认值),
* 也就是说 `--concurrency` 一旦超过 9 就是个静默失效的假旋钮,多出来的 worker 全卡在池上排队。
* 现在改成读**通用**的 PAC_DB_CONCURRENCY,由各 CLI 在 Nest 启动前按自己的 --concurrency 设进来;
* PAC_COHORT_CONCURRENCY 保留为向后兼容的别名,两者取大。
*/ */
function withCohortDerivedPool(rawUrl: string | undefined): string | undefined { export function withCohortDerivedPool(rawUrl: string | undefined): string | undefined {
if (!rawUrl) return rawUrl; if (!rawUrl) return rawUrl;
let url: URL; let url: URL;
try { try {
...@@ -21,7 +28,8 @@ function withCohortDerivedPool(rawUrl: string | undefined): string | undefined { ...@@ -21,7 +28,8 @@ function withCohortDerivedPool(rawUrl: string | undefined): string | undefined {
return rawUrl; // 非标准 URL(如 unix socket)— 交给 Prisma 原样处理 return rawUrl; // 非标准 URL(如 unix socket)— 交给 Prisma 原样处理
} }
if (url.searchParams.has('connection_limit')) return rawUrl; if (url.searchParams.has('connection_limit')) return rawUrl;
const n = Math.max(1, parseInt(process.env.PAC_COHORT_CONCURRENCY ?? '1', 10) || 1); const num = (v: string | undefined) => Math.max(1, parseInt(v ?? '1', 10) || 1);
const n = Math.max(num(process.env.PAC_DB_CONCURRENCY), num(process.env.PAC_COHORT_CONCURRENCY));
if (n <= 1) return rawUrl; if (n <= 1) return rawUrl;
url.searchParams.set('connection_limit', String(Math.min(n * 6 + 5, 40))); url.searchParams.set('connection_limit', String(Math.min(n * 6 + 5, 40)));
return url.toString(); return url.toString();
......
import { execFileSync } from 'node:child_process';
import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
/**
* deploy-prod.sh 的「机器形态自检」回归 —— 锁住 2026-07-26 生产事故的防线。
*
* 事故:在 friday 生产机漏了 `COMPOSE_MANAGED=1` 跑 deploy-prod.sh。不叠加 managed override 时,
* docker-compose.prod.yml 把 DATABASE_URL 硬编码成 `postgres:5432`(容器内自带 PG),于是:
* · 当场起了 pac-postgres-1 / pac-redis-1 两个空容器
* · 29 条迁移建到那个**空库**上(托管库本身没被写坏,已逐项核对数据完好)
* 真正的后遗症是机器上从此留了个「schema 齐全的空库」—— 下次再漏 COMPOSE_MANAGED,
* service 会顺利连上它、health 200、脚本三项验证全绿,**生产静默服务空库且零告警**。
* 原本"空 PG 起不来会崩"这张天然安全网就此失效。
*
* 防线口径:不依赖人记得加环境变量,而是问机器自己 —— 读 `.env` 的 DATABASE_URL 指向哪儿。
* 指向 postgres/localhost/127.0.0.1 → 自带 DB 形态,放行
* 指向别的主机(RDS 等) → 托管形态,必须 COMPOSE_MANAGED=1,否则 die
*/
const SCRIPT = resolve(__dirname, '../../../deploy/deploy-prod.sh');
/** 造一棵最小目录树(deploy/ + 两个 .env),跑脚本并回收 stdout+stderr。 */
function runGuard(dbUrl: string, env: NodeJS.ProcessEnv): { code: number; out: string } {
const dir = mkdtempSync(join(tmpdir(), 'pac-deploy-guard-'));
try {
mkdirSync(join(dir, 'deploy'), { recursive: true });
mkdirSync(join(dir, 'apps/pac-service'), { recursive: true });
mkdirSync(join(dir, 'apps/pac-web'), { recursive: true });
cpSync(SCRIPT, join(dir, 'deploy/deploy-prod.sh'));
writeFileSync(join(dir, 'apps/pac-service/.env'), `DATABASE_URL="${dbUrl}"\n`);
writeFileSync(join(dir, 'apps/pac-web/.env'), '');
try {
const out = execFileSync('bash', ['deploy/deploy-prod.sh', '--no-pull'], {
cwd: dir,
// git/docker 在临时目录里必然失败 —— 无所谓,闸在它们之前,只看闸放不放行
env: { ...process.env, ...env },
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 30_000,
});
return { code: 0, out };
} catch (e) {
const err = e as { status?: number; stdout?: string; stderr?: string };
return { code: err.status ?? -1, out: `${err.stdout ?? ''}${err.stderr ?? ''}` };
}
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
const RDS = 'postgresql://pac:s3cr3t@fridaypac.pg.rds.aliyuncs.com:5432/pac?schema=public';
const BUNDLED = 'postgresql://pac:pac@postgres:5432/pac?schema=public';
describe('deploy-prod.sh 机器形态自检(COMPOSE_MANAGED 防线)', () => {
test('⭐ 托管 DB + 漏设 COMPOSE_MANAGED → 必须退出,且给出正确命令', () => {
const { code, out } = runGuard(RDS, { COMPOSE_MANAGED: '' });
expect(code).not.toBe(0);
expect(out).toContain('托管 DB 形态');
expect(out).toContain('COMPOSE_MANAGED=1 bash deploy/deploy-prod.sh');
});
test('⭐ 报错信息只暴露主机名,不能把 .env 里的口令打出来', () => {
const { out } = runGuard(RDS, { COMPOSE_MANAGED: '' });
expect(out).toContain('fridaypac.pg.rds.aliyuncs.com');
expect(out).not.toContain('s3cr3t');
});
test('⭐ 托管 DB + COMPOSE_MANAGED=1 → 放行(闸不拦,后面失败是因为临时目录没 git/docker)', () => {
const { out } = runGuard(RDS, { COMPOSE_MANAGED: '1' });
expect(out).not.toContain('托管 DB 形态');
});
test('自带 DB(postgres:5432)+ 不设 COMPOSE_MANAGED → 放行(测试机形态,不能误伤)', () => {
const { out } = runGuard(BUNDLED, { COMPOSE_MANAGED: '' });
expect(out).not.toContain('托管 DB 形态');
});
test('localhost / 127.0.0.1 同样按自带 DB 放行', () => {
for (const host of ['localhost', '127.0.0.1']) {
const { out } = runGuard(`postgresql://pac:pac@${host}:5432/pac`, { COMPOSE_MANAGED: '' });
expect(out).not.toContain('托管 DB 形态');
}
});
});
import { runPool } from '../src/common/run-pool';
import { withCohortDerivedPool } from '../src/prisma/prisma.service';
/**
* 画像重算的两个吞吐修复(2026-07-26,依据测试服 12 万患者实测):
*
* 基线 p50 139ms / p90 230ms / p99 386ms / max 75.7s,均值 165ms
* concurrency=8 理论上限 8÷0.165 = 2,909 人/分,实际 ~1,600 → **效率 55%**
*
* ① 批次栅栏 → worker pool:缺口正好是 E[8 次抽样最大值](≈p90)与均值之比;
* 那个 75.7 秒的患者会把同批 7 个 worker 一起冻住。
* ② --concurrency 没放大连接池:实测跑 --concurrency=8 时进程恰好 9 条连接
* (= 4核×2+1 的 Prisma 默认值),超过 9 的并发全卡在池上排队,旋钮静默失效。
*/
describe('runPool —— 连续调度,不做批次栅栏', () => {
test('全部跑完,且顺序不丢项', async () => {
const items = Array.from({ length: 50 }, (_, i) => i);
const seen: number[] = [];
await runPool(items, 7, async (x) => { seen.push(x); });
expect(seen.sort((a, b) => a - b)).toEqual(items);
});
test('并发上限严格 ≤ concurrency', async () => {
let inFlight = 0;
let peak = 0;
await runPool(Array.from({ length: 40 }, (_, i) => i), 5, async () => {
peak = Math.max(peak, ++inFlight);
await new Promise((r) => setTimeout(r, 5));
inFlight--;
});
expect(peak).toBeLessThanOrEqual(5);
expect(peak).toBe(5); // 也要真的用满,不是退化成串行
});
test('⭐ 一个慢任务只拖住自己那条线,不冻住同批其他 worker', async () => {
// 4 个任务:第 0 个慢 200ms,其余 3 个各 10ms;并发 2。
// 栅栏写法:[0,1] 一批 → 等 200ms;[2,3] 一批 → 10ms;总 ≈210ms
// worker pool:worker A 卡在 0 上,worker B 依次做完 1、2、3;总 ≈200ms 且 1/2/3 早早完成
const done: Array<{ id: number; at: number }> = [];
const t0 = Date.now();
await runPool([0, 1, 2, 3], 2, async (x) => {
await new Promise((r) => setTimeout(r, x === 0 ? 200 : 10));
done.push({ id: x, at: Date.now() - t0 });
});
const slow = done.find((d) => d.id === 0)!;
const fastest3 = done.filter((d) => d.id !== 0);
expect(fastest3).toHaveLength(3);
// 三个快任务必须在慢任务之前就都做完了 —— 栅栏写法下 2、3 只能等慢任务结束才开始
for (const f of fastest3) expect(f.at).toBeLessThan(slow.at);
});
test('concurrency 大于任务数 / 空列表 都不炸', async () => {
const seen: number[] = [];
await runPool([1, 2], 99, async (x) => { seen.push(x); });
expect(seen.sort()).toEqual([1, 2]);
await expect(runPool([], 8, async () => { throw new Error('不该被调用'); })).resolves.toBeUndefined();
});
test('fn 抛错会向上冒(调用方各自 try/catch,跟原 .map(runOne) 用法一致)', async () => {
await expect(runPool([1], 2, async () => { throw new Error('boom'); })).rejects.toThrow('boom');
});
});
describe('连接池随并发放大 —— PAC_DB_CONCURRENCY', () => {
const URL_ = 'postgresql://u:p@db:5432/pac?schema=public';
const saved = { db: process.env.PAC_DB_CONCURRENCY, cohort: process.env.PAC_COHORT_CONCURRENCY };
const set = (db?: string, cohort?: string) => {
if (db === undefined) delete process.env.PAC_DB_CONCURRENCY; else process.env.PAC_DB_CONCURRENCY = db;
if (cohort === undefined) delete process.env.PAC_COHORT_CONCURRENCY; else process.env.PAC_COHORT_CONCURRENCY = cohort;
};
afterAll(() => set(saved.db, saved.cohort));
const limitOf = (u: string | undefined) =>
u ? new URL(u).searchParams.get('connection_limit') : null;
/// 口径与实现同源:n×6+5,封顶 40
const expected = (n: number) => String(Math.min(n * 6 + 5, 40));
test('⭐ 修的正是这条:PAC_DB_CONCURRENCY 要能放大池(原来只认 COHORT,CLI 旋钮静默失效)', () => {
set('4', undefined);
expect(limitOf(withCohortDerivedPool(URL_))).toBe(expected(4)); // 29
set('8', undefined);
expect(limitOf(withCohortDerivedPool(URL_))).toBe(expected(8)); // 53 → 封顶 40
});
test('向后兼容:PAC_COHORT_CONCURRENCY 仍然有效(摄入路径不能被改坏)', () => {
set(undefined, '4');
expect(limitOf(withCohortDerivedPool(URL_))).toBe(expected(4));
});
test('两者都设 → 取大', () => {
set('2', '5');
expect(limitOf(withCohortDerivedPool(URL_))).toBe(expected(5)); // 35,不是 17
});
test('封顶 40(守住 Postgres max_connections=100)', () => {
set('64', undefined);
expect(limitOf(withCohortDerivedPool(URL_))).toBe('40');
});
test('都没设 / =1(常驻 API 进程)→ 不动 URL,走 Prisma 默认池', () => {
set(undefined, undefined);
expect(withCohortDerivedPool(URL_)).toBe(URL_);
set('1', '1');
expect(withCohortDerivedPool(URL_)).toBe(URL_);
});
test('URL 里已显式写了 connection_limit → 尊重,不覆盖(逃生口)', () => {
set('16', undefined);
const explicit = 'postgresql://u:p@db:5432/pac?connection_limit=7';
expect(withCohortDerivedPool(explicit)).toBe(explicit);
});
test('非标准 URL(unix socket 等)原样返回,不抛', () => {
set('8', undefined);
expect(withCohortDerivedPool('not-a-url')).toBe('not-a-url');
expect(withCohortDerivedPool(undefined)).toBeUndefined();
});
});
...@@ -26,6 +26,33 @@ die() { printf '\033[1;31mFAIL: %s\033[0m\n' "$*" >&2; exit 1; } ...@@ -26,6 +26,33 @@ die() { printf '\033[1;31mFAIL: %s\033[0m\n' "$*" >&2; exit 1; }
main() { main() {
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
# ⭐ 机器形态自检(2026-07-26 事故防线)——「该托管却没带 COMPOSE_MANAGED=1」直接退出。
#
# 事故经过:在 friday 生产机漏了 COMPOSE_MANAGED=1 跑本脚本 → 不叠加 managed override →
# docker-compose.prod.yml 里 DATABASE_URL 硬编码成 postgres:5432(容器内自带 PG),
# 于是当场起了 pac-postgres-1 / pac-redis-1 两个空容器,并把 29 条迁移建到那个**空库**上。
# 托管库本身没被写坏,但机器上从此留下一个「schema 齐全的空库」——
# 下次再漏 COMPOSE_MANAGED,service 会顺利连上它、health 200、三项验证全绿,
# **生产静默服务空库且无任何告警**。原本"空 PG 起不来会崩"的天然安全网就此失效。
#
# 判据不依赖人记得加环境变量,而是问机器自己:.env 的 DATABASE_URL 指向哪儿。
# 指向 postgres:5432 / localhost → 自带 DB 形态,COMPOSE_MANAGED 可不设
# 指向别的主机(RDS 等) → 托管形态,必须 COMPOSE_MANAGED=1,否则退出
local SVC_ENV="apps/pac-service/.env"
if [[ -f "$SVC_ENV" ]]; then
local dburl
dburl=$(grep -E '^\s*DATABASE_URL=' "$SVC_ENV" | tail -1 | cut -d= -f2- | tr -d '"'"'"' ')
if [[ -n "$dburl" && "$dburl" != *"@postgres:"* && "$dburl" != *"@localhost:"* && "$dburl" != *"@127.0.0.1:"* ]]; then
# 托管形态。注意只打印主机名,不打印含口令的整串。
local dbhost="${dburl#*@}"; dbhost="${dbhost%%/*}"
[[ "${COMPOSE_MANAGED:-0}" == "1" ]] || die \
"本机是【托管 DB 形态】(.env 的 DATABASE_URL 指向 $dbhost),但没设 COMPOSE_MANAGED=1。
这样跑会起自带 PG/Redis 容器、把迁移建到空库,并跳过私网可达性验证。
正确命令: COMPOSE_MANAGED=1 bash deploy/deploy-prod.sh
(若确实想用自带 DB,请改 .env 的 DATABASE_URL 指向 postgres:5432)"
fi
fi
# 托管 DB/Redis:COMPOSE_MANAGED=1 叠加 override(不启自带 PG/Redis,URL 走 .env 的托管端点) # 托管 DB/Redis:COMPOSE_MANAGED=1 叠加 override(不启自带 PG/Redis,URL 走 .env 的托管端点)
local COMPOSE=(docker compose -f docker-compose.prod.yml) local COMPOSE=(docker compose -f docker-compose.prod.yml)
[[ "${COMPOSE_MANAGED:-0}" == "1" ]] && COMPOSE+=(-f docker-compose.managed.yml) [[ "${COMPOSE_MANAGED:-0}" == "1" ]] && COMPOSE+=(-f docker-compose.managed.yml)
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment