From d4e7b8bbaf11b2898596bfcd0d5a18a909b370dd Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Fri, 11 Sep 2026 19:35:25 +0800 Subject: [PATCH] =?UTF-8?q?=E9=98=9F=E5=88=97=E6=BB=A1=E6=97=B6=E8=A2=AB?= =?UTF-8?q?=E5=90=9E=E6=8E=89=E7=9A=84=E6=8C=89=E9=9C=80=E8=AF=B7=E6=B1=82?= =?UTF-8?q?=E7=95=99=E7=97=95=EF=BC=9A=E7=82=B9=E6=92=AD=E6=94=BE=E6=B2=A1?= =?UTF-8?q?=E5=8F=8D=E5=BA=94=E4=B8=8D=E5=86=8D=E6=98=AF=E9=9D=99=E9=BB=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - requestPreview 在队列达上限且没有可顶掉的可见性预取时,按需请求(detail/play)此前直接 return,用户在可见卡上点播放没有任何请求与痕迹; - 新增 droppedRequestsRef:单调计数 + 最近一条明细(identity / reason / queueLength / at),并补一条 [preview-queue] console.warn; - previewQueueSnapshot 暴露 droppedRequestCount 与 lastDroppedRequest,排障与用例可直接观测; - 可见性预取撞上限属于设计内背压(下一轮兜底扫描会补),不计入丢弃口径; - 队列上限、3 个并发槽、优先级顺序(play > detail > visible)全部不动; - 新增用例:压满 96 条 detail 后 play 被丢弃必须留下标记,且 visible 背压不计数。 --- .../useProjectResourceCardPreviews.ts | 41 +++++++++ .../useProjectResourceCardPreviews.test.ts | 83 +++++++++++++++++++ 2 files changed, 124 insertions(+) diff --git a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts index 4dc751f6f..fc9384003 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts @@ -338,6 +338,21 @@ export function useProjectResourceCardPreviews(input: { const observedCardsRef = useRef(new Map()); const queueRef = useRef([]); const queuedIdentitiesRef = useRef(new Map()); + /** + * 被队列上限吞掉的**按需**请求(`detail` / `play`)。 + * + * 这类丢弃此前完全不可见:用户在可见卡上点播放,没有请求、没有报错、没有 loading。 + * 记为单调计数 + 最后一条明细,供排障与用例观测;`visible` 的背压不计入(那是设计内的)。 + */ + const droppedRequestsRef = useRef<{ + count: number; + last: { + identity: string; + reason: PreviewRequestReason; + queueLength: number; + at: number; + } | null; + }>({ count: 0, last: null }); const pendingIdentitiesRef = useRef(new Map()); const cacheOrderRef = useRef(new Map()); const cachedPreviewsRef = useRef(new Map()); @@ -664,6 +679,28 @@ export function useProjectResourceCardPreviews(input: { } } if (reason === 'visible' || replaceIndex < 0) { + /* + * 队列被占满、又找不到可以顶掉的可见性预取时,这次请求只能丢掉。 + * + * `visible` 撞上限是设计内的背压(下一轮兜底扫描会把仍可见的卡补回来), + * 但 **`detail` / `play` 是按需请求**:用户在可见卡上点了播放却什么都不发生, + * 此前连一条痕迹都没有 —— 这里必须留下可观测标记(快照 + 日志)。 + * 队列上限、3 个并发槽与优先级顺序都不放宽,只是把"被吞掉的那次"记下来。 + */ + if (reason !== 'visible') { + droppedRequestsRef.current = { + count: droppedRequestsRef.current.count + 1, + last: { + identity, + reason, + queueLength: queueRef.current.length, + at: Date.now(), + }, + }; + console.warn( + `[preview-queue] 按需请求被队列上限丢弃 reason=${reason} queueLength=${queueRef.current.length}`, + ); + } return; } const [replacedJob] = queueRef.current.splice(replaceIndex, 1); @@ -1114,6 +1151,10 @@ export function useProjectResourceCardPreviews(input: { })), activeReadCount: activeReadsRef.current.count, prefetchScopeKey: prefetchScopeKeyRef.current, + /** 被队列上限吞掉的按需请求条数;`visible` 的背压不计入。 */ + droppedRequestCount: droppedRequestsRef.current.count, + /** 最近一次被吞掉的按需请求明细,从未发生时为 `null`。 */ + lastDroppedRequest: droppedRequestsRef.current.last, }), }; } diff --git a/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts b/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts index d5d1ad9ab..52f49a3e7 100644 --- a/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts +++ b/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts @@ -7,6 +7,7 @@ import { isProjectResourcePreviewCancellation } from '../src/services/projectRes import { PROJECT_RESOURCE_CARD_PREVIEW_CACHE_BYTE_LIMIT, PROJECT_RESOURCE_CARD_PREVIEW_CACHE_LIMIT, + PROJECT_RESOURCE_CARD_PREVIEW_QUEUE_LIMIT, projectResourceCardPreviewEvictionIdentities, } from '../src/view/project-development/resourceCardPreviewModel'; import type { ProjectResource } from '../src/view/project-development/resourceProjectionModel'; @@ -1613,6 +1614,88 @@ describe('useProjectResourceCardPreviews', () => { }); }); +describe('预览队列上限吞掉按需请求时的可观测性', () => { + it('records an on-demand request that a full queue had to drop', async () => { + // 现场:队列压满 96 条、又找不到可以顶掉的可见性预取时,`detail` / `play` 请求 + // 被直接丢掉 —— 用户在可见卡上点播放,没有请求、没有报错,此前连痕迹都没有。 + const resources = Array.from({ length: 101 }, (_, index) => + resource(`queue-drop-${index.toString().padStart(3, '0')}`), + ); + // 所有读取都挂住:队列只进不出,才能在用例里稳定压满。 + const invoke = vi.fn(() => new Promise(() => undefined)); + window.__TAURI__ = { core: { invoke } }; + const canvasRef = { current: document.createElement('div') }; + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const { result } = renderHook(() => + useProjectResourceCardPreviews({ + projectPath: '/tmp/preview-queue-drop', + projectId: 'preview-queue-drop', + mode: 'dependency', + resources, + canvasRef, + }), + ); + + act(() => { + for (const candidate of resources.slice(0, 99)) { + result.current.requestPreview( + candidate, + result.current.identityByResourceId.get(candidate.id)!, + 'detail', + ); + } + }); + expect(result.current.previewQueueSnapshot().queue).toHaveLength( + PROJECT_RESOURCE_CARD_PREVIEW_QUEUE_LIMIT, + ); + expect(result.current.previewQueueSnapshot().droppedRequestCount).toBe(0); + expect(result.current.previewQueueSnapshot().lastDroppedRequest).toBeNull(); + + const dropped = resources[99]!; + act(() => { + result.current.requestPreview( + dropped, + result.current.identityByResourceId.get(dropped.id)!, + 'play', + ); + }); + + expect(result.current.previewQueueSnapshot().droppedRequestCount).toBe(1); + expect( + result.current.previewQueueSnapshot().lastDroppedRequest, + ).toMatchObject({ + identity: result.current.identityByResourceId.get(dropped.id), + reason: 'play', + queueLength: PROJECT_RESOURCE_CARD_PREVIEW_QUEUE_LIMIT, + }); + expect( + result.current + .previewQueueSnapshot() + .queue.some( + (job) => + job.identity === + result.current.identityByResourceId.get(dropped.id), + ), + ).toBe(false); + expect( + warn.mock.calls.some(([message]) => + String(message).includes('[preview-queue]'), + ), + ).toBe(true); + + // 可见性预取撞上限是设计内的背压,不算"吞掉用户动作",不计数。 + const backpressure = resources[100]!; + act(() => { + result.current.requestPreview( + backpressure, + result.current.identityByResourceId.get(backpressure.id)!, + 'visible', + ); + }); + expect(result.current.previewQueueSnapshot().droppedRequestCount).toBe(1); + }); +}); + describe('预览缓存驱逐后的可见性复核', () => { it('re-requests a card that is still visible right after the LRU evicts it', async () => { // 现场:LRU 只按条数/字节驱逐,不看可见性 —— 当前屏幕上那张卡会被后加载的图挤掉,