fix(models): invalidate stale requests when clearing cache

This commit is contained in:
Supra4E8C
2026-09-12 12:03:23 +08:00
parent ed5f1c48e1
commit 3be19dfbd1
2 changed files with 103 additions and 2 deletions
+9 -2
View File
@@ -25,6 +25,8 @@ interface ModelsState {
isCacheValid: (apiBase: string, apiKey?: string) => boolean;
}
let modelsRequestToken = 0;
export const useModelsStore = create<ModelsState>((set, get) => ({
models: [],
loading: false,
@@ -32,12 +34,13 @@ export const useModelsStore = create<ModelsState>((set, get) => ({
cache: null,
fetchModels: async (apiBase, apiKey, forceRefresh = false) => {
const requestId = (modelsRequestToken += 1);
const { cache, isCacheValid } = get();
const apiKeyScope = apiKey?.trim() || '';
// 检查缓存
if (!forceRefresh && isCacheValid(apiBase, apiKeyScope) && cache) {
set({ models: cache.data, error: null });
set({ models: cache.data, loading: false, error: null });
return cache.data;
}
@@ -45,6 +48,8 @@ export const useModelsStore = create<ModelsState>((set, get) => ({
try {
const list = await modelsApi.fetchModels(apiBase, apiKeyScope || undefined);
// 清空缓存或后续加载已接管状态时,旧请求仅向调用方返回,不再写入 store。
if (requestId !== modelsRequestToken) return list;
const now = Date.now();
set({
@@ -55,6 +60,7 @@ export const useModelsStore = create<ModelsState>((set, get) => ({
return list;
} catch (error: unknown) {
if (requestId !== modelsRequestToken) throw error;
const message =
error instanceof Error
? error.message
@@ -71,7 +77,8 @@ export const useModelsStore = create<ModelsState>((set, get) => ({
},
clearCache: () => {
set({ cache: null, models: [] });
modelsRequestToken += 1;
set({ cache: null, models: [], loading: false, error: null });
},
isCacheValid: (apiBase, apiKey) => {
+94
View File
@@ -0,0 +1,94 @@
import { afterEach, describe, expect, it, spyOn } from 'bun:test';
import { modelsApi } from '../src/services/api/models';
import { useModelsStore } from '../src/stores/useModelsStore';
import type { ModelInfo } from '../src/utils/models';
function deferred() {
let resolve!: (models: ModelInfo[]) => void;
let reject!: (error: Error) => void;
const promise = new Promise<ModelInfo[]>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
const originalFetch = modelsApi.fetchModels;
afterEach(() => {
modelsApi.fetchModels = originalFetch;
useModelsStore.getState().clearCache();
});
describe('models cache request isolation', () => {
it('does not restore models or connection cache after clearing an in-flight request', async () => {
const request = deferred();
spyOn(modelsApi, 'fetchModels').mockReturnValue(request.promise);
const pending = useModelsStore.getState().fetchModels('https://old.invalid');
useModelsStore.getState().clearCache();
expect(useModelsStore.getState().loading).toBe(false);
request.resolve([{ name: 'old' }]);
expect(await pending).toEqual([{ name: 'old' }]);
expect(useModelsStore.getState()).toMatchObject({
models: [],
cache: null,
loading: false,
error: null,
});
});
it('ignores stale failures without clearing a newer request loading state', async () => {
const old = deferred();
const current = deferred();
spyOn(modelsApi, 'fetchModels')
.mockReturnValueOnce(old.promise)
.mockReturnValueOnce(current.promise);
const pending = useModelsStore.getState().fetchModels('https://old.invalid');
useModelsStore.getState().clearCache();
const next = useModelsStore.getState().fetchModels('https://new.invalid');
old.reject(new Error('old failure'));
await expect(pending).rejects.toThrow('old failure');
expect(useModelsStore.getState()).toMatchObject({ loading: true, error: null });
current.resolve([{ name: 'new' }]);
await next;
expect(useModelsStore.getState().cache?.apiBase).toBe('https://new.invalid');
});
it('allows only the latest request to publish, even without an explicit clear', async () => {
const old = deferred();
const current = deferred();
spyOn(modelsApi, 'fetchModels')
.mockReturnValueOnce(old.promise)
.mockReturnValueOnce(current.promise);
const pending = useModelsStore.getState().fetchModels('https://old.invalid');
const next = useModelsStore.getState().fetchModels('https://new.invalid');
current.resolve([{ name: 'new' }]);
await next;
old.resolve([{ name: 'old' }]);
await pending;
expect(useModelsStore.getState().models).toEqual([{ name: 'new' }]);
});
it('a cache hit supersedes an in-flight refresh and resets loading', async () => {
const request = deferred();
spyOn(modelsApi, 'fetchModels')
.mockResolvedValueOnce([{ name: 'cached' }])
.mockReturnValueOnce(request.promise);
await useModelsStore.getState().fetchModels('https://cache.invalid', ' test-key ');
const pending = useModelsStore
.getState()
.fetchModels('https://cache.invalid', 'test-key', true);
await useModelsStore.getState().fetchModels('https://cache.invalid', 'test-key');
expect(useModelsStore.getState().loading).toBe(false);
request.resolve([{ name: 'stale refresh' }]);
await pending;
expect(useModelsStore.getState().models).toEqual([{ name: 'cached' }]);
});
it('reports active failures, then clears error and loading on invalidation', async () => {
spyOn(modelsApi, 'fetchModels').mockRejectedValue(new Error('active failure'));
await expect(useModelsStore.getState().fetchModels('https://test.invalid')).rejects.toThrow();
expect(useModelsStore.getState().error).toBe('active failure');
useModelsStore.getState().clearCache();
expect(useModelsStore.getState()).toMatchObject({ error: null, loading: false });
});
});