From a4dbca4f3f64c3c37a75da75bacc803d8906daa6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 20 Dec 2025 17:45:10 +0000 Subject: [PATCH 1/4] Initial plan From 25a009a7247a3932b55c0738e97d132604a7fde2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 20 Dec 2025 17:53:09 +0000 Subject: [PATCH 2/4] Add MultiCacheProvider implementation with tests Co-authored-by: devbro1 <29712935+devbro1@users.noreply.github.com> --- .../src/providers/MultiCacheProvider.mts | 99 ++++++++++ neko-cache/src/providers/index.mts | 1 + .../tests/test1/multi_cache_provider.spec.ts | 175 ++++++++++++++++++ pashmak/src/facades.mts | 26 ++- pashmak/src/factories.mts | 5 + 5 files changed, 305 insertions(+), 1 deletion(-) create mode 100644 neko-cache/src/providers/MultiCacheProvider.mts create mode 100644 neko-cache/tests/test1/multi_cache_provider.spec.ts diff --git a/neko-cache/src/providers/MultiCacheProvider.mts b/neko-cache/src/providers/MultiCacheProvider.mts new file mode 100644 index 00000000..6e9206f7 --- /dev/null +++ b/neko-cache/src/providers/MultiCacheProvider.mts @@ -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 { + 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 { + 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 { + 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 { + 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 { + // 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; + } +} diff --git a/neko-cache/src/providers/index.mts b/neko-cache/src/providers/index.mts index b8936402..ca307aa1 100644 --- a/neko-cache/src/providers/index.mts +++ b/neko-cache/src/providers/index.mts @@ -3,3 +3,4 @@ export * from './FileCacheProvider.mjs'; export * from './MemoryCacheProvider.mjs'; export * from './MemcacheCacheProvider.mjs'; export * from './DisabledCacheProvider.mjs'; +export * from './MultiCacheProvider.mjs'; diff --git a/neko-cache/tests/test1/multi_cache_provider.spec.ts b/neko-cache/tests/test1/multi_cache_provider.spec.ts new file mode 100644 index 00000000..7c07d87e --- /dev/null +++ b/neko-cache/tests/test1/multi_cache_provider.spec.ts @@ -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); + }); +}); diff --git a/pashmak/src/facades.mts b/pashmak/src/facades.mts index 8031adee..f471a317 100644 --- a/pashmak/src/facades.mts +++ b/pashmak/src/facades.mts @@ -162,9 +162,33 @@ 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`); + } + + // 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`); + } + 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); diff --git a/pashmak/src/factories.mts b/pashmak/src/factories.mts index c01a4421..2ac5b824 100644 --- a/pashmak/src/factories.mts +++ b/pashmak/src/factories.mts @@ -21,6 +21,7 @@ import { RedisCacheProvider, FileCacheProvider, DisabledCacheProvider, + MultiCacheProvider, } from "@devbro/neko-cache"; import { AWSS3StorageProvider, @@ -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); }); From 2bb9c473e826576c731858371bcb3d29c9d899a4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 20 Dec 2025 17:56:08 +0000 Subject: [PATCH 3/4] Add integration tests for multi cache provider Co-authored-by: devbro1 <29712935+devbro1@users.noreply.github.com> --- pashmak/tests/facades.spec.ts | 66 +++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/pashmak/tests/facades.spec.ts b/pashmak/tests/facades.spec.ts index d340479a..42ef118a 100644 --- a/pashmak/tests/facades.spec.ts +++ b/pashmak/tests/facades.spec.ts @@ -24,6 +24,20 @@ beforeAll(() => { provider: "memory", config: {}, }, + memory_primary: { + provider: "memory", + config: {}, + }, + memory_secondary: { + provider: "memory", + config: {}, + }, + multi_cache: { + provider: "multi", + config: { + caches: ["memory_primary", "memory_secondary"], + }, + }, }, loggers: { default: { @@ -138,4 +152,56 @@ describe("Facade property accessors", () => { expect(schedule).toBeDefined(); }).not.toThrow(); }); + + test("multi cache provider should cascade through caches", async () => { + const { cache } = await import("../src/facades.mjs"); + + // Create a multi cache instance + const multiCache = cache("multi_cache"); + + // Put value into multi cache (should go to all underlying caches) + await multiCache.put("multi-test-key", "multi-value", 10); + + // Get value from multi cache + const result = await multiCache.get("multi-test-key"); + expect(result).toBe("multi-value"); + + // Verify both underlying caches have the value + const primary = cache("memory_primary"); + const secondary = cache("memory_secondary"); + + expect(await primary.get("multi-test-key")).toBe("multi-value"); + expect(await secondary.get("multi-test-key")).toBe("multi-value"); + + // Delete from multi cache (should delete from all) + await multiCache.delete("multi-test-key"); + + // Verify deleted from all caches + expect(await multiCache.get("multi-test-key")).toBeUndefined(); + expect(await primary.get("multi-test-key")).toBeUndefined(); + expect(await secondary.get("multi-test-key")).toBeUndefined(); + }); + + test("multi cache provider should return first match when cascading", async () => { + const { cache } = await import("../src/facades.mjs"); + + const multiCache = cache("multi_cache"); + const primary = cache("memory_primary"); + const secondary = cache("memory_secondary"); + + // Put different values in each cache + await primary.put("cascade-test", "from-primary", 10); + await secondary.put("cascade-test", "from-secondary", 10); + + // Multi cache should return value from first cache (primary) + const result = await multiCache.get("cascade-test"); + expect(result).toBe("from-primary"); + + // Delete from primary only + await primary.delete("cascade-test"); + + // Multi cache should now return from secondary + const result2 = await multiCache.get("cascade-test"); + expect(result2).toBe("from-secondary"); + }); }); From 4e24b54e7d1cecd2e49805ea60316bb5898b5e5d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 20 Dec 2025 17:57:53 +0000 Subject: [PATCH 4/4] Add circular dependency validation for multi cache Co-authored-by: devbro1 <29712935+devbro1@users.noreply.github.com> --- pashmak/src/facades.mts | 9 +++++++++ pashmak/tests/facades.spec.ts | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/pashmak/src/facades.mts b/pashmak/src/facades.mts index f471a317..4498d65a 100644 --- a/pashmak/src/facades.mts +++ b/pashmak/src/facades.mts @@ -171,12 +171,21 @@ export const cache = wrapSingletonWithAccessors( 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, diff --git a/pashmak/tests/facades.spec.ts b/pashmak/tests/facades.spec.ts index 42ef118a..43cefef2 100644 --- a/pashmak/tests/facades.spec.ts +++ b/pashmak/tests/facades.spec.ts @@ -204,4 +204,36 @@ describe("Facade property accessors", () => { const result2 = await multiCache.get("cascade-test"); expect(result2).toBe("from-secondary"); }); + + test("multi cache provider should prevent circular references", async () => { + const { config } = await import("@devbro/neko-config"); + + // Add a config with circular reference + config.set("caches.circular", { + provider: "multi", + config: { + caches: ["circular", "memory_primary"], + }, + }); + + // Importing cache should throw when trying to create circular cache + const { cache } = await import("../src/facades.mjs"); + expect(() => cache("circular")).toThrow("cannot reference itself"); + }); + + test("multi cache provider should prevent nested multi caches", async () => { + const { config } = await import("@devbro/neko-config"); + + // Add a nested multi cache config + config.set("caches.nested_multi", { + provider: "multi", + config: { + caches: ["multi_cache", "memory_primary"], + }, + }); + + // Should throw when trying to create nested multi cache + const { cache } = await import("../src/facades.mjs"); + expect(() => cache("nested_multi")).toThrow("cannot contain another multi cache"); + }); });