mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 13:35:42 +09:00
chore(repo): prune dead code and duplicate abstractions (#3413)
This commit is contained in:
1 parent
c17091768c
commit
c9fdf8fead
2937 files changed
+42233
-176793
No files matched your search
-8
@@ -5,12 +5,6 @@ const CACHE_INFLIGHT_JOIN_RETRIES = 1;
|
||||
const CACHE_PRODUCE_TIMEOUT_MS = 15000;
|
||||
const CACHE_PRODUCE_TIMEOUT_MESSAGE = 'Cache produce timed out';
|
||||
|
||||
interface CacheMSetEntry<T> {
|
||||
key: string;
|
||||
value: T;
|
||||
ttlSeconds?: number;
|
||||
}
|
||||
|
||||
interface CacheProduceTracking {
|
||||
generation: number;
|
||||
produces: number;
|
||||
@@ -54,8 +48,6 @@ export abstract class ICacheService {
|
||||
|
||||
abstract mget<T>(keys: Array<string>): Promise<Array<T | null>>;
|
||||
|
||||
abstract mset<T>(entries: Array<CacheMSetEntry<T>>): Promise<void>;
|
||||
|
||||
abstract deletePattern(pattern: string): Promise<number>;
|
||||
|
||||
abstract acquireLock(key: string, ttlSeconds: number): Promise<string | null>;
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {KVCacheProvider} from '@pkgs/cache/src/providers/KVCacheProvider';
|
||||
import type {IKVPipeline, IKVProvider} from '@pkgs/kv_client/src/IKVProvider';
|
||||
import {computeHashSlot} from '@pkgs/kv_client/src/KVHashSlots';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
function createRecordingProvider(): {
|
||||
client: IKVProvider;
|
||||
commands: Array<Array<string>>;
|
||||
} {
|
||||
const commands: Array<Array<string>> = [];
|
||||
const client = {
|
||||
set: async (key: string) => {
|
||||
commands.push([key]);
|
||||
return 'OK';
|
||||
},
|
||||
setex: async (key: string) => {
|
||||
commands.push([key]);
|
||||
},
|
||||
isClustered: () => true,
|
||||
pipeline: () => {
|
||||
const keys: Array<string> = [];
|
||||
commands.push(keys);
|
||||
const batch = {
|
||||
set: (key: string) => {
|
||||
keys.push(key);
|
||||
return batch;
|
||||
},
|
||||
setex: (key: string) => {
|
||||
keys.push(key);
|
||||
return batch;
|
||||
},
|
||||
exec: async () => [],
|
||||
} as unknown as IKVPipeline;
|
||||
return batch;
|
||||
},
|
||||
} as unknown as IKVProvider;
|
||||
return {client, commands};
|
||||
}
|
||||
|
||||
describe('KVCacheProvider cluster hash slots', () => {
|
||||
it('keeps a multi entry write off batched commands that span hash slots', async () => {
|
||||
const {client, commands} = createRecordingProvider();
|
||||
const provider = new KVCacheProvider({client});
|
||||
|
||||
expect(computeHashSlot('cache:alpha')).not.toBe(computeHashSlot('cache:beta'));
|
||||
|
||||
await provider.mset([
|
||||
{key: 'cache:alpha', value: 1, ttlSeconds: 60},
|
||||
{key: 'cache:beta', value: 2},
|
||||
]);
|
||||
|
||||
expect(commands.flat().sort()).toEqual(['cache:alpha', 'cache:beta']);
|
||||
expect(commands.filter((keys) => new Set(keys.map(computeHashSlot)).size > 1)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,107 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {KVCacheProvider} from '@pkgs/cache/src/providers/KVCacheProvider';
|
||||
import type {IKVPipeline, IKVProvider} from '@pkgs/kv_client/src/IKVProvider';
|
||||
import {computeHashSlot} from '@pkgs/kv_client/src/KVHashSlots';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
const MAX_CONCURRENT_ROUND_TRIPS = 16;
|
||||
|
||||
interface RecordingProvider {
|
||||
client: IKVProvider;
|
||||
batches: Array<Array<string>>;
|
||||
peakInFlight: number;
|
||||
}
|
||||
|
||||
function createRecordingProvider(clustered: boolean): RecordingProvider {
|
||||
const recorder: RecordingProvider = {
|
||||
client: {} as IKVProvider,
|
||||
batches: [],
|
||||
peakInFlight: 0,
|
||||
};
|
||||
let inFlight = 0;
|
||||
const trackRoundTrip = async (keys: Array<string>): Promise<void> => {
|
||||
recorder.batches.push(keys);
|
||||
inFlight += 1;
|
||||
recorder.peakInFlight = Math.max(recorder.peakInFlight, inFlight);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
inFlight -= 1;
|
||||
};
|
||||
recorder.client = {
|
||||
isClustered: () => clustered,
|
||||
set: async (key: string) => {
|
||||
await trackRoundTrip([key]);
|
||||
return 'OK';
|
||||
},
|
||||
setex: async (key: string) => {
|
||||
await trackRoundTrip([key]);
|
||||
},
|
||||
pipeline: () => {
|
||||
const keys: Array<string> = [];
|
||||
const batch = {
|
||||
set: (key: string) => {
|
||||
keys.push(key);
|
||||
return batch;
|
||||
},
|
||||
setex: (key: string) => {
|
||||
keys.push(key);
|
||||
return batch;
|
||||
},
|
||||
exec: async () => {
|
||||
await trackRoundTrip(keys);
|
||||
return [];
|
||||
},
|
||||
} as unknown as IKVPipeline;
|
||||
return batch;
|
||||
},
|
||||
} as unknown as IKVProvider;
|
||||
return recorder;
|
||||
}
|
||||
|
||||
function createEntries(count: number): Array<{key: string; value: number; ttlSeconds: number}> {
|
||||
return Array.from({length: count}, (_unused, index) => ({
|
||||
key: `cache:entry:${index}`,
|
||||
value: index,
|
||||
ttlSeconds: 60,
|
||||
}));
|
||||
}
|
||||
|
||||
describe('KVCacheProvider multi entry write fan out', () => {
|
||||
it('writes every entry in one round trip outside cluster mode', async () => {
|
||||
const recorder = createRecordingProvider(false);
|
||||
const provider = new KVCacheProvider({client: recorder.client});
|
||||
|
||||
await provider.mset(createEntries(1000));
|
||||
|
||||
expect(recorder.batches.map((keys) => keys.length)).toEqual([1000]);
|
||||
expect(recorder.peakInFlight).toBe(1);
|
||||
});
|
||||
|
||||
it('surfaces a failed command inside a batched write', async () => {
|
||||
const client = {
|
||||
isClustered: () => false,
|
||||
pipeline: () => {
|
||||
const batch = {
|
||||
set: () => batch,
|
||||
setex: () => batch,
|
||||
exec: async () => [[new Error('write rejected'), null]],
|
||||
} as unknown as IKVPipeline;
|
||||
return batch;
|
||||
},
|
||||
} as unknown as IKVProvider;
|
||||
const provider = new KVCacheProvider({client});
|
||||
|
||||
await expect(provider.mset(createEntries(2))).rejects.toThrow('write rejected');
|
||||
});
|
||||
|
||||
it('bounds concurrent round trips when entries span hash slots', async () => {
|
||||
const recorder = createRecordingProvider(true);
|
||||
const provider = new KVCacheProvider({client: recorder.client});
|
||||
|
||||
await provider.mset(createEntries(1000));
|
||||
|
||||
expect(recorder.peakInFlight).toBeLessThanOrEqual(MAX_CONCURRENT_ROUND_TRIPS);
|
||||
expect(recorder.batches.filter((keys) => new Set(keys.map(computeHashSlot)).size > 1)).toEqual([]);
|
||||
expect(recorder.batches.flat().length).toBe(1000);
|
||||
});
|
||||
});
|
||||
+17
-38
@@ -1,38 +1,16 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {InMemoryProvider} from '@pkgs/cache/src/providers/InMemoryProvider';
|
||||
import {KVCacheProvider} from '@pkgs/cache/src/providers/KVCacheProvider';
|
||||
import type {IKVProvider} from '@pkgs/kv_client/src/IKVProvider';
|
||||
import {MockKVProvider} from '@pkgs/kv_client/src/testing/MockKVProvider';
|
||||
import {describe, expect, it, vi} from 'vitest';
|
||||
|
||||
function createKVCacheProvider(): {
|
||||
provider: KVCacheProvider;
|
||||
store: Map<string, string>;
|
||||
ttls: Array<[string, number]>;
|
||||
} {
|
||||
const store = new Map<string, string>();
|
||||
const ttls: Array<[string, number]> = [];
|
||||
const client = {
|
||||
get: async (key: string) => store.get(key) ?? null,
|
||||
set: async (key: string, value: string) => {
|
||||
store.set(key, value);
|
||||
return 'OK';
|
||||
},
|
||||
setex: async (key: string, ttlSeconds: number, value: string) => {
|
||||
ttls.push([key, ttlSeconds]);
|
||||
store.set(key, value);
|
||||
},
|
||||
} as unknown as IKVProvider;
|
||||
return {provider: new KVCacheProvider({client}), store, ttls};
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
describe('ICacheService.getOrSet', () => {
|
||||
it('runs the factory once for concurrent callers on the same key', async () => {
|
||||
const cache = new InMemoryProvider();
|
||||
const cache = new KVCacheProvider({client: new MockKVProvider()});
|
||||
const factory = vi.fn(async () => {
|
||||
await delay(10);
|
||||
return 7;
|
||||
@@ -48,7 +26,7 @@ describe('ICacheService.getOrSet', () => {
|
||||
});
|
||||
|
||||
it('does not coalesce concurrent callers on different keys', async () => {
|
||||
const cache = new InMemoryProvider();
|
||||
const cache = new KVCacheProvider({client: new MockKVProvider()});
|
||||
const factory = vi.fn(async () => {
|
||||
await delay(10);
|
||||
return 1;
|
||||
@@ -58,7 +36,7 @@ describe('ICacheService.getOrSet', () => {
|
||||
});
|
||||
|
||||
it('caches a null factory result and serves it as a hit', async () => {
|
||||
const cache = new InMemoryProvider();
|
||||
const cache = new KVCacheProvider({client: new MockKVProvider()});
|
||||
const factory = vi.fn(async () => null);
|
||||
await expect(cache.getOrSet<number | null>('key', factory, 60)).resolves.toBeNull();
|
||||
await expect(cache.getOrSet<number | null>('key', factory, 60)).resolves.toBeNull();
|
||||
@@ -66,36 +44,37 @@ describe('ICacheService.getOrSet', () => {
|
||||
});
|
||||
|
||||
it('serves a stored json null from the kv provider as a hit', async () => {
|
||||
const {provider, store} = createKVCacheProvider();
|
||||
const client = new MockKVProvider();
|
||||
const provider = new KVCacheProvider({client});
|
||||
const factory = vi.fn(async () => null);
|
||||
await expect(provider.getOrSet<number | null>('key', factory, 60)).resolves.toBeNull();
|
||||
expect(store.get('key')).toBe('null');
|
||||
expect(await client.get('key')).toBe('null');
|
||||
await expect(provider.getOrSet<number | null>('key', factory, 60)).resolves.toBeNull();
|
||||
expect(factory).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('treats an unparseable stored value as a miss', async () => {
|
||||
const {provider, store} = createKVCacheProvider();
|
||||
store.set('key', '{not json');
|
||||
const client = new MockKVProvider();
|
||||
const provider = new KVCacheProvider({client});
|
||||
await client.set('key', '{not json');
|
||||
const factory = vi.fn(async () => 3);
|
||||
await expect(provider.getOrSet('key', factory, 60)).resolves.toBe(3);
|
||||
expect(factory).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('resolves the ttl from the produced value', async () => {
|
||||
const {provider, store, ttls} = createKVCacheProvider();
|
||||
const client = new MockKVProvider();
|
||||
const provider = new KVCacheProvider({client});
|
||||
const resolver = (value: number | null) => (value === null ? 5 : 30);
|
||||
await expect(provider.getOrSet<number | null>('present', async () => 1, resolver)).resolves.toBe(1);
|
||||
await expect(provider.getOrSet<number | null>('absent', async () => null, resolver)).resolves.toBeNull();
|
||||
expect(ttls).toEqual([
|
||||
['present', 30],
|
||||
['absent', 5],
|
||||
]);
|
||||
expect(store.get('absent')).toBe('null');
|
||||
expect(await client.ttl('present')).toBe(30);
|
||||
expect(await client.ttl('absent')).toBe(5);
|
||||
expect(await client.get('absent')).toBe('null');
|
||||
});
|
||||
|
||||
it('rejects every waiter after a single coalesced retry when the factory keeps failing', async () => {
|
||||
const cache = new InMemoryProvider();
|
||||
const cache = new KVCacheProvider({client: new MockKVProvider()});
|
||||
const failing = vi.fn(async () => {
|
||||
await delay(10);
|
||||
throw new Error('factory failed');
|
||||
@@ -115,7 +94,7 @@ describe('ICacheService.getOrSet', () => {
|
||||
});
|
||||
|
||||
it('does not fan a transient producer failure out to the callers that joined it', async () => {
|
||||
const cache = new InMemoryProvider();
|
||||
const cache = new KVCacheProvider({client: new MockKVProvider()});
|
||||
let calls = 0;
|
||||
const factory = vi.fn(async () => {
|
||||
calls += 1;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {InMemoryProvider} from '@pkgs/cache/src/providers/InMemoryProvider';
|
||||
import type {ICacheService} from '@pkgs/cache/src/ICacheService';
|
||||
import {KVCacheProvider} from '@pkgs/cache/src/providers/KVCacheProvider';
|
||||
import {MockKVProvider} from '@pkgs/kv_client/src/testing/MockKVProvider';
|
||||
import {describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const INFLIGHT_OVERFLOW_ENTRIES = 10000;
|
||||
@@ -31,13 +33,13 @@ function settleWithin<T>(pending: Promise<T>, ms: number): Promise<T | 'pinned'
|
||||
]);
|
||||
}
|
||||
|
||||
function trackedProduceKeys(cache: InMemoryProvider): Array<string> {
|
||||
function trackedProduceKeys(cache: ICacheService): Array<string> {
|
||||
return [...(cache as unknown as {produceInvalidations: Map<string, unknown>}).produceInvalidations.keys()];
|
||||
}
|
||||
|
||||
describe('cache invalidation during an in-flight produce', () => {
|
||||
it('does not resurrect a value deleted while the factory was running', async () => {
|
||||
const cache = new InMemoryProvider();
|
||||
const cache = new KVCacheProvider({client: new MockKVProvider()});
|
||||
const gate = deferred<string>();
|
||||
const pending = cache.getOrSet('session', async () => await gate.promise, 30);
|
||||
await cache.delete('session');
|
||||
@@ -47,7 +49,7 @@ describe('cache invalidation during an in-flight produce', () => {
|
||||
});
|
||||
|
||||
it('still stores the value when no invalidation happens', async () => {
|
||||
const cache = new InMemoryProvider();
|
||||
const cache = new KVCacheProvider({client: new MockKVProvider()});
|
||||
const gate = deferred<string>();
|
||||
const pending = cache.getOrSet('session', async () => await gate.promise, 30);
|
||||
gate.resolve('live-session');
|
||||
@@ -56,7 +58,7 @@ describe('cache invalidation during an in-flight produce', () => {
|
||||
});
|
||||
|
||||
it('does not resurrect a value deleted while a retried produce was running', async () => {
|
||||
const cache = new InMemoryProvider();
|
||||
const cache = new KVCacheProvider({client: new MockKVProvider()});
|
||||
const gates: Array<ReturnType<typeof deferred<string>>> = [];
|
||||
const factory = async () => {
|
||||
const gate = deferred<string>();
|
||||
@@ -77,7 +79,7 @@ describe('cache invalidation during an in-flight produce', () => {
|
||||
});
|
||||
|
||||
it('stores the value a retried produce built when no invalidation happens', async () => {
|
||||
const cache = new InMemoryProvider();
|
||||
const cache = new KVCacheProvider({client: new MockKVProvider()});
|
||||
const gates: Array<ReturnType<typeof deferred<string>>> = [];
|
||||
const factory = async () => {
|
||||
const gate = deferred<string>();
|
||||
@@ -96,7 +98,7 @@ describe('cache invalidation during an in-flight produce', () => {
|
||||
});
|
||||
|
||||
it('stores the value a retried produce built after the first produce was invalidated', async () => {
|
||||
const cache = new InMemoryProvider();
|
||||
const cache = new KVCacheProvider({client: new MockKVProvider()});
|
||||
const gates: Array<ReturnType<typeof deferred<string>>> = [];
|
||||
const factory = async () => {
|
||||
const gate = deferred<string>();
|
||||
@@ -117,7 +119,7 @@ describe('cache invalidation during an in-flight produce', () => {
|
||||
});
|
||||
|
||||
it('does not resurrect a value deleted after a concurrent caller released its produce', async () => {
|
||||
const cache = new InMemoryProvider();
|
||||
const cache = new KVCacheProvider({client: new MockKVProvider()});
|
||||
const gate = deferred<string>();
|
||||
const pending = cache.getOrSet('session', async () => await gate.promise, 30);
|
||||
await flush();
|
||||
@@ -130,7 +132,7 @@ describe('cache invalidation during an in-flight produce', () => {
|
||||
});
|
||||
|
||||
it('does not resurrect a value deleted while a second overflow produce was running', async () => {
|
||||
const cache = new InMemoryProvider();
|
||||
const cache = new KVCacheProvider({client: new MockKVProvider()});
|
||||
const fillers: Array<ReturnType<typeof deferred<string>>> = [];
|
||||
const filling: Array<Promise<string>> = [];
|
||||
for (let index = 0; index < INFLIGHT_OVERFLOW_ENTRIES; index++) {
|
||||
@@ -157,7 +159,7 @@ describe('cache invalidation during an in-flight produce', () => {
|
||||
});
|
||||
|
||||
it('drops produce tracking once the last produce for a key settles', async () => {
|
||||
const cache = new InMemoryProvider();
|
||||
const cache = new KVCacheProvider({client: new MockKVProvider()});
|
||||
for (let index = 0; index < 50; index++) {
|
||||
const gate = deferred<string>();
|
||||
const pending = cache.getOrSet(`session:${index}`, async () => await gate.promise, 30);
|
||||
@@ -184,7 +186,7 @@ describe('cache invalidation during an in-flight produce', () => {
|
||||
});
|
||||
|
||||
it('does not pin a key forever when the factory never settles', async () => {
|
||||
const cache = new InMemoryProvider();
|
||||
const cache = new KVCacheProvider({client: new MockKVProvider()});
|
||||
const stuck = deferred<string>();
|
||||
const pinned = cache.getOrSet('session', async () => await stuck.promise, 30, PRODUCE_TIMEOUT_MS);
|
||||
await expect(settleWithin(pinned, 500)).resolves.toBe('rejected');
|
||||
@@ -197,7 +199,7 @@ describe('cache invalidation during an in-flight produce', () => {
|
||||
});
|
||||
|
||||
it('does not store a value produced by a factory that settled after the timeout', async () => {
|
||||
const cache = new InMemoryProvider();
|
||||
const cache = new KVCacheProvider({client: new MockKVProvider()});
|
||||
const stuck = deferred<string>();
|
||||
const pending = cache.getOrSet('session', async () => await stuck.promise, 30, PRODUCE_TIMEOUT_MS);
|
||||
await expect(pending).rejects.toThrow(PRODUCE_TIMEOUT_MESSAGE);
|
||||
@@ -208,7 +210,7 @@ describe('cache invalidation during an in-flight produce', () => {
|
||||
});
|
||||
|
||||
it('retries once for the joiners when the producer times out', async () => {
|
||||
const cache = new InMemoryProvider();
|
||||
const cache = new KVCacheProvider({client: new MockKVProvider()});
|
||||
const gates: Array<ReturnType<typeof deferred<string>>> = [];
|
||||
const factory = async () => {
|
||||
const gate = deferred<string>();
|
||||
@@ -229,7 +231,7 @@ describe('cache invalidation during an in-flight produce', () => {
|
||||
});
|
||||
|
||||
it('releases produce tracking when the factory never settles', async () => {
|
||||
const cache = new InMemoryProvider();
|
||||
const cache = new KVCacheProvider({client: new MockKVProvider()});
|
||||
const stuck = deferred<string>();
|
||||
const pending = cache.getOrSet('session', async () => await stuck.promise, 30, PRODUCE_TIMEOUT_MS);
|
||||
await expect(pending).rejects.toThrow(PRODUCE_TIMEOUT_MESSAGE);
|
||||
@@ -238,7 +240,7 @@ describe('cache invalidation during an in-flight produce', () => {
|
||||
});
|
||||
|
||||
it('stores a sibling produce that succeeded after an overflow produce timed out', async () => {
|
||||
const cache = new InMemoryProvider();
|
||||
const cache = new KVCacheProvider({client: new MockKVProvider()});
|
||||
const fillers: Array<ReturnType<typeof deferred<string>>> = [];
|
||||
const filling: Array<Promise<string>> = [];
|
||||
for (let index = 0; index < INFLIGHT_OVERFLOW_ENTRIES; index++) {
|
||||
@@ -262,7 +264,7 @@ describe('cache invalidation during an in-flight produce', () => {
|
||||
});
|
||||
|
||||
it('does not hold the event loop open while a produce is in flight', async () => {
|
||||
const cache = new InMemoryProvider();
|
||||
const cache = new KVCacheProvider({client: new MockKVProvider()});
|
||||
const stuck = deferred<string>();
|
||||
const timers: Array<NodeJS.Timeout> = [];
|
||||
const scheduled = globalThis.setTimeout;
|
||||
@@ -282,7 +284,7 @@ describe('cache invalidation during an in-flight produce', () => {
|
||||
});
|
||||
|
||||
it('keeps a later produce cacheable after an earlier one was invalidated', async () => {
|
||||
const cache = new InMemoryProvider();
|
||||
const cache = new KVCacheProvider({client: new MockKVProvider()});
|
||||
const first = deferred<string>();
|
||||
const pending = cache.getOrSet('session', async () => await first.promise, 30);
|
||||
await cache.delete('session');
|
||||
|
||||
@@ -1,273 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
formatLockKey,
|
||||
generateLockToken,
|
||||
validateLockKey,
|
||||
validateLockToken,
|
||||
} from '@pkgs/cache/src/CacheLockValidation';
|
||||
import {type CacheLookupResult, ICacheService} from '@pkgs/cache/src/ICacheService';
|
||||
|
||||
interface CacheEntry {
|
||||
expiresAt?: number;
|
||||
}
|
||||
|
||||
interface CacheValueEntry extends CacheEntry {
|
||||
kind: 'value';
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
interface CacheSetEntry extends CacheEntry {
|
||||
kind: 'set';
|
||||
value: Set<string>;
|
||||
}
|
||||
|
||||
interface CacheLockEntry extends CacheEntry {
|
||||
token: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
interface InMemoryProviderConfig {
|
||||
maxSize?: number;
|
||||
cleanupIntervalMs?: number;
|
||||
}
|
||||
|
||||
export class InMemoryProvider extends ICacheService {
|
||||
private readonly cache = new Map<string, CacheValueEntry | CacheSetEntry>();
|
||||
private readonly locks = new Map<string, CacheLockEntry>();
|
||||
private readonly maxSize: number;
|
||||
private cleanupInterval?: NodeJS.Timeout;
|
||||
|
||||
constructor(config: InMemoryProviderConfig = {}) {
|
||||
super();
|
||||
this.maxSize = config.maxSize ?? 10000;
|
||||
if (config.cleanupIntervalMs) {
|
||||
this.cleanupInterval = setInterval(() => this.cleanup(), config.cleanupIntervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
private cleanup(): void {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of this.cache.entries()) {
|
||||
if (this.isExpired(entry, now)) {
|
||||
this.cache.delete(key);
|
||||
}
|
||||
}
|
||||
for (const [key, lock] of this.locks.entries()) {
|
||||
if (this.isExpired(lock, now)) {
|
||||
this.locks.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private isExpired(entry: CacheEntry, now = Date.now()): boolean {
|
||||
return entry.expiresAt !== undefined && now >= entry.expiresAt;
|
||||
}
|
||||
|
||||
private getLiveEntry(key: string): CacheValueEntry | CacheSetEntry | undefined {
|
||||
const entry = this.cache.get(key);
|
||||
if (entry && this.isExpired(entry)) {
|
||||
this.cache.delete(key);
|
||||
return undefined;
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
private getValueEntry(key: string): CacheValueEntry | undefined {
|
||||
const entry = this.getLiveEntry(key);
|
||||
if (entry && entry.kind !== 'value') {
|
||||
throw new Error('Cache key contains a set, not a value.');
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
private getSetEntry(key: string): CacheSetEntry | undefined {
|
||||
const entry = this.getLiveEntry(key);
|
||||
if (entry && entry.kind !== 'set') {
|
||||
throw new Error('Cache key contains a value, not a set.');
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
private getLiveLock(key: string): CacheLockEntry | undefined {
|
||||
const lock = this.locks.get(key);
|
||||
if (lock && this.isExpired(lock)) {
|
||||
this.locks.delete(key);
|
||||
return undefined;
|
||||
}
|
||||
return lock;
|
||||
}
|
||||
|
||||
private evictIfNeeded(): void {
|
||||
if (this.cache.size >= this.maxSize) {
|
||||
const firstKey = this.cache.keys().next().value;
|
||||
if (firstKey !== undefined) {
|
||||
this.cache.delete(firstKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getEntry<T>(key: string): Promise<CacheLookupResult<T>> {
|
||||
const entry = this.getValueEntry(key);
|
||||
return entry ? {hit: true, value: entry.value as T} : {hit: false};
|
||||
}
|
||||
|
||||
async set<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
|
||||
if (!this.cache.has(key)) {
|
||||
this.evictIfNeeded();
|
||||
}
|
||||
const entry: CacheValueEntry = {
|
||||
kind: 'value',
|
||||
value,
|
||||
expiresAt: ttlSeconds ? Date.now() + ttlSeconds * 1000 : undefined,
|
||||
};
|
||||
this.cache.set(key, entry);
|
||||
}
|
||||
|
||||
protected async deleteEntry(key: string): Promise<void> {
|
||||
this.cache.delete(key);
|
||||
}
|
||||
|
||||
async getAndDelete<T>(key: string): Promise<T | null> {
|
||||
const entry = this.getValueEntry(key);
|
||||
this.cache.delete(key);
|
||||
return entry ? (entry.value as T) : null;
|
||||
}
|
||||
|
||||
async exists(key: string): Promise<boolean> {
|
||||
return this.getLiveEntry(key) !== undefined;
|
||||
}
|
||||
|
||||
async expire(key: string, ttlSeconds: number): Promise<void> {
|
||||
const entry = this.getLiveEntry(key);
|
||||
if (entry) {
|
||||
entry.expiresAt = Date.now() + ttlSeconds * 1000;
|
||||
}
|
||||
}
|
||||
|
||||
async ttl(key: string): Promise<number> {
|
||||
const entry = this.getLiveEntry(key);
|
||||
if (!entry) {
|
||||
return -2;
|
||||
}
|
||||
if (entry.expiresAt === undefined) {
|
||||
return -1;
|
||||
}
|
||||
const ttlMs = entry.expiresAt - Date.now();
|
||||
return Math.max(0, Math.floor(ttlMs / 1000));
|
||||
}
|
||||
|
||||
async mget<T>(keys: Array<string>): Promise<Array<T | null>> {
|
||||
return keys.map((key) => {
|
||||
const entry = this.getLiveEntry(key);
|
||||
return entry?.kind === 'value' ? (entry.value as T) : null;
|
||||
});
|
||||
}
|
||||
|
||||
async mset<T>(
|
||||
entries: Array<{
|
||||
key: string;
|
||||
value: T;
|
||||
ttlSeconds?: number;
|
||||
}>,
|
||||
): Promise<void> {
|
||||
for (const entry of entries) {
|
||||
await this.set(entry.key, entry.value, entry.ttlSeconds);
|
||||
}
|
||||
}
|
||||
|
||||
async deletePattern(pattern: string): Promise<number> {
|
||||
const regex = new RegExp(pattern.replace(/\*/g, '.*'));
|
||||
let deletedCount = 0;
|
||||
for (const key of this.cache.keys()) {
|
||||
if (regex.test(key)) {
|
||||
this.cache.delete(key);
|
||||
deletedCount++;
|
||||
}
|
||||
}
|
||||
return deletedCount;
|
||||
}
|
||||
|
||||
async acquireLock(key: string, ttlSeconds: number): Promise<string | null> {
|
||||
validateLockKey(key);
|
||||
const lockKey = formatLockKey(key);
|
||||
if (this.getLiveLock(lockKey)) {
|
||||
return null;
|
||||
}
|
||||
const token = generateLockToken();
|
||||
this.locks.set(lockKey, {
|
||||
token,
|
||||
expiresAt: Date.now() + ttlSeconds * 1000,
|
||||
});
|
||||
return token;
|
||||
}
|
||||
|
||||
async releaseLock(key: string, token: string): Promise<boolean> {
|
||||
validateLockKey(key);
|
||||
validateLockToken(token);
|
||||
const lockKey = formatLockKey(key);
|
||||
const lock = this.getLiveLock(lockKey);
|
||||
if (!lock || lock.token !== token) {
|
||||
return false;
|
||||
}
|
||||
this.locks.delete(lockKey);
|
||||
return true;
|
||||
}
|
||||
|
||||
async extendLock(key: string, token: string, ttlSeconds: number): Promise<boolean> {
|
||||
validateLockKey(key);
|
||||
validateLockToken(token);
|
||||
const lockKey = formatLockKey(key);
|
||||
const lock = this.getLiveLock(lockKey);
|
||||
if (!lock || lock.token !== token) {
|
||||
return false;
|
||||
}
|
||||
lock.expiresAt = Date.now() + ttlSeconds * 1000;
|
||||
return true;
|
||||
}
|
||||
|
||||
async publish(_channel: string, _message: string): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
async sadd(key: string, member: string, ttlSeconds?: number): Promise<void> {
|
||||
let entry = this.getSetEntry(key);
|
||||
if (!entry) {
|
||||
this.evictIfNeeded();
|
||||
entry = {kind: 'set', value: new Set<string>()};
|
||||
this.cache.set(key, entry);
|
||||
}
|
||||
entry.value.add(member);
|
||||
if (ttlSeconds) {
|
||||
entry.expiresAt = Date.now() + ttlSeconds * 1000;
|
||||
}
|
||||
}
|
||||
|
||||
async srem(key: string, member: string): Promise<void> {
|
||||
const entry = this.getSetEntry(key);
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
entry.value.delete(member);
|
||||
if (entry.value.size === 0) {
|
||||
this.cache.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
async smembers(key: string): Promise<Set<string>> {
|
||||
return new Set(this.getSetEntry(key)?.value);
|
||||
}
|
||||
|
||||
async sismember(key: string, member: string): Promise<boolean> {
|
||||
return this.getSetEntry(key)?.value.has(member) ?? false;
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval);
|
||||
this.cleanupInterval = undefined;
|
||||
}
|
||||
this.cache.clear();
|
||||
this.locks.clear();
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import type {CacheLogger, CacheTelemetry} from '@pkgs/cache/src/CacheProviderTyp
|
||||
import {parseCachedValue, safeJsonParse, serializeValue} from '@pkgs/cache/src/CacheSerialization';
|
||||
import {type CacheLookupResult, ICacheService} from '@pkgs/cache/src/ICacheService';
|
||||
import type {IKVPipeline, IKVProvider} from '@pkgs/kv_client/src/IKVProvider';
|
||||
import {runSlotBatches, splitIntoSlotBatches} from '@pkgs/kv_client/src/KVHashSlots';
|
||||
|
||||
interface KVCacheProviderConfig {
|
||||
client: IKVProvider;
|
||||
@@ -138,33 +137,6 @@ export class KVCacheProvider extends ICacheService {
|
||||
});
|
||||
}
|
||||
|
||||
async mset<T>(
|
||||
entries: Array<{
|
||||
key: string;
|
||||
value: T;
|
||||
ttlSeconds?: number;
|
||||
}>,
|
||||
): Promise<void> {
|
||||
if (entries.length === 0) return;
|
||||
const serialized = entries.map((entry) => ({
|
||||
key: entry.key,
|
||||
value: serializeValue(entry.value),
|
||||
ttlSeconds: entry.ttlSeconds,
|
||||
}));
|
||||
const batches = splitIntoSlotBatches(serialized, (entry) => entry.key, this.client.isClustered());
|
||||
await runSlotBatches(batches, async (batch) => {
|
||||
const pipeline = this.client.pipeline();
|
||||
for (const entry of batch) {
|
||||
if (entry.ttlSeconds) {
|
||||
pipeline.setex(entry.key, entry.ttlSeconds, entry.value);
|
||||
} else {
|
||||
pipeline.set(entry.key, entry.value);
|
||||
}
|
||||
}
|
||||
await this.executePipeline(pipeline);
|
||||
});
|
||||
}
|
||||
|
||||
async deletePattern(pattern: string): Promise<number> {
|
||||
const keys = await this.client.scan(pattern, 1000);
|
||||
if (keys.length === 0) return 0;
|
||||
|
||||
Reference in new issue
Block a user