Skip to content
Merged
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
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
115 changes: 115 additions & 0 deletions src/cache.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
});
});
88 changes: 84 additions & 4 deletions src/cache.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<void> {
await Promise.allSettled(
this.levels.map((level) =>
Expand All @@ -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<T>(
keys: string[],
values: T[],
ttl = this.defaultTTL,
): Promise<void> {
await Promise.allSettled(
this.levels.map((level) =>
handleGracefully(
() => level.mset<T>(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<unknown>(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
Expand Down Expand Up @@ -135,6 +205,16 @@ export class CacheService {
return newValue as T;
}

async mdel(keys: string[]): Promise<void> {
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
Expand Down
9 changes: 9 additions & 0 deletions src/levels/interfaces/cache-level.ts
Original file line number Diff line number Diff line change
@@ -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<T>(keys: string[], values: T[], ttl?: number): Promise<T[]>;

/**
* Retrieve multiple values from the cache.
* @param key The cache key.
Expand Down
21 changes: 20 additions & 1 deletion src/levels/memory/memory.level.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,25 @@ export class MemoryCacheLevel
triggerMemoryChange();
}

async mset<T>(
keys: string[],
values: T[],
ttl: number = DEFAULT_TTL,
): Promise<T[]> {
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.
Expand All @@ -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);
}
Expand Down
14 changes: 14 additions & 0 deletions src/levels/redis/redis.level.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,20 @@ export class RedisCacheLevel implements CacheLevel, Lockable {
return parseIfJSON<T>(cachedValue);
}

async mset<T>(keys: string[], values: T[], ttl = DEFAULT_TTL): Promise<T[]> {
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<T>(keys: string[]): Promise<T[]> {
const results = await this.client.mget(...keys);
const finalResults: T[] = [];
Expand Down
20 changes: 20 additions & 0 deletions src/utils/backfill.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,26 @@ export async function backfillVersionedLevels(
);
}

export async function backfillLevelsWithMultiKeys(
failedLevels: CacheLevel[],
keys: string[],
values: unknown[],
ttl?: number,
): Promise<void> {
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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -22,15 +22,15 @@ import {
instrumentRedisCache,
populateCache,
runBenchmark,
} from "./utilities/benchmark.utilities";
} from "../utilities/benchmark.utilities";
import {
printBenchmarkHeader,
printBenchmarkResults,
printCacheHitRateResults,
printMemoryEfficiency,
printPerformanceComparison,
printWritePerformanceComparison,
} from "./utilities/benchmark-output.utilities";
} from "../utilities/benchmark-output.utilities";

const TOTAL_CALLS = 10000;

Expand Down
Loading