队列满时被吞掉的按需请求留痕:点播放没反应不再是静默

- requestPreview 在队列达上限且没有可顶掉的可见性预取时,按需请求(detail/play)此前直接 return,用户在可见卡上点播放没有任何请求与痕迹;
- 新增 droppedRequestsRef:单调计数 + 最近一条明细(identity / reason / queueLength / at),并补一条 [preview-queue] console.warn;
- previewQueueSnapshot 暴露 droppedRequestCount 与 lastDroppedRequest,排障与用例可直接观测;
- 可见性预取撞上限属于设计内背压(下一轮兜底扫描会补),不计入丢弃口径;
- 队列上限、3 个并发槽、优先级顺序(play > detail > visible)全部不动;
- 新增用例:压满 96 条 detail 后 play 被丢弃必须留下标记,且 visible 背压不计数。
This commit is contained in:
2026-09-11 19:35:25 +08:00
parent 56a8c2c32c
commit d4e7b8bbaf
2 changed files with 124 additions and 0 deletions
@@ -338,6 +338,21 @@ export function useProjectResourceCardPreviews(input: {
const observedCardsRef = useRef(new Map<HTMLElement, ObservedPreviewCard>());
const queueRef = useRef<PreviewJob[]>([]);
const queuedIdentitiesRef = useRef(new Map<string, number>());
/**
* 被队列上限吞掉的**按需**请求(`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<string, number>());
const cacheOrderRef = useRef(new Map<string, true>());
const cachedPreviewsRef = useRef(new Map<string, CachedPreview>());
@@ -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,
}),
};
}
@@ -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 只按条数/字节驱逐,不看可见性 —— 当前屏幕上那张卡会被后加载的图挤掉,