Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions src/api/http/project/cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>>()
.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
Expand Down
14 changes: 9 additions & 5 deletions src/api/http/project/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,13 @@ function evictIfNeeded(): void {

export async function cachedRead<T>(
cacheKey: string,
fetcher: () => Promise<T>
fetcher: () => Promise<T>,
options?: { maxAgeMs?: number }
): Promise<T> {
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;
}

Expand All @@ -63,10 +65,12 @@ export async function cachedRead<T>(
// 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;
})
Expand Down
42 changes: 42 additions & 0 deletions src/api/http/project/client.purge.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
83 changes: 68 additions & 15 deletions src/api/http/project/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number>;
lastRunAt?: number;
}

const purgeStateByProject = new Map<string, PurgeState>();

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
// ============================================
Expand Down Expand Up @@ -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}`, () =>
Expand Down Expand Up @@ -500,13 +531,35 @@ export async function restoreWorkItem(
export async function purgeExpiredDeletedWorkItems(
projectSlug: string
): Promise<number> {
const result = await invoke<number>(
"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<number>("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`
Expand Down
Loading