Kaynağa Gözat

fix: restore dashboard and payroll reads below request timeout

彭峰 1 hafta önce
ebeveyn
işleme
4c34288

+ 41 - 0
docs/qa/admin-payroll-timeout-recovery-2026-10-15.md

@@ -0,0 +1,41 @@
+# 工作台与工资统计超时恢复(2026-10-15)
+
+## 原因与范围
+
+前端 `CloudFunctionsService.operations` 的 20 秒超时是截图错误的直接触发条件。线上实际请求仍返回成功:首次并发探测工作台 55.155 秒、工资收益统计 61.766 秒,数据没有消失。
+
+两条路径共同调用工资来源、版本和截止核验。`ReviewRewardIncome` 原视图通过 `jsonb_populate_record(to_jsonb(...))` 逐条重建全部凭据,租户/月筛选不能提前下推;计划显示约 15.6 万条全表记录进入函数扫描。工资来源的高估成本还触发昂贵的 JIT 编译。对限定函数禁用 JIT、直接投影视图字段后,保持原有计薪口径和来源核验。
+
+## 发布
+
+已在 2026-10-15 14:04:47(北京时间)发布 `scripts/sql/payroll-read-timeout-recovery.sql`:
+
+- 仅设置 `xs_payroll_sources`、`xs_payroll_source_version`、`xs_payroll_cutoff_snapshot` 的函数级 `jit=off`;没有修改全局数据库配置。
+- 替换 `ReviewRewardIncome` 读取投影,保留所有列、顺序、类型及纠正逻辑。
+- 没有更新工资凭据、工资批次、业务记录或网关函数;没有放宽前端超时。
+- 正式来源 SQL 定义同步保留函数设置,后续重建函数不会丢失修复。
+
+发布前备份实际线上定义,校验无运行中同步、线上定义未漂移;在同一可重复读事务内对比修改前后的收入投影摘要及来源版本,不一致即回滚。
+
+156,057 条投影的排序摘要前后均为 `08d5547285eae9d3a7c75a0f342ece57`,来源版本前后均为 `10d46191d1e7a53e98ca3a6a72b7d656`。备份与原始验收响应保存在本机私有目录 `/Users/mac/.local/state/xiaoshu/cloud-releases/2026-10-15-admin-recovery/`,不入库。
+
+## 验证
+
+- 工资完整性、截止核验、复习奖励回归 39 项通过;新增恢复迁移等价与重复执行测试 1 项通过。
+- 发布后真实接口:工作台 15.117 秒、工资统计 14.220 秒;工作台指标/提醒及工资明细逐条与发布前一致。
+- 10 月工资:28 位老师、219 节有效课次,带课工资 8,380 元、复习工资 1,278 元,总计 9,658 元。
+- 本地管理端 `http://127.0.0.1:4300` 连接真实线上接口的浏览器验收:工作台 19.204 秒、工资页 18.165 秒显示真实统计和列表;无 JS 错误、无页面报错。截图保存在上述私有目录。
+- 首次浏览器脚本等待了不存在的 `.metric-card` 选择器;修正为实际 `.overview-metrics` 后两个页面通过。
+- 页面仍显示原有来源核验待处理提示(763 条差异)。该结算保护与本次读取故障不同,没有跳过核验或擅自发放工资。
+
+这些是故障定位与恢复的小样本,不是交替 10 轮的 p75 性能结论;仍有约 14–19 秒等待,完整性能优化正在独立任务中处理。本次不提交该任务的查询、视图源文件等未完成改动。
+
+## 回滚
+
+在受控事务中恢复私有 `sql-baseline.json` 的三个 `pg_get_functiondef` 定义,并以 `view.json` 中的原视图定义执行 `CREATE OR REPLACE VIEW`。只回滚定义,不撤销或删除任何凭据、防重约束和历史调整。回滚会重新带来已知超时,应以再次修复查询为优先。
+
+## 复现
+
+```sh
+node --test scripts/tests/payroll-integrity.test.mjs scripts/tests/payroll-cutoff.test.mjs scripts/tests/review-rewards.test.mjs scripts/tests/payroll-read-timeout-recovery.test.mjs
+```

+ 1 - 1
scripts/sql/payroll-cutoff.sql

@@ -21,7 +21,7 @@ BEGIN
   RETURN NEXT jsonb_build_object('id',r."objectId",'dataset',r.dataset,'legacyKey',r."legacyKey",'student',r.title,'course',r.subtitle,'date',dt,'fields',fields,'reason',payload->>'reason','current',item,'fingerprint',v_fingerprint,'cosmetic',cosmetic,'resolved',cosmetic OR EXISTS(SELECT 1 FROM "PayrollCutoffDecision" d WHERE d.company=co AND d.month=mon AND d."conflictId"=r."objectId" AND d.fingerprint=v_fingerprint));
  END LOOP;
 END $$;
-CREATE OR REPLACE FUNCTION xs_payroll_cutoff_snapshot(co text, mon text) RETURNS jsonb LANGUAGE plpgsql AS $$
+CREATE OR REPLACE FUNCTION xs_payroll_cutoff_snapshot(co text, mon text) RETURNS jsonb LANGUAGE plpgsql SET jit=off AS $$
 DECLARE run "LegacyDirectRun"; ev record; table_name text; matches integer; complete boolean:=true; conflicts jsonb; version text; approval "PayrollCutoffApproval"; data_version text; source_version text; pending integer; datasets jsonb:='[]'; ready boolean;
 BEGIN
  IF mon !~ '^\d{4}-(0[1-9]|1[0-2])$' THEN RAISE EXCEPTION '工资月份无效'; END IF;

+ 2 - 2
scripts/sql/payroll-integrity.sql

@@ -34,7 +34,7 @@ END $$;
 
 -- Canonical completion records are authoritative, including tombstones. Facts only
 -- supplement records absent from the canonical table; a deletion cannot reappear.
-CREATE OR REPLACE FUNCTION xs_payroll_sources(co text) RETURNS SETOF jsonb LANGUAGE sql STABLE AS $$
+CREATE OR REPLACE FUNCTION xs_payroll_sources(co text) RETURNS SETOF jsonb LANGUAGE sql STABLE SET jit=off AS $$
  WITH canonical AS (
  SELECT (to_jsonb(l)-'kzsj')||jsonb_build_object(
   'lessonObjectId',l."objectId",'legacyGeneralId',COALESCE(c."generalId",l."legacyGeneralId",0),
@@ -58,7 +58,7 @@ CREATE OR REPLACE FUNCTION xs_payroll_sources(co text) RETURNS SETOF jsonb LANGU
  FROM "LessonRecordFact" f LEFT JOIN LATERAL (SELECT a.* FROM "CourseAppointment" a JOIN "CommonModel" ac ON ac."company"=co AND ac."modelId"=54 AND ac."itemId"=a."id" WHERE a."company"=co AND ac."generalId"::text=f."yyds"::text ORDER BY ac."objectId" LIMIT 1) a ON true WHERE f."company"=co AND NOT EXISTS(SELECT 1 FROM canonical c WHERE c.row_data->>'legacyGeneralId'=f."legacyGeneralId"::text AND COALESCE(f."legacyGeneralId",0)>0)
  ) SELECT row_data FROM canonical UNION ALL SELECT row_data FROM fallback
 $$;
-CREATE OR REPLACE FUNCTION xs_payroll_source_version(co text) RETURNS text LANGUAGE sql STABLE AS $$
+CREATE OR REPLACE FUNCTION xs_payroll_source_version(co text) RETURNS text LANGUAGE sql STABLE SET jit=off AS $$
  SELECT md5(COALESCE((SELECT string_agg(s::text,'|' ORDER BY s::text) FROM xs_payroll_sources(co) s),'')||
  COALESCE((SELECT string_agg(to_jsonb(r)::text,'|' ORDER BY r."objectId") FROM "TeachingCompensationRule" r WHERE r."company"=co),'')||
  COALESCE((SELECT string_agg(to_jsonb(p)::text,'|' ORDER BY p."objectId") FROM "CourseTeachingProfile" p WHERE p."company"=co),'')||

+ 38 - 0
scripts/sql/payroll-read-timeout-recovery.sql

@@ -0,0 +1,38 @@
+-- Incident 2026-10-15: keep JIT compilation out of interactive payroll reads.
+-- Scope is these functions only; restore the backed-up definitions/view to roll back.
+BEGIN;
+SET LOCAL lock_timeout='5s';
+SELECT pg_advisory_xact_lock(hashtext('xs-payroll-read-recovery'));
+ALTER FUNCTION xs_payroll_sources(text) SET jit=off;
+ALTER FUNCTION xs_payroll_source_version(text) SET jit=off;
+ALTER FUNCTION xs_payroll_cutoff_snapshot(text,text) SET jit=off;
+-- Preserve every column and correction rule while allowing tenant/month predicates
+-- to reach the indexed receipt table before projecting corrected amounts.
+CREATE OR REPLACE VIEW "ReviewRewardIncome" AS
+SELECT
+  r."objectId",
+  r."company",
+  r."taskId",
+  r."taskKey",
+  r."coachId",
+  r."coachName",
+  r."studentId",
+  r."studentName",
+  r."courseName",
+  r."storeId",
+  r."storeName",
+  r."plannedDate",
+  r."completedAt",
+  r."incomeMonth",(r.amount+COALESCE(c.amount,0))::numeric(12,2) AS amount,
+  CASE WHEN c."objectId" IS NULL THEN r.status ELSE 'ineligible' END AS status,
+  r."exception",
+  r."createdAt",
+  CASE WHEN c."objectId" IS NULL THEN r."ineligibleReason" ELSE 'corrected_overdue' END AS "ineligibleReason",
+  r.amount AS "originalAmount",
+  c."objectId" AS "correctionId",
+  c.reason AS "correctionReason",
+  c.amount AS "correctionAmount",
+  c."adjustmentBatchId"
+FROM "ReviewReward" r LEFT JOIN "ReviewRewardCorrection" c ON c.company=r.company AND c."rewardId"=r."objectId";
+
+COMMIT;

+ 32 - 0
scripts/tests/payroll-read-timeout-recovery.test.mjs

@@ -0,0 +1,32 @@
+import {test} from 'node:test';
+import assert from 'node:assert/strict';
+import {readFileSync} from 'node:fs';
+import {PGlite} from '@electric-sql/pglite';
+
+test('read recovery preserves corrected receipts, tenant fields and function results; rerun is safe',async()=>{
+ const db=new PGlite();try{
+ const rewards=readFileSync(new URL('../sql/review-rewards.sql',import.meta.url),'utf8');
+ const table=name=>rewards.match(new RegExp('CREATE TABLE IF NOT EXISTS "'+name+'" \\([^]*?\\n\\);'))[0];
+ await db.exec(table('ReviewReward')+`;ALTER TABLE "ReviewReward" ADD COLUMN "ineligibleReason" text NOT NULL DEFAULT '';`);
+ await db.exec(table('ReviewRewardCorrection'));
+ await db.exec(`CREATE FUNCTION xs_payroll_sources(text) RETURNS SETOF jsonb LANGUAGE sql STABLE AS $$ SELECT '{"amount":1}'::jsonb $$;
+ CREATE FUNCTION xs_payroll_source_version(text) RETURNS text LANGUAGE sql STABLE AS $$ SELECT 'unchanged'::text $$;
+ CREATE FUNCTION xs_payroll_cutoff_snapshot(text,text) RETURNS jsonb LANGUAGE sql STABLE AS $$ SELECT '{"approved":false}'::jsonb $$;
+ INSERT INTO "ReviewReward" ("objectId",company,"taskId","coachId","completedAt","incomeMonth",status,amount,"ineligibleReason") VALUES
+ ('earned','a','t1',1,now(),'2026-10','earned',1,''),('corrected','a','t2',1,now(),'2026-10','earned',1,''),
+ ('overdue','a','t3',1,now(),'2026-09','ineligible',0,'overdue'),('other','b','t4',2,now(),'2026-10','earned',1,'');
+ INSERT INTO "ReviewRewardCorrection" ("objectId",company,"rewardId",amount,reason,actor) VALUES ('fix','a','corrected',-1,'核对逾期','admin');
+ CREATE VIEW "ReviewRewardIncome" AS SELECT projected.*,r.amount AS "originalAmount",c."objectId" AS "correctionId",c.reason AS "correctionReason",c.amount AS "correctionAmount",c."adjustmentBatchId"
+ FROM "ReviewReward" r LEFT JOIN "ReviewRewardCorrection" c ON c.company=r.company AND c."rewardId"=r."objectId"
+ CROSS JOIN LATERAL jsonb_populate_record(NULL::"ReviewReward",to_jsonb(r)||jsonb_build_object('amount',r.amount+COALESCE(c.amount,0),'status',CASE WHEN c."objectId" IS NULL THEN r.status ELSE 'ineligible' END,'ineligibleReason',CASE WHEN c."objectId" IS NULL THEN r."ineligibleReason" ELSE 'corrected_overdue' END)) projected;`);
+ const sql='SELECT * FROM "ReviewRewardIncome" ORDER BY "objectId"',before=(await db.query(sql)).rows;
+ const patch=readFileSync(new URL('../sql/payroll-read-timeout-recovery.sql',import.meta.url),'utf8');
+ for(let i=0;i<2;i++){
+ await db.exec(patch);assert.deepEqual((await db.query(sql)).rows,before);
+ assert.equal((await db.query("SELECT xs_payroll_source_version('a') v")).rows[0].v,'unchanged');
+ assert.deepEqual((await db.query('SELECT "objectId" FROM "ReviewRewardIncome" WHERE company=$1 AND "incomeMonth"=$2 AND status=\'earned\'',['a','2026-10'])).rows,[{objectId:'earned'}]);
+ const functions=(await db.query("SELECT proconfig FROM pg_proc WHERE proname IN ('xs_payroll_sources','xs_payroll_source_version','xs_payroll_cutoff_snapshot')")).rows;
+ assert.equal(functions.length,3);assert.ok(functions.every(f=>f.proconfig.includes('jit=off')));
+ }
+ }finally{await db.close();}
+});