mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-16 17:11:04 -07:00
feat(core): implement generic CacheService and optimize setupUser (#21374)
This commit is contained in:
198
packages/core/src/utils/cache.test.ts
Normal file
198
packages/core/src/utils/cache.test.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { createCache } from './cache.js';
|
||||
|
||||
describe('CacheService', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('Basic operations', () => {
|
||||
it('should store and retrieve values by default (Map)', () => {
|
||||
const cache = createCache<string, string>({ storage: 'map' });
|
||||
cache.set('key', 'value');
|
||||
expect(cache.get('key')).toBe('value');
|
||||
});
|
||||
|
||||
it('should return undefined for missing keys', () => {
|
||||
const cache = createCache<string, string>({ storage: 'map' });
|
||||
expect(cache.get('missing')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should delete entries', () => {
|
||||
const cache = createCache<string, string>({ storage: 'map' });
|
||||
cache.set('key', 'value');
|
||||
cache.delete('key');
|
||||
expect(cache.get('key')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should clear all entries (Map)', () => {
|
||||
const cache = createCache<string, string>({ storage: 'map' });
|
||||
cache.set('k1', 'v1');
|
||||
cache.set('k2', 'v2');
|
||||
cache.clear();
|
||||
expect(cache.get('k1')).toBeUndefined();
|
||||
expect(cache.get('k2')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should throw on clear() for WeakMap', () => {
|
||||
const cache = createCache<object, string>({ storage: 'weakmap' });
|
||||
expect(() => cache.clear()).toThrow(
|
||||
'clear() is not supported on WeakMap storage',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TTL and Expiration', () => {
|
||||
it('should expire entries based on defaultTtl', () => {
|
||||
const cache = createCache<string, string>({
|
||||
storage: 'map',
|
||||
defaultTtl: 1000,
|
||||
});
|
||||
cache.set('key', 'value');
|
||||
|
||||
vi.advanceTimersByTime(500);
|
||||
expect(cache.get('key')).toBe('value');
|
||||
|
||||
vi.advanceTimersByTime(600); // Total 1100
|
||||
expect(cache.get('key')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should expire entries based on specific ttl override', () => {
|
||||
const cache = createCache<string, string>({
|
||||
storage: 'map',
|
||||
defaultTtl: 5000,
|
||||
});
|
||||
cache.set('key', 'value', 1000);
|
||||
|
||||
vi.advanceTimersByTime(1100);
|
||||
expect(cache.get('key')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not expire if ttl is undefined', () => {
|
||||
const cache = createCache<string, string>({ storage: 'map' });
|
||||
cache.set('key', 'value');
|
||||
|
||||
vi.advanceTimersByTime(100000);
|
||||
expect(cache.get('key')).toBe('value');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOrCreate', () => {
|
||||
it('should return existing value if not expired', () => {
|
||||
const cache = createCache<string, string>({ storage: 'map' });
|
||||
cache.set('key', 'old');
|
||||
const creator = vi.fn().mockReturnValue('new');
|
||||
|
||||
const result = cache.getOrCreate('key', creator);
|
||||
expect(result).toBe('old');
|
||||
expect(creator).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should create and store value if missing', () => {
|
||||
const cache = createCache<string, string>({ storage: 'map' });
|
||||
const creator = vi.fn().mockReturnValue('new');
|
||||
|
||||
const result = cache.getOrCreate('key', creator);
|
||||
expect(result).toBe('new');
|
||||
expect(creator).toHaveBeenCalled();
|
||||
expect(cache.get('key')).toBe('new');
|
||||
});
|
||||
|
||||
it('should recreate value if expired', () => {
|
||||
const cache = createCache<string, string>({
|
||||
storage: 'map',
|
||||
defaultTtl: 1000,
|
||||
});
|
||||
cache.set('key', 'old');
|
||||
vi.advanceTimersByTime(1100);
|
||||
|
||||
const creator = vi.fn().mockReturnValue('new');
|
||||
const result = cache.getOrCreate('key', creator);
|
||||
expect(result).toBe('new');
|
||||
expect(creator).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Promise Support', () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should remove failed promises from cache by default', async () => {
|
||||
const cache = createCache<string, Promise<string>>({ storage: 'map' });
|
||||
const promise = Promise.reject(new Error('fail'));
|
||||
|
||||
// We need to catch it to avoid unhandled rejection in test
|
||||
promise.catch(() => {});
|
||||
|
||||
cache.set('key', promise);
|
||||
expect(cache.get('key')).toBe(promise);
|
||||
|
||||
// Wait for promise to settle
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(cache.get('key')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should NOT remove failed promises if deleteOnPromiseFailure is false', async () => {
|
||||
const cache = createCache<string, Promise<string>>({
|
||||
storage: 'map',
|
||||
deleteOnPromiseFailure: false,
|
||||
});
|
||||
const promise = Promise.reject(new Error('fail'));
|
||||
promise.catch(() => {});
|
||||
|
||||
cache.set('key', promise);
|
||||
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(cache.get('key')).toBe(promise);
|
||||
});
|
||||
|
||||
it('should only delete the specific failed entry', async () => {
|
||||
const cache = createCache<string, Promise<string>>({ storage: 'map' });
|
||||
|
||||
const failPromise = Promise.reject(new Error('fail'));
|
||||
failPromise.catch(() => {});
|
||||
|
||||
cache.set('key', failPromise);
|
||||
|
||||
// Overwrite with a new success promise before failure settles
|
||||
const successPromise = Promise.resolve('ok');
|
||||
cache.set('key', successPromise);
|
||||
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
// Should still be successPromise
|
||||
expect(cache.get('key')).toBe(successPromise);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WeakMap Storage', () => {
|
||||
it('should work with object keys explicitly', () => {
|
||||
const cache = createCache<object, string>({ storage: 'weakmap' });
|
||||
const key = { id: 1 };
|
||||
cache.set(key, 'value');
|
||||
expect(cache.get(key)).toBe('value');
|
||||
});
|
||||
|
||||
it('should default to Map for objects', () => {
|
||||
const cache = createCache<object, string>();
|
||||
const key = { id: 1 };
|
||||
cache.set(key, 'value');
|
||||
expect(cache.get(key)).toBe('value');
|
||||
// clear() should NOT throw because default is Map
|
||||
expect(() => cache.clear()).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
151
packages/core/src/utils/cache.ts
Normal file
151
packages/core/src/utils/cache.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export interface CacheEntry<V> {
|
||||
value: V;
|
||||
timestamp: number;
|
||||
ttl?: number;
|
||||
}
|
||||
|
||||
export interface CacheOptions {
|
||||
/**
|
||||
* Default Time To Live in milliseconds.
|
||||
*/
|
||||
defaultTtl?: number;
|
||||
|
||||
/**
|
||||
* If true, and V is a Promise, the entry will be removed from the cache
|
||||
* if the promise rejects.
|
||||
*/
|
||||
deleteOnPromiseFailure?: boolean;
|
||||
|
||||
/**
|
||||
* The underlying storage mechanism.
|
||||
* Use 'weakmap' (default) for object keys to allow garbage collection.
|
||||
* Use 'map' if you need to use strings as keys or need the clear() method.
|
||||
*/
|
||||
storage?: 'map' | 'weakmap';
|
||||
}
|
||||
|
||||
/**
|
||||
* A generic caching service with TTL support.
|
||||
*/
|
||||
export class CacheService<K extends object | string | undefined, V> {
|
||||
private readonly storage:
|
||||
| Map<K, CacheEntry<V>>
|
||||
| WeakMap<WeakKey, CacheEntry<V>>;
|
||||
private readonly defaultTtl?: number;
|
||||
private readonly deleteOnPromiseFailure: boolean;
|
||||
|
||||
constructor(options: CacheOptions = {}) {
|
||||
// Default to map for safety unless weakmap is explicitly requested.
|
||||
this.storage =
|
||||
options.storage === 'weakmap'
|
||||
? new WeakMap<WeakKey, CacheEntry<V>>()
|
||||
: new Map<K, CacheEntry<V>>();
|
||||
this.defaultTtl = options.defaultTtl;
|
||||
this.deleteOnPromiseFailure = options.deleteOnPromiseFailure ?? true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a value from the cache. Returns undefined if missing or expired.
|
||||
*/
|
||||
get(key: K): V | undefined {
|
||||
// We have to cast to Map or WeakMap specifically to call get()
|
||||
// but since they have the same signature for object keys, we can
|
||||
// safely cast to 'any' internally for the dispatch.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
|
||||
const entry = (this.storage as any).get(key) as CacheEntry<V> | undefined;
|
||||
if (!entry) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const ttl = entry.ttl ?? this.defaultTtl;
|
||||
if (ttl !== undefined && Date.now() - entry.timestamp > ttl) {
|
||||
this.delete(key);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return entry.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores a value in the cache.
|
||||
*/
|
||||
set(key: K, value: V, ttl?: number): void {
|
||||
const entry: CacheEntry<V> = {
|
||||
value,
|
||||
timestamp: Date.now(),
|
||||
ttl,
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
|
||||
(this.storage as any).set(key, entry);
|
||||
|
||||
if (this.deleteOnPromiseFailure && value instanceof Promise) {
|
||||
value.catch(() => {
|
||||
// Only delete if this exact entry is still in the cache
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
|
||||
if ((this.storage as any).get(key) === entry) {
|
||||
this.delete(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to retrieve a value or create it if missing/expired.
|
||||
*/
|
||||
getOrCreate(key: K, creator: () => V, ttl?: number): V {
|
||||
let value = this.get(key);
|
||||
if (value === undefined) {
|
||||
value = creator();
|
||||
this.set(key, value, ttl);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes an entry from the cache.
|
||||
*/
|
||||
delete(key: K): void {
|
||||
if (this.storage instanceof Map) {
|
||||
this.storage.delete(key);
|
||||
} else {
|
||||
// WeakMap.delete returns a boolean, we can ignore it.
|
||||
// Cast to any to bypass the WeakKey constraint since we've already
|
||||
// confirmed the storage type.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
|
||||
(this.storage as any).delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all entries. Only supported if using Map storage.
|
||||
*/
|
||||
clear(): void {
|
||||
if (this.storage instanceof Map) {
|
||||
this.storage.clear();
|
||||
} else {
|
||||
throw new Error('clear() is not supported on WeakMap storage');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function to create a new cache.
|
||||
*/
|
||||
export function createCache<K extends string | undefined, V>(
|
||||
options: CacheOptions & { storage: 'map' },
|
||||
): CacheService<K, V>;
|
||||
export function createCache<K extends object, V>(
|
||||
options?: CacheOptions,
|
||||
): CacheService<K, V>;
|
||||
export function createCache<K extends object | string | undefined, V>(
|
||||
options: CacheOptions = {},
|
||||
): CacheService<K, V> {
|
||||
return new CacheService<K, V>(options);
|
||||
}
|
||||
Reference in New Issue
Block a user