记录完美像素跨会话续命层的存废条件
本分支相对 master 新增约 16758 行,测试占 45%,而像素规整算法本体只改了 100 行。体量几乎全部来自「这条链路没有 durable job」这一个架构选择。 按存在理由把客户端机制分三层并写进文档:A 层服务端正确性与是否有 job 无关, 保留;C 层 POST 后一次对账保护的是「结果未知却谎报失败」,属于主链路,保留; B 层跨会话续命(本机账本、刷新恢复与孤儿对账、75 秒窗口与锚点、marker 与账本 寿命对齐、exact retry、inline 占位到期与归属登记)只因为没有 job 而存在。 现在不删 B 层——它刚写完刚测过刚修完六个缺陷,删除本身有风险,收益在未来。 但迁移到 enqueue_editor_generation_job 时必须整层清除、不得与队列并存,否则 两套收口机制会产生「谁是终态权威」的二义性。写为阶段 4 的验收条件。 一并记录支撑结论的实测:requiresLiveSession 全仓仅一处置位、claim/release/has 五个调用点全在完美像素路径,故整层删除边界清晰;以及七条复查发现里 F1-F6 六条 全部落在 B 层。 另记一条待办:图集拆分的 catch 只 alert「拆分图集失败」,不区分确定失败与网关 合成的未知结果,服务端已落库却报失败属于谎报,落在主链路一侧。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -6462,3 +6462,15 @@
|
||||
- 与 exact retry 的关系:本条不否定 exact retry。它是**用户自愿选择**的幂等路径(原样重放同一 identity,服务端同内容重放返回 `AlreadyApplied`),属于给用户多一个选项,不是限制;被禁止的是把它变成用户唯一能走的路。
|
||||
- 影响范围:仅文档与后续评审口径。不改代码、不改测试。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`(已写入同名判据与禁止清单)。
|
||||
|
||||
## 2026-08-05 完美像素跨会话续命层记为「迁移到 durable job 时整层清除」
|
||||
|
||||
- 背景:本分支相对 master 新增约 16758 行,其中测试占 45%(Rust 内联 `#[cfg(test)]` 按行号切开重算:`editor_project.rs` 新增 2882 行里 1221 行是测试,`editor_project_storage.rs` 是 24%)。功能本体极小——像素规整算法在 `pixel_art_snapper.rs` 只改了 100 行。体量几乎全部来自「这条链路没有 durable job」这一个架构选择:免费 + 同步 + 不进生成队列,服务端不留任何「这次请求发出过」的记录,于是「结果是否落库」这个在其它生成路径由 job 行免费回答的问题,必须由客户端自造一整套机制回答。
|
||||
- 三层划分(本条的核心结论):**A 层**服务端正确性(单事务原子落库、preflight、归属校验、稳定 object key、预算边界,约 2800 行)与是否有 job 无关,任何形态都保留;**C 层**POST 后一次 GET 对账(applied / dialog-missing / unknown 三档,约 180 行)保护的是「结果未知却谎报失败」,属于主链路,保留;**B 层**跨会话续命(本机账本、刷新恢复与孤儿对账、75 秒窗口与锚点、marker 与账本的寿命对齐、exact retry、inline 占位到期与归属登记,约 1100 行生产 + 2500 行测试)只因为没有 job 而存在。
|
||||
- 决策:**现在不删 B 层**——它刚写完、刚测过、刚修完六个缺陷,删除本身是有风险的改动,收益兑现在未来的维护成本上。但**迁移到 `enqueue_editor_generation_job` 时必须整层清除,不得与队列并存**:两套收口机制并行会产生「谁是终态权威」的二义性,比任何一套单独存在都糟。该要求已写入专题文档,作为阶段 4 的验收条件之一。
|
||||
- 支撑该结论的实测(免得后来者重新推导):B 层只服务完美像素——`requiresLiveSession: true` 全仓仅一处置位,`claimActiveInlineGenerationDialog` / `releaseActiveInlineGenerationDialog` / `hasActiveInlineGenerationDialog` 的全部五个调用点都在完美像素的提交、重试与恢复路径上。因此整层删除的边界清晰、不会波及其它生成链路。
|
||||
- 缺陷密度佐证:2026-08-05 那轮对抗性复查的七条发现里,F1–F6 六条**全部**落在 B 层(F7 是文档同步)。B 层的核心不变式「持久化标记的寿命必须与短寿命本地状态对齐」反直觉且容易写错,是这条链路缺陷最密集的地方。
|
||||
- 仓库内既有的廉价答案:手动图集拆分同样免费、同步、无 durable job,客户端只有约 85 行——失败即 `window.alert` 报错,`taskId` 用 `build_prefixed_uuid_id("editor-atlas-split-")` 随机生成,结构上不可能幂等,也没有任何对账。它符合上文的优先级判据,**完美像素的 B 层才是特例**,不得据它给其它链路加同样的机制。
|
||||
- 顺带记录的待办(不在本次范围):图集拆分的 catch 只做 `window.alert('拆分图集失败')`,不区分「确定失败」与「网关合成的未知结果」。服务端可能已经切完并落库 N 个 asset 却报失败,用户照提示重拆就会拿到双份——这属于谎报,落在主链路一侧,与「丢资源不阻断」不是一类问题。最小修法是复用现成的 `isGatewayUnknownOutcomeError` 改文案,十几行,不需要照搬 B 层。
|
||||
- 影响范围:仅文档。不改代码、不改测试。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`(已写入三层划分、B 层边界实测与清除条件)。
|
||||
|
||||
@@ -51,6 +51,8 @@
|
||||
- 已有图片像素规整固定调用登录态同源 `POST /api/editor/images/pixel-art-snaps`,复用同一纯内存 Rust snapper、CPU 并发许可和输入尺寸上限。该入口免费、只走当前 HTTP 请求内的 inline 处理,不创建 `external_generation_job`,不刷新或自动打开任务侧栏,也不进入泥点扣费 / 退款链路。它另有一层端点级并发闸(最大 4、等待队列上限 2048),设在首次 IO 之前;队列满返回 `503` 并带 `Retry-After`,等待超预算返回 `504`。30 秒总预算从 handler 入口起算,覆盖归属校验的 SpacetimeDB 读取、OSS 下载、两层排队与规整,不是只算 CPU 部分。
|
||||
- **完美像素的优先级判据(约束实现、评审与复查)**:该操作免费(`generation_cost_mud_points = 0`)、同步、纯几何规整,重做一次的代价接近于零。因此凡是「已生成的资源在意外情况下丢失关联、需要用户重做或自行去素材库取回」这一类问题,一律**不构成阻断项**;只有主链路本身出问题才阻断——发起被拒、处理失败、结果没落库、已落库的结果既不回填画布也不进素材库。把前一类现象报成必须修复的缺陷之前,先确认它落在主链路上。
|
||||
- **明确禁止的补救方向**:不得为了防止上述丢失而新增任何限制用户操作的特性,具体包括但不限于「禁止用户删除占位」「禁止用户重试」「禁止同一张图被处理两遍」。用户对自己画布上的元素始终保有删除与重做的权利;重复处理的最坏后果只是素材库多一份、用户可自行删除,这个代价远小于剥夺用户操作权。历史上引入过的同类封锁(未收口 operation 不可删除、随源图层清理豁免)已被逐条作废,不得以任何理由重新引入。既有的 `existingOperation` 闸(占位仍在时拦住从源图重新发起)是本条确立之前的遗留,方向与本条相反,后续应放宽而不是加固——尤其不得改成「让本机账本也参与防重」,那正是被本条禁止的「禁止一张图处理两遍」。
|
||||
- **跨会话续命层的存废条件(迁移到 durable job 时必须整层清除)**:完美像素的客户端机制按存在理由分三层。**A 层**——服务端单事务原子落库、preflight、归属校验、稳定 object key、预算边界——与是否有 job 无关,任何形态都要保留。**C 层**——POST 之后立刻一次 GET 对账,把结果分成 applied / dialog-missing / unknown 三档——保护的是「结果未知却告诉用户失败」这类谎报,属于主链路,也要保留(约 180 行)。**B 层**——本机请求账本 `perfectPixelOperationStore`、刷新后的 GET-only 恢复与孤儿对账、75 秒绝对窗口与它的锚点、`perfectPixelOperationId` 标记与账本的寿命对齐、exact retry、inline 占位到期与跨标签页归属登记(`useInlineGenerationPlaceholderExpiry`)——**只因为这条链路没有 durable job 而存在**,它提供的全部能力可概括为「关掉浏览器后还能找回结果 / 不重复」,而这两件事已被上文的优先级判据明确判为可接受损失。一旦完美像素改为走 `enqueue_editor_generation_job`,B 层的能力由队列与 job 行提供,**必须整层删除,不得与队列并存**——两套收口机制并行会产生「谁是终态权威」的二义性,比任何一套单独存在都糟。
|
||||
- B 层的边界是可验证的、且已确认只服务完美像素:`requiresLiveSession: true` 全仓仅有一处置位(完美像素提交路径),`claimActiveInlineGenerationDialog` / `releaseActiveInlineGenerationDialog` / `hasActiveInlineGenerationDialog` 的全部五个调用点也都在完美像素的提交、重试与恢复上。直接体量:`perfectPixelOperationStore.ts` 203 行(测试 228 行)、`useInlineGenerationPlaceholderExpiry.ts` 140 行(测试 401 行)、`hydratePerfectPixelOperation` 128 行,加上工作流里的恢复 effect 与窗口锚定,生产代码约 1100 行、测试约 2500 行(后两个数字是估算,前面几个是实测)。同为免费、同步、无 durable job 的手动图集拆分只用约 85 行客户端代码(失败即报错,`taskId` 用随机 UUID,无幂等、无对账),是本仓库对同类问题的既有廉价答案;完美像素额外的 B 层是**特例而非范式**,不得据它给其它链路加同样的机制。
|
||||
- 前端提交前先创建关闭 composer 的右侧生成占位,再解析或上传源图以取得稳定引用,随后把版本化 `perfectPixelOperation` 请求快照写入**本机账本**(占位本身只带 `perfectPixelOperationId` 标记)并 flush 当前项目布局,最后才发送 POST。`canvasCompletion.dialogId` 同时作为 operation identity、稳定 task identity 的输入和本地源图上传 ID;同一 operation 的上传路径与后续 POST 请求都不得随机漂移。`sourceImageSrc` 优先由当前图层已有的 `objectKey / resourceId / sourceAssetId` 解析;尚未登记的浏览器本地图片只执行 `ticket → OSS PUT → confirm → objectKey`,不为这条持久化输入换取 signed URL。一个 `AbortSignal` 必须贯穿源文件 fetch / 图片解析边界、ticket、PUT、confirm,完整上传 helper 的可选换签也必须透传同一 signal。正式请求不得包含 `data:` / `blob:`、signed URL 或普通外链。后端在读取源图前必须把该字段解析为当前 owner 已登记的私有 OSS object key,并核对 project / resource / asset 归属。
|
||||
- 源准备与 operation journal 使用两段绝对预算:`ticket → PUT → confirm` 连同源解析共用 90 秒;confirm 成功后形成稳定 `perfectPixelOperation` 并**同步写入本机账本**(`perfectPixelOperationStore`,owner + project 双键的 localStorage),布局里只留 `perfectPixelOperationId` 标记。原先的 strict layout save 通道(60 秒绝对预算、revision ACK 前 POST 为零)已整体删除:账本不再寄生在用户布局上,本机写入不过网络也不受服务端校验影响,同样能保证请求可被追溯。被解除的是**客户端侧**「拿不到 revision ack 就拒发」这一层阻断;端到端依赖仍在——布局 PATCH 被校验拒绝、占位因此从未落库时,POST 仍会被服务端以 409 拒收。POST 前仍然 `await` 一次 best-effort 布局保存——服务端要求占位**此前已经持久化**,否则 `validate_editor_pixel_art_snap_placeholder_exists` 直接 409;但 best-effort 不再提供成功 ACK,因此客户端**无法证明**该前置已满足,只能提高满足它的概率(占位可能已由此前的自动保存落库,PATCH 也可能成功而 ACK 丢失)。该 flush 没有整体上限,所以 75 秒对账窗口必须在 flush 返回、authority 复核通过之后才锚定,且首次提交与人工重试同此口径;锚定只覆盖 `submittedAt / reconcileUntil`,按同一 `operationId` 覆盖账本,request 与 dialog / operation / task identity 逐字节不变。此阶段失败持久化为 `failed + perfectPixelOperation`,保留同一 `sourceImageSrc / dialogId / taskId / request`;重试请求必须与账本中的 POST JSON byte-for-byte 一致且不得重新上传。**明确接受的行为,不是缺口**:占位恢复可删除之后,用户删掉未收口占位再从源图发起会得到第二个 identity,旧的服务端操作若迟到落库就会多出一份素材,两个 `taskId` 无法幂等合并。按上文的优先级判据,这属于「已生成资源丢失关联」而非主链路故障,代价是用户自行删掉多余素材,**不得**通过让本机账本参与防重来「闭合」——那是被明令禁止的「禁止一张图处理两遍」。confirm 成功后浏览器在 operation 首次 PATCH 落库前立即崩溃仍可能留下 object-only 记录;完全消除该窗口需要服务端 durable upload journal,不属于当前前端修复。
|
||||
- 该已有图片入口使用 strict 语义:只接受静态 PNG / JPEG / WebP,GIF、APNG、动画 WebP、图片序列及其它非静态媒体必须在处理前拒绝。strict 与生成风格复用完全相同的 legacy profile、峰值估算、单轴步长补全、walker、采样和编码;仅当横纵两轴都未检测到步长、legacy 即将使用 `min(width,height)/64` 统一网格兜底时拒绝。任一轴已检测到步长时,两条路径行为和输出必须一致。源图读取、解码、尺寸校验、排队、像素规整或 PNG 编码任一步失败 / 超时 / 不适用时,请求失败,不保留原图副本冒充成功,不执行最终 OSS PUT,也不创建 project resource、账号素材或结果图层。成功时只对最终 PNG 执行一次 OSS PUT,并至多各创建一个 `editor_project_resource` 和一个 `editor_asset`,再按 `canvasCompletion` 写回一个派生图层;不得保存逻辑低分辨率图、诊断图或前后对比图。
|
||||
|
||||
Reference in New Issue
Block a user