Voyager 业务线学习:异步图像导出从可恢复 Workflow 走向大页有界并行
这条业务线解决的是多页设计导出不能再被单次 HTTP 请求、同步 ZIP 内存和单页串行等待限制的问题。用户从编辑器或 Platform API 提交导出,快速拿到 job id,等待服务端完成渲染,再下载临时结果;工程上则要同时处理可变文件内容、页面顺序、workspace 权限、浏览器渲染资源、R2 中间态、…
source_automation: voyager-merged-pr run_date: 2026-07-28 anchor_pr_number: 5320 pr_number: 5320 pr_title: “perf(export): parallelize image workflow steps” pr_url: https://github.com/adastralab-ai/voyager/pull/5320 ↗ author: “Zihua Li (@luin)” merged_at: “2026-07-28T08:46:47Z” modules:
- backend/workers/export/worker/workflows/exportImageWorkflow.ts
- backend/workers/export/worker/workflows/exportImageWorkflow.test.ts
- backend/workers/export/package.json
- pnpm-lock.yaml files_changed: 4 changed_files:
- backend/workers/export/worker/workflows/exportImageWorkflow.ts
- backend/workers/export/worker/workflows/exportImageWorkflow.test.ts
- backend/workers/export/package.json
- pnpm-lock.yaml learning_tags:
- export-pipeline
- cloudflare-workflows
- bounded-concurrency
- durable-steps
- image-workflow
- zip-streaming
- r2-multipart-upload
- memory-bounds
- performance
- reliability-test business_line: “异步图像导出 Workflow 的大页扩展与有界并行” related_prs: [5235, 5290, 5303, 5312] related_prior_prs: [5235] line_stage: “PPTX/JPG 接入异步 Workflow -> 提交尾延迟监测 -> 100 页与流式归档 -> 移除高成本幂等创建 -> prepare/render 有界并行” open_questions:
- “Cloudflare Workflows 真实运行时是否按 p-map 预期并发执行 step.do,并保持每个 durable step 的重放、失败和重试语义?”
- “render 并发上限 3 与 Browser Run session、Worker CPU、R2 和下游 API 的真实配额是否匹配?100 页长尾尚无真实压测证据。”
- “并发 prepare/render 中单个 page step 失败时,Workflow 是否只重试失败 page,还是会重放已完成的 sibling steps?”
- “流式 ZIP 失败或中止后,page object、snapshot 和 multipart upload 的清理由谁负责?”
- “PR #5312 移除创建幂等后,客户端或上游重试会创建多个 job;重复 job 的产品语义和用户侧去重策略是什么?”
- “PR #5290 的 claim idempotency span 已随 #5312 删除,是否仍需区分 API 到 worker 的排队、创建和 Workflow accepted 延迟?”
- “ZIP32 超限时的错误码、已上传 parts 回收和 App 可操作提示是否足够明确?” feishu_doc_url: null github_repository: joyehuang/ai-agent-field-notes github_path: outputs/voyager-daily-pr-study/2026-07-28-pr-5320-export-workflow-parallelism.md github_commit: 0705334470dffa9414947af1047a19e43535a165 voyager_merge_commit: 5fcf38858025472104bea9280a4e3d9537a5aa11
业务线概览#
这条业务线解决的是多页设计导出不能再被单次 HTTP 请求、同步 ZIP 内存和单页串行等待限制的问题。用户从编辑器或 Platform API 提交导出,快速拿到 job id,等待服务端完成渲染,再下载临时结果;工程上则要同时处理可变文件内容、页面顺序、workspace 权限、浏览器渲染资源、R2 中间态、Workflow 重试和大文件归档。
PR #5235 已经把 JPG/PPTX 接入 Cloudflare Workflows:PNG/JPG 共享 image pipeline,PPTX 使用独立 pipeline;Go 负责授权和下载签名,worker 负责 snapshot、render、R2 和状态,Site 负责 polling。今天的锚点 #5320 没有重新设计 job 契约,而是针对 image Workflow 的两个昂贵阶段做运行时收敛:prepare snapshot 最多 10 个并发,render image 最多 3 个并发,同时保留每页独立 durable step 和按页面顺序归档。
所以本次学习的单位不是一个孤立的 perf PR,而是 Export Job 从“先能异步跑起来”走向“支持 100 页、控制资源压力、让失败可恢复”的工程演进。#5303 解决大页图片导出原先的 ZIP 内存边界,#5290 暴露并放宽提交阶段尾延迟,#5312 删除高成本的创建幂等记录;#5320 在这些前提上继续压缩串行等待时间。
今日锚点#
| 字段 | 内容 |
|---|---|
| PR | #5320 perf(export): parallelize image workflow steps ↗ |
| 作者 | Zihua Li / luin |
| merge 时间 | 2026-07-28 18:46:47 AEST / 2026-07-28T08:46:47Z |
| merge commit | 5fcf38858025472104bea9280a4e3d9537a5aa11 |
| 变更规模 | 4 个文件,98 additions / 20 deletions |
| 直接代码变化 | 引入 p-map;prepare 并发上限 10;render 并发上限 3;新增 fake Workflow step 并发断言 |
| 选择理由 | Melbourne 今日窗口内最晚合入,且能自然串起 #5235、#5290、#5303、#5312 的 Export runtime 演进 |
今天已有合适锚点,因此没有扩大到 2026-07-27 Melbourne 日界线之外。#5316 等文档变更、界面微调和不共享 Export runtime 边界的候选没有作为锚点。#5320 的四个文件直接落在 export worker 的 Workflow 和测试上,PR body 也明确把近 100 页图片 Workflow 的串行等待作为性能症状。
演进时间线#
| 阶段 | PR 与代码证据 | 改变的层 | 本次学习中的意义 |
|---|---|---|---|
| 异步基础与跨格式入口 | #5235 ↗,merge 831eab365364e1a401fb7281b71759d53c7b175b,60 files | TypeSpec discriminator、Go exportjob service/client、image/PPTX Workflow、R2 snapshot/result、Site polling | 把长导出从同步路由移到可恢复 job;PNG/JPG 复用 image Workflow,PPTX 保持独立 renderer。 |
| 提交阶段尾延迟 | #5290 ↗,merge fd42185a7424aa542467308dc64cfcc7a6225312 | Go 到 worker 的 HTTP client、worker exportJobs.ts | 生产中并行提交 PNG/PPTX 时出现接近 10 秒尾延迟;超时从 10 秒提升到 30 秒,并在 R2 claim 与 Workflow start 周围加 timing span。#5312 随后移除了 claim 阶段。 |
| 大页与流式归档 | #5303 ↗,merge e15748213c5e0055ae9288731c903861af3b59ff | TypeSpec/Go page limit、worker job input、image Workflow、R2 multipart、结果授权检查 | image Workflow 的 PNG/JPG 上限提升到 100,PPTX 保持 20;多页 ZIP 改为逐页读 R2、流式压缩和 multipart upload,上传失败会 abort。 |
| 创建语义收敛 | #5312 ↗,merge ecdb7f008d1d859d0266e953801d883219aa5e2f | TypeSpec compatibility header、Go client/service、worker job creation、contract tests | 移除 R2 idempotency record、lookup route 和冲突处理;旧 Idempotency-Key 仍可附带但被忽略。每次 create 生成新 job,降低复杂度但改变 retry 语义。 |
| 今日锚点:有界并行 | #5320 ↗,merge 5fcf38858025472104bea9280a4e3d9537a5aa11 | ExportImageWorkflow 调度、依赖、Workflow fake test | 保持每页 durable step 的 identity 和失败边界,把同阶段等待从完全串行改成有界并发;所有 render 完成后仍按 page number 创建归档。 |
上述五个 merge commit 均已用 git merge-base —is-ancestor merge origin/main 验证。关联不是按标题拼接:#5290 和 #5312 改同一个 export job 提交边界,#5303 和 #5320 改同一个 image Workflow,#5235 提供共同的 format/page/snapshot/status 数据流。
当前架构与数据流#
flowchart LR
A["Site ExportButton: JPG/PPTX"] --> B["Go session handler"]
P["Platform PAT client"] --> C["Go platform handler"]
B --> D["shared exportjob.Service"]
C --> D
D --> E["HMAC Go to export-worker"]
E --> F["createExportJob + Workflow instance"]
F --> G["ExportImageWorkflow"]
G --> H["R2 page snapshot and rendered page"]
H --> I["streamImageArchive + multipart ZIP"]
I --> J["Workflow status and output"]
J --> K["Go Get: workspace/page recheck + signed URL"]
K --> L["Site poll + download"]plaintext- 用户入口与契约。 当前 ExportButton 将 JPG 和 PPTX 的 fileId、pageIds、format 交给 downloadExportJob;Site 先发送 session API 的 x-workspace-id,再以 3 秒初始延迟和 300ms 到 5s 退避轮询。Platform API 使用 PAT,但复用同一个 exportjob.Service。
- Go handler 与 IDL discriminator。 TypeSpec 的 Platform export-jobs 请求把 format 作为 discriminator,fileId 必填,PNG/JPG 允许最多 100 个 resolved pages,PPTX 最多 20 个;旧 Idempotency-Key 是可选 deprecated header,服务端忽略它。当前契约 ↗ 通过 codegen 生成 Go/TS 类型。
- Go service 是 mutable source 和权限边界。 Submit 先校正 scale/quality 和 format-specific defaults,再按 workspace 读取一个 file,验证所选 page 属于该 file 且是 DESIGN;没有显式 page selection 时,按 file order 收集 DESIGN pages。#5303 让 PageLimit 可按入口传入,随后把 ResolvedPageIDs 传给 worker。完成 job 查询时,Go 用结果中的 fileId 和 pageIds 再做 workspace/page 检查,然后才签发下载 URL。
- worker 负责创建和查询 Workflow。 当前 createExportJob 为每个请求生成 job id,按 format 把 PNG/JPG 映射到 image Workflow、PPTX 映射到 pptx Workflow;Workflow instance id 包含 user、workspace、job。创建异常时会用同一个 instance id 做 get fallback,但随机 job id 意味着这不是跨 HTTP retry 的幂等。
- image Workflow 分为四个阶段。 第一阶段对每页执行 ensurePageSnapshot,第二阶段从 R2 snapshot render 到单页对象;#5320 使两个阶段分别有界并行,但阶段之间仍是 barrier:所有 prepare 完成后才开始 render。单页直接返回 image object,多页在所有 render 完成后进入 create image archive,按 page number 顺序流式读取 R2。
- 归档与交付。 #5303 的 archive 使用 5 MiB multipart parts,并通过 outputWrites 等待压缩输出被消费;失败时终止 ZIP、abort multipart upload 并向上抛错。Workflow output 携带 fileId/pageIds/objectKey,Go 在 status completed 时签名临时 URL,Site 下载并保留 warnings。
关键代码#
1. 先在 Go 边界解析可变 file,再把稳定 page snapshot 交给 worker#
证据:PR #5303 diff ↗,当前 mainline 的 service.go:85-110 ↗。
pageLimit := input.PageLimit
if pageLimit == 0 {
pageLimit = defaultPageLimit
}
resolvedFile, err := s.fileForExport(ctx, workspaceID, input.FileID)
pageIDs, err := designPageIDsForFile(resolvedFile, input.PageIDs, pageLimit)
input.ResolvedPageIDs = pageIDs
job, err := s.client.Create(ctx, identity.UserID, workspaceID, input)plaintext代码事实:worker 收到的是 Go 已验证的 fileId 和 resolved pageIds,而不是自己重新读取 file。这样 page 顺序、DESIGN 类型和 workspace 可见性集中在 Go;Workflow 内的 snapshot 固定后续 render 输入。合理推断是并行 render 不会因为导出期间 file 变化而产生跨页版本混合,但 snapshot 仍是短期 R2 状态,生命周期和清理需单独治理。
2. 大页 ZIP 使用 R2 流式读取和 backpressure#
证据:PR #5303 image Workflow diff ↗,当前 mainline 的 exportImageWorkflow.ts:162-251 ↗。
for (const page of pages) {
const object = await bucket.get(page.objectKey);
const entry = new ZipPassThrough(page.fileName);
archive.add(entry);
const reader = object.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
entry.push(value);
await outputWrites;
}
entry.push(new Uint8Array(), true);
}
archive.end();
await outputWrites;plaintext代码事实:一次只打开一个 page object,压缩器输出未消费完就不继续读取;5 MiB part 满后按 ZIP 输出顺序上传;归档出错会 abort。这个设计关闭了 #5235 报告中“多页 JPG 超过 32 MiB 时如何处理”的一部分缺口,但没有消除 ZIP32 总大小上限,也没有证明 100 页真实 Browser Rendering 的内存和时间长尾。
3. #5290 把提交尾延迟从客户端超时边界中释放出来#
证据:PR #5290 diff ↗,当前 mainline 的 client.go:20-45 ↗。
const (
exportWorkerTimeout = 30 * time.Second
)plaintext#5290 同时在 worker create path 里为 R2 idempotency claim 和 Workflow start 加 export.submit timing spans,并将生产症状定位到 API 到 worker 的约 10 秒尾延迟。创建 job 的 HTTP 请求只应等待“Workflow 已被接受”,不应等待 render;但 10 秒仍不足以覆盖真实 Cloudflare latency。#5312 删除 idempotency 后,claim span 随之消失,当前 worker 仍保留 start workflow span,说明 observability 必须随架构删减同步更新。
4. #5312 重新定义 retry 语义:每次 create 都是新 job#
证据:PR #5312 diff ↗,当前 mainline 的 exportJobs.ts:143-180 ↗ 与 platform.tsp:86-93 ↗。
const jobId = generateCompactUUID();
const instanceId = exportWorkflowInstanceId(userId, workspaceId, jobId);
await span({ name: "start workflow", op: "export.submit" }, () =>
startWorkflow(workflowForFormat(workflows, format), instanceId, target),
);
return { id: jobId };plaintext代码事实:R2 idempotency record、lookup endpoint、same-request conflict 和 24 小时 key retention 被删除;旧 header 只保留为兼容形状。合理推断是这降低了提交路径的读、写、CAS 和 lookup 复杂度,却把“网络超时后重试是否会重复导出”交给上层。startWorkflow 的 get fallback 只处理同一个随机 job id 的创建竞态,不能合并两个独立请求。
5. #5320 把 durable step 调度改为分阶段有界并行#
证据:PR #5320 diff ↗,当前 mainline 的 exportImageWorkflow.ts:102-135 ↗。
await pMap(pages, ({ pageId, pageNumber, snapshotKey }) =>
step.do("prepare image snapshot " + pageNumber, stepConfig, () =>
ensurePageSnapshot(env, snapshotKey, schema, { workspaceId, pageId }, options)
), { concurrency: MAX_CONCURRENT_PREPARE_STEPS });
await pMap(pages, ({ pageNumber, snapshotKey, objectKey }) =>
step.do("render image " + pageNumber, stepConfig, () =>
renderImageSnapshot(env, snapshotKey, objectKey)
), { concurrency: MAX_CONCURRENT_RENDER_STEPS });plaintext代码事实:并行在 step.do 外层通过 p-map 限流,step name 仍由稳定 page number 决定;prepare/render 使用不同上限,render 的 3 不是沿用 prepare 的 10。测试用 11 页和可控 Promise 证明 prepare 达到 10 时 render 仍为 0,释放 prepare 后 render 达到 3,最后断言两个最大并发值分别为 10 和 3(exportImageWorkflow.test.ts:283-340 ↗)。
这个改法保留每页 durable retry seam,却没有引入“prepare 完一页就立即 render 一页”的流水线调度;后者会减少 barrier 等待,但会增加同时活跃的 step 类型和更复杂的失败/重放交互。#5320 选择的是更容易解释和限流的两阶段模型。
工程取舍#
性能与资源边界#
- #5320 不使用无界 Promise.all,而是为 page source API 和 Browser Render 设置不同配额。prepare 更像 API/R2 I/O,render 还占用 Browser Run session,因此 render 上限 3 低于 prepare 上限 10。
- 这不是全链路流水线并行:100 页会先完成所有 prepare,再进入 render,再 archive。实现简单、顺序清晰,但在 prepare 与 render 耗时差异很大时仍有阶段性空闲。
- #5303 的 100 页上限只是输入规模边界,不等于 100 页都能在产品可接受时间内完成;#5320 需要真实测量 11、20、100 页在不同图片体积和浏览器 session 压力下的 p50/p95。
可恢复性与内存#
- 每页 snapshot 和 render 都仍然是独立 step.do,stable page number 让 Workflow step identity 可观察;#5320 只改变调度,不把页面数据重新放回内存数组。
- #5303 用 R2 page object 作为 render/archive 之间的持久化 seam,ZipPassThrough 加 multipart upload 让归档峰值内存受 part size 和 backpressure 约束;但上传失败后中间 page object 是否回收没有在这条 diff 中解决。
- MAX_ZIP32_ARCHIVE_BYTES 仍然保护 classic ZIP 的 central directory 偏移;“突破同步 32 MiB source cap”和“允许无限大 ZIP”是两件不同的事。
契约、兼容性与 retry#
- TypeSpec、Go、worker schema 和测试共同表达 image 100/page 与 PPTX 20/page 的差异;这比把 100 作为所有格式的全局上限更诚实,但入口之间仍必须保持 codegen、handler 和 UI selector 同步。
- #5312 的 header 兼容是线格式兼容,不是行为兼容:旧客户端继续发送 Idempotency-Key 不会再得到相同 job。重试安全性从 worker 的 R2 CAS 保护转移到了调用方和产品层。
- #5290 的 30 秒 client timeout 仍保留,保护的是提交请求和 Workflow start 的上游窗口;它不能替代 #5320 的 render concurrency,也不能保证 worker downstream 一定有容量。
测试策略与证据边界#
代码层面的测试证据是分层的:
- #5303 增加 worker test 验证 image 100 页、PPTX 20 页,以及 Go service 用一次 file read 校验 selected pages;Workflow test 验证超过同步 ZIP source limit 仍能流式生成 archive,并在 multipart upload 失败时及时 abort。
- #5320 增加 prepare/render 最大并发的 fake WorkflowStep 测试,覆盖本次新增的调度 invariant,也保留有序 ZIP 和 page render 次数断言。
- #5312 更新 Go/worker/contract tests 以反映没有 lookup/idempotency 的新形状;这能证明契约迁移,但不证明客户端网络重试不会产生重复 job。
- #5290 的 PR 变更只有 Go client 和 worker submission code,没有新增自动化测试文件;现有 timing span 也没有被真实 trace 验证。
这些证据足以支持“代码把并发上限、流式归档和新 job 语义写进了实现”,但不能升级为生产级结论。当前仍缺 Cloudflare Workflow 真实实例、Browser Run session 配额、100 页长尾、R2 orphan cleanup、部署 skew 和客户端重复提交的端到端回归。
和最近学习记录的关系#
最近一篇是 2026-07-26 的 #5235 报告。那篇报告关注如何把 PNG 异步基础扩成 JPG/PPTX,并把 format、page snapshot、status、warning 和 App polling 组织成跨层契约;本次不重复讲 format discriminator,而是追踪它合入后两天内的 runtime 扩展:
- #5303 回应上一份报告留下的多页 JPG/PNG 32 MiB 和大 ZIP 内存问题,把每页结果对象变成可流式读取的 archive input,并把 image limit 从 20 推到 100。
- #5290 回应提交阶段接近 10 秒尾延迟,说明 job API 的“快返回”本身也有上游超时边界。
- #5312 不是简单的性能优化,而是删除 R2 idempotency record / lookup 形成的复杂状态机;它让系统更简单,同时新增重复 job 风险。
- #5320 再处理 render 端的串行等待,补上“输入可持久化但执行仍太慢”的阶段。
本次新增视角是:异步架构的下一步不是继续堆更多格式,而是同时管理 source snapshot、archive memory、submission latency、Workflow concurrency 和 retry semantics。不能只看导出最终能否下载;要把一次 job 的创建、准备、渲染、归档和轮询分成不同可靠性边界。
我会怎么吸收#
- 先固定 durable seam,再并行化。 如果每页输入和中间结果没有稳定存储、命名和重试边界,直接把串行循环换成 Promise.all 只会把失败和内存问题放大。
- 按资源类型设置不同并发配额。 I/O 密集的 prepare 和 Browser/CPU 密集的 render 不应共享一个最大并发常数;限制值应和下游 session、请求、内存配额对应,并由测试锁定为 invariant。
- 大文件要靠流式和 backpressure,而不是只调高页数。 输入页数、单页对象、ZIP part、ZIP 总大小和 abort/cleanup 是不同边界,必须分别表达。
- 扩展 API 时同步收敛 IDL、handler、worker schema 和结果授权。 fileId、resolved pageIds 和结果 fileId 让 Go 能在完成下载前再次校验 ownership;仅在 UI 中限制页数不够。
- 删除幂等时必须重新写出 retry contract。 代码复杂度降低不代表可靠性自动提高;如果相同 HTTP 请求可能创建多个 job,就要有明确的调用方重试、指标和用户提示策略。
边界、风险与未解问题#
代码验证事实#
- #5320 保留每页 step.do,并以 p-map 将 prepare/render 限制为 10/3;测试在 fake step 层断言两个上限。
- #5303 使用 R2 multipart upload、逐页 ReadableStream 和 outputWrites backpressure;异常路径调用 archive.terminate 和 upload.abort。
- #5312 当前代码每次生成新的 job id,TypeSpec 只把 Idempotency-Key 保留为 optional deprecated header;worker 不再读写 idempotency R2 record。
- 所有选入报告的 merge commit 都能从当前 origin/main 到达;没有把非 mainline 分支或仅靠标题相关的 PR 当作正式关联项。
合理推断#
- prepare 上限 10、render 上限 3 反映 render 对 Browser session 的更高资源成本;仓库代码没有给出这两个数字与生产配额的可验证映射。
- stable page-number step name 加上 snapshot/object key,应该使单页重试比整个 job 重跑更便宜;真实 Workflow 重放时的 sibling scheduling 仍需确认。
- #5312 移除 R2 CAS 后,提交链路更短、部署状态更少,但 API 超时或用户重复点击更容易产生重复渲染;这不是当前代码自动解决的问题。
待确认#
- 用真实 Cloudflare Workflow 和真实 Browser Run 验证 100 页下的并发、重试、限流、step replay、CPU/内存和结果顺序。
- 追踪 archive 失败、中止、Workflow 过期后的 snapshot、page object、multipart 是否有 TTL 或清理任务。
- 定义重复 export job 的产品语义,并为 create timeout、重复点击、浏览器重试和跨 tab 同时提交补充 telemetry/测试。
- 决定是否把 ZIP32 超限、page render 失败、archive upload 失败映射成可操作的 job error code,而不是统一的 workflow failed。
候选说明#
2026-07-28 Melbourne 窗口包含多个已合入 PR。#5320 是能以最近 #5235 为前置,并把同日 #5290、#5303、#5312 的真实代码变更串成一条 Export runtime 演进线的候选:它们分别位于同一 Go submission boundary、同一 export job schema/service、同一 image Workflow/archive 实现。今天已有合适锚点,因此没有回看更早日期,也没有为了凑数量加入无直接代码关系的 Agent、Docs、Sheet 或 UI PR。
正式关联 PR 为 #5235、#5290、#5303、#5312;它们的 merge commit 分别是 831eab365364e1a401fb7281b71759d53c7b175b、fd42185a7424aa542467308dc64cfcc7a6225312、e15748213c5e0055ae9288731c903861af3b59ff、ecdb7f008d1d859d0266e953801d883219aa5e2f,均已验证为 origin/main ancestor。
GitHub 文档#
- 报告文件:outputs/voyager-daily-pr-study/2026-07-28-pr-5320-export-workflow-parallelism.md
- 锚点 PR:github.com/adastralab-ai/voyager/pull/5320 ↗
- Voyager merge commit:5fcf38858025472104bea9280a4e3d9537a5aa11
- 目标仓库:joyehuang/ai-agent-field-notes
- 报告首次写入 commit:0705334470dffa9414947af1047a19e43535a165
- 最终分支同步 commit:以本次自动化日志中的 githubCommit 为准。