对账窗口改在 pre-POST flush 之后锚定
查服务端确认了一件推翻先前定性的事实:validate_editor_pixel_art_snap_ placeholder_exists 在处理前检查占位是否已经持久化,既没有同 ID 的已持久化 dialog、也没有同 operation 的稳定 resource 时直接返回 409。所以 POST 前那次 await flush 不是可省的画布同步,而是服务端硬前置,不能取消。 据此更正上一条决策里的错误声明:不是「布局保存失败只降级成 asset-only」, 而是「占位从未持久化时服务端返回 409;best-effort flush 不再提供成功 ACK, 客户端因此无法证明该前置已满足,只能提高满足它的概率」。被解除的是客户端侧 「拿不到 revision ack 就拒发」的阻断,不是端到端依赖。 缺陷:首次提交与人工 exact retry 都在这次 flush 之前就算好 submittedAt / reconcileUntil。该 flush 没有整体上限,慢保存足以在 POST 发出前烧光整个 75 秒窗口,请求带着已过期的 deadline 发出,对账退化成读一次就收尾。 修复:flush 返回、authority 复核通过之后才用 createPerfectPixelReconciliationOperation 重新锚定,按同一 operationId 覆盖账本与 dialog,登记新的 recovery key,随后立即 POST。只覆盖时间字段, request 与 dialog / operation / task identity 逐字节不变,不产生第二条账本。 保留 flush 前的预写而不是整体后移:flush 期间另一标签页可能加载同一项目, 服务端已有带 marker 的占位而 localStorage 跨标签共享,本机若没有账本那条占位 会被 hydrate 成 failed + invalid。provisional 账本正好堵住这个窗口。 同步修正专题文档中已被近几个提交推翻的条款:strict layout save 预算与 revision ACK 前 POST 为零、未收口 operation 不可删除、durable operation 不得被普通删除路径清理。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2917,6 +2917,173 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('anchors the first submission reconciliation window at POST time when the pre-POST flush is slow', async () => {
|
||||
// 中文注释:pre-POST flush 是服务端硬前置(占位未持久化会被 409 拒收),且没有整体
|
||||
// 上限。窗口若锚在 flush 之前,慢保存会让 POST 带着已过期的 reconciliation deadline
|
||||
// 发出。这里把时钟停住手工推进,断言 POST 那一刻窗口是刚建立的完整 75 秒。
|
||||
let clock = 1_800_000_000_000;
|
||||
const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => clock);
|
||||
try {
|
||||
const flushGate = createDeferred<void>();
|
||||
const flushProjectPersistence = vi.fn(() => flushGate.promise);
|
||||
let requestAtPost: EditorPixelArtSnapInput | undefined;
|
||||
let ledgerAtPost: ReturnType<typeof readPerfectPixelOperations> =
|
||||
new Map();
|
||||
let clockAtPost = 0;
|
||||
snapImageToPerfectPixelsMock.mockImplementationOnce(
|
||||
async (request: EditorPixelArtSnapInput) => {
|
||||
requestAtPost = request;
|
||||
clockAtPost = clock;
|
||||
ledgerAtPost = readPerfectPixelOperations('user-a', 'project-1');
|
||||
throw new ApiClientError({
|
||||
message: '素材类型校验失败。',
|
||||
status: 400,
|
||||
code: 'HTTP_400',
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
render(
|
||||
<GenerationWorkflowHarness
|
||||
projectId="project-1"
|
||||
currentUserId="user-a"
|
||||
applyProjectSnapshot={vi.fn()}
|
||||
flushProjectPersistence={flushProjectPersistence}
|
||||
initialLayers={[
|
||||
createLayer({
|
||||
objectKey: 'generated-images/editor/source.png',
|
||||
src: '/generated-images/editor/source.png',
|
||||
}),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '完美像素' }));
|
||||
await waitFor(() => {
|
||||
expect(flushProjectPersistence).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
// 中文注释:flush 期间必须已有 provisional 账本——否则另一标签页加载同一项目时,
|
||||
// 服务端那条带 marker 的占位会被 hydrate 成 failed + invalid。
|
||||
const provisional = readPerfectPixelOperations('user-a', 'project-1');
|
||||
expect(provisional.size).toBe(1);
|
||||
const [provisionalOperation] = [...provisional.values()];
|
||||
|
||||
clock += 90_000;
|
||||
flushGate.resolve();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(snapImageToPerfectPixelsMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
const submitted = ledgerAtPost.get(provisionalOperation!.operationId);
|
||||
expect(ledgerAtPost.size).toBe(1);
|
||||
expect(submitted?.submittedAt).toBe(clockAtPost);
|
||||
expect(submitted!.reconcileUntil - submitted!.submittedAt).toBe(75_000);
|
||||
// 中文注释:只覆盖时间字段——identity 与请求体逐字节不变,也不产生第二条账本。
|
||||
expect(submitted?.taskId).toBe(provisionalOperation!.taskId);
|
||||
expect(JSON.stringify(submitted!.request)).toBe(
|
||||
JSON.stringify(provisionalOperation!.request),
|
||||
);
|
||||
expect(JSON.stringify(requestAtPost)).toBe(
|
||||
JSON.stringify(provisionalOperation!.request),
|
||||
);
|
||||
} finally {
|
||||
nowSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('anchors the exact retry reconciliation window at POST time when the pre-POST flush is slow', async () => {
|
||||
let clock = 1_800_000_000_000;
|
||||
const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => clock);
|
||||
try {
|
||||
const retryFlushGate = createDeferred<void>();
|
||||
const flushProjectPersistence = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockImplementationOnce(() => retryFlushGate.promise);
|
||||
let requestAtRetryPost: EditorPixelArtSnapInput | undefined;
|
||||
let ledgerAtRetryPost: ReturnType<typeof readPerfectPixelOperations> =
|
||||
new Map();
|
||||
let clockAtRetryPost = 0;
|
||||
snapImageToPerfectPixelsMock
|
||||
.mockImplementationOnce(async (request: EditorPixelArtSnapInput) =>
|
||||
createMismatchedPerfectPixelResult(request),
|
||||
)
|
||||
.mockImplementationOnce(async (request: EditorPixelArtSnapInput) => {
|
||||
requestAtRetryPost = request;
|
||||
clockAtRetryPost = clock;
|
||||
ledgerAtRetryPost = readPerfectPixelOperations('user-a', 'project-1');
|
||||
throw new ApiClientError({
|
||||
message: '素材类型校验失败。',
|
||||
status: 400,
|
||||
code: 'HTTP_400',
|
||||
});
|
||||
});
|
||||
loadEditorProjectMock.mockImplementation(async () => {
|
||||
const request = snapImageToPerfectPixelsMock.mock.calls.at(-1)?.[0] as
|
||||
| EditorPixelArtSnapInput
|
||||
| undefined;
|
||||
return createConflictingPerfectPixelProject(
|
||||
request!.canvasCompletion.dialogId,
|
||||
);
|
||||
});
|
||||
|
||||
render(
|
||||
<GenerationWorkflowHarness
|
||||
projectId="project-1"
|
||||
currentUserId="user-a"
|
||||
applyProjectSnapshot={vi.fn()}
|
||||
flushProjectPersistence={flushProjectPersistence}
|
||||
initialLayers={[
|
||||
createLayer({
|
||||
objectKey: 'generated-images/editor/source.png',
|
||||
src: '/generated-images/editor/source.png',
|
||||
}),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '完美像素' }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('dialog').textContent).toContain(
|
||||
'pending-confirmation',
|
||||
);
|
||||
});
|
||||
const beforeRetry = JSON.parse(
|
||||
screen.getByTestId('perfect-pixel-operation').textContent!,
|
||||
) as { operationId: string; taskId: string; submittedAt: number };
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '重试完美像素' }));
|
||||
await waitFor(() => {
|
||||
expect(flushProjectPersistence).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
// 中文注释:flush 期间不得提前刷新窗口——挂着的仍是旧 operation。
|
||||
const duringFlush = JSON.parse(
|
||||
screen.getByTestId('perfect-pixel-operation').textContent!,
|
||||
) as { submittedAt: number };
|
||||
expect(duringFlush.submittedAt).toBe(beforeRetry.submittedAt);
|
||||
|
||||
clock += 90_000;
|
||||
retryFlushGate.resolve();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(snapImageToPerfectPixelsMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
const retried = ledgerAtRetryPost.get(beforeRetry.operationId);
|
||||
expect(ledgerAtRetryPost.size).toBe(1);
|
||||
expect(retried?.submittedAt).toBe(clockAtRetryPost);
|
||||
expect(retried!.reconcileUntil - retried!.submittedAt).toBe(75_000);
|
||||
expect(retried?.taskId).toBe(beforeRetry.taskId);
|
||||
const firstRequest = snapImageToPerfectPixelsMock.mock.calls[0]?.[0] as
|
||||
| EditorPixelArtSnapInput
|
||||
| undefined;
|
||||
expect(JSON.stringify(requestAtRetryPost)).toBe(
|
||||
JSON.stringify(firstRequest),
|
||||
);
|
||||
} finally {
|
||||
nowSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('reuses one inline upload and the exact persisted POST bytes when an unknown operation is retried', async () => {
|
||||
const flushProjectPersistence = vi.fn().mockResolvedValue(undefined);
|
||||
uploadEditorMediaAssetObjectFileMock.mockResolvedValueOnce({
|
||||
|
||||
@@ -2394,14 +2394,45 @@ export function useImageCanvasGenerationWorkflow({
|
||||
normalizedProjectId,
|
||||
perfectPixelOperation,
|
||||
);
|
||||
// 中文注释:布局保存尽力而为——占位存进服务端后,服务端才能用 canvasCompletion
|
||||
// 就地替换它。保存失败不拦 POST,只是把结果降级成「只进素材库」,由对账提示用户。
|
||||
// 中文注释:这次 flush 不是可省的画布同步,而是服务端的硬前置——POST 到达时若占位
|
||||
// 从未持久化,`validate_editor_pixel_art_snap_placeholder_exists` 直接 409。改成
|
||||
// best-effort 之后客户端不再拿到成功 ACK,因此**无法证明**该前置已满足,只能提高
|
||||
// 满足它的概率:占位可能已由此前的自动保存落库,PATCH 也可能成功而 ACK 丢失。
|
||||
await flushProjectPersistence({
|
||||
preferLatestGenerationDialogs: true,
|
||||
}).catch(() => undefined);
|
||||
if (!isPerfectPixelAuthorityCurrent(operationAuthority)) {
|
||||
return;
|
||||
}
|
||||
// 中文注释:对账窗口必须锚在 POST 发出的时刻。上面这次 flush 没有整体上限(单次
|
||||
// PATCH 60 秒 × 最多 4 次尝试),慢保存足以把 75 秒窗口在 POST 之前烧光,让请求
|
||||
// 带着已过期的 deadline 发出。这里重新锚定——只覆盖时间字段,request、dialog /
|
||||
// operation / task identity 与预写的那条完全一致,账本按同一 operationId 覆盖,
|
||||
// 不会产生第二条。
|
||||
//
|
||||
// 预写仍然保留在 flush 之前:flush 期间另一标签页可能加载同一项目,此时服务端已有
|
||||
// 带 marker 的占位,本机若没有账本就会被 hydrate 成 `failed + invalid`。预写的
|
||||
// provisional 账本正好堵住这个窗口。
|
||||
const submittedOperation = createPerfectPixelReconciliationOperation(
|
||||
perfectPixelOperation,
|
||||
);
|
||||
perfectPixelOperation = submittedOperation;
|
||||
observedPerfectPixelRecoveryKeysRef.current.add(
|
||||
perfectPixelRecoveryKey(
|
||||
currentUserId,
|
||||
normalizedProjectId,
|
||||
submittedOperation,
|
||||
),
|
||||
);
|
||||
savePerfectPixelOperation(
|
||||
currentUserId,
|
||||
normalizedProjectId,
|
||||
submittedOperation,
|
||||
);
|
||||
updateCanvasGenerationDialogById(perfectPixelDialogId, (dialog) => ({
|
||||
...dialog,
|
||||
perfectPixelOperation: submittedOperation,
|
||||
}));
|
||||
perfectPixelPostAttempted = true;
|
||||
const result = await snapImageToPerfectPixels(
|
||||
perfectPixelOperation.request,
|
||||
@@ -2607,8 +2638,11 @@ export function useImageCanvasGenerationWorkflow({
|
||||
if (perfectPixelLayerIdsRef.current.has(lockKey)) {
|
||||
return;
|
||||
}
|
||||
const retriedOperation =
|
||||
createPerfectPixelReconciliationOperation(operation);
|
||||
// 中文注释:flush 之前继续沿用旧 operation——窗口只能锚在 POST 发出的时刻,而这次
|
||||
// flush 是服务端硬前置且没有整体上限,先刷新窗口再等它等于把窗口烧在等待上。
|
||||
// request 与 dialog / operation / task identity 前后完全一致,只有时间字段会在
|
||||
// flush 之后被重新锚定。
|
||||
let retriedOperation = operation;
|
||||
let postAttempted = false;
|
||||
perfectPixelLayerIdsRef.current.add(lockKey);
|
||||
claimActiveInlineGenerationDialog(normalizedDialogId);
|
||||
@@ -2620,6 +2654,24 @@ export function useImageCanvasGenerationWorkflow({
|
||||
return nextLayerIds;
|
||||
});
|
||||
}
|
||||
// 中文注释:UI 先切到处理中,让用户立刻看到重试已经开始;此时挂的仍是旧
|
||||
// operation,账本里已有的那条与它一致,不需要重写。
|
||||
updateCanvasGenerationDialogById(normalizedDialogId, (current) => ({
|
||||
...current,
|
||||
status: 'generating',
|
||||
errorMessage: undefined,
|
||||
requiresLiveSession: undefined,
|
||||
perfectPixelOperationId: normalizedDialogId,
|
||||
perfectPixelOperation: operation,
|
||||
}));
|
||||
await flushProjectPersistence({
|
||||
preferLatestGenerationDialogs: true,
|
||||
}).catch(() => undefined);
|
||||
if (!isPerfectPixelAuthorityCurrent(operationAuthority)) {
|
||||
return;
|
||||
}
|
||||
// 中文注释:flush 之后才重新锚定对账窗口,并按同一 operationId 覆盖账本与 dialog。
|
||||
retriedOperation = createPerfectPixelReconciliationOperation(operation);
|
||||
observedPerfectPixelRecoveryKeysRef.current.add(
|
||||
perfectPixelRecoveryKey(
|
||||
currentUserId,
|
||||
@@ -2627,25 +2679,15 @@ export function useImageCanvasGenerationWorkflow({
|
||||
retriedOperation,
|
||||
),
|
||||
);
|
||||
updateCanvasGenerationDialogById(normalizedDialogId, (current) => ({
|
||||
...current,
|
||||
status: 'generating',
|
||||
errorMessage: undefined,
|
||||
requiresLiveSession: undefined,
|
||||
perfectPixelOperationId: normalizedDialogId,
|
||||
perfectPixelOperation: retriedOperation,
|
||||
}));
|
||||
savePerfectPixelOperation(
|
||||
currentUserId,
|
||||
normalizedProjectId,
|
||||
retriedOperation,
|
||||
);
|
||||
await flushProjectPersistence({
|
||||
preferLatestGenerationDialogs: true,
|
||||
}).catch(() => undefined);
|
||||
if (!isPerfectPixelAuthorityCurrent(operationAuthority)) {
|
||||
return;
|
||||
}
|
||||
updateCanvasGenerationDialogById(normalizedDialogId, (current) => ({
|
||||
...current,
|
||||
perfectPixelOperation: retriedOperation,
|
||||
}));
|
||||
postAttempted = true;
|
||||
const result = await snapImageToPerfectPixels(retriedOperation.request);
|
||||
if (!isPerfectPixelAuthorityCurrent(operationAuthority)) {
|
||||
|
||||
Reference in New Issue
Block a user