Ver Fonte

perf(export): keep progress reads small and yield during workbook download

彭峰 há 7 horas atrás
pai
commit
f815b666e2

+ 4 - 0
perf/cloud-concurrency-2026-11-15/PROGRESS.md

@@ -37,3 +37,7 @@ Ruling: 没有生产定义/连接池/峰值证据时不猜测扩容额度、不
 
 Ruling: 重读仍共享两个跨节点槽位,使用原池及事务内 SQL 时限,不使用启动参数超时或新的连接池;理由是实测数据库代理不支持该参数,且单连接会串行化页面原有的独立读取。代价是快速读取存在少量准入开销,必须在混合负载与报告中单独列出,不能计为提速收益。
 Ruling: 导出快照在数据库内落表,JavaScript 每批只处理 500 条;理由是旧全量 JS 快照实验曾超内存并触发 1GB 进程重启。代价是后台任务占用临时快照表和较长事务,已通过 6 万记录及固定快照回读验证。
+
+- 三槽配置在隔离测试中通过 5 分钟、893 次真实管理员/教师/学生混合读取,0 失败,期间持续叠加 10 请求/秒训练负载;两槽初始配置曾在冷查询混合负载下出现繁忙,保留失败记录,不计为通过。正式配置将在发布阶段设置为三槽,原数据库池不变。
+- 大文件浏览器实测发现旧下载解码最长任务 1719ms;已改为 256KB Base64 分块并让出事件循环,同一 15,711,484 字节、59,794 行工作簿再次下载回读一致,最长任务 74ms。进度/创建查询改为只选择任务元数据,只有下载请求读取文件正文。
+- 最终当前源码检查:服务端 620/620、管理端 408/408、移动端 419/419(移动源码本轮未再改动),两端生产构建通过。

+ 6 - 0
projects/xiaoshu-admin/src/app/pages/lesson-records.component.spec.ts

@@ -86,4 +86,10 @@ describe('训练记录与复习转交',()=>{
   await c.saveTransfer();const first=ops.request.calls.mostRecent().args[1];expect(c.preview()?.version).toBe('v1');await c.saveTransfer();expect(ops.request.calls.mostRecent().args[1]?.['idempotencyKey']).toBe(first?.['idempotencyKey']);
   ops.request.and.returnValues(Promise.resolve({count:2}),Promise.resolve({studentId:20,appointmentId:'1000'}));await c.saveTransfer();expect(c.preview()).toBeNull();expect(c.message()).toContain('2');
  });
+ it('大文件下载让出事件循环并保留跨分块的原始字节',async()=>{
+  const c=TestBed.runInInjectionContext(()=>new LessonRecordsComponent());c.exportJob.set({jobId:'j',status:'ready',total:1,processed:1});const binary='\x00\xffabc'.repeat(70000);ops.request.and.resolveTo({base64:btoa(binary),contentType:'application/octet-stream',fileName:'export.xlsx'});
+  let blob:Blob|undefined;spyOn(URL,'createObjectURL').and.callFake(value=>{blob=value as Blob;return'blob:test';});spyOn(URL,'revokeObjectURL');spyOn(HTMLAnchorElement.prototype,'click');let yielded=false;setTimeout(()=>yielded=true,0);
+  await c.downloadExport();expect(yielded).toBeTrue();expect(c.downloading()).toBeFalse();const bytes=new Uint8Array(await blob!.arrayBuffer());expect(bytes.length).toBe(binary.length);expect(bytes[262143]).toBe(binary.charCodeAt(262143));expect(bytes.at(-1)).toBe(99);
+ });
+
 });

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

@@ -68,5 +68,7 @@ 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(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);}}
+ 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 parts:BlobPart[]=[];const chunkSize=256*1024;
+   for(let offset=0;offset<file.base64.length;offset+=chunkSize){const binary=atob(file.base64.slice(offset,offset+chunkSize)),bytes=new Uint8Array(binary.length);for(let i=0;i<binary.length;i++)bytes[i]=binary.charCodeAt(i);parts.push(bytes);if(offset+chunkSize<file.base64.length)await new Promise<void>(resolve=>setTimeout(resolve,0));}
+   const url=URL.createObjectURL(new Blob(parts,{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);}}
 }

+ 5 - 5
scripts/cloud/lesson-export.js

@@ -8,24 +8,24 @@ async function lessonExportCreate(context,input){
  return Psql.tx(async db=>{
   await db.one('SELECT pg_advisory_xact_lock(hashtext($1),hashtext($2))',[company,actor]);
   await db.none('DELETE FROM xs_history_export_job WHERE company=$1 AND actor=$2 AND expires_at<=now()',[company,actor]);
-  const prior=(await db.query('SELECT * FROM xs_history_export_job WHERE company=$1 AND actor=$2 AND request_key=$3 AND expires_at>now()',[company,actor,key]))[0];
+  const prior=(await db.query('SELECT id,filters,scope,status,total,processed,error,snapshot_ready,expires_at FROM xs_history_export_job WHERE company=$1 AND actor=$2 AND request_key=$3 AND expires_at>now()',[company,actor,key]))[0];
   if(prior){if(JSON.stringify(prior.filters)!==JSON.stringify(JSON.parse(JSON.stringify(filters)))||prior.scope!==scope){
     // PostgreSQL jsonb canonicalizes key order; compare its canonical representation instead.
     const same=await db.one('SELECT filters=$2::jsonb same FROM xs_history_export_job WHERE id=$1',[prior.id,JSON.stringify(filters)]);if(!same.same||prior.scope!==scope)fail(409,'导出请求条件已变化');
    }return lessonExportProjection(prior);}
-  const active=(await db.query("SELECT * FROM xs_history_export_job WHERE company=$1 AND actor=$2 AND status IN('queued','running') AND expires_at>now()",[company,actor]))[0];
+  const active=(await db.query("SELECT id,filters,scope,status,total,processed,error,snapshot_ready,expires_at FROM xs_history_export_job WHERE company=$1 AND actor=$2 AND status IN('queued','running') AND expires_at>now()",[company,actor]))[0];
   if(active){const same=await db.one('SELECT filters=$2::jsonb same FROM xs_history_export_job WHERE id=$1',[active.id,JSON.stringify(filters)]);if(!same.same||active.scope!==scope)fail(409,'已有导出进行中,请等待或取消');return lessonExportProjection(active);}
   const job=await db.one('INSERT INTO xs_history_export_job(company,actor,scope,request_key,filters) VALUES($1,$2,$3,$4,$5::jsonb) RETURNING *',[company,actor,scope,key,JSON.stringify(filters)]);return lessonExportProjection(job);
  });
 }
-async function lessonExportOwned(context,input,db=Psql){
+async function lessonExportOwned(context,input,db=Psql,includeFile=false){
  requireRole(context,['ops-manager','ops-staff','ops-auditor']);
- const job=(await db.query('SELECT * FROM xs_history_export_job WHERE id=$1 AND company=$2 AND actor=$3 AND scope=$4 AND expires_at>now()',[text(input.jobId),companyIdOf(context,input),text(context.current.id),lessonExportScope(context)]))[0];
+ const job=(await db.query('SELECT id,status,total,processed,error,snapshot_ready,expires_at'+(includeFile?',file_base64':'')+' FROM xs_history_export_job WHERE id=$1 AND company=$2 AND actor=$3 AND scope=$4 AND expires_at>now()',[text(input.jobId),companyIdOf(context,input),text(context.current.id),lessonExportScope(context)]))[0];
  if(!job)fail(404,'未找到有效导出任务');return job;
 }
 async function lessonExportStatus(context,input){return lessonExportProjection(await lessonExportOwned(context,input));}
 async function lessonExportCancel(context,input){const job=await lessonExportOwned(context,input);await Psql.none("UPDATE xs_history_export_job SET status='cancelled',lease=NULL,lease_until=NULL,file_base64=NULL WHERE id=$1 AND status IN('queued','running')",[job.id]);return lessonExportStatus(context,input);}
-async function lessonExportDownload(context,input){const job=await lessonExportOwned(context,input);if(job.status!=='ready'||!job.file_base64)fail(409,'导出尚未完成');return{fileName:'训练记录.xlsx',contentType:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',base64:job.file_base64};}
+async function lessonExportDownload(context,input){const job=await lessonExportOwned(context,input,Psql,true);if(job.status!=='ready'||!job.file_base64)fail(409,'导出尚未完成');return{fileName:'训练记录.xlsx',contentType:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',base64:job.file_base64};}
 async function lessonExportClaim(context){
  requireSuperAdmin(context);
  return Psql.tx(async db=>{

+ 3 - 2
scripts/tests/lesson-export.test.mjs

@@ -4,7 +4,7 @@ test('export jobs are idempotent, owner scoped, globally leased and cancellation
  assert.ok(existsSync(file),'durable export implementation exists');const db=new PGlite();
  try{
  await db.exec(readFileSync(new URL('../sql/lesson-export.sql',import.meta.url),'utf8'));
- const adapter=tx=>({query:async(s,p=[])=>(await tx.query(s,p)).rows,one:async(s,p=[])=>(await tx.query(s,p)).rows[0],none:async(s,p=[])=>{await tx.query(s,p);}});
+ const statements=[];const adapter=tx=>({query:async(s,p=[])=>{statements.push(s);return(await tx.query(s,p)).rows;},one:async(s,p=[])=>(await tx.query(s,p)).rows[0],none:async(s,p=[])=>{await tx.query(s,p);}});
  const Psql={...adapter(db),tx:work=>db.transaction(tx=>work(adapter(tx)))};
  const context={current:{id:'owner'},company:'co',roles:['ops-manager'],isSuperAdmin:true};
  const ctx=vm.createContext({Psql,text:v=>String(v??''),number:v=>Number(v)||0,companyIdOf:c=>c.company,roleOf:c=>c.roles[0],requireRole:()=>{},requireSuperAdmin:c=>{if(!c.isSuperAdmin)throw Error('forbidden');},fail:(_,m)=>{throw Error(m);},Date,JSON});vm.runInContext(readFileSync(file,'utf8'),ctx);
@@ -13,13 +13,14 @@ test('export jobs are idempotent, owner scoped, globally leased and cancellation
  await assert.rejects(ctx.lessonExportStatus({...context,current:{id:'other'}},{jobId:first.jobId}),/未找到/);
  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');
+ assert.equal((await ctx.lessonExportStatus(context,{jobId:first.jobId})).status,'cancelled');assert.ok(!/SELECT \*/.test(statements.at(-1)),'polling must not load file_base64');
  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=='}),/租约/);
+ await db.query("UPDATE xs_history_export_job SET status='ready',file_base64='eA==' WHERE id=$1",[resumed.jobId]);assert.equal((await ctx.lessonExportDownload(context,{jobId:resumed.jobId})).base64,'eA==');assert.ok(statements.at(-1).includes('file_base64'),'download explicitly fetches the file');
 
  }finally{await db.close();}
 });