Skip to content
Draft
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
99 changes: 99 additions & 0 deletions neko-cache/src/providers/MultiCacheProvider.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { CacheProviderInterface } from '../CacheProviderInterface.mjs';
import { JSONValue, JSONObject } from '@devbro/neko-helper';

/**
* Configuration options for the multi-cache provider.
*/
export type MultiCacheConfig = {
/** Array of cache provider instances to use in order */
caches: CacheProviderInterface[];
};

/**
* A cache provider that cascades through multiple cache backends.
* Checks caches in order and returns the first hit. Writes go to all caches.
* Useful for creating multi-tier cache hierarchies (e.g., memory -> redis -> file).
*/
export class MultiCacheProvider implements CacheProviderInterface {
private caches: CacheProviderInterface[];

/**
* Creates a new MultiCacheProvider instance.
* @param config - Configuration object containing array of cache providers
* @throws Error if caches array is empty or not provided
*/
constructor(config: MultiCacheConfig) {
if (!config.caches || config.caches.length < 1) {
throw new Error('MultiCacheProvider requires at least one cache provider');
}
this.caches = config.caches;
}

/**
* Retrieves a value from the cache by checking each cache in order.
* Returns the first value found.
* @param key - The cache key
* @returns The cached value or undefined if not found in any cache
*/
async get(key: string): Promise<JSONValue | JSONObject | undefined> {
for (const cache of this.caches) {
const value = await cache.get(key);
if (value !== undefined) {
return value;
}
}
return undefined;
}

/**
* Stores a value in all caches.
* @param key - The cache key
* @param value - The value to cache
* @param ttl - Time to live in seconds (optional)
*/
async put(key: string, value: JSONValue | JSONObject, ttl?: number): Promise<void> {
await Promise.all(this.caches.map((cache) => cache.put(key, value, ttl)));
}

/**
* Deletes a value from all caches.
* @param key - The cache key to delete
*/
async delete(key: string): Promise<void> {
await Promise.all(this.caches.map((cache) => cache.delete(key)));
}

/**
* Checks if a key exists in any cache.
* @param key - The cache key to check
* @returns True if the key exists in at least one cache, false otherwise
*/
async has(key: string): Promise<boolean> {
for (const cache of this.caches) {
if (await cache.has(key)) {
return true;
}
}
return false;
}

/**
* Increments a numeric value in the first cache only.
* This ensures atomic increments without conflicts between caches.
* @param key - The cache key to increment
* @param amount - The amount to increment by (default: 1)
* @returns The new value after incrementing
*/
async increment(key: string, amount: number = 1): Promise<number> {
// Only increment in the first cache to maintain atomicity
const newValue = await this.caches[0].increment(key, amount);

// Optionally sync the new value to other caches
if (this.caches.length > 1) {
const promises = this.caches.slice(1).map((cache) => cache.put(key, newValue));
await Promise.all(promises);
}

return newValue;
}
}
1 change: 1 addition & 0 deletions neko-cache/src/providers/index.mts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export * from './FileCacheProvider.mjs';
export * from './MemoryCacheProvider.mjs';
export * from './MemcacheCacheProvider.mjs';
export * from './DisabledCacheProvider.mjs';
export * from './MultiCacheProvider.mjs';
175 changes: 175 additions & 0 deletions neko-cache/tests/test1/multi_cache_provider.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import { MultiCacheProvider, MemoryCacheProvider } from '@/index';
import { describe, expect, test, beforeEach } from 'vitest';

describe('MultiCacheProvider', () => {
test('requires at least one cache provider', () => {
expect(() => {
new MultiCacheProvider({ caches: [] });
}).toThrow('MultiCacheProvider requires at least one cache provider');
});

test('get returns value from first cache that has it', async () => {
const cache1 = new MemoryCacheProvider();
const cache2 = new MemoryCacheProvider();
const cache3 = new MemoryCacheProvider();

const multiCache = new MultiCacheProvider({
caches: [cache1, cache2, cache3],
});

// Put value only in cache2
await cache2.put('key1', 'value1', 10);

const result = await multiCache.get('key1');
expect(result).toBe('value1');
});

test('get returns undefined if no cache has the value', async () => {
const cache1 = new MemoryCacheProvider();
const cache2 = new MemoryCacheProvider();

const multiCache = new MultiCacheProvider({
caches: [cache1, cache2],
});

const result = await multiCache.get('nonexistent');
expect(result).toBeUndefined();
});

test('get returns from first cache when multiple caches have the value', async () => {
const cache1 = new MemoryCacheProvider();
const cache2 = new MemoryCacheProvider();

// Put different values in each cache
await cache1.put('key1', 'value_from_cache1', 10);
await cache2.put('key1', 'value_from_cache2', 10);

const multiCache = new MultiCacheProvider({
caches: [cache1, cache2],
});

const result = await multiCache.get('key1');
expect(result).toBe('value_from_cache1');
});

test('put writes to all caches', async () => {
const cache1 = new MemoryCacheProvider();
const cache2 = new MemoryCacheProvider();
const cache3 = new MemoryCacheProvider();

const multiCache = new MultiCacheProvider({
caches: [cache1, cache2, cache3],
});

await multiCache.put('key1', 'shared_value', 10);

// Verify all caches have the value
expect(await cache1.get('key1')).toBe('shared_value');
expect(await cache2.get('key1')).toBe('shared_value');
expect(await cache3.get('key1')).toBe('shared_value');
});

test('delete removes from all caches', async () => {
const cache1 = new MemoryCacheProvider();
const cache2 = new MemoryCacheProvider();

// Pre-populate both caches
await cache1.put('key1', 'value1', 10);
await cache2.put('key1', 'value1', 10);

const multiCache = new MultiCacheProvider({
caches: [cache1, cache2],
});

await multiCache.delete('key1');

// Verify both caches no longer have the value
expect(await cache1.get('key1')).toBeUndefined();
expect(await cache2.get('key1')).toBeUndefined();
});

test('has returns true if any cache has the key', async () => {
const cache1 = new MemoryCacheProvider();
const cache2 = new MemoryCacheProvider();

await cache2.put('key1', 'value1', 10);

const multiCache = new MultiCacheProvider({
caches: [cache1, cache2],
});

const result = await multiCache.has('key1');
expect(result).toBe(true);
});

test('has returns false if no cache has the key', async () => {
const cache1 = new MemoryCacheProvider();
const cache2 = new MemoryCacheProvider();

const multiCache = new MultiCacheProvider({
caches: [cache1, cache2],
});

const result = await multiCache.has('nonexistent');
expect(result).toBe(false);
});

test('increment works on first cache and syncs to others', async () => {
const cache1 = new MemoryCacheProvider();
const cache2 = new MemoryCacheProvider();
const cache3 = new MemoryCacheProvider();

const multiCache = new MultiCacheProvider({
caches: [cache1, cache2, cache3],
});

// First increment
const result1 = await multiCache.increment('counter', 5);
expect(result1).toBe(5);

// Verify all caches have the value
expect(await cache1.get('counter')).toBe(5);
expect(await cache2.get('counter')).toBe(5);
expect(await cache3.get('counter')).toBe(5);

// Second increment
const result2 = await multiCache.increment('counter', 3);
expect(result2).toBe(8);

// Verify all caches have the updated value
expect(await cache1.get('counter')).toBe(8);
expect(await cache2.get('counter')).toBe(8);
expect(await cache3.get('counter')).toBe(8);
});

test('works with single cache', async () => {
const cache1 = new MemoryCacheProvider();

const multiCache = new MultiCacheProvider({
caches: [cache1],
});

await multiCache.put('key1', 'value1', 10);
const result = await multiCache.get('key1');
expect(result).toBe('value1');
});

test('handles complex values', async () => {
const cache1 = new MemoryCacheProvider();
const cache2 = new MemoryCacheProvider();

const multiCache = new MultiCacheProvider({
caches: [cache1, cache2],
});

const complexValue = {
name: 'test',
nested: { value: 123 },
array: [1, 2, 3],
};

await multiCache.put('complex', complexValue, 10);
const result = await multiCache.get('complex');
expect(result).toEqual(complexValue);
});
});
35 changes: 34 additions & 1 deletion pashmak/src/facades.mts
Original file line number Diff line number Diff line change
Expand Up @@ -162,9 +162,42 @@ export const cache = wrapSingletonWithAccessors(
if (!cache_config) {
throw new Error(`Cache configuration for '${label}' not found`);
}

let providerConfig = cache_config.config;

// Handle multi cache provider specially
if (cache_config.provider === "multi") {
if (!providerConfig?.caches || !Array.isArray(providerConfig.caches)) {
throw new Error(`Multi cache provider requires 'caches' array in config`);
}

// Validate no circular references
if (providerConfig.caches.includes(label)) {
throw new Error(`Multi cache '${label}' cannot reference itself`);
}

// Resolve cache names to actual cache providers
const cacheProviders = providerConfig.caches.map((cacheName: string) => {
const cacheConfig: any = config.get(["caches", cacheName].join("."));
if (!cacheConfig) {
throw new Error(`Cache configuration for '${cacheName}' not found`);
}
// Prevent multi caches from containing other multi caches to avoid complex circular dependencies
if (cacheConfig.provider === "multi") {
throw new Error(`Multi cache '${label}' cannot contain another multi cache '${cacheName}'`);
}
return CacheProviderFactory.create(
cacheConfig.provider,
cacheConfig.config,
);
});

providerConfig = { caches: cacheProviders };
}

const provider = CacheProviderFactory.create(
cache_config.provider,
cache_config.config,
providerConfig,
);

return new Cache(provider);
Expand Down
5 changes: 5 additions & 0 deletions pashmak/src/factories.mts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
RedisCacheProvider,
FileCacheProvider,
DisabledCacheProvider,
MultiCacheProvider,
} from "@devbro/neko-cache";
import {
AWSS3StorageProvider,
Expand Down Expand Up @@ -127,6 +128,10 @@ CacheProviderFactory.register("disabled", (opt) => {
return new DisabledCacheProvider();
});

CacheProviderFactory.register("multi", (opt) => {
return new MultiCacheProvider(opt);
});

StorageProviderFactory.register("local", (opt) => {
return new LocalStorageProvider(opt);
});
Expand Down
Loading