From 4b860a55dbfa7dd4f878471b6aa35d31dd153bb2 Mon Sep 17 00:00:00 2001 From: hanafish <1106510024@qq.com> Date: Fri, 24 Jul 2026 23:20:03 +0800 Subject: [PATCH] perf(projects): coordinate filtered reads and purge --- src/api/http/project/cache.test.ts | 18 +++++ src/api/http/project/cache.ts | 14 ++-- src/api/http/project/client.purge.test.ts | 42 ++++++++++++ src/api/http/project/client.ts | 83 +++++++++++++++++++---- 4 files changed, 137 insertions(+), 20 deletions(-) create mode 100644 src/api/http/project/client.purge.test.ts diff --git a/src/api/http/project/cache.test.ts b/src/api/http/project/cache.test.ts index 78c38a444..f54402a08 100644 --- a/src/api/http/project/cache.test.ts +++ b/src/api/http/project/cache.test.ts @@ -27,6 +27,24 @@ describe("project read cache invalidation fencing", () => { ]); }); + it("can deduplicate only the active request without caching its result", async () => { + const fetcher = vi + .fn<() => Promise>() + .mockResolvedValueOnce("first") + .mockResolvedValueOnce("second"); + + const first = cachedRead("project:filtered", fetcher, { maxAgeMs: 0 }); + const joined = cachedRead("project:filtered", fetcher, { maxAgeMs: 0 }); + await expect(Promise.all([first, joined])).resolves.toEqual([ + "first", + "first", + ]); + await expect( + cachedRead("project:filtered", fetcher, { maxAgeMs: 0 }) + ).resolves.toBe("second"); + expect(fetcher).toHaveBeenCalledTimes(2); + }); + it("never lets a pre-invalidation Promise resurrect or return stale data", async () => { let resolveStale: ((value: string) => void) | undefined; const fetcher = vi diff --git a/src/api/http/project/cache.ts b/src/api/http/project/cache.ts index 6fa208980..29c9421fd 100644 --- a/src/api/http/project/cache.ts +++ b/src/api/http/project/cache.ts @@ -42,11 +42,13 @@ function evictIfNeeded(): void { export async function cachedRead( cacheKey: string, - fetcher: () => Promise + fetcher: () => Promise, + options?: { maxAgeMs?: number } ): Promise { + const maxAgeMs = options?.maxAgeMs ?? CACHE_TTL_MS; const now = Date.now(); const existing = cache.get(cacheKey); - if (existing && now - existing.timestamp < CACHE_TTL_MS) { + if (maxAgeMs > 0 && existing && now - existing.timestamp < maxAgeMs) { return existing.data as T; } @@ -63,10 +65,12 @@ export async function cachedRead( // stale snapshot; converge the original waiter onto the post-change // read (or its already-running shared Promise) instead. if (inflight.get(cacheKey) === promise) inflight.delete(cacheKey); - return cachedRead(cacheKey, fetcher); + return cachedRead(cacheKey, fetcher, options); + } + if (maxAgeMs > 0) { + evictIfNeeded(); + cache.set(cacheKey, { data: result, timestamp: Date.now() }); } - evictIfNeeded(); - cache.set(cacheKey, { data: result, timestamp: Date.now() }); if (inflight.get(cacheKey) === promise) inflight.delete(cacheKey); return result; }) diff --git a/src/api/http/project/client.purge.test.ts b/src/api/http/project/client.purge.test.ts new file mode 100644 index 000000000..eee106467 --- /dev/null +++ b/src/api/http/project/client.purge.test.ts @@ -0,0 +1,42 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { __TESTS_ONLY, purgeExpiredDeletedWorkItems } from "./client"; + +const { invokeMock } = vi.hoisted(() => ({ + invokeMock: vi.fn(), +})); + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: invokeMock, +})); + +describe("expired work-item purge coordination", () => { + beforeEach(() => { + __TESTS_ONLY.resetPurgeCoordinator(); + invokeMock.mockReset(); + }); + + it("shares an active purge and throttles later filter refreshes", async () => { + invokeMock.mockResolvedValue(0); + + const first = purgeExpiredDeletedWorkItems("project-a"); + const joined = purgeExpiredDeletedWorkItems("project-a"); + await expect(Promise.all([first, joined])).resolves.toEqual([0, 0]); + await expect(purgeExpiredDeletedWorkItems("project-a")).resolves.toBe(0); + + expect(invokeMock).toHaveBeenCalledTimes(1); + }); + + it("releases a failed purge so the next request can retry", async () => { + invokeMock + .mockRejectedValueOnce(new Error("database busy")) + .mockResolvedValueOnce(0); + + await expect(purgeExpiredDeletedWorkItems("project-a")).rejects.toThrow( + "database busy" + ); + await expect(purgeExpiredDeletedWorkItems("project-a")).resolves.toBe(0); + + expect(invokeMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/api/http/project/client.ts b/src/api/http/project/client.ts index fdcad6f28..6fd132425 100644 --- a/src/api/http/project/client.ts +++ b/src/api/http/project/client.ts @@ -36,6 +36,28 @@ import type { WorkItemsViewData, } from "./types"; +const PURGE_DELETED_ITEMS_MIN_INTERVAL_MS = 5 * 60 * 1_000; +const MAX_PURGE_PROJECTS = 50; + +interface PurgeState { + inFlight?: Promise; + lastRunAt?: number; +} + +const purgeStateByProject = new Map(); + +function getPurgeState(projectSlug: string): PurgeState { + const existing = purgeStateByProject.get(projectSlug); + if (existing) return existing; + if (purgeStateByProject.size >= MAX_PURGE_PROJECTS) { + const oldestKey = purgeStateByProject.keys().next().value; + if (oldestKey) purgeStateByProject.delete(oldestKey); + } + const state: PurgeState = {}; + purgeStateByProject.set(projectSlug, state); + return state; +} + // ============================================ // Init / discovery // ============================================ @@ -377,17 +399,26 @@ export async function readWorkItemsViewData( const { statusFilter, searchQuery } = options ?? {}; const scopePayload = scopeInvokePayload(options); const scopeSegment = scopeCacheSegment(options); + const normalizedSearchQuery = searchQuery?.trim() || undefined; const hasFilters = - (statusFilter && statusFilter !== "all") || - (searchQuery && searchQuery.trim()); + (statusFilter && statusFilter !== "all") || normalizedSearchQuery; if (hasFilters) { - return invoke("project_read_work_items_view_data", { - projectSlug, - ...scopePayload, - statusFilter: statusFilter ?? null, - searchQuery: searchQuery ?? null, - }); + const filterSegment = JSON.stringify([ + statusFilter ?? null, + normalizedSearchQuery ?? null, + ]); + return cachedRead( + `${projectSlug}:workitems-view:${scopeSegment}:${filterSegment}`, + () => + invoke("project_read_work_items_view_data", { + projectSlug, + ...scopePayload, + statusFilter: statusFilter ?? null, + searchQuery: normalizedSearchQuery ?? null, + }), + { maxAgeMs: 0 } + ); } return cachedRead(`${projectSlug}:workitems-view:${scopeSegment}`, () => @@ -500,13 +531,35 @@ export async function restoreWorkItem( export async function purgeExpiredDeletedWorkItems( projectSlug: string ): Promise { - const result = await invoke( - "project_purge_expired_deleted_work_items", - { projectSlug } - ); - invalidateCache(projectSlug); - return result; -} + const state = getPurgeState(projectSlug); + if (state.inFlight) return state.inFlight; + if ( + state.lastRunAt !== undefined && + Date.now() - state.lastRunAt < PURGE_DELETED_ITEMS_MIN_INTERVAL_MS + ) { + return 0; + } + + const request = invoke("project_purge_expired_deleted_work_items", { + projectSlug, + }).then((result) => { + state.lastRunAt = Date.now(); + if (result > 0) invalidateCache(projectSlug); + return result; + }); + state.inFlight = request; + const release = () => { + if (state.inFlight === request) state.inFlight = undefined; + }; + void request.then(release, release); + return request; +} + +export const __TESTS_ONLY = { + resetPurgeCoordinator(): void { + purgeStateByProject.clear(); + }, +}; /** * Atomic partial update; the Rust handler holds an `IMMEDIATE`