队列满时被吞掉的按需请求留痕:点播放没反应不再是静默
- 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:
+41
@@ -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 只按条数/字节驱逐,不看可见性 —— 当前屏幕上那张卡会被后加载的图挤掉,
|
||||
|
||||
Reference in New Issue
Block a user