From b6f0c83608e94229a54570a87d1babca7d413376 Mon Sep 17 00:00:00 2001 From: shunli Date: Sun, 27 Sep 2026 12:27:04 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=87=E8=B4=AD=E6=8F=90=E4=BA=A4=E4=B8=8E?= =?UTF-8?q?=E6=8A=A5=E9=94=80=E6=96=B0=E5=BB=BA/=E6=8F=90=E4=BA=A4?= =?UTF-8?q?=E4=BA=8B=E5=8A=A1=E6=98=BE=E5=BC=8F=E4=BD=BF=E7=94=A810?= =?UTF-8?q?=E7=A7=92=E4=B8=8A=E9=99=90=20[09-27-purchase-submit-tx-timeout?= =?UTF-8?q?]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 负载下采购提交事务超过 Prisma 交互事务默认 5000ms(P2028)返回 500。 分段计时显示无单一瓶颈(总时长中位 843ms、最大 3506ms,编号锁等待 最多 535ms),是约 20 条顺序查询整体变慢贴近上限。报销新建/提交同结构。 三处改用项目既有的 { maxWait: 5000, timeout: 10000 },事务内容不变; 新增采购与报销提交的事务选项回归断言。 Co-Authored-By: Claude Opus 5.5 --- .../check.jsonl | 0 .../implement.jsonl | 0 .../implement.md | 7 ++++ .../09-27-purchase-submit-tx-timeout/prd.md | 33 +++++++++++++++++++ .../task.json | 33 +++++++++++++++++++ server/src/routes/purchaseRequests.js | 4 ++- server/src/routes/reimbursements.js | 4 +-- .../purchase.submit-budget-precheck.test.js | 11 +++++++ .../reimbursements.expense-standards.test.js | 4 ++- 9 files changed, 92 insertions(+), 4 deletions(-) create mode 100644 .trellis/tasks/09-27-purchase-submit-tx-timeout/check.jsonl create mode 100644 .trellis/tasks/09-27-purchase-submit-tx-timeout/implement.jsonl create mode 100644 .trellis/tasks/09-27-purchase-submit-tx-timeout/implement.md create mode 100644 .trellis/tasks/09-27-purchase-submit-tx-timeout/prd.md create mode 100644 .trellis/tasks/09-27-purchase-submit-tx-timeout/task.json diff --git a/.trellis/tasks/09-27-purchase-submit-tx-timeout/check.jsonl b/.trellis/tasks/09-27-purchase-submit-tx-timeout/check.jsonl new file mode 100644 index 00000000..e69de29b diff --git a/.trellis/tasks/09-27-purchase-submit-tx-timeout/implement.jsonl b/.trellis/tasks/09-27-purchase-submit-tx-timeout/implement.jsonl new file mode 100644 index 00000000..e69de29b diff --git a/.trellis/tasks/09-27-purchase-submit-tx-timeout/implement.md b/.trellis/tasks/09-27-purchase-submit-tx-timeout/implement.md new file mode 100644 index 00000000..c6f8ff95 --- /dev/null +++ b/.trellis/tasks/09-27-purchase-submit-tx-timeout/implement.md @@ -0,0 +1,7 @@ +## 2026-09-27 实施(当前) + +- [x] 临时分段计时(未提交,已还原)定位:事务总时长中位 843ms/最大 3506ms,无单一瓶颈,编号锁等待最多 535ms。 +- [x] 采购提交、报销新建、报销提交三处 `$transaction` 显式 `{ maxWait: 5000, timeout: 10000 }`;事务内容不变。 +- [x] 回归断言:采购提交(去掉修复即失败)、报销提交。 +- [x] 后端全量单测:4451 通过 / 1 原跳过。 +- [ ] 负载复现确认无采购提交 P2028;随下次发布候选走完整门禁。 diff --git a/.trellis/tasks/09-27-purchase-submit-tx-timeout/prd.md b/.trellis/tasks/09-27-purchase-submit-tx-timeout/prd.md new file mode 100644 index 00000000..b6cc0413 --- /dev/null +++ b/.trellis/tasks/09-27-purchase-submit-tx-timeout/prd.md @@ -0,0 +1,33 @@ +# 采购提交事务负载下超时返回500 + +## Goal + +消除采购提交(及同结构的报销新建/提交)在负载下超过 Prisma 交互事务默认 5000ms 上限而返回 500(P2028)的问题。 + +## Background / Evidence(2026-09-26/27) + +- 09-26 负载复现(采购 ×6–8 + permission-direct-access ×6–8,4 worker,2 核主机)每轮出现 1–2 次 `purchase submit failed: 500`,服务端为 `P2028 Transaction API error ... timeout 5000 ms, 5352 ms passed`,位于 `server/src/routes/purchaseRequests.js` 提交事务内的 `workflowTask.create`。正式门禁 6 轮未出现。 +- 生产 `b1d25d3c` 与当前代码同写法(默认超时),非近期回归。 +- 09-27 事务内分段计时(16 次提交全部成功):锁定单据中位 41/最大 290ms;编号前校验与预检 466/1659ms;编号分配含锁等待 101/535ms;编号后至提交 317/2194ms;**事务总时长中位 843ms、最大 3506ms**。无单一瓶颈:编号行锁等待最多 0.5s,主要是约 20 条顺序查询在负载下整体变慢,贴近 5s 上限。 +- 报销新建(`POST /reimbursements`)与提交(`/reimbursements/:id/submit`)同样在默认超时事务内分配编号、执行多步写入,属同一风险。 + +## Scope + +- 采购提交、报销新建、报销提交三处交互事务显式使用项目既有写法 `{ maxWait: 5000, timeout: 10000 }`(付款批次、回执 OCR 等已采用)。 +- 不改事务内容、顺序、锁、编号或业务语义。 + +## Out of Scope + +缩短编号行持锁时间的重排、其他路由事务的统一治理、部署。 + +## Acceptance Criteria + +- [ ] 三处事务传入 `{ maxWait: 5000, timeout: 10000 }`,采购与报销提交各有回归断言(去掉修复即失败)。 +- [ ] 后端全量单测通过。 +- [ ] 负载复现不再出现采购提交 P2028。 + +## Test Plan + +- `server/tests/unit/routes/purchase.submit-budget-precheck.test.js`:新增断言提交事务选项;已验证去掉修复时该用例失败。 +- `server/tests/unit/routes/reimbursements.expense-standards.test.js`:在既有成功提交用例中断言事务选项。 +- 后端全量 vitest;负载复现脚本(见 09-26-approve-success-state-unchanged/research/repro.sh)。 diff --git a/.trellis/tasks/09-27-purchase-submit-tx-timeout/task.json b/.trellis/tasks/09-27-purchase-submit-tx-timeout/task.json new file mode 100644 index 00000000..535f0483 --- /dev/null +++ b/.trellis/tasks/09-27-purchase-submit-tx-timeout/task.json @@ -0,0 +1,33 @@ +{ + "id": "purchase-submit-tx-timeout", + "name": "purchase-submit-tx-timeout", + "title": "采购提交事务负载下超时返回500", + "description": "负载复现中采购提交交互事务超过Prisma默认5000ms(P2028)返回500;生产b1d25d3c起同写法。查清耗时构成(锁等待/事务内查询),给出最小修复。", + "status": "planning", + "dev_type": null, + "scope": "transaction-timeout", + "package": null, + "priority": "P2", + "creator": "shunli", + "assignee": "shunli", + "createdAt": "2026-09-27", + "completedAt": null, + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [ + "server/src/routes/purchaseRequests.js", + "server/src/routes/reimbursements.js", + "server/tests/unit/routes/purchase.submit-budget-precheck.test.js", + "server/tests/unit/routes/reimbursements.expense-standards.test.js" + ], + "notes": "", + "meta": { + "implementationApproval": "APPROVED_BY_USER_20260927" + } +} diff --git a/server/src/routes/purchaseRequests.js b/server/src/routes/purchaseRequests.js index 3d155a6f..026fcb67 100644 --- a/server/src/routes/purchaseRequests.js +++ b/server/src/routes/purchaseRequests.js @@ -1701,6 +1701,8 @@ async function purchaseRequestRoutes(fastify, options = {}) { throw buildBusinessValidationError("purchase_request.approver_unavailable", { workflow: "no_actionable_approver" }, 409); } + // ~20 sequential queries plus document numbering; under load this exceeded + // Prisma's 5s interactive-transaction default (P2028 -> 500). const submitResult = await fastify.prisma.$transaction(async (tx) => { const locked = await loadPurchaseRequestForUpdate(tx, purchaseRequestId); ensureDraftOwner(request, locked); @@ -1949,7 +1951,7 @@ async function purchaseRequestRoutes(fastify, options = {}) { budgetReservation, }, }; - }); + }, { maxWait: 5000, timeout: 10000 }); void fastify.prisma.auditLog.create({ data: buildAuditLogData(request, { diff --git a/server/src/routes/reimbursements.js b/server/src/routes/reimbursements.js index 660060c3..a5784953 100644 --- a/server/src/routes/reimbursements.js +++ b/server/src/routes/reimbursements.js @@ -1863,7 +1863,7 @@ async function reimbursementRoutes(fastify) { ...created, items: createdItems, }; - }); + }, { maxWait: 5000, timeout: 10000 }); return sendData(reply, request, { id: reimbursement.id, @@ -4498,7 +4498,7 @@ async function reimbursementRoutes(fastify) { actorId: request.employee.id, }, }; - }); + }, { maxWait: 5000, timeout: 10000 }); await finalizeReimbursementSubmitSideEffects({ prisma: fastify.prisma, diff --git a/server/tests/unit/routes/purchase.submit-budget-precheck.test.js b/server/tests/unit/routes/purchase.submit-budget-precheck.test.js index 49e7ee21..985b6823 100644 --- a/server/tests/unit/routes/purchase.submit-budget-precheck.test.js +++ b/server/tests/unit/routes/purchase.submit-budget-precheck.test.js @@ -315,6 +315,17 @@ describe("purchase request submit budget precheck gate", () => { await app.close(); }); + + it("runs the submit transaction with a 10s budget instead of Prisma's 5s default", async () => { + resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck({ budgetHint: { controlMode: "block" } })); + const { app, prisma } = await buildApp({ budgetControlMode: "warn", budgetPrecheckEnabled: false }); + + const response = await app.inject({ method: "POST", url: `/api/purchase-requests/${PURCHASE_REQUEST_ID}/submit` }); + + expect(response.statusCode).toBe(200); + expect(prisma.$transaction).toHaveBeenCalledWith(expect.any(Function), { maxWait: 5000, timeout: 10000 }); + await app.close(); + }); }); it.each([1, 2])("submits a stored v%s purchase draft under active policy without reclassifying it", async (version) => { diff --git a/server/tests/unit/routes/reimbursements.expense-standards.test.js b/server/tests/unit/routes/reimbursements.expense-standards.test.js index 92296ed1..2057e8b2 100644 --- a/server/tests/unit/routes/reimbursements.expense-standards.test.js +++ b/server/tests/unit/routes/reimbursements.expense-standards.test.js @@ -325,7 +325,7 @@ describe("reimbursements routes expense standard integration", () => { it.each([true, false])("avoids unused bound-invoice counts: per-item guard=%s", async invoiceGuardEnabled => { resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck()); - const { app, tx } = await buildApp({ invoiceGuardEnabled, reimbursementItems: [{ + const { app, prisma, tx } = await buildApp({ invoiceGuardEnabled, reimbursementItems: [{ id: "item-no-invoice", amount: "180.00", expense_type: { code: "office", category: "office", requires_invoice: invoiceGuardEnabled }, }] }); @@ -336,6 +336,8 @@ describe("reimbursements routes expense standard integration", () => { expect(tx.reimbursementItem.findFirst).not.toHaveBeenCalled(); expect(tx.reimbursementItem.aggregate).toHaveBeenCalledTimes(1); expect(tx.workflowHistory.create).toHaveBeenCalled(); + // Prisma's 5s interactive-transaction default was exceeded under load (P2028 -> 500). + expect(prisma.$transaction).toHaveBeenCalledWith(expect.any(Function), { maxWait: 5000, timeout: 10000 }); } finally { await app.close(); } });