Explorar el Código

feat(perf): bound shared cloud reads and page export snapshots

彭峰 hace 7 horas
padre
commit
a8214620af

+ 3 - 1
deployment/systemd/xiaoshu-lesson-export.service

@@ -7,7 +7,9 @@ Wants=network-online.target
 Type=simple
 WorkingDirectory=/opt/xiaoshu-angular
 Environment=XIAOSHU_EXPORT_ENV_FILE=/etc/xiaoshu/lesson-export.env
-ExecStart=/usr/bin/node scripts/lesson-export-worker.mjs
+ExecStart=/usr/bin/node --max-old-space-size=512 scripts/lesson-export-worker.mjs
+Nice=10
+MemoryMax=1G
 Restart=always
 RestartSec=5
 User=xiaoshu

+ 21 - 1
perf/cloud-concurrency-2026-11-15/PROGRESS.md

@@ -12,8 +12,28 @@
 - [x] 隔离真实 PostgreSQL 18.4 多连接正确性及超载保护验证。
 - [ ] 生产峰值混合负载、30分钟稳定性和真实设备验收。
 - Git:本次改动已整合主分支最新代码验证;提交及推送结果以聊天最终回执为准。
-- [ ] 正式发布:待权限核对、容量验收和用户批准。
+- [ ] 正式发布:用户已批准达标后直接上线;等待最终容量验收与线上回读。
 
 Ruling: 修订版本采用事务专属追加事件并包含未完成旧事务,替代共享计数行;避免新的业务死锁,代价是每日维护事件表。
 Ruling: 大导出快照可单独使用60秒SQL上限,由独立worker承担;代价是首次快照仍可能因数据规模失败,不进行前台重试风暴。
 Ruling: 没有生产定义/连接池/峰值证据时不猜测扩容额度、不盲建索引;代价是全局冷查询减半及上线容量验收仍未完成。
+
+## 追加执行(用户已授权达标后直接上线)
+
+- 生产定义已使用既有发布凭据读取并备份;之前 HTTP 400 的访问问题已解决。
+- PostgreSQL 17.9,worker SQL池2,2核/约4GB;隔离两份数据副本验证4个索引,训练全局SQL p75 3484→859ms,结果哈希一致。
+- 正在隔离副本进行50并发用户、10请求/秒、30分钟持续负载与轻查询隔离验证。私有证据目录:`~/.local/state/xiaoshu/qa/2026-11-15-cloud-concurrency-final/`,soak-progress.json/soak-final-private.json记录状态。
+- 已添加跨接口数据库租约及语句预算、训练列表带权限的跨请求合并缓存。生产尚未部署。
+- 后续发布沿用原子哈希校验并保留线上额外修复,先迁移与worker后端,再发布前端。用户本轮已明确批准达到验收条件后直接上线,不重复请求批准。
+
+### 最终回归与发布准备
+
+- 生产为三个节点,每节点原 SQL 池最多 2 个;保留原连接池。独立池的启动参数被数据库代理拒绝,已撤回实验并关闭三个节点的临时池;核对 statement_timeout 恢复为原值 0。
+- 事务控制、SET LOCAL 与查询合为单次协议交换,错误先 ROLLBACK 再释放连接;不在事务隔离级别前执行 SELECT。真实 PostgreSQL 已核对超时取消、参数转义、空/多行错误及连接可继续使用。
+- 浏览器已核对真实管理员列表/翻页,教师和学生首页/账户(390px,非实体手机);无未捕获脚本错误。独立后台导出核对刷新恢复、查询可用、重复下载防护,144 条 XLSX 回读一致。
+- 59,794 条独立导出约 65 秒完成,Node 20 远端 worker 峰值 RSS 815,562,752 字节;15,711,484 字节文件已回读核对 13 列、59,794 行。
+- 一轮稳定测试 30 分钟、17,687 个请求、0 失败;随后预算重测在约 13 分钟出现 503,已停止注入。进程日志证实其中一个 server 于 22:33:11 被进程管理器停止并启动,没有该时刻的内存超限日志;该轮不算通过,使用最终实现重新计时。
+- 三个节点只暂存导出 worker 文件;未启动常驻 worker、未发布前端、未替换正式网关。生产源业务数据未做修复或扣费写入。
+
+Ruling: 重读仍共享两个跨节点槽位,使用原池及事务内 SQL 时限,不使用启动参数超时或新的连接池;理由是实测数据库代理不支持该参数,且单连接会串行化页面原有的独立读取。代价是快速读取存在少量准入开销,必须在混合负载与报告中单独列出,不能计为提速收益。
+Ruling: 导出快照在数据库内落表,JavaScript 每批只处理 500 条;理由是旧全量 JS 快照实验曾超内存并触发 1GB 进程重启。代价是后台任务占用临时快照表和较长事务,已通过 6 万记录及固定快照回读验证。

+ 4 - 3
projects/shared/request-scheduler.ts

@@ -6,7 +6,7 @@ export interface RequestPolicy<T> {
   heavy?: boolean;
   background?: boolean;
   valid?: () => boolean;
-  cacheMs?: number;
+  cacheMs?: number | ((value: T) => number);
   refresh?: boolean;
   cacheable?: (value: T) => boolean;
 }
@@ -75,10 +75,11 @@ export class RequestScheduler {
           this.drain();
         };
       }).pipe(tap(value => {
-        if (key && policy.cacheMs && policy.cacheable?.(value) && revision === this.revision) {
+        const cacheMs = typeof policy.cacheMs === 'function' ? policy.cacheMs(value) : policy.cacheMs;
+        if (key && cacheMs && cacheMs > 0 && policy.cacheable?.(value) && revision === this.revision) {
           // Bounded cache, timestamps originate at the response, never at a cache hit.
           if (this.cache.size >= 100) this.cache.delete(this.cache.keys().next().value!);
-          this.cache.set(key, { value, expires: this.now() + Math.min(30_000, policy.cacheMs) });
+          this.cache.set(key, { value, expires: this.now() + Math.min(30_000, cacheMs) });
         }
       }), finalize(() => { if (key && this.pending.get(key) === shared) this.pending.delete(key); }));
       const shared = request.pipe(shareReplay({ bufferSize: 1, refCount: true }));

+ 17 - 0
projects/xiaoshu-admin/src/app/cloud-functions.service.spec.ts

@@ -145,3 +145,20 @@ describe('CloudFunctionsService', () => {
 describe('课时查询与记账区分',()=>{
  for(const operation of ['ops/users/credit-preview','ops/users/credit-accounts','ops/users/credit-ledger','ops/users/credit-status'])it(`${operation} 超时不提示已经提交`,fakeAsync(()=>{TestBed.configureTestingModule({providers:[CloudFunctionsService,provideHttpClient(),provideHttpClientTesting(),{provide:AdminSessionService,useValue:{session:()=>({sessionToken:'token'})}}]});const service=TestBed.inject(CloudFunctionsService),http=TestBed.inject(HttpTestingController);let message='';service.operations(operation).catch(e=>message=e.message);http.expectOne(`${CLOUD_FUNCTION_BASE_URL}/xiaoshu/ops/gateway-v3`);tick(20001);expect(message).toContain('加载');expect(message).not.toContain('提交结果');http.verify();}));
 });
+
+describe('训练列表缓存时间不叠加',()=>{
+ it('服务端只剩1秒缓存时客户端也只复用1秒',fakeAsync(()=>{
+  TestBed.configureTestingModule({providers:[provideHttpClient(),provideHttpClientTesting(),{provide:AdminSessionService,useValue:{session:()=>({sessionToken:'r:token'})}}]});
+  const service=TestBed.inject(CloudFunctionsService),http=TestBed.inject(HttpTestingController);
+  service.operations('ops/lessons/list').then(()=>{});http.expectOne(`${CLOUD_FUNCTION_BASE_URL}/xiaoshu/ops/gateway-v3`).flush({success:true,data:{items:[]},readCache:{cached:true,remainingMs:1000}});tick();
+  tick(1001);service.operations('ops/lessons/list').then(()=>{});http.expectOne(`${CLOUD_FUNCTION_BASE_URL}/xiaoshu/ops/gateway-v3`).flush({success:true,data:{items:[]}});tick();http.verify();
+ }));
+});
+
+describe('后台导出下载不阻塞交互查询',()=>{
+ it('下载文件期间仍能读取训练列表',fakeAsync(()=>{
+  TestBed.configureTestingModule({providers:[provideHttpClient(),provideHttpClientTesting(),{provide:AdminSessionService,useValue:{session:()=>({sessionToken:'r:token'})}}]});const service=TestBed.inject(CloudFunctionsService),http=TestBed.inject(HttpTestingController);
+  service.operations('ops/lessons/export/download',{jobId:'job'}).then(()=>{});service.operations('ops/lessons/list').then(()=>{});
+  const requests=http.match(`${CLOUD_FUNCTION_BASE_URL}/xiaoshu/ops/gateway-v3`);expect(requests.length).toBe(2);requests.forEach(r=>r.flush({success:true,data:{items:[]}}));tick();http.verify();
+ }));
+});

+ 11 - 7
projects/xiaoshu-admin/src/app/cloud-functions.service.ts

@@ -1,7 +1,7 @@
 import { RequestScheduler, stableRequestJson } from '../../../shared/request-scheduler';
 import { HttpClient, HttpErrorResponse } from '@angular/common/http';
 import { inject, Injectable } from '@angular/core';
-import { firstValueFrom, Subject, timeout, throwError, retry, timer, mergeMap, of, Observable } from 'rxjs';
+import { firstValueFrom, Subject, timeout, throwError, retry, timer, mergeMap, of, Observable, tap } from 'rxjs';
 import { CLOUD_FUNCTION_BASE_URL } from './admin.constants';
 import { AdminSessionService } from './admin-session.service';
 import { CloudEnvelope, LegacyCloudEnvelope } from './admin.models';
@@ -58,7 +58,8 @@ export class CloudFunctionsService {
       const identity = stableRequestJson(this.sessions.session());
       const {refresh: _refresh,...cacheParams}=params;
       const key = stableRequestJson([path, identity, cacheParams]);
-      const execute = () => this.http.post<T>(`${CLOUD_FUNCTION_BASE_URL}/${path}`, { token, params }).pipe(
+      let requestStarted=Date.now();
+      const execute = () => {requestStarted=Date.now();return this.http.post<T>(`${CLOUD_FUNCTION_BASE_URL}/${path}`, { token, params }).pipe(
         timeout({ first: timeoutMs, with: () => throwError(() => new CloudFunctionError(this.transportMessage(readOnly))) }),
         retry({ count: 1, delay: (error) => {
           const transient = error instanceof HttpErrorResponse && [0, 502, 503, 504].includes(error.status);
@@ -67,11 +68,14 @@ export class CloudFunctionsService {
           const suggested=Number(error.error?.retryAfterMs);
           return timer(Math.max(400,Number.isFinite(suggested)?Math.min(10000,suggested):0)+Math.floor(Math.random()*100)).pipe(mergeMap(() => this.sessions.session()?.sessionToken === token ? of(0) : throwError(() => error)));
         } }),
-      );
+      ).pipe(tap(value=>{
+        const envelope=value as {data?:{clientFetchedAt?:number};readCache?:{remainingMs:number}};
+        if(operation==='ops/lessons/list'&&envelope.data&&typeof envelope.data==='object')envelope.data.clientFetchedAt=requestStarted+Math.max(0,Math.min(30000,envelope.readCache?.remainingMs??30000))-30000;
+      }));};
       const scheduled = this.scheduler.schedule(execute, {
-        read: readOnly, key, heavy: /^ops\/(?:lessons\/(?:list|detail|export\/download)|payroll\/(?:overview|export))$/.test(operation),
-        background: operation === 'ops/live/versions', valid: () => stableRequestJson(this.sessions.session()) === identity,
-        cacheMs: ['ops/lessons/list', 'ops/vocabulary/tree', 'ops/pay-rates/list', 'ops/teaching-rules/list'].includes(operation) ? 30_000 : 0,
+        read: readOnly, key, heavy: /^ops\/(?:lessons\/(?:list|detail)|payroll\/(?:overview|export))$/.test(operation),
+        background: operation === 'ops/live/versions' || operation === 'ops/lessons/export/download', valid: () => stableRequestJson(this.sessions.session()) === identity,
+        cacheMs: value=>['ops/lessons/list', 'ops/vocabulary/tree', 'ops/pay-rates/list', 'ops/teaching-rules/list'].includes(operation)?Math.max(0,((value as {data?:{clientFetchedAt?:number}}).data?.clientFetchedAt??Date.now())+30000-Date.now()):0,
         refresh: params['refresh'] === true,
         cacheable: value => (value as CloudEnvelope<unknown>)?.success === true,
       });
@@ -140,7 +144,7 @@ export class CloudFunctionsService {
     try {
       const envelope = await this.request<CloudEnvelope<T>>('xiaoshu/ops/gateway-v3', token,
         { ...params, operation }, this.isReadOperation(operation),
-        /^ops\/vocabulary\/import-(preview|commit)$/.test(operation) ? 60000 : /^ops\/lessons\/export\/snapshot$/.test(operation) ? 90000 : 20000, options.signal);
+        /^ops\/vocabulary\/import-(preview|commit)$/.test(operation) ? 60000 : operation==='ops/lessons/export/download' ? 120000 : /^ops\/lessons\/export\/snapshot$/.test(operation) ? 90000 : 20000, options.signal);
       if (!envelope.success) { await this.handleAuthenticationFailure(token, 0, envelope); throw new CloudFunctionError(envelope.message || envelope.error || '运营云函数执行失败'); }
       if (this.isMutation(operation)) this.announceDataChange();
       if(operation==='ops/lessons/list'&&envelope.data&&typeof envelope.data==='object'){

+ 1 - 1
projects/xiaoshu-admin/src/app/pages/lesson-records.component.html

@@ -1,7 +1,7 @@
 <section class="lesson-history">
 <header class="page-header"><div><h1>{{embedded?'训练记录':'记录管理 · 训练记录'}}</h1><p>查询完整训练历史、查看课堂反馈与转交抗遗忘复习</p></div><button class="ui-button secondary" (click)="export()" [disabled]="exporting()||busy()">{{exporting()?'后台正在导出…':'导出 Excel'}}</button></header>
 @if(exportJob();as job){<div class="notice" role="status">
- @if(job.status==='ready'){导出已完成 <button class="ui-button secondary" (click)="downloadExport()">下载 Excel</button>}
+ @if(job.status==='ready'){导出已完成 <button class="ui-button secondary" (click)="downloadExport()" [disabled]="downloading()">{{downloading()?'正在下载…':'下载 Excel'}}</button>}
  @else if(job.status==='queued'||job.status==='running'){{{job.status==='queued'?'导出排队中':'正在生成导出文件'}} · {{job.processed}} / {{job.total||'待统计'}} 条
  <button class="ui-button secondary" (click)="cancelExport()">取消导出</button>@if(!exporting()){<button class="ui-button secondary" (click)="continueExport()">继续查看进度</button>}}
  @else{导出{{job.status==='cancelled'?'已取消':'未完成'}}}

+ 2 - 1
projects/xiaoshu-admin/src/app/pages/lesson-records.component.ts

@@ -9,6 +9,7 @@ import {LessonHistoryPage} from './lesson-record.models';
 @Component({selector:'app-lesson-records',standalone:true,imports:[CommonModule,FormsModule,LessonRecordDetailComponent,OperationsDateFilterComponent],templateUrl:'./lesson-records.component.html',styleUrls:['./operations-professional.scss','./lesson-records.component.scss'],changeDetection:ChangeDetectionStrategy.OnPush})
 export class LessonRecordsComponent implements OnInit,OnChanges,OnDestroy {
  @Input() studentId=0;@Input() embedded=false;@Input() refreshVersion=0;
+ readonly downloading=signal(false);
  readonly exportJob=signal<{jobId:string;status:string;total:number;processed:number;error?:string}|null>(null);
  private exportController?:AbortController;private exportKey='';
  readonly data=signal<LessonHistoryPage|null>(null);readonly busy=signal(false);readonly error=signal('');readonly detailId=signal('');readonly exporting=signal(false);
@@ -67,5 +68,5 @@ export class LessonRecordsComponent implements OnInit,OnChanges,OnDestroy {
  }
  continueExport():void{const job=this.exportJob();if(job)void this.monitorExport(job.jobId);else this.resumeExport();}
  async cancelExport():Promise<void>{const job=this.exportJob();if(!job)return;try{const result=await this.ops.request<{jobId:string;status:string;total:number;processed:number}>('ops/lessons/export/cancel',{jobId:job.jobId});if(this.exportJob()?.jobId!==job.jobId)return;this.exportController?.abort();this.exporting.set(false);this.exportJob.set(result);}catch(e){this.error.set(e instanceof Error?e.message:'取消失败');}}
- async downloadExport():Promise<void>{const job=this.exportJob();if(!job||job.status!=='ready')return;try{const file=await this.ops.request<{base64:string;fileName:string;contentType:string}>('ops/lessons/export/download',{jobId:job.jobId});const bytes=Uint8Array.from(atob(file.base64),c=>c.charCodeAt(0)),url=URL.createObjectURL(new Blob([bytes],{type:file.contentType}));const link=document.createElement('a');link.href=url;link.download=file.fileName;link.click();setTimeout(()=>URL.revokeObjectURL(url),1000);}catch(e){this.error.set(e instanceof Error?e.message:'下载失败');}}
+ async downloadExport():Promise<void>{const job=this.exportJob();if(this.downloading()||!job||job.status!=='ready')return;this.downloading.set(true);try{const file=await this.ops.request<{base64:string;fileName:string;contentType:string}>('ops/lessons/export/download',{jobId:job.jobId});const bytes=Uint8Array.from(atob(file.base64),c=>c.charCodeAt(0)),url=URL.createObjectURL(new Blob([bytes],{type:file.contentType}));const link=document.createElement('a');link.href=url;link.download=file.fileName;link.click();setTimeout(()=>URL.revokeObjectURL(url),1000);}catch(e){this.error.set(e instanceof Error?e.message:'下载失败');}finally{this.downloading.set(false);}}
 }

+ 23 - 9
scripts/cloud/lesson-export.js

@@ -1,5 +1,5 @@
 // Persistent jobs are processed by scripts/lesson-export-worker.mjs, never by detached request work.
-function lessonExportProjection(job){return{jobId:job.id,status:job.status,total:number(job.total),processed:number(job.processed),error:job.error||'',expiresAt:job.expires_at};}
+function lessonExportProjection(job){return{jobId:job.id,status:job.status,total:number(job.total),processed:number(job.processed),error:job.error||'',snapshotReady:job.snapshot_ready===true,expiresAt:job.expires_at};}
 function lessonExportScope(context){return JSON.stringify([companyIdOf(context,{}),context.storeId||0,roleOf(context)]);}
 function lessonExportFilters(input){const out={};for(const key of ['studentId','search','coachName','storeName','courseName','dateFrom','dateTo'])if(input[key]!==undefined)out[key]=input[key];return out;}
 async function lessonExportCreate(context,input){
@@ -34,7 +34,7 @@ async function lessonExportClaim(context){
   await db.none("UPDATE xs_history_export_job SET status='failed',error='后台任务多次中断,请重新导出',lease=NULL,lease_until=NULL WHERE status='running' AND lease_until<now() AND attempts>=3");
   const active=await db.one("SELECT EXISTS(SELECT 1 FROM xs_history_export_job WHERE status='running' AND lease_until>now()) active");if(active.active)return{};
   const job=(await db.query("SELECT * FROM xs_history_export_job WHERE status='queued' OR (status='running' AND lease_until<now() AND attempts<3) ORDER BY created_at LIMIT 1 FOR UPDATE SKIP LOCKED"))[0];if(!job)return{};
-  const claimed=await db.one("UPDATE xs_history_export_job SET status='running',processed=0,lease=md5(random()::text||clock_timestamp()::text),lease_until=now()+interval '2 minutes',attempts=attempts+1 WHERE id=$1 RETURNING *",[job.id]);
+  const claimed=await db.one("UPDATE xs_history_export_job SET status='running',processed=0,error='',lease=md5(random()::text||clock_timestamp()::text),lease_until=now()+interval '2 minutes',attempts=attempts+1 WHERE id=$1 RETURNING *",[job.id]);
   return{...lessonExportProjection(claimed),lease:claimed.lease,snapshotReady:claimed.snapshot_ready};
  });
 }
@@ -48,11 +48,22 @@ async function lessonExportSnapshot(context,input){
   const user=await new Parse.Query('_User').get(job.actor,{useMasterKey:true}),owner=await auth({user});
   if(lessonExportScope(owner)!==job.scope||companyIdOf(owner,{})!==job.company)fail(403,'导出账号权限已变化');
   requireRole(owner,['ops-manager','ops-staff','ops-auditor']);
-  const plan=lessonHistoryPlan(owner,job.filters,null,null,true),result=await db.one(plan.q,plan.args);
-  // Resolve categories in the same snapshot as the history, then freeze final exported fields.
-  const raw=result.items||[],rows=[];for(let i=0;i<raw.length;i+=500)rows.push(...(await lessonHistoryTypeRows(raw.slice(i,i+500),db)).map(lessonHistoryRow));
-  await db.none('INSERT INTO xs_history_export_row(job_id,position,value) SELECT $1,ordinality::int,value FROM jsonb_array_elements($2::jsonb) WITH ORDINALITY',[job.id,JSON.stringify(rows)]);
-  const updated=await db.one("UPDATE xs_history_export_job SET total=$2,snapshot_ready=true,lease_until=now()+interval '2 minutes' WHERE id=$1 RETURNING *",[job.id,rows.length]);return lessonExportProjection(updated);
+  const plan=lessonHistoryPlan(owner,job.filters,null,null,true),jobParameter=plan.args.length+1;
+  // Keep the large snapshot inside PostgreSQL. Only 500 rows enter JS at once.
+  const inserted=await db.one(`WITH inserted AS (
+   INSERT INTO xs_history_export_row(job_id,position,value)
+   SELECT $${jobParameter}::text,element.ordinality::int,element.value FROM (${plan.q}) snapshot
+   CROSS JOIN LATERAL jsonb_array_elements(snapshot.items) WITH ORDINALITY element(value,ordinality) RETURNING 1
+  ) SELECT count(*)::int total FROM inserted`,[...plan.args,job.id]);
+  let position=0;
+  while(position<number(inserted.total)){
+   const batch=await db.query('SELECT position,value FROM xs_history_export_row WHERE job_id=$1 AND position>$2 ORDER BY position LIMIT 500',[job.id,position]);
+   if(!batch.length)fail(409,'导出快照不完整');
+   const resolved=(await lessonHistoryTypeRows(batch.map(row=>row.value),db)).map(lessonHistoryRow);
+   await db.none('UPDATE xs_history_export_row r SET value=b.value FROM jsonb_to_recordset($2::jsonb) b(position integer,value jsonb) WHERE r.job_id=$1 AND r.position=b.position',[job.id,JSON.stringify(batch.map((row,index)=>({position:row.position,value:resolved[index]})))]);
+   position=batch.at(-1).position;
+  }
+  const updated=await db.one("UPDATE xs_history_export_job SET total=$2,snapshot_ready=true,lease_until=now()+interval '2 minutes' WHERE id=$1 RETURNING *",[job.id,number(inserted.total)]);return lessonExportProjection(updated);
  });
 }
 async function lessonExportChunk(context,input){
@@ -63,11 +74,14 @@ async function lessonExportChunk(context,input){
 }
 async function lessonExportFinish(context,input){
  return Psql.tx(async db=>{const job=await lessonExportLeased(context,input,db);const data=text(input.base64);
- if(!job.snapshot_ready||number(input.rowCount)!==job.total||!data||data.length>14_000_000||!/^[A-Za-z0-9+/]+={0,2}$/.test(data))fail(400,'导出文件或记录数量无效');
+ if(!job.snapshot_ready||number(input.rowCount)!==job.total||!data||data.length>40_000_000||!/^[A-Za-z0-9+/]+={0,2}$/.test(data))fail(400,'导出文件或记录数量无效');
  await db.none("UPDATE xs_history_export_job SET status='ready',processed=total,file_base64=$2,lease=NULL,lease_until=NULL WHERE id=$1",[job.id,data]);return{jobId:job.id,status:'ready'};});
 }
 async function lessonExportFail(context,input){return Psql.tx(async db=>{const job=await lessonExportLeased(context,input,db);await db.none("UPDATE xs_history_export_job SET status='failed',error='导出未能完成,请稍后重试',lease=NULL,lease_until=NULL WHERE id=$1",[job.id]);return{jobId:job.id,status:'failed'};});}
 async function lessonExportMaintenance(context){
  requireSuperAdmin(context);const installed=await Psql.one("SELECT to_regprocedure('xs_prune_read_revisions()') IS NOT NULL installed");
- if(installed.installed)await Psql.none('SELECT xs_prune_read_revisions()');return{completed:true};
+ if(installed.installed)await Psql.one('SELECT xs_prune_read_revisions()');
+ const cache=await Psql.one("SELECT to_regclass('xs_read_cache') IS NOT NULL installed");if(cache.installed)await Psql.none('DELETE FROM xs_read_cache WHERE expires_at<now() AND (lease_until IS NULL OR lease_until<now())');return{completed:true};
 }
+
+async function lessonExportRelease(context,input){return Psql.tx(async db=>{const job=await lessonExportLeased(context,input,db);await db.none("UPDATE xs_history_export_job SET lease_until=now()-interval '1 second' WHERE id=$1",[job.id]);return{jobId:job.id,status:'running'};});}

+ 17 - 4
scripts/cloud/lesson-records.js

@@ -99,14 +99,27 @@ function lessonHistoryPlan(context,input,summary=null,revision=null,snapshot=fal
  }
  args.push(snapshot?2147483647:paging.pageSize,snapshot||input.cursor?0:(paging.page-1)*paging.pageSize);
  const filters={...(input.filters||{}),...input},lean=!context.storeId&&!['search','studentName','coachName','storeName','courseName','storeId'].some(key=>text(filters[key]));
- const q=lessonHistoryBase(lean,studentParameter)+`,filtered AS MATERIALIZED (SELECT * FROM lessons${where}), page AS (SELECT * FROM filtered${cursorWhere} ORDER BY "actualAt" DESC NULLS LAST,"objectId" DESC LIMIT $${args.length-1} OFFSET $${args.length})${lean?`,display_page AS (SELECT l.*,${lessonHistoryLabelColumns()} FROM page l ${lessonHistoryLabelJoins()})`:``}
+ const q=lessonHistoryBase(lean,studentParameter)+`,filtered AS MATERIALIZED (SELECT * FROM lessons${where}), page AS (SELECT * FROM filtered${cursorWhere} ORDER BY "actualAt" DESC NULLS LAST,"objectId" DESC LIMIT $${args.length-1} OFFSET $${args.length})${lean?`,display_page AS (SELECT l.*,${lessonHistoryLabelColumns()} FROM page l ${lessonHistoryLabelJoins()})`:``}${snapshot?`,review_pending AS MATERIALIZED (
+ SELECT m.* FROM "MemoryPracticeRecord" m WHERE m.company=$1 AND COALESCE(m.fxzt,'0')<>'1' AND NOT COALESCE(m."rewardDuplicate",false)
+ AND EXISTS(SELECT 1 FROM page p WHERE p.xymz=m.yhid::text)
+),review_counts AS MATERIALIZED (
+ SELECT link."studentId",link."appointmentId",m.plid::text coach,count(*)::int n FROM review_pending m
+ JOIN xs_review_task_link link ON link.company=m.company AND link."taskId"=m."objectId"
+ WHERE link."appointmentId" IS NOT NULL GROUP BY link."studentId",link."appointmentId",m.plid
+),review_names AS MATERIALIZED (
+ SELECT c.coach,COALESCE(NULLIF(u."realName",''),NULLIF(u.nickname,''),u."legacyUserData"->>'HoneyName',u.username,'陪练 '||c.coach) name
+ FROM (SELECT DISTINCT coach FROM review_counts) c LEFT JOIN LATERAL(SELECT * FROM "_User" u WHERE u.company=$1 AND u."legacyUserId"::text=c.coach ORDER BY u."objectId" LIMIT 1) u ON true
+),review_lookup AS (
+ SELECT c."studentId",c."appointmentId",sum(c.n) n,jsonb_agg(jsonb_build_object('id',c.coach,'name',u.name,'count',c.n) ORDER BY u.name) coaches
+ FROM review_counts c JOIN review_names u ON u.coach=c.coach GROUP BY c."studentId",c."appointmentId"
+)`:``}
  SELECT ${revisionParameter?`md5($${revisionParameter}::text)`:`md5(COALESCE((SELECT string_agg("objectId"||COALESCE("updatedAt"::text,''),',' ORDER BY "objectId") FROM filtered),''))`} AS "historyVersion",${summary?number(summary.total):`(SELECT count(*)::int FROM filtered)`} total,${summary?number(summary.cumulativeTotal):`(SELECT count(*)::int FROM lessons${cumulativeWhere})`} "cumulativeTotal",
- COALESCE((SELECT jsonb_agg(to_jsonb(p)||jsonb_build_object('pendingReviews',rv.n,'reviewCoaches',rv.coaches) ORDER BY p."actualAt" DESC NULLS LAST,p."objectId" DESC) FROM ${lean?'display_page':'page'} p
- LEFT JOIN LATERAL (SELECT COALESCE(sum(n),0) n,jsonb_agg(jsonb_build_object('id',coach,'name',name,'count',n) ORDER BY name) coaches FROM (
+ COALESCE((SELECT jsonb_agg(to_jsonb(p)||jsonb_build_object('pendingReviews',COALESCE(rv.n,0),'reviewCoaches',rv.coaches) ORDER BY p."actualAt" DESC NULLS LAST,p."objectId" DESC) FROM ${lean?'display_page':'page'} p
+ ${snapshot?` LEFT JOIN review_lookup rv ON rv."studentId"=p.xymz AND rv."appointmentId"=p.yyds`:` LEFT JOIN LATERAL (SELECT COALESCE(sum(n),0) n,jsonb_agg(jsonb_build_object('id',coach,'name',name,'count',n) ORDER BY name) coaches FROM (
  SELECT m.plid::text coach,COALESCE(NULLIF(u."realName",''),NULLIF(u.nickname,''),u."legacyUserData"->>'HoneyName',u.username,'陪练 '||m.plid::text) name,count(*)::int n
  FROM xs_review_task_link link JOIN "MemoryPracticeRecord" m ON m.company=link.company AND m."objectId"=link."taskId"
  LEFT JOIN LATERAL(SELECT * FROM "_User" u WHERE u.company=m.company AND u."legacyUserId"::text=m.plid::text ORDER BY u."objectId" LIMIT 1) u ON true
- WHERE link.company=p.company AND link."studentId"=p.xymz AND link."appointmentId"=p.yyds AND COALESCE(m.fxzt,'0')<>'1' AND NOT COALESCE(m."rewardDuplicate",false) GROUP BY m.plid,u."realName",u.nickname,u."legacyUserData",u.username) grouped) rv ON true),'[]'::jsonb) items`;
+ WHERE link.company=p.company AND link."studentId"=p.xymz AND link."appointmentId"=p.yyds AND COALESCE(m.fxzt,'0')<>'1' AND NOT COALESCE(m."rewardDuplicate",false) GROUP BY m.plid,u."realName",u.nickname,u."legacyUserData",u.username) grouped) rv ON true`}),'[]'::jsonb) items`;
  // Cached totals remove placeholders used only by the cumulative count. Compact them.
  const used=[...new Set([...q.matchAll(/\$(\d+)/g)].map(match=>Number(match[1])))].sort((a,b)=>a-b);
  return {q:q.replace(/\$(\d+)/g,(_,n)=>'$'+(used.indexOf(Number(n))+1)),args:used.map(n=>args[n-1]),paging};

+ 2 - 1
scripts/deploy-admin-functions.mjs

@@ -4312,6 +4312,7 @@ async function handler(request,response) {
     if(operation==='ops/lessons/export/snapshot') return response.json({success:true,data:await lessonExportSnapshot(context,input)});
     if(operation==='ops/lessons/export/chunk') return response.json({success:true,data:await lessonExportChunk(context,input)});
     if(operation==='ops/lessons/export/finish') return response.json({success:true,data:await lessonExportFinish(context,input)});
+    if(operation==='ops/lessons/export/release') return response.json({success:true,data:await lessonExportRelease(context,input)});
     if(operation==='ops/lessons/export/fail') return response.json({success:true,data:await lessonExportFail(context,input)});
     if(operation==='ops/lessons/list') return response.json({success:true,data:await lessonHistoryPage(context,input)});
     if(operation==='ops/learning-records/list') return response.json({success:true,data:await businessRows('learning-records',context,input)});
@@ -4514,7 +4515,7 @@ async function upsert(definition) {
 }
 
 for (const definition of definitions) {
-  if (/^xiaoshu\/(?:app|ops|admin)\/gateway(?:-v[0-9]+)?$/.test(definition.path)) definition.code = withXiaoshuRuntime(definition.code);
+  if (/^xiaoshu\/(?:app|ops|admin)\/gateway(?:-v[0-9]+)?$/.test(definition.path)) definition.code = withXiaoshuRuntime(definition.code,{readPressure:definition.path.split('/')[1]});
 }
 
 const selectedDefinitions = onlyName ? definitions.filter((definition) => definition.name === onlyName || definition.path === onlyName) : definitions;

+ 4 - 2
scripts/lesson-export-worker.mjs

@@ -1,5 +1,6 @@
 #!/usr/bin/env node
 import {readFileSync} from 'node:fs';
+import {setPriority} from 'node:os';
 import {pathToFileURL} from 'node:url';
 import * as XLSX from 'xlsx';
 import {lessonExportColumns} from '../projects/shared/lesson-export-columns.mjs';
@@ -15,14 +16,15 @@ export async function processLessonExport(call){
   const book=XLSX.utils.book_new();XLSX.utils.book_append_sheet(book,sheet,'训练记录');
   const base64=XLSX.write(book,{type:'base64',bookType:'xlsx',compression:true});
   await call('finish',{...identity,rowCount:offset,base64});return true;
- }catch(e){await call('fail',identity).catch(()=>{});throw Error('export_job_failed');}
+ }catch(e){await call(e?.transient?'release':'fail',identity).catch(()=>{});throw Object.assign(Error('export_job_failed'),{transient:e?.transient===true});}
 }
 async function main(){
+ try{setPriority(0,10);}catch{} // Background XLSX compression yields CPU to interactive requests.
  const config=process.env.XIAOSHU_EXPORT_ENV_FILE||'/Users/mac/.config/xiaoshu/legacy-sync-nightly.env',env={...process.env};
  for(const line of readFileSync(config,'utf8').split('\n')){const m=line.match(/^(?:export\s+)?(XIAOSHU_[A-Z0-9_]+)=(.*)$/);if(m&&!env[m[1]])env[m[1]]=m[2].replace(/^['"]|['"]$/g,'');}
  const token=env.XIAOSHU_EXPORT_OPERATOR_TOKEN||env.XIAOSHU_SYNC_OPERATOR_TOKEN;if(!token)throw Error('export_operator_missing');
  const base=env.XIAOSHU_EXPORT_GATEWAY_URL||'https://server.xiaoshu.pro/api/functions/xiaoshu/ops/gateway-v3';
- const call=async(action,params)=>{const r=await fetch(base,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token,params:{operation:'ops/lessons/export/'+action,...params}}),signal:AbortSignal.timeout(action==='snapshot'?90_000:20_000)});const data=await r.json();if(!r.ok||!data.success)throw Error('export_gateway_'+r.status);return data.data;};
+ const call=async(action,params)=>{let r;try{r=await fetch(base,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token,params:{operation:'ops/lessons/export/'+action,...params}}),signal:AbortSignal.timeout(action==='snapshot'?90_000:20_000)});}catch{throw Object.assign(Error('export_transport'),{transient:true});}let data;try{data=await r.json();}catch{throw Object.assign(Error('export_gateway_'+r.status),{transient:true});}if(!r.ok||!data.success)throw Object.assign(Error('export_gateway_'+r.status),{transient:[429,500,502,503,504].includes(r.status)});return data.data;};
  let lastMaintenance=0;let stopped=false;for(const event of ['SIGINT','SIGTERM'])process.on(event,()=>{stopped=true;});
  do{try{if(Date.now()-lastMaintenance>86_400_000){await call('maintenance',{});lastMaintenance=Date.now();}await processLessonExport(call);}catch{console.error('导出任务失败,错误状态已回写或将由租约恢复');}if(process.argv.includes('--once'))break;if(!stopped)await new Promise(r=>setTimeout(r,5000));}while(!stopped);
 }

+ 90 - 0
scripts/lib/read-pressure-runtime.mjs

@@ -0,0 +1,90 @@
+export function readPressurePolicy(kind,params){
+ const operation=String(kind==='app'?params.action:params.operation||'');
+ const operations=['ops/users/list','ops/users/detail','ops/stores/list','ops/stores/detail','ops/coaches/list','ops/coaches/detail','ops/schedule/calendar','ops/payroll/overview','ops/payroll/preview','ops/payroll/detail','ops/payroll/export','ops/dashboard/summary','ops/dashboard/schedule-trend','ops/lessons/list','ops/lessons/detail','ops/learning-reports/list','ops/learning-reports/detail','ops/learning-reports/export','ops/vocabulary/list','ops/vocabulary/tree','ops/special-training/list'];
+ const actions=['app_review_page','app_companion_overview','app_learning_overview','app_account_overview','app_lesson_study','app_lesson_report','order_list','order_get','node_list','content_list','content_get','e_record_list','e_record_detail','app_exam_history'];
+ return{heavy:(kind==='ops'?operations:kind==='app'?actions:[]).includes(operation),cache:kind==='ops'&&operation==='ops/lessons/list',operation};
+}
+export function readPressureStable(value){
+ if(Array.isArray(value))return '['+value.map(readPressureStable).join(',')+']';
+ if(value&&typeof value==='object')return '{'+Object.keys(value).filter(k=>value[k]!==undefined).sort().map(k=>JSON.stringify(k)+':'+readPressureStable(value[k])).join(',')+'}';
+ return JSON.stringify(value)??'null';
+}
+export function readPressureDb(database,deadline,inTransaction=false){
+ const methods=new Set(['query','one','oneOrNone','none','any','many','manyOrNone','result','multi','multiResult']);
+ const check=()=>{const remaining=deadline-Date.now();if(remaining<=0)throw Object.assign(Error('查询暂未完成,请稍后重试'),{status:408});return remaining;};
+ return new Proxy(Object.create(null),{get(_target,key){
+  const target=database;
+  if(key==='__xiaoshuReadBudget')return true;
+  const value=target[key];
+  if(key==='tx'&&typeof value==='function')return(...args)=>{const callback=args.at(-1);if(typeof callback!=='function')throw Error('Invalid transaction callback');args[args.length-1]=async tx=>{await tx.none("SET LOCAL statement_timeout='"+Math.max(1,check())+"ms'");return callback(readPressureDb(tx,deadline,true));};return value.apply(target,args);};
+  if(methods.has(key)&&typeof value==='function')return(...args)=>{
+   const pgp=target.$config?.pgp;
+   if(!inTransaction&&typeof args[0]==='string'&&typeof target.task==='function'&&typeof pgp?.as?.format==='function'&&['query','one','oneOrNone','none','any','many','manyOrNone','result'].includes(key))return target.task(async connection=>{
+    const sql=pgp.as.format(args[0],args[1]),timeout=Math.max(1,Math.min(12000,check()));
+    let result;
+    try{const results=await connection.multiResult("BEGIN;SET LOCAL statement_timeout='"+timeout+"ms';"+sql+"\n;COMMIT;");result=results.at(-2);}
+    catch(error){await connection.none('ROLLBACK').catch(()=>{});throw error;}
+    const rows=result.rows,errors=pgp.errors;
+    if((key==='one'||key==='many')&&!rows.length)throw new errors.QueryResultError(errors.queryResultErrorCode.noData,result,args[0],args[1]);
+    if((key==='one'||key==='oneOrNone')&&rows.length>1)throw new errors.QueryResultError(errors.queryResultErrorCode.multiple,result,args[0],args[1]);
+    if(key==='none'&&rows.length)throw new errors.QueryResultError(errors.queryResultErrorCode.notEmpty,result,args[0],args[1]);
+    const output=key==='result'?result:key==='none'?null:key==='one'||key==='oneOrNone'?rows[0]||null:rows;
+    return typeof args[2]==='function'?args[2](output):output;
+   });
+   const execute=async tx=>{await tx.none("SET LOCAL statement_timeout='"+Math.max(1,check())+"ms'");check();return tx[key](...args);};
+   return inTransaction?execute(target):target.tx(execute);
+  };
+  return typeof value==='function'?value.bind(target):value;
+ }});
+}
+export async function dispatchReadPressure(database,request,response,kind,execute,authorize){
+ const params=request.body?.params||{},policy=readPressurePolicy(kind,params);
+ if(!policy.heavy)return execute(database);
+ const availabilitySymbol=Symbol.for('xiaoshu.read-pressure.availability.v1');
+ const availability=globalThis[availabilitySymbol]||(globalThis[availabilitySymbol]=new WeakMap()),availabilityKey=database.$pool||database;
+ let available=availability.get(availabilityKey);
+ if(!available||available.until<Date.now()){available={...await database.one("SELECT to_regclass('xs_read_permit') IS NOT NULL installed"),until:Date.now()+10000};availability.set(availabilityKey,available);}
+ if(!available.installed)return execute(database);
+ const crypto=await import('node:crypto'),owner=crypto.randomUUID(),wait=ms=>new Promise(resolve=>setTimeout(resolve,ms));
+ const start=Date.now(),deadline=start+14_000;
+ let cacheKey,revision,identity,ownsCache=false,permit;
+ const refresh=params.refresh===true;
+ const refreshSince=refresh?(await database.one('SELECT clock_timestamp()::text stamp')).stamp:null;
+ const busy=()=>{throw Object.assign(Error('查询较多,请稍后重试'),{status:429,retryAfterMs:2000});};
+ const cacheRead=async()=>{const row=(await database.query('SELECT value,expires_at,GREATEST(0,extract(epoch FROM expires_at-clock_timestamp())*1000)::int remaining_ms FROM xs_read_cache WHERE key=$1 AND revision=$2 AND expires_at>clock_timestamp() AND value IS NOT NULL',[cacheKey,revision]))[0];return row;};
+ try{
+  if(policy.cache){
+   identity=await authorize();if(!identity?.company||!identity?.scope)throw Error('Missing read scope');
+   revision=(await database.one("SELECT xs_read_version($1,'lessons') revision",[identity.company])).revision;
+   const {refresh:_refresh,requestId:_requestId,...keyParams}=params;
+   cacheKey=crypto.createHash('sha256').update(readPressureStable([identity.company,identity.scope,policy.operation,keyParams])).digest('hex');
+   if(!refresh){const value=await cacheRead();if(value)return response.json({...value.value,readCache:{cached:true,remainingMs:value.remaining_ms}});}
+   while(Date.now()<deadline){
+    const claimed=await database.query("INSERT INTO xs_read_cache(key,company,scope,revision,owner,lease_until) VALUES($1,$2,$3,$4,$5,clock_timestamp()+interval '16 seconds') ON CONFLICT(key) DO UPDATE SET revision=excluded.revision,owner=excluded.owner,lease_until=excluded.lease_until,value=NULL,expires_at='epoch' WHERE (xs_read_cache.owner IS NULL OR xs_read_cache.lease_until<=clock_timestamp()) AND (xs_read_cache.revision<>excluded.revision OR xs_read_cache.expires_at<=clock_timestamp() OR ($6 AND xs_read_cache.expires_at<$7::timestamptz+interval '30 seconds')) RETURNING key",[cacheKey,identity.company,identity.scope,revision,owner,refresh,refreshSince]);
+    if(claimed.length){ownsCache=true;break;}
+    // A concurrent force refresh shares only the result produced after this call started.
+    const result=(await database.query("SELECT value,expires_at,GREATEST(0,extract(epoch FROM expires_at-clock_timestamp())*1000)::int remaining_ms,expires_at>=$3::timestamptz+interval '30 seconds' refreshed FROM xs_read_cache WHERE key=$1 AND revision=$2 AND value IS NOT NULL AND expires_at>clock_timestamp()",[cacheKey,revision,refreshSince]))[0];
+    if(result&&(!refresh||result.refreshed===true))return response.json({...result.value,readCache:{cached:true,remainingMs:result.remaining_ms}});
+    await wait(100+Math.floor(Math.random()*80));
+   }
+   if(!ownsCache)busy();
+  }
+  const permitDeadline=Math.min(deadline,Date.now()+2000);
+  while(Date.now()<permitDeadline){
+   const claimed=await database.query("UPDATE xs_read_permit SET owner=$1,lease_until=clock_timestamp()+interval '16 seconds' WHERE slot=(SELECT slot FROM xs_read_permit WHERE slot<=(SELECT concurrency FROM xs_read_pressure_config WHERE id) AND (owner IS NULL OR lease_until<=clock_timestamp()) ORDER BY slot FOR UPDATE SKIP LOCKED LIMIT 1) RETURNING slot",[owner]);
+   if(claimed.length){permit=claimed[0].slot;break;}await wait(80+Math.floor(Math.random()*60));
+  }
+  if(!permit)busy();
+  const bounded=readPressureDb(database,Math.min(deadline,Date.now()+12000));
+  let envelope;const original=response.json;
+  if(ownsCache)response.json=function(body){envelope=body?.success===true?{...body,readCache:{cached:false,remainingMs:30000}}:body;return original.call(this,envelope);};
+  try{return await execute(bounded);}finally{
+   response.json=original;
+   if(ownsCache&&envelope?.success===true)await database.none("UPDATE xs_read_cache SET value=$3::jsonb,expires_at=clock_timestamp()+interval '30 seconds',owner=NULL,lease_until=NULL WHERE key=$1 AND owner=$2",[cacheKey,owner,JSON.stringify(envelope)]);
+  }
+ }finally{
+  if(permit)await database.none('UPDATE xs_read_permit SET owner=NULL,lease_until=NULL WHERE slot=$1 AND owner=$2',[permit,owner]).catch(()=>{});
+  if(ownsCache)await database.none('UPDATE xs_read_cache SET owner=NULL,lease_until=NULL WHERE key=$1 AND owner=$2',[cacheKey,owner]).catch(()=>{});
+ }
+}
+export function readPressureRuntimeSource(){return [readPressurePolicy,readPressureStable,readPressureDb,dispatchReadPressure].map(fn=>fn.toString()).join('\n');}

+ 18 - 3
scripts/lib/xiaoshu-runtime-compat.mjs

@@ -1,3 +1,4 @@
+import {readPressureRuntimeSource} from './read-pressure-runtime.mjs';
 // The multi-app worker provides a deliberately small REST facade, not the Parse SDK.
 // Adapt only Xiaoshu gateway-local bindings inside its own isolated worker.
 export async function resolveXiaoshuRuntime(nativeParse, nativePsql, request, loadEnvironment) {
@@ -37,13 +38,13 @@ export async function resolveXiaoshuRuntime(nativeParse, nativePsql, request, lo
   return { Parse: SDK, Psql: db };
 }
 
-export function withXiaoshuRuntime(code) {
+export function withXiaoshuRuntime(code,options={}) {
   const marker = '// XIAOSHU_ISOLATED_RUNTIME_COMPAT_V1';
   if (code.includes(marker)) return code;
   const pattern = /async function handler\s*\(/g;
   if ([...code.matchAll(pattern)].length !== 1) throw new Error('Expected exactly one async handler');
   return code.replace(pattern, 'async function xiaoshuBusinessHandler(') + '\n' + marker + '\n'
-    + resolveXiaoshuRuntime.toString() + `
+    + resolveXiaoshuRuntime.toString() + '\n' + (options.readPressure ? readPressureRuntimeSource() : '') + `
 async function handler(request, response) {
   try {
     const runtime = await resolveXiaoshuRuntime(Parse, Psql, request);
@@ -53,7 +54,21 @@ async function handler(request, response) {
     console.error('[XiaoshuRuntime] ' + String(error?.message || error));
     return response.status(503).json({ success: false, error: 'runtime_compatibility_error', message: '小树服务暂时不可用,请稍后重试', retcode: -1, retmsg: '小树服务暂时不可用,请稍后重试', result: null });
   }
-  return xiaoshuBusinessHandler(request, response);
+  ${options.readPressure?`const pressureStartedAt=Date.now();
+  if(typeof response.on==='function')response.on('finish',()=>{const durationMs=Date.now()-pressureStartedAt;if(response.statusCode>=400||durationMs>=1000||Math.random()<0.01)console.info(JSON.stringify({event:'xiaoshu_read_pressure',operation:readPressurePolicy(${JSON.stringify(options.readPressure)},request.body?.params||{}).operation,status:response.statusCode,durationMs}));});
+  try {
+    return await dispatchReadPressure(Psql,request,response,${JSON.stringify(options.readPressure)},async bounded=>{const original=Psql;Psql=bounded;try{return await xiaoshuBusinessHandler(request,response);}finally{Psql=original;}},async()=>{
+      const input=inputOf(request),context=await auth(request),payload=input.payload&&typeof input.payload==='object'?input.payload:{};
+      if(context.storeId)await prepareStoreRequest(context,input,payload);
+      requireRole(context,['ops-manager','ops-staff','ops-auditor']);
+      if(context.current.get('passwordResetRequired')===true)fail(403,'请先修改临时密码');
+      return{company:companyIdOf(context,input),scope:JSON.stringify([roleOf(context),context.storeId||0])};
+    });
+  }catch(error){
+    if(response.headersSent)return response;
+    const status=Number(error.status||(error.code==='57014'?408:500)),message=status===429?'查询较多,请稍后重试':status===408?'查询暂未完成,请稍后重试':status<500?String(error.message||'请求失败'):'数据加载失败,请稍后重试';
+    return response.status(status).json({success:false,error:status===429?'read_capacity_busy':'read_failed',message,retcode:-1,retmsg:message,...(status===429?{retryAfterMs:2000}:{})});
+  }`:'return xiaoshuBusinessHandler(request, response);'}
 }
 `;
 }

+ 33 - 0
scripts/read-pressure-release.mjs

@@ -0,0 +1,33 @@
+import ts from 'typescript';import {readFileSync} from 'node:fs';
+import {withXiaoshuRuntime} from './lib/xiaoshu-runtime-compat.mjs';
+function tree(source){return ts.createSourceFile('gateway.js',source,ts.ScriptTarget.Latest,true,ts.ScriptKind.JS);}
+function lastFunction(source,name){return tree(source).statements.filter(n=>ts.isFunctionDeclaration(n)&&n.name?.text===name).at(-1);}
+function patchFunction(source,nodeText,name){const node=lastFunction(source,name);return node?source.slice(0,node.getStart())+nodeText+source.slice(node.end):source+'\n'+nodeText+'\n';}
+export function patchReadPressureGateway(baseline,kind){
+ const marker='// XIAOSHU_ISOLATED_RUNTIME_COMPAT_V1';let source=baseline;
+ if(kind==='ops'){
+  const history=readFileSync(new URL('./cloud/lesson-records.js',import.meta.url),'utf8'),parsed=tree(history);
+  const owned=new Set(['lessonHistoryBase','lessonHistoryTypeRows','lessonHistoryRead','lessonHistoryPage','lessonHistoryPageData','lessonHistoryPlan']);
+  for(const node of parsed.statements)if(ts.isFunctionDeclaration(node)&&owned.has(node.name.text))source=patchFunction(source,node.getText(parsed),node.name.text);
+  if(!source.includes('const LESSON_RECORDS_VERSION='))source="const LESSON_RECORDS_VERSION='2026-10-13-lesson-records-v10';\n"+source;
+  const exports=readFileSync(new URL('./cloud/lesson-export.js',import.meta.url),'utf8'),exportsTree=tree(exports);
+  for(const node of exportsTree.statements)if(ts.isFunctionDeclaration(node))source=patchFunction(source,node.getText(exportsTree),node.name.text);
+  const operations=['create','status','cancel','download','maintenance','claim','snapshot','chunk','finish','fail','release'];
+  const handler=lastFunction(source,'xiaoshuBusinessHandler')||lastFunction(source,'handler');if(!handler)throw Error('Missing operations handler');
+  let text=handler.getText();const anchor="if(operation==='ops/lessons/list')";if(!text.includes(anchor))throw Error('Missing lesson route anchor');
+  const routes=operations.filter(name=>!text.includes("operation==='ops/lessons/export/"+name+"'")).map(name=>"if(operation==='ops/lessons/export/"+name+"') return response.json({success:true,data:await lessonExport"+name[0].toUpperCase()+name.slice(1)+"(context,input)});\n    ").join('');
+  text=text.replace(anchor,routes+anchor);source=source.slice(0,handler.getStart())+text+source.slice(handler.end);
+  const version=lastFunction(source,'operationsScopeVersion');if(version&&!version.getText().includes("to_regprocedure('xs_read_version(text,text)')")){
+   const replacement=version.getText().replace('{',`{\n const installed=await Psql.one("SELECT to_regprocedure('xs_read_version(text,text)') IS NOT NULL installed");if(installed.installed)return(await Psql.one('SELECT xs_read_version($1,$2) version',[companyId,scope])).version;\n`);source=source.slice(0,version.getStart())+replacement+source.slice(version.end);
+  }
+  const parsedSource=tree(source),store=parsedSource.statements.find(n=>ts.isVariableStatement(n)&&n.declarationList.declarations.some(d=>d.name.getText()==='STORE_OPERATIONS'));
+  if(store&&!store.getText().includes('ops/lessons/export/create')){const replacement=store.getText().replace("'ops/lessons/list'", "'ops/lessons/export/create','ops/lessons/export/status','ops/lessons/export/cancel','ops/lessons/export/download','ops/lessons/list'");if(replacement===store.getText())throw Error('Missing store route anchor');source=source.slice(0,store.getStart())+replacement+source.slice(store.end);}
+ }
+ if(lastFunction(source,'xiaoshuBusinessHandler')){
+  const oldWrapper=lastFunction(source,'handler');if(!oldWrapper?.getText().includes('resolveXiaoshuRuntime')||!oldWrapper.getText().includes('xiaoshuBusinessHandler'))throw Error('Unknown production runtime wrapper');
+  const wrapper=withXiaoshuRuntime('async function handler(request,response){return null;}',{readPressure:kind}),parsed=tree(wrapper);
+  const replace=new Set(['readPressurePolicy','readPressureStable','readPressureDb','dispatchReadPressure','handler']);
+  for(const node of parsed.statements)if(ts.isFunctionDeclaration(node)&&replace.has(node.name.text))source=patchFunction(source,node.getText(parsed),node.name.text);
+ }else source=withXiaoshuRuntime(source,{readPressure:kind});
+ new Function(source);return source;
+}

+ 6 - 0
scripts/sql/lesson-history-indexes.sql

@@ -0,0 +1,6 @@
+-- Execute outside a transaction, using a pinned connection with bounded timeouts.
+-- Measured against two isolated copies of production data; no source row rewrite.
+CREATE INDEX CONCURRENTLY IF NOT EXISTS xs_history_report_lesson ON "LessonCompletionReport"(company,"lessonId","submittedAt" DESC);
+CREATE INDEX CONCURRENTLY IF NOT EXISTS xs_history_report_appointment ON "LessonCompletionReport"(company,("appointmentGeneralId"::text),"submittedAt" DESC);
+CREATE INDEX CONCURRENTLY IF NOT EXISTS xs_history_review_student ON "MemoryPracticeRecord"(company,(yhid::text),"objectId");
+CREATE INDEX CONCURRENTLY IF NOT EXISTS xs_history_user_legacy ON "_User"((company::text),("legacyUserId"::text),"objectId");

+ 11 - 0
scripts/sql/read-pressure.sql

@@ -0,0 +1,11 @@
+BEGIN;
+CREATE TABLE IF NOT EXISTS xs_read_pressure_config(id boolean PRIMARY KEY DEFAULT true CHECK(id),concurrency integer NOT NULL DEFAULT 2 CHECK(concurrency BETWEEN 1 AND 8));
+INSERT INTO xs_read_pressure_config(id) VALUES(true) ON CONFLICT DO NOTHING;
+CREATE TABLE IF NOT EXISTS xs_read_permit(slot integer PRIMARY KEY,owner text,lease_until timestamptz);
+INSERT INTO xs_read_permit(slot) SELECT generate_series(1,8) ON CONFLICT DO NOTHING;
+CREATE TABLE IF NOT EXISTS xs_read_cache(
+ key text PRIMARY KEY,company text NOT NULL,scope text NOT NULL,revision text NOT NULL,
+ value jsonb,expires_at timestamptz NOT NULL DEFAULT 'epoch',owner text,lease_until timestamptz
+);
+CREATE INDEX IF NOT EXISTS xs_read_cache_expiry ON xs_read_cache(expires_at);
+COMMIT;

+ 12 - 0
scripts/tests/lesson-export.test.mjs

@@ -14,5 +14,17 @@ test('export jobs are idempotent, owner scoped, globally leased and cancellation
  const claim=await ctx.lessonExportClaim(context);assert.equal(claim.jobId,first.jobId);assert.ok(claim.lease);assert.equal((await ctx.lessonExportClaim(context)).jobId,undefined);
  await ctx.lessonExportCancel(context,{jobId:first.jobId});await assert.rejects(ctx.lessonExportChunk(context,{jobId:first.jobId,lease:claim.lease,offset:0}),/租约/);
  assert.equal((await ctx.lessonExportStatus(context,{jobId:first.jobId})).status,'cancelled');
+ const resumed=await ctx.lessonExportCreate(context,{idempotencyKey:'resume-key-12345678'}),lease1=await ctx.lessonExportClaim(context);
+ await db.query("UPDATE xs_history_export_job SET snapshot_ready=true,total=1 WHERE id=$1",[resumed.jobId]);await db.query("INSERT INTO xs_history_export_row(job_id,position,value) VALUES($1,1,'{\"lessonId\":\"fixed\"}')",[resumed.jobId]);
+ await ctx.lessonExportRelease(context,{jobId:resumed.jobId,lease:lease1.lease});await assert.rejects(ctx.lessonExportChunk(context,{jobId:resumed.jobId,lease:lease1.lease}),/租约/);
+ const lease2=await ctx.lessonExportClaim(context);assert.notEqual(lease2.lease,lease1.lease);assert.equal(lease2.snapshotReady,true);
+ assert.equal((await ctx.lessonExportChunk(context,{jobId:resumed.jobId,lease:lease2.lease})).items[0].lessonId,'fixed');
+ await assert.rejects(ctx.lessonExportFinish(context,{jobId:resumed.jobId,lease:lease1.lease,rowCount:1,base64:'eA=='}),/租约/);
+
  }finally{await db.close();}
 });
+test('worker releases transient failures and retains fixed snapshots for retry',async()=>{
+ const {processLessonExport}=await import('../lesson-export-worker.mjs');const calls=[];
+ await assert.rejects(processLessonExport(async(action,params)=>{calls.push({action,params});if(action==='claim')return{jobId:'job',lease:'owner-lease',snapshotReady:true};if(action==='chunk')throw Object.assign(Error('503'),{transient:true});return{};}),e=>e.message==='export_job_failed'&&e.transient===true);
+ assert.deepEqual(calls.map(c=>c.action),['claim','chunk','release']);assert.deepEqual(calls.at(-1).params,{jobId:'job',lease:'owner-lease'});
+});

+ 1 - 1
scripts/tests/lesson-records.test.mjs

@@ -153,7 +153,7 @@ test('durable export freezes history, resumes expired workers and produces a com
  const created=await ctx.lessonExportCreate(manager,{studentId:20,idempotencyKey:'e2e-history-export-01'});
  const leased=await ctx.lessonExportClaim(worker);assert.equal(leased.jobId,created.jobId);
  await ctx.lessonExportSnapshot(worker,leased);
- const first=await ctx.lessonExportChunk(worker,{...leased,offset:0});assert.equal(first.items.length,56);
+ const first=await ctx.lessonExportChunk(worker,{...leased,offset:0});assert.equal(first.items.length,56);const normal=await ctx.lessonHistoryPage(manager,{studentId:20,pageSize:100});assert.deepEqual(JSON.parse(JSON.stringify(first.items)),JSON.parse(JSON.stringify(normal.items)));
  await query(`UPDATE "LessonRecord" SET pldp='snapshot must stay unchanged' WHERE company='c'`);
  await query("UPDATE xs_history_export_job SET lease_until=now()-interval '1 second' WHERE id=$1",[created.jobId]);
  const replacement=await ctx.lessonExportClaim(worker);assert.equal(replacement.snapshotReady,true);assert.notEqual(replacement.lease,leased.lease);

+ 12 - 0
scripts/tests/read-pressure-release.test.mjs

@@ -0,0 +1,12 @@
+import {test} from 'node:test';import assert from 'node:assert/strict';import {readFileSync,existsSync} from 'node:fs';
+const file=new URL('../read-pressure-release.mjs',import.meta.url);
+test('release patch preserves unrelated production functions and upgrades only owned read paths',async()=>{
+ assert.ok(existsSync(file),'surgical release builder exists');const {patchReadPressureGateway}=await import(file);
+ const before=`const STORE_OPERATIONS=new Set(['ops/lessons/list']);function untouched(){return 'production-hotfix';}\nasync function operationsScopeVersion(scope,companyId){return 'legacy-custom';}\nasync function handler(request,response){const operation='';if(operation==='ops/lessons/list')return response.json({success:true});}`;
+ const after=patchReadPressureGateway(before,'ops');assert.ok(after.includes("function untouched(){return 'production-hotfix';}"));assert.ok(after.includes("return 'legacy-custom'"));assert.ok(after.includes('ops/lessons/export/create'));assert.ok(after.includes('dispatchReadPressure'));new Function(after);
+});
+test('late hotfixes after the runtime marker and unrelated lesson transfer writes survive',async()=>{
+ const {patchReadPressureGateway}=await import(file);const {withXiaoshuRuntime}=await import('../lib/xiaoshu-runtime-compat.mjs');
+ const source=withXiaoshuRuntime(`const STORE_OPERATIONS=new Set(['ops/lessons/list']);async function lessonTransferCommit(){return 'write-hotfix';}async function handler(request,response){const operation='';if(operation==='ops/lessons/list')return response.json({success:true});}`)+"\nfunction memberCreditStatus(){return 'late-critical-hotfix';}";
+ const patched=patchReadPressureGateway(source,'ops');assert.ok(patched.includes("function memberCreditStatus(){return 'late-critical-hotfix';}"));assert.ok(patched.includes("async function lessonTransferCommit(){return 'write-hotfix';}"));
+});

+ 41 - 0
scripts/tests/read-pressure.test.mjs

@@ -0,0 +1,41 @@
+import {test} from 'node:test';import assert from 'node:assert/strict';import {PGlite} from '@electric-sql/pglite';import {readFileSync,existsSync} from 'node:fs';
+const file=new URL('../lib/read-pressure-runtime.mjs',import.meta.url);
+test('runtime protects named heavy reads, preserves unknown writes and isolates permission scopes',async()=>{
+ assert.ok(existsSync(file),'read pressure runtime exists');const {readPressurePolicy}=await import(file);
+ assert.equal(readPressurePolicy('ops',{operation:'ops/payroll/overview'}).heavy,true);
+ assert.equal(readPressurePolicy('app',{action:'app_review_page',operation:'ops/lessons/list'}).heavy,true);
+ assert.equal(readPressurePolicy('app',{action:'content_add',operation:'ops/lessons/list'}).heavy,false);
+ assert.equal(readPressurePolicy('ops',{operation:'ops/payroll/create'}).heavy,false);
+});
+test('shared row cache coalesces separate requests, validates each caller and refreshes after revision changes',async()=>{
+ assert.ok(existsSync(file));const {dispatchReadPressure}=await import(file);const pg=new PGlite();
+ const query=async(s,p=[])=>(await pg.query(s,p)).rows;const db={query,any:query,one:async(s,p=[])=>(await query(s,p))[0],none:async(s,p=[])=>{await query(s,p);},tx:async work=>work(db)};
+ try{await pg.exec('CREATE TABLE "LessonRecord"(company text,id int);');await pg.exec(readFileSync(new URL('../sql/read-revisions.sql',import.meta.url),'utf8'));await pg.exec(readFileSync(new URL('../sql/read-pressure.sql',import.meta.url),'utf8'));
+ let executions=0,auths=0;const response=()=>({body:null,json(value){this.body=value;return this;},status(){return this;}});
+ const req={body:{params:{operation:'ops/lessons/list'}}};const authorize=async()=>{auths++;return{company:'co',scope:'manager:0'};};
+ const execute=async(sql,r)=>{executions++;await new Promise(r=>setTimeout(r,15));return r.json({success:true,data:{items:[{id:executions}]}});};
+ const a=response(),b=response();await Promise.all([dispatchReadPressure(db,req,a,'ops',sql=>execute(sql,a),authorize),dispatchReadPressure(db,req,b,'ops',sql=>execute(sql,b),authorize)]);
+ assert.equal(executions,1);assert.equal(auths,2);assert.deepEqual(a.body.data,b.body.data);assert.ok(b.body.readCache.remainingMs<=30000);
+ await pg.exec("INSERT INTO \"LessonRecord\" VALUES('co',1)");const c=response();await dispatchReadPressure(db,req,c,'ops',sql=>execute(sql,c),authorize);assert.equal(executions,2);
+ const other=response();await dispatchReadPressure(db,req,other,'ops',sql=>execute(sql,other),async()=>({company:'co',scope:'manager:10'}));assert.equal(executions,3);
+ await new Promise(r=>setTimeout(r,5));const count=executions;await Promise.all(Array.from({length:3},()=>{const r=response();return dispatchReadPressure(db,{body:{params:{operation:'ops/lessons/list',refresh:true}}},r,'ops',sql=>execute(sql,r),authorize);}));assert.equal(executions-count,1,'forced refresh calls must coalesce');
+ }finally{await pg.close();}
+});
+test('budget adapter supports pg-promise frozen method properties',async()=>{
+ const {readPressureDb}=await import(file);const tx=Object.freeze({none:async()=>{},one:async()=>({ok:true})});const db=Object.freeze({tx:async work=>work(tx),one:async()=>({ok:true})});
+ assert.deepEqual(await readPressureDb(db,Date.now()+1000).one('SELECT 1'),{ok:true});
+});
+test('budget batches transaction control in one exchange and rolls back on query failure',async()=>{
+ const {readPressureDb}=await import(file);const calls=[];let fail=false;
+ const connection={multiResult:async sql=>{calls.push(sql);if(fail)throw Error('sql failed');return[{rows:[]},{rows:[]},{rows:[{ok:1}]},{rows:[]}];},none:async sql=>calls.push(sql)};
+ const db={$config:{pgp:{as:{format:sql=>sql}}},task:async work=>work(connection),one:async()=>{throw Error('Must batch');}};
+ assert.deepEqual(await readPressureDb(db,Date.now()+5000).one('SELECT 1'),{ok:1});assert.match(calls[0],/^BEGIN;SET LOCAL statement_timeout='\d+ms';SELECT 1\n;COMMIT;$/);
+ fail=true;await assert.rejects(readPressureDb(db,Date.now()+5000).one('SELECT 1'),/sql failed/);assert.equal(calls.at(-1),'ROLLBACK');
+});
+test('budget preserves callers that select transaction isolation before their first query',async()=>{
+ const {readPressureDb}=await import(file);let isolation=false;const calls=[];
+ const tx={none:async sql=>{calls.push(sql);if(sql.startsWith('SET TRANSACTION'))isolation=true;},one:async sql=>{if(!isolation)throw Error('SET TRANSACTION must precede queries');return{ok:true};}};
+ const db={tx:async work=>work(tx)};
+ await readPressureDb(db,Date.now()+1000).tx(async bounded=>{await bounded.none('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ');assert.deepEqual(await bounded.one('SELECT 1'),{ok:true});});
+ assert.ok(calls[0].startsWith('SET LOCAL statement_timeout='));
+});