diff --git a/package-lock.json b/package-lock.json index 11d383c..3071851 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cacheforge", - "version": "1.0.6", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cacheforge", - "version": "1.0.6", + "version": "1.1.0", "dependencies": { "@datastructures-js/heap": "^4.3.7", "@sesamecare-oss/redlock": "^1.4.0", diff --git a/package.json b/package.json index a91fc9b..7c5d55b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cacheforge", - "version": "1.0.6", + "version": "1.1.0", "description": "A multi-level caching library for Node.js applications, supporting in-memory and Redis, and custom cache levels.", "main": "dist/src/index.js", "types": "dist/src/index.d.ts", diff --git a/src/cache.service.spec.ts b/src/cache.service.spec.ts index 2e8ad24..502c904 100644 --- a/src/cache.service.spec.ts +++ b/src/cache.service.spec.ts @@ -511,3 +511,118 @@ describe("should handle flushAll across levels", () => { expect(await cache.get(key, null)).toBe(null); }); }); + +describe("Multi-get, Multi-delete and multi set functionality", () => { + beforeAll(async () => { + redisContainer = await new RedisContainer("redis:7.2").start(); + client = new Redis(redisContainer.getConnectionUrl()); + redisLevel = new RedisCacheLevel(client); + + cacheService = new CacheService({ + levels: [memoryLevel, redisLevel], + }); + }); + + it("should mget and mdel across multiple levels", async () => { + const cache = new CacheService({ levels: [memoryLevel, redisLevel] }); + const bingo1 = faker.number.bigInt(); + const bingo2 = faker.number.bigInt(); + const bingo3 = faker.number.bigInt(); + + await cache.set("bingo", bingo1); + await cache.set("bingo1", bingo2); + await cache.set("bingo2", bingo3); + const multiValues = await cache.mget(["bingo", "bingo1", "bingo2"]); + + // Compare as strings to handle BigInt/Number serialization differences + expect(multiValues.map(String)).toEqual( + [bingo1, bingo2, bingo3].map(String), + ); + // Now delete them + await cache.mdel(["bingo", "bingo1", "bingo2"]); + + expect(await cache.mget(["bingo", "bingo1", "bingo2"])).toEqual([ + null, + null, + null, + ]); + }); + + it("should retrieve values from redis if memory level misses in mget", async () => { + const cache = new CacheService({ levels: [memoryLevel, redisLevel] }); + const cacheKey = faker.string.alpha(10); + const value = faker.string.alpha(10); + + // Directly set the value in the Redis level (lower level) + await redisLevel.set(cacheKey, value, 3600); + + // Ensure memory level does not have the value initially + expect(await memoryLevel.get(cacheKey)).toBeUndefined(); + + // Now attempt to mget the value via the cache service + const retrievedValues = await cache.mget([cacheKey]); + + expect( + retrievedValues[0], + "Retrieved value should match the original value from Redis", + ).toBe(value); + + // Now check if the value has been backfilled to the memory level (higher level) + const memoryValue = await memoryLevel.get(cacheKey); + expect( + memoryValue, + "Memory cache should have been backfilled with the value", + ).toBe(value); + }); + + it("should set multiple values across levels in mset", async () => { + const cache = new CacheService({ levels: [memoryLevel, redisLevel] }); + const keys = [ + faker.string.alpha(10), + faker.string.alpha(10), + faker.string.alpha(10), + ]; + const values = [ + faker.string.alpha(10), + faker.string.alpha(10), + faker.string.alpha(10), + ]; + + await cache.mset(keys, values, 3600); + + const retrievedValues = await cache.mget(keys); + + expect(retrievedValues).toEqual(values); + + await cache.mdel(keys); + + expect(await cache.mget(keys)).toEqual([null, null, null]); + }); +}); + +describe("CacheService should instantiate with default TTL and Lock TTL values", () => { + it("should use provided defaultTTL and defaultLockTTL values", () => { + const customDefaultTTL = 5000; + const customDefaultLockTTL = 100; + + const cache = new CacheService({ + levels: [memoryLevel, redisLevel], + defaultTTL: customDefaultTTL, + defaultLockTTL: customDefaultLockTTL, + }); + + expect(cache.defaultTTL).toBe(customDefaultTTL); + expect(cache.defaultLockTTL).toBe(customDefaultLockTTL); + }); + + it("should fallback to DEFAULT_TTL and DEFAULT_LOCK_TTL if invalid values are provided", () => { + const cache = new CacheService({ + levels: [memoryLevel, redisLevel], + defaultTTL: NaN, + defaultLockTTL: NaN, + }); + + expect(cache.defaultTTL).toBe(3600); // DEFAULT_TTL + expect(cache.defaultLockTTL).toBe(30); // DEFAULT_LOCK_TTL + }); +}); diff --git a/src/cache.service.ts b/src/cache.service.ts index 0d61958..ffc5ff0 100644 --- a/src/cache.service.ts +++ b/src/cache.service.ts @@ -4,6 +4,7 @@ import type { CacheLevel } from "./levels/interfaces/cache-level"; import type { Lockable } from "./levels/interfaces/lockable"; import { backfillLevels, + backfillLevelsWithMultiKeys, backfillVersionedLevels, } from "./utils/backfill.utils"; import { handleGracefully } from "./utils/error.utils"; @@ -22,12 +23,25 @@ export class CacheService { protected versioning: boolean; constructor(options: CacheServiceOptions) { - this.levels = options.levels; - this.defaultTTL = options.defaultTTL ?? DEFAULT_TTL; - this.defaultLockTTL = options.defaultLockTTL ?? DEFAULT_LOCK_TTL; - this.versioning = options.versioning ?? false; + const { defaultTTL, defaultLockTTL, levels, versioning } = options; + + this.levels = levels; + this.defaultTTL = + typeof defaultTTL === "number" && !Number.isNaN(defaultTTL) + ? defaultTTL + : DEFAULT_TTL; + this.defaultLockTTL = + typeof defaultLockTTL === "number" && !Number.isNaN(defaultLockTTL) + ? defaultLockTTL + : DEFAULT_LOCK_TTL; + this.versioning = versioning ?? false; } + /** + * Flush all cache levels + * Note: Do not use in production as it will clear the entire cache and may lead to performance issues. + * @return void + */ async flushAll(): Promise { await Promise.allSettled( this.levels.map((level) => @@ -38,6 +52,62 @@ export class CacheService { ); } + /** + * Loop through cache levels to set the values for the given keys + * @param keys - cache keys + * @param values - values to cache + * @param ttl - time to live in seconds + */ + + async mset( + keys: string[], + values: T[], + ttl = this.defaultTTL, + ): Promise { + await Promise.allSettled( + this.levels.map((level) => + handleGracefully( + () => level.mset(keys, values, ttl), + "Failed to mset keys in cache level", + ), + ), + ); + } + + /** + * Loop through cache levels to get the values for the given keys, + * upon failure backfill missing keys + * @param keys - cache keys + * @returns array of cached values or null if not found + */ + async mget(keys: string[]) { + const results: unknown[] = new Array(keys.length).fill(null); + const missingKeysIndexes: number[] = []; + const failedLevels: CacheLevel[] = []; + + for (const level of this.levels) { + const levelResults = await level.mget(keys); + + for (let i = 0; i < keys.length; i++) { + const value = levelResults[i]; + if (value !== undefined && value !== null) { + results[i] = value; + } else { + failedLevels.push(level); + missingKeysIndexes.push(i); + } + } + } + + await backfillLevelsWithMultiKeys( + failedLevels, + keys.filter((_, index) => missingKeysIndexes.includes(index)), + results.filter((_, index) => missingKeysIndexes.includes(index)), + ); + + return results; + } + /** * Loop through cache levels to get the value for the given key * @param key - cache key @@ -135,6 +205,16 @@ export class CacheService { return newValue as T; } + async mdel(keys: string[]): Promise { + await Promise.allSettled( + this.levels.map((level) => + handleGracefully(async () => { + return await level.mdel(keys); + }, "Failed to mdel keys from cache level"), + ), + ); + } + /** * Set the value for the given key in all cache levels * @param key - cache key diff --git a/src/levels/interfaces/cache-level.ts b/src/levels/interfaces/cache-level.ts index 92b27da..6352c04 100644 --- a/src/levels/interfaces/cache-level.ts +++ b/src/levels/interfaces/cache-level.ts @@ -1,4 +1,13 @@ export interface CacheLevel { + /** + * Store multiple values in the cache. + * @param keys The cache keys. + * @param values The values to cache. + * @param ttl Time to live in seconds. + * @returns The cached values. + */ + mset(keys: string[], values: T[], ttl?: number): Promise; + /** * Retrieve multiple values from the cache. * @param key The cache key. diff --git a/src/levels/memory/memory.level.ts b/src/levels/memory/memory.level.ts index 18d5b81..1d342cc 100644 --- a/src/levels/memory/memory.level.ts +++ b/src/levels/memory/memory.level.ts @@ -52,6 +52,25 @@ export class MemoryCacheLevel triggerMemoryChange(); } + async mset( + keys: string[], + values: T[], + ttl: number = DEFAULT_TTL, + ): Promise { + await Promise.allSettled( + keys.map((key, index) => { + const value = values[index]; + const expiryDate = Date.now() + ttl * 1000; + const storedItem = { value, expiry: expiryDate }; + this.updateStore(key, storedItem); + + return Promise.resolve(); + }), + ); + + return values; + } + /** * Retrieve multiple values from the cache. * @param key The cache key. @@ -63,7 +82,7 @@ export class MemoryCacheLevel const cachedValue = this.store.get(k) as StoredItem | undefined; if (cachedValue === null || cachedValue === undefined) { - results.push(undefined as T); + results.push(cachedValue as T); } else { results.push(cachedValue.value as T); } diff --git a/src/levels/redis/redis.level.ts b/src/levels/redis/redis.level.ts index c366a11..a2da3c2 100644 --- a/src/levels/redis/redis.level.ts +++ b/src/levels/redis/redis.level.ts @@ -32,6 +32,20 @@ export class RedisCacheLevel implements CacheLevel, Lockable { return parseIfJSON(cachedValue); } + async mset(keys: string[], values: T[], ttl = DEFAULT_TTL): Promise { + const pipeline = this.client.pipeline(); + + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + const value = values[i]; + pipeline.set(key, serializeForRedis(value), "EX", ttl); + } + + await pipeline.exec(); + + return values; + } + async mget(keys: string[]): Promise { const results = await this.client.mget(...keys); const finalResults: T[] = []; diff --git a/src/utils/backfill.utils.ts b/src/utils/backfill.utils.ts index e8bc210..dc7751e 100644 --- a/src/utils/backfill.utils.ts +++ b/src/utils/backfill.utils.ts @@ -24,6 +24,26 @@ export async function backfillVersionedLevels( ); } +export async function backfillLevelsWithMultiKeys( + failedLevels: CacheLevel[], + keys: string[], + values: unknown[], + ttl?: number, +): Promise { + if (failedLevels.length === 0) return; + await Promise.allSettled( + failedLevels.map((failedLevel) => { + return handleGracefully( + () => { + return failedLevel.mset(keys, values, ttl); + }, + "Failed to backfill mset, gracefully continuing with next level.", + false, + ); + }), + ); +} + export async function backfillLevels( failedLevels: CacheLevel[], key: string, diff --git a/tests/cache.benchmark.spec.ts b/tests/benchmarks/cache.benchmark.spec.ts similarity index 95% rename from tests/cache.benchmark.spec.ts rename to tests/benchmarks/cache.benchmark.spec.ts index b39451f..bad88f9 100644 --- a/tests/cache.benchmark.spec.ts +++ b/tests/benchmarks/cache.benchmark.spec.ts @@ -4,14 +4,14 @@ import { } from "@testcontainers/redis"; import { Redis } from "ioredis"; import { afterAll, beforeAll, describe, it } from "vitest"; -import { CacheService } from "../src/cache.service"; +import { CacheService } from "../../src/cache.service"; import { MemoryCacheLevel, RedisCacheLevel, -} from "../src/levels"; -import { FirstExpiringMemoryPolicy } from "../src/policies/first-expiring-memory.policy"; -import { MemoryPercentageLimitStrategy } from "../src/strategies/memory-percentage-limit.strategy"; -import type { StoredHeapItem } from "../src/levels/memory/memory.level"; +} from "../../src/levels"; +import { FirstExpiringMemoryPolicy } from "../../src/policies/first-expiring-memory.policy"; +import { MemoryPercentageLimitStrategy } from "../../src/strategies/memory-percentage-limit.strategy"; +import type { StoredHeapItem } from "../../src/levels/memory/memory.level"; import { type BenchmarkResult, calculateLatencyStats, @@ -22,7 +22,7 @@ import { instrumentRedisCache, populateCache, runBenchmark, -} from "./utilities/benchmark.utilities"; +} from "../utilities/benchmark.utilities"; import { printBenchmarkHeader, printBenchmarkResults, @@ -30,7 +30,7 @@ import { printMemoryEfficiency, printPerformanceComparison, printWritePerformanceComparison, -} from "./utilities/benchmark-output.utilities"; +} from "../utilities/benchmark-output.utilities"; const TOTAL_CALLS = 10000; diff --git a/vitest.config.ts b/vitest.config.ts index 2ac153d..5238aea 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -8,7 +8,9 @@ export default defineConfig({ "**/interfaces/**", // Exclude all files in any interfaces directory "**/*.d.ts", // Exclude all type definition files, "dist/**", + "tests/**", ], }, + exclude: ["**/benchmarks/**", "node_modules/**"], }, });