Jelajahi Sumber

fix(export): start under PM2 and budget background file transfers

彭峰 6 jam lalu
induk
melakukan
44d9a699aa

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

@@ -41,3 +41,14 @@ Ruling: 导出快照在数据库内落表,JavaScript 每批只处理 500 条
 - 三槽配置在隔离测试中通过 5 分钟、893 次真实管理员/教师/学生混合读取,0 失败,期间持续叠加 10 请求/秒训练负载;两槽初始配置曾在冷查询混合负载下出现繁忙,保留失败记录,不计为通过。正式配置将在发布阶段设置为三槽,原数据库池不变。
 - 大文件浏览器实测发现旧下载解码最长任务 1719ms;已改为 256KB Base64 分块并让出事件循环,同一 15,711,484 字节、59,794 行工作簿再次下载回读一致,最长任务 74ms。进度/创建查询改为只选择任务元数据,只有下载请求读取文件正文。
 - 最终当前源码检查:服务端 620/620、管理端 408/408、移动端 419/419(移动源码本轮未再改动),两端生产构建通过。
+
+### 正式发布及线上验收
+
+- 网关与前端版本:f815b666e28a918ef3a02ab0988ab4d6e9ed9780。已发布三份网关定义、三项元数据迁移、4 个 CONCURRENTLY 索引(全部 valid/ready)及三槽配置;两端 201 个静态文件、源站/公网清单与深链接已回读一致。
+- 三个节点的导出 worker 已启用。线上验收发现 PM2 的 argv 入口不同,已改为同时识别直接命令行和 pm_exec_path,并测试继承 PM2 环境的命令行场景。
+- 真实账号线上浏览器:首页、账户、训练列表与翻页通过。线上 68 条筛选导出验证了后台处理、刷新恢复、交互可用及下载回读。
+- 首次全量正式导出在回传时三次中断,保留失败日志;原固定快照重领后 19,934 行成功,随后新的全量任务 19,937 行、13 列、7,987,910 字节、约30秒一次完成。未把首次失败写成通过。文件回传网络预算改为90秒,新增不含令牌/正文的 RPC action/status/durationMs 及错误原因标签;不能据此反推首次中断的确切原因。
+- 用户仍在进行完课,跨时间查询总量及课酬发生自然变化,未覆盖或回滚这些记录;相同时间点的数据一致性由只读快照对照验证。
+- 最新服务端全量 621/621 通过;上线后的最终30分钟观察仍在进行,最终记录以 REPORT.md 与发布回执为准。
+
+Ruling: 后台文件回传采用90秒网络预算,与普通读取的14秒总预算分开;只允许一个导出任务生成并由租约隔离。代价是文件网络异常时后台任务可能等待更久,UI仍可查询并显示进度;阶段日志用于区分网络、网关和处理失败。

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

@@ -16,7 +16,7 @@ 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(e?.transient?'release':'fail',identity).catch(()=>{});throw Object.assign(Error('export_job_failed'),{transient:e?.transient===true});}
+ }catch(e){await call(e?.transient?'release':'fail',identity).catch(()=>{});throw Object.assign(Error('export_job_failed'),{transient:e?.transient===true,reason:/^export_(?:transport|gateway_\d{3})$/.test(e?.message||'')?e.message:'export_processing'});}
 }
 async function main(){
  try{setPriority(0,10);}catch{} // Background XLSX compression yields CPU to interactive requests.
@@ -24,8 +24,16 @@ async function main(){
  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)=>{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;};
+ const call=async(action,params)=>{
+  const started=Date.now();let status=0;
+  try{
+   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(['snapshot','finish'].includes(action)?90_000:20_000)});}catch{throw Object.assign(Error('export_transport'),{transient:true});}
+   status=r.status;let data;try{data=await r.json();}catch{throw Object.assign(Error('export_gateway_'+r.status),{transient:r.ok||r.status===429||r.status>=500});}
+   if(!r.ok||!data.success)throw Object.assign(Error('export_gateway_'+r.status),{transient:[408,429,500,502,503,504].includes(r.status)});return data.data;
+  }finally{const durationMs=Date.now()-started;if(durationMs>=1000||status>=400||!status)console.info(JSON.stringify({event:'lesson_export_rpc',operation:action,status,durationMs}));}
+ };
  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);
+ do{try{if(Date.now()-lastMaintenance>86_400_000){await call('maintenance',{});lastMaintenance=Date.now();}await processLessonExport(call);}catch(error){console.error('导出任务失败,错误状态已回写或将由租约恢复:'+(error?.reason||'export_service'));}if(process.argv.includes('--once'))break;if(!stopped)await new Promise(r=>setTimeout(r,5000));}while(!stopped);
 }
-if(process.argv[1]&&import.meta.url===pathToFileURL(process.argv[1]).href)main().catch(()=>{console.error('导出 worker 启动失败,请检查私密配置和权限');process.exitCode=1;});
+export function isLessonExportEntrypoint(url,argv=process.argv,env=process.env){return[argv[1],env.pm_exec_path].some(entry=>Boolean(entry&&url===pathToFileURL(entry).href));}
+if(isLessonExportEntrypoint(import.meta.url))main().catch(()=>{console.error('导出 worker 启动失败,请检查私密配置和权限');process.exitCode=1;});

+ 8 - 1
scripts/tests/lesson-export.test.mjs

@@ -26,6 +26,13 @@ test('export jobs are idempotent, owner scoped, globally leased and cancellation
 });
 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);
+ 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('export_gateway_504'),{transient:true});return{};}),e=>e.message==='export_job_failed'&&e.transient===true&&e.reason==='export_gateway_504');
  assert.deepEqual(calls.map(c=>c.action),['claim','chunk','release']);assert.deepEqual(calls.at(-1).params,{jobId:'job',lease:'owner-lease'});
 });
+test('worker recognizes PM2 and direct CLI entrypoints but stays idle when imported',async()=>{
+ const module=await import('../lesson-export-worker.mjs');assert.equal(typeof module.isLessonExportEntrypoint,'function');
+ const url='file:///opt/exports/worker.mjs';assert.equal(module.isLessonExportEntrypoint(url,['node','/pm2/ProcessContainerFork.js'],{pm_exec_path:'/opt/exports/worker.mjs'}),true);
+ assert.equal(module.isLessonExportEntrypoint(url,['node','/opt/exports/worker.mjs'],{}),true);
+ assert.equal(module.isLessonExportEntrypoint(url,['node','/opt/exports/worker.mjs'],{pm_exec_path:'/opt/server/main.js'}),true);
+ assert.equal(module.isLessonExportEntrypoint(url,['node','/opt/tests/test.mjs'],{}),false);
+});