refactor progress
This commit is contained in:
278
packages/cache/src/providers/InMemoryProvider.tsx
vendored
Normal file
278
packages/cache/src/providers/InMemoryProvider.tsx
vendored
Normal file
@@ -0,0 +1,278 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {
|
||||
formatLockKey,
|
||||
generateLockToken,
|
||||
validateLockKey,
|
||||
validateLockToken,
|
||||
} from '@fluxer/cache/src/CacheLockValidation';
|
||||
import {ICacheService} from '@fluxer/cache/src/ICacheService';
|
||||
|
||||
interface CacheEntry<T> {
|
||||
value: T;
|
||||
expiresAt?: number;
|
||||
}
|
||||
|
||||
export interface InMemoryProviderConfig {
|
||||
maxSize?: number;
|
||||
cleanupIntervalMs?: number;
|
||||
}
|
||||
|
||||
export class InMemoryProvider extends ICacheService {
|
||||
private cache = new Map<string, CacheEntry<unknown>>();
|
||||
private sets = new Map<string, Set<string>>();
|
||||
private locks = new Map<string, {token: string; expiresAt: number}>();
|
||||
private 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 (entry.expiresAt && entry.expiresAt <= now) {
|
||||
this.cache.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, lock] of this.locks.entries()) {
|
||||
if (lock.expiresAt <= now) {
|
||||
this.locks.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private isExpired(entry: CacheEntry<unknown>): boolean {
|
||||
if (!entry.expiresAt) return false;
|
||||
return Date.now() >= entry.expiresAt;
|
||||
}
|
||||
|
||||
private evictIfNeeded(): void {
|
||||
if (this.cache.size >= this.maxSize) {
|
||||
const firstKey = this.cache.keys().next().value;
|
||||
if (firstKey !== undefined) {
|
||||
this.cache.delete(firstKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
const entry = this.cache.get(key) as CacheEntry<T> | undefined;
|
||||
if (!entry) return null;
|
||||
|
||||
if (this.isExpired(entry)) {
|
||||
this.cache.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry.value;
|
||||
}
|
||||
|
||||
async set<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
|
||||
this.evictIfNeeded();
|
||||
|
||||
const entry: CacheEntry<T> = {
|
||||
value,
|
||||
expiresAt: ttlSeconds ? Date.now() + ttlSeconds * 1000 : undefined,
|
||||
};
|
||||
|
||||
this.cache.set(key, entry);
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<void> {
|
||||
this.cache.delete(key);
|
||||
}
|
||||
|
||||
async getAndDelete<T>(key: string): Promise<T | null> {
|
||||
const value = await this.get<T>(key);
|
||||
if (value !== null) {
|
||||
this.cache.delete(key);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async exists(key: string): Promise<boolean> {
|
||||
const entry = this.cache.get(key);
|
||||
if (!entry) return false;
|
||||
if (this.isExpired(entry)) {
|
||||
this.cache.delete(key);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async expire(key: string, ttlSeconds: number): Promise<void> {
|
||||
const entry = this.cache.get(key);
|
||||
if (entry && !this.isExpired(entry)) {
|
||||
entry.expiresAt = Date.now() + ttlSeconds * 1000;
|
||||
}
|
||||
}
|
||||
|
||||
async ttl(key: string): Promise<number> {
|
||||
const entry = this.cache.get(key);
|
||||
if (!entry || this.isExpired(entry)) {
|
||||
return -2;
|
||||
}
|
||||
|
||||
if (!entry.expiresAt) {
|
||||
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>> {
|
||||
const results: Array<T | null> = [];
|
||||
for (const key of keys) {
|
||||
results.push(await this.get<T>(key));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
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);
|
||||
const existingLock = this.locks.get(lockKey);
|
||||
|
||||
if (existingLock && existingLock.expiresAt > Date.now()) {
|
||||
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.locks.get(lockKey);
|
||||
|
||||
if (!lock || lock.token !== token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.locks.delete(lockKey);
|
||||
return true;
|
||||
}
|
||||
|
||||
async getAndRenewTtl<T>(key: string, newTtlSeconds: number): Promise<T | null> {
|
||||
const value = await this.get<T>(key);
|
||||
if (value !== null) {
|
||||
await this.expire(key, newTtlSeconds);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async publish(_channel: string, _message: string): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
async sadd(key: string, member: string, ttlSeconds?: number): Promise<void> {
|
||||
let set = this.sets.get(key);
|
||||
if (!set) {
|
||||
set = new Set<string>();
|
||||
this.sets.set(key, set);
|
||||
}
|
||||
|
||||
set.add(member);
|
||||
|
||||
if (ttlSeconds) {
|
||||
await this.set(`${key}:expiry`, {}, ttlSeconds);
|
||||
}
|
||||
}
|
||||
|
||||
async srem(key: string, member: string): Promise<void> {
|
||||
const set = this.sets.get(key);
|
||||
if (set) {
|
||||
set.delete(member);
|
||||
if (set.size === 0) {
|
||||
this.sets.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async smembers(key: string): Promise<Set<string>> {
|
||||
const expiryExists = await this.exists(`${key}:expiry`);
|
||||
if (!expiryExists && this.sets.has(key)) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
return this.sets.get(key) ?? new Set<string>();
|
||||
}
|
||||
|
||||
async sismember(key: string, member: string): Promise<boolean> {
|
||||
const set = this.sets.get(key);
|
||||
if (!set) return false;
|
||||
|
||||
const expiryExists = await this.exists(`${key}:expiry`);
|
||||
if (!expiryExists) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return set.has(member);
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval);
|
||||
this.cleanupInterval = undefined;
|
||||
}
|
||||
this.cache.clear();
|
||||
this.sets.clear();
|
||||
this.locks.clear();
|
||||
}
|
||||
}
|
||||
244
packages/cache/src/providers/KVCacheProvider.tsx
vendored
Normal file
244
packages/cache/src/providers/KVCacheProvider.tsx
vendored
Normal file
@@ -0,0 +1,244 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {classifyKeyType} from '@fluxer/cache/src/CacheKeyClassification';
|
||||
import {
|
||||
formatLockKey,
|
||||
generateLockToken,
|
||||
validateLockKey,
|
||||
validateLockToken,
|
||||
} from '@fluxer/cache/src/CacheLockValidation';
|
||||
import type {CacheLogger, CacheTelemetry} from '@fluxer/cache/src/CacheProviderTypes';
|
||||
import {safeJsonParse, serializeValue} from '@fluxer/cache/src/CacheSerialization';
|
||||
import {ICacheService} from '@fluxer/cache/src/ICacheService';
|
||||
import type {IKVProvider} from '@fluxer/kv_client/src/IKVProvider';
|
||||
|
||||
export interface KVCacheProviderConfig {
|
||||
client: IKVProvider;
|
||||
cacheName?: string;
|
||||
logger?: CacheLogger;
|
||||
telemetry?: CacheTelemetry;
|
||||
}
|
||||
|
||||
export class KVCacheProvider extends ICacheService {
|
||||
private client: IKVProvider;
|
||||
private cacheName: string;
|
||||
private logger?: CacheLogger;
|
||||
private telemetry?: CacheTelemetry;
|
||||
|
||||
constructor(config: KVCacheProviderConfig) {
|
||||
super();
|
||||
this.client = config.client;
|
||||
this.cacheName = config.cacheName ?? 'kv';
|
||||
this.logger = config.logger;
|
||||
this.telemetry = config.telemetry;
|
||||
}
|
||||
|
||||
private async instrumented<T>(
|
||||
operation: string,
|
||||
key: string,
|
||||
fn: () => Promise<T>,
|
||||
statusResolver?: (result: T) => string,
|
||||
): Promise<T> {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const result = await fn();
|
||||
const duration = Date.now() - start;
|
||||
const status = statusResolver ? statusResolver(result) : 'success';
|
||||
|
||||
this.telemetry?.recordCounter({
|
||||
name: 'cache.operation',
|
||||
dimensions: {operation, cache_name: this.cacheName, status, key_type: classifyKeyType(key)},
|
||||
});
|
||||
this.telemetry?.recordHistogram({
|
||||
name: 'cache.operation_latency',
|
||||
valueMs: duration,
|
||||
dimensions: {operation, cache_name: this.cacheName},
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
const duration = Date.now() - start;
|
||||
this.telemetry?.recordCounter({
|
||||
name: 'cache.operation',
|
||||
dimensions: {operation, cache_name: this.cacheName, status: 'error', key_type: classifyKeyType(key)},
|
||||
});
|
||||
this.telemetry?.recordHistogram({
|
||||
name: 'cache.operation_latency',
|
||||
valueMs: duration,
|
||||
dimensions: {operation, cache_name: this.cacheName},
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
return this.instrumented(
|
||||
'get',
|
||||
key,
|
||||
async () => {
|
||||
const value = await this.client.get(key);
|
||||
if (value == null) return null;
|
||||
return safeJsonParse<T>(value, this.logger);
|
||||
},
|
||||
(result) => (result == null ? 'miss' : 'hit'),
|
||||
);
|
||||
}
|
||||
|
||||
async set<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
|
||||
return this.instrumented('set', key, async () => {
|
||||
const serialized = serializeValue(value);
|
||||
if (ttlSeconds) {
|
||||
await this.client.setex(key, ttlSeconds, serialized);
|
||||
} else {
|
||||
await this.client.set(key, serialized);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<void> {
|
||||
return this.instrumented('delete', key, async () => {
|
||||
await this.client.del(key);
|
||||
});
|
||||
}
|
||||
|
||||
async getAndDelete<T>(key: string): Promise<T | null> {
|
||||
const value = await this.client.getdel(key);
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
return safeJsonParse<T>(value, this.logger);
|
||||
}
|
||||
|
||||
async exists(key: string): Promise<boolean> {
|
||||
const result = await this.client.exists(key);
|
||||
return result === 1;
|
||||
}
|
||||
|
||||
async expire(key: string, ttlSeconds: number): Promise<void> {
|
||||
await this.client.expire(key, ttlSeconds);
|
||||
}
|
||||
|
||||
async ttl(key: string): Promise<number> {
|
||||
return await this.client.ttl(key);
|
||||
}
|
||||
|
||||
async mget<T>(keys: Array<string>): Promise<Array<T | null>> {
|
||||
if (keys.length === 0) return [];
|
||||
|
||||
const values = await this.client.mget(...keys);
|
||||
return values.map((value: string | null) => {
|
||||
if (value == null) return null;
|
||||
return safeJsonParse<T>(value, this.logger);
|
||||
});
|
||||
}
|
||||
|
||||
async mset<T>(entries: Array<{key: string; value: T; ttlSeconds?: number}>): Promise<void> {
|
||||
if (entries.length === 0) return;
|
||||
|
||||
const withoutTtl: Array<{key: string; value: T}> = [];
|
||||
const withTtl: Array<{key: string; value: T; ttlSeconds: number}> = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.ttlSeconds) {
|
||||
withTtl.push({
|
||||
key: entry.key,
|
||||
value: entry.value,
|
||||
ttlSeconds: entry.ttlSeconds,
|
||||
});
|
||||
} else {
|
||||
withoutTtl.push({
|
||||
key: entry.key,
|
||||
value: entry.value,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const pipeline = this.client.pipeline();
|
||||
|
||||
if (withoutTtl.length > 0) {
|
||||
const flatArgs: Array<string> = [];
|
||||
for (const entry of withoutTtl) {
|
||||
flatArgs.push(entry.key, serializeValue(entry.value));
|
||||
}
|
||||
pipeline.mset(...flatArgs);
|
||||
}
|
||||
|
||||
for (const entry of withTtl) {
|
||||
pipeline.setex(entry.key, entry.ttlSeconds, serializeValue(entry.value));
|
||||
}
|
||||
|
||||
await pipeline.exec();
|
||||
}
|
||||
|
||||
async deletePattern(pattern: string): Promise<number> {
|
||||
const keys = await this.client.scan(pattern, 1000);
|
||||
if (keys.length === 0) return 0;
|
||||
return await this.client.del(...keys);
|
||||
}
|
||||
|
||||
async acquireLock(key: string, ttlSeconds: number): Promise<string | null> {
|
||||
validateLockKey(key);
|
||||
const token = generateLockToken();
|
||||
const lockKey = formatLockKey(key);
|
||||
|
||||
const result = await this.client.set(lockKey, token, 'EX', ttlSeconds, 'NX');
|
||||
return result === 'OK' ? token : null;
|
||||
}
|
||||
|
||||
async releaseLock(key: string, token: string): Promise<boolean> {
|
||||
validateLockKey(key);
|
||||
validateLockToken(token);
|
||||
const lockKey = formatLockKey(key);
|
||||
return await this.client.releaseLock(lockKey, token);
|
||||
}
|
||||
|
||||
async getAndRenewTtl<T>(key: string, newTtlSeconds: number): Promise<T | null> {
|
||||
const value = await this.client.getex(key, newTtlSeconds);
|
||||
if (value == null) return null;
|
||||
return safeJsonParse<T>(value, this.logger);
|
||||
}
|
||||
|
||||
async publish(channel: string, message: string): Promise<void> {
|
||||
await this.client.publish(channel, message);
|
||||
}
|
||||
|
||||
async sadd(key: string, member: string, ttlSeconds?: number): Promise<void> {
|
||||
const pipeline = this.client.pipeline();
|
||||
pipeline.sadd(key, member);
|
||||
if (ttlSeconds) {
|
||||
pipeline.expire(key, ttlSeconds);
|
||||
}
|
||||
await pipeline.exec();
|
||||
}
|
||||
|
||||
async srem(key: string, member: string): Promise<void> {
|
||||
await this.client.srem(key, member);
|
||||
}
|
||||
|
||||
async smembers(key: string): Promise<Set<string>> {
|
||||
const members = await this.client.smembers(key);
|
||||
return new Set(members);
|
||||
}
|
||||
|
||||
async sismember(key: string, member: string): Promise<boolean> {
|
||||
const result = await this.client.sismember(key, member);
|
||||
return result === 1;
|
||||
}
|
||||
}
|
||||
178
packages/cache/src/providers/RedisCacheProvider.tsx
vendored
Normal file
178
packages/cache/src/providers/RedisCacheProvider.tsx
vendored
Normal file
@@ -0,0 +1,178 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {formatLockKey, generateLockToken, validateLockKey} from '@fluxer/cache/src/CacheLockValidation';
|
||||
import {safeJsonParse, serializeValue} from '@fluxer/cache/src/CacheSerialization';
|
||||
import {ICacheService} from '@fluxer/cache/src/ICacheService';
|
||||
import type {RedisClient} from '@fluxer/cache/src/RedisClientTypes';
|
||||
|
||||
export interface RedisCacheProviderConfig {
|
||||
client: RedisClient;
|
||||
cacheName?: string;
|
||||
}
|
||||
|
||||
export class RedisCacheProvider extends ICacheService {
|
||||
private client: RedisClient;
|
||||
|
||||
constructor(config: RedisCacheProviderConfig) {
|
||||
super();
|
||||
this.client = config.client;
|
||||
}
|
||||
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
const value = await this.client.get(key);
|
||||
if (value == null) return null;
|
||||
return safeJsonParse<T>(value);
|
||||
}
|
||||
|
||||
async set<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
|
||||
const serialized = serializeValue(value);
|
||||
if (ttlSeconds) {
|
||||
await this.client.setex(key, ttlSeconds, serialized);
|
||||
} else {
|
||||
await this.client.set(key, serialized);
|
||||
}
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<void> {
|
||||
await this.client.del(key);
|
||||
}
|
||||
|
||||
async getAndDelete<T>(key: string): Promise<T | null> {
|
||||
const value = await this.client.getdel(key);
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
return safeJsonParse<T>(value);
|
||||
}
|
||||
|
||||
async exists(key: string): Promise<boolean> {
|
||||
const result = await this.client.exists(key);
|
||||
return result === 1;
|
||||
}
|
||||
|
||||
async expire(key: string, ttlSeconds: number): Promise<void> {
|
||||
await this.client.expire(key, ttlSeconds);
|
||||
}
|
||||
|
||||
async ttl(key: string): Promise<number> {
|
||||
return await this.client.ttl(key);
|
||||
}
|
||||
|
||||
async mget<T>(keys: Array<string>): Promise<Array<T | null>> {
|
||||
if (keys.length === 0) return [];
|
||||
|
||||
const values = await this.client.mget(...keys);
|
||||
return values.map((value) => {
|
||||
if (value == null) return null;
|
||||
return safeJsonParse<T>(value);
|
||||
});
|
||||
}
|
||||
|
||||
async mset<T>(entries: Array<{key: string; value: T; ttlSeconds?: number}>): Promise<void> {
|
||||
if (entries.length === 0) return;
|
||||
|
||||
const withoutTtl: Array<{key: string; value: T}> = [];
|
||||
const withTtl: Array<{key: string; value: T; ttlSeconds: number}> = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.ttlSeconds) {
|
||||
withTtl.push({
|
||||
key: entry.key,
|
||||
value: entry.value,
|
||||
ttlSeconds: entry.ttlSeconds,
|
||||
});
|
||||
} else {
|
||||
withoutTtl.push({
|
||||
key: entry.key,
|
||||
value: entry.value,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const pipeline = this.client.pipeline();
|
||||
|
||||
if (withoutTtl.length > 0) {
|
||||
const flatArgs: Array<string> = [];
|
||||
for (const entry of withoutTtl) {
|
||||
flatArgs.push(entry.key, serializeValue(entry.value));
|
||||
}
|
||||
pipeline.mset(...flatArgs);
|
||||
}
|
||||
|
||||
for (const entry of withTtl) {
|
||||
pipeline.setex(entry.key, entry.ttlSeconds, serializeValue(entry.value));
|
||||
}
|
||||
|
||||
await pipeline.exec();
|
||||
}
|
||||
|
||||
async deletePattern(pattern: string): Promise<number> {
|
||||
const keys = await this.client.scan(pattern, 1000);
|
||||
if (keys.length === 0) return 0;
|
||||
return await this.client.del(...keys);
|
||||
}
|
||||
|
||||
async acquireLock(key: string, ttlSeconds: number): Promise<string | null> {
|
||||
validateLockKey(key);
|
||||
const token = generateLockToken();
|
||||
const lockKey = formatLockKey(key);
|
||||
|
||||
await this.client.set(lockKey, token);
|
||||
await this.client.expire(lockKey, ttlSeconds);
|
||||
return token;
|
||||
}
|
||||
|
||||
async releaseLock(_key: string, _token: string): Promise<boolean> {
|
||||
throw new Error('releaseLock not implemented for RedisCacheProvider');
|
||||
}
|
||||
|
||||
async getAndRenewTtl<T>(key: string, newTtlSeconds: number): Promise<T | null> {
|
||||
const value = await this.client.getex(key, newTtlSeconds);
|
||||
if (value == null) return null;
|
||||
return safeJsonParse<T>(value);
|
||||
}
|
||||
|
||||
async publish(channel: string, message: string): Promise<void> {
|
||||
await this.client.publish(channel, message);
|
||||
}
|
||||
|
||||
async sadd(key: string, member: string, ttlSeconds?: number): Promise<void> {
|
||||
const pipeline = this.client.pipeline();
|
||||
pipeline.sadd(key, member);
|
||||
if (ttlSeconds) {
|
||||
pipeline.expire(key, ttlSeconds);
|
||||
}
|
||||
await pipeline.exec();
|
||||
}
|
||||
|
||||
async srem(key: string, member: string): Promise<void> {
|
||||
await this.client.srem(key, member);
|
||||
}
|
||||
|
||||
async smembers(key: string): Promise<Set<string>> {
|
||||
const members = await this.client.smembers(key);
|
||||
return new Set(members);
|
||||
}
|
||||
|
||||
async sismember(key: string, member: string): Promise<boolean> {
|
||||
const result = await this.client.sismember(key, member);
|
||||
return result === 1;
|
||||
}
|
||||
}
|
||||
748
packages/cache/src/providers/tests/InMemoryProvider.test.tsx
vendored
Normal file
748
packages/cache/src/providers/tests/InMemoryProvider.test.tsx
vendored
Normal file
@@ -0,0 +1,748 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {InMemoryProvider} from '@fluxer/cache/src/providers/InMemoryProvider';
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
describe('InMemoryProvider', () => {
|
||||
let cache: InMemoryProvider;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
cache = new InMemoryProvider();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cache.destroy();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('get and set', () => {
|
||||
it('returns null for non-existent key', async () => {
|
||||
const result = await cache.get('nonexistent');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('stores and retrieves a string value', async () => {
|
||||
await cache.set('key', 'value');
|
||||
const result = await cache.get<string>('key');
|
||||
expect(result).toBe('value');
|
||||
});
|
||||
|
||||
it('stores and retrieves an object value', async () => {
|
||||
const obj = {name: 'test', count: 42, nested: {foo: 'bar'}};
|
||||
await cache.set('obj', obj);
|
||||
const result = await cache.get<typeof obj>('obj');
|
||||
expect(result).toEqual(obj);
|
||||
});
|
||||
|
||||
it('stores and retrieves an array value', async () => {
|
||||
const arr = [1, 2, 3, 'four', {five: 5}];
|
||||
await cache.set('arr', arr);
|
||||
const result = await cache.get<typeof arr>('arr');
|
||||
expect(result).toEqual(arr);
|
||||
});
|
||||
|
||||
it('stores and retrieves null value', async () => {
|
||||
await cache.set('nullKey', null);
|
||||
const result = await cache.get('nullKey');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('stores and retrieves zero value', async () => {
|
||||
await cache.set('zero', 0);
|
||||
const result = await cache.get<number>('zero');
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
|
||||
it('stores and retrieves empty string', async () => {
|
||||
await cache.set('empty', '');
|
||||
const result = await cache.get<string>('empty');
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('stores and retrieves boolean values', async () => {
|
||||
await cache.set('true', true);
|
||||
await cache.set('false', false);
|
||||
expect(await cache.get<boolean>('true')).toBe(true);
|
||||
expect(await cache.get<boolean>('false')).toBe(false);
|
||||
});
|
||||
|
||||
it('overwrites existing value', async () => {
|
||||
await cache.set('key', 'first');
|
||||
await cache.set('key', 'second');
|
||||
const result = await cache.get<string>('key');
|
||||
expect(result).toBe('second');
|
||||
});
|
||||
});
|
||||
|
||||
describe('TTL and expiration', () => {
|
||||
it('returns value before TTL expires', async () => {
|
||||
await cache.set('key', 'value', 60);
|
||||
vi.advanceTimersByTime(30000);
|
||||
const result = await cache.get<string>('key');
|
||||
expect(result).toBe('value');
|
||||
});
|
||||
|
||||
it('returns null after TTL expires', async () => {
|
||||
await cache.set('key', 'value', 1);
|
||||
vi.advanceTimersByTime(1001);
|
||||
const result = await cache.get<string>('key');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null exactly at TTL boundary', async () => {
|
||||
await cache.set('key', 'value', 5);
|
||||
vi.advanceTimersByTime(5000);
|
||||
const result = await cache.get<string>('key');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('stores value without TTL indefinitely', async () => {
|
||||
await cache.set('key', 'value');
|
||||
vi.advanceTimersByTime(999999999);
|
||||
const result = await cache.get<string>('key');
|
||||
expect(result).toBe('value');
|
||||
});
|
||||
|
||||
it('handles very short TTL', async () => {
|
||||
await cache.set('key', 'value', 1);
|
||||
const beforeExpiry = await cache.get<string>('key');
|
||||
expect(beforeExpiry).toBe('value');
|
||||
|
||||
vi.advanceTimersByTime(1000);
|
||||
const afterExpiry = await cache.get<string>('key');
|
||||
expect(afterExpiry).toBeNull();
|
||||
});
|
||||
|
||||
it('handles long TTL values', async () => {
|
||||
const oneYear = 365 * 24 * 60 * 60;
|
||||
await cache.set('key', 'value', oneYear);
|
||||
vi.advanceTimersByTime(oneYear * 1000 - 1);
|
||||
expect(await cache.get<string>('key')).toBe('value');
|
||||
|
||||
vi.advanceTimersByTime(2);
|
||||
expect(await cache.get<string>('key')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('deletes existing key', async () => {
|
||||
await cache.set('key', 'value');
|
||||
await cache.delete('key');
|
||||
const result = await cache.get('key');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('does not throw when deleting non-existent key', async () => {
|
||||
await expect(cache.delete('nonexistent')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('only deletes specified key', async () => {
|
||||
await cache.set('key1', 'value1');
|
||||
await cache.set('key2', 'value2');
|
||||
await cache.delete('key1');
|
||||
expect(await cache.get('key1')).toBeNull();
|
||||
expect(await cache.get<string>('key2')).toBe('value2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAndDelete', () => {
|
||||
it('returns value and deletes key', async () => {
|
||||
await cache.set('key', 'value');
|
||||
const result = await cache.getAndDelete<string>('key');
|
||||
expect(result).toBe('value');
|
||||
expect(await cache.get('key')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for non-existent key', async () => {
|
||||
const result = await cache.getAndDelete('nonexistent');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for expired key', async () => {
|
||||
await cache.set('key', 'value', 1);
|
||||
vi.advanceTimersByTime(1001);
|
||||
const result = await cache.getAndDelete('key');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('exists', () => {
|
||||
it('returns true for existing key', async () => {
|
||||
await cache.set('key', 'value');
|
||||
const result = await cache.exists('key');
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for non-existent key', async () => {
|
||||
const result = await cache.exists('nonexistent');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for expired key', async () => {
|
||||
await cache.set('key', 'value', 1);
|
||||
vi.advanceTimersByTime(1001);
|
||||
const result = await cache.exists('key');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('cleans up expired key on exists check', async () => {
|
||||
await cache.set('key', 'value', 1);
|
||||
vi.advanceTimersByTime(1001);
|
||||
await cache.exists('key');
|
||||
await cache.set('key', 'newValue');
|
||||
expect(await cache.get<string>('key')).toBe('newValue');
|
||||
});
|
||||
});
|
||||
|
||||
describe('expire', () => {
|
||||
it('sets TTL on existing key', async () => {
|
||||
await cache.set('key', 'value');
|
||||
await cache.expire('key', 5);
|
||||
vi.advanceTimersByTime(4999);
|
||||
expect(await cache.get<string>('key')).toBe('value');
|
||||
vi.advanceTimersByTime(2);
|
||||
expect(await cache.get('key')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not set TTL on non-existent key', async () => {
|
||||
await cache.expire('nonexistent', 5);
|
||||
expect(await cache.exists('nonexistent')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not set TTL on expired key', async () => {
|
||||
await cache.set('key', 'value', 1);
|
||||
vi.advanceTimersByTime(1001);
|
||||
await cache.expire('key', 5);
|
||||
expect(await cache.exists('key')).toBe(false);
|
||||
});
|
||||
|
||||
it('overwrites existing TTL', async () => {
|
||||
await cache.set('key', 'value', 10);
|
||||
await cache.expire('key', 2);
|
||||
vi.advanceTimersByTime(2001);
|
||||
expect(await cache.get('key')).toBeNull();
|
||||
});
|
||||
|
||||
it('extends TTL on key with existing TTL', async () => {
|
||||
await cache.set('key', 'value', 5);
|
||||
vi.advanceTimersByTime(3000);
|
||||
await cache.expire('key', 10);
|
||||
vi.advanceTimersByTime(8000);
|
||||
expect(await cache.get<string>('key')).toBe('value');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ttl', () => {
|
||||
it('returns -2 for non-existent key', async () => {
|
||||
const result = await cache.ttl('nonexistent');
|
||||
expect(result).toBe(-2);
|
||||
});
|
||||
|
||||
it('returns -1 for key without TTL', async () => {
|
||||
await cache.set('key', 'value');
|
||||
const result = await cache.ttl('key');
|
||||
expect(result).toBe(-1);
|
||||
});
|
||||
|
||||
it('returns remaining TTL in seconds', async () => {
|
||||
await cache.set('key', 'value', 60);
|
||||
vi.advanceTimersByTime(30000);
|
||||
const result = await cache.ttl('key');
|
||||
expect(result).toBe(30);
|
||||
});
|
||||
|
||||
it('returns 0 when TTL is almost expired', async () => {
|
||||
await cache.set('key', 'value', 1);
|
||||
vi.advanceTimersByTime(999);
|
||||
const result = await cache.ttl('key');
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
|
||||
it('returns -2 for expired key', async () => {
|
||||
await cache.set('key', 'value', 1);
|
||||
vi.advanceTimersByTime(1001);
|
||||
const result = await cache.ttl('key');
|
||||
expect(result).toBe(-2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mget', () => {
|
||||
it('returns values for multiple keys', async () => {
|
||||
await cache.set('key1', 'value1');
|
||||
await cache.set('key2', 'value2');
|
||||
await cache.set('key3', 'value3');
|
||||
const results = await cache.mget<string>(['key1', 'key2', 'key3']);
|
||||
expect(results).toEqual(['value1', 'value2', 'value3']);
|
||||
});
|
||||
|
||||
it('returns null for missing keys', async () => {
|
||||
await cache.set('key1', 'value1');
|
||||
const results = await cache.mget<string>(['key1', 'missing', 'key1']);
|
||||
expect(results).toEqual(['value1', null, 'value1']);
|
||||
});
|
||||
|
||||
it('returns empty array for empty input', async () => {
|
||||
const results = await cache.mget([]);
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles expired keys correctly', async () => {
|
||||
await cache.set('key1', 'value1', 1);
|
||||
await cache.set('key2', 'value2', 10);
|
||||
vi.advanceTimersByTime(2000);
|
||||
const results = await cache.mget<string>(['key1', 'key2']);
|
||||
expect(results).toEqual([null, 'value2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mset', () => {
|
||||
it('sets multiple keys at once', async () => {
|
||||
await cache.mset([
|
||||
{key: 'key1', value: 'value1'},
|
||||
{key: 'key2', value: 'value2'},
|
||||
{key: 'key3', value: 'value3'},
|
||||
]);
|
||||
expect(await cache.get<string>('key1')).toBe('value1');
|
||||
expect(await cache.get<string>('key2')).toBe('value2');
|
||||
expect(await cache.get<string>('key3')).toBe('value3');
|
||||
});
|
||||
|
||||
it('sets TTL for individual keys', async () => {
|
||||
await cache.mset([
|
||||
{key: 'short', value: 'shortVal', ttlSeconds: 1},
|
||||
{key: 'long', value: 'longVal', ttlSeconds: 60},
|
||||
]);
|
||||
vi.advanceTimersByTime(2000);
|
||||
expect(await cache.get('short')).toBeNull();
|
||||
expect(await cache.get<string>('long')).toBe('longVal');
|
||||
});
|
||||
|
||||
it('handles empty array', async () => {
|
||||
await expect(cache.mset([])).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('overwrites existing keys', async () => {
|
||||
await cache.set('key1', 'original');
|
||||
await cache.mset([{key: 'key1', value: 'updated'}]);
|
||||
expect(await cache.get<string>('key1')).toBe('updated');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deletePattern', () => {
|
||||
it('deletes keys matching pattern', async () => {
|
||||
await cache.set('user:1', 'user1');
|
||||
await cache.set('user:2', 'user2');
|
||||
await cache.set('session:1', 'session1');
|
||||
|
||||
const count = await cache.deletePattern('user:*');
|
||||
expect(count).toBe(2);
|
||||
expect(await cache.get('user:1')).toBeNull();
|
||||
expect(await cache.get('user:2')).toBeNull();
|
||||
expect(await cache.get<string>('session:1')).toBe('session1');
|
||||
});
|
||||
|
||||
it('returns 0 for no matches', async () => {
|
||||
await cache.set('key1', 'value1');
|
||||
const count = await cache.deletePattern('nonexistent:*');
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
|
||||
it('handles complex patterns', async () => {
|
||||
await cache.set('prefix:middle:suffix', 'value1');
|
||||
await cache.set('prefix:other:suffix', 'value2');
|
||||
await cache.set('other:middle:suffix', 'value3');
|
||||
|
||||
const count = await cache.deletePattern('prefix:*:suffix');
|
||||
expect(count).toBe(2);
|
||||
expect(await cache.get('prefix:middle:suffix')).toBeNull();
|
||||
expect(await cache.get('prefix:other:suffix')).toBeNull();
|
||||
expect(await cache.get<string>('other:middle:suffix')).toBe('value3');
|
||||
});
|
||||
|
||||
it('handles wildcard at start', async () => {
|
||||
await cache.set('test:suffix', 'value1');
|
||||
await cache.set('other:suffix', 'value2');
|
||||
await cache.set('test:other', 'value3');
|
||||
|
||||
const count = await cache.deletePattern('*:suffix');
|
||||
expect(count).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('acquireLock', () => {
|
||||
it('acquires lock successfully', async () => {
|
||||
const token = await cache.acquireLock('resource', 60);
|
||||
expect(token).not.toBeNull();
|
||||
expect(token).toMatch(/^[a-f0-9]{32}$/);
|
||||
});
|
||||
|
||||
it('fails to acquire lock when already held', async () => {
|
||||
await cache.acquireLock('resource', 60);
|
||||
const secondToken = await cache.acquireLock('resource', 60);
|
||||
expect(secondToken).toBeNull();
|
||||
});
|
||||
|
||||
it('allows reacquiring lock after expiry', async () => {
|
||||
await cache.acquireLock('resource', 1);
|
||||
vi.advanceTimersByTime(1001);
|
||||
const newToken = await cache.acquireLock('resource', 60);
|
||||
expect(newToken).not.toBeNull();
|
||||
});
|
||||
|
||||
it('throws on invalid key format', async () => {
|
||||
await expect(cache.acquireLock('invalid key!', 60)).rejects.toThrow('Invalid lock key format');
|
||||
});
|
||||
|
||||
it('allows valid key characters', async () => {
|
||||
const token = await cache.acquireLock('valid-key_123:test', 60);
|
||||
expect(token).not.toBeNull();
|
||||
});
|
||||
|
||||
it('acquires different locks independently', async () => {
|
||||
const token1 = await cache.acquireLock('resource1', 60);
|
||||
const token2 = await cache.acquireLock('resource2', 60);
|
||||
expect(token1).not.toBeNull();
|
||||
expect(token2).not.toBeNull();
|
||||
expect(token1).not.toBe(token2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('releaseLock', () => {
|
||||
it('releases lock with correct token', async () => {
|
||||
const token = await cache.acquireLock('resource', 60);
|
||||
const released = await cache.releaseLock('resource', token!);
|
||||
expect(released).toBe(true);
|
||||
|
||||
const newToken = await cache.acquireLock('resource', 60);
|
||||
expect(newToken).not.toBeNull();
|
||||
});
|
||||
|
||||
it('fails to release with wrong token', async () => {
|
||||
await cache.acquireLock('resource', 60);
|
||||
const released = await cache.releaseLock('resource', 'wrongtoken123456789012');
|
||||
expect(released).toBe(false);
|
||||
});
|
||||
|
||||
it('fails to release non-existent lock', async () => {
|
||||
const released = await cache.releaseLock('nonexistent', 'sometoken12345678901234');
|
||||
expect(released).toBe(false);
|
||||
});
|
||||
|
||||
it('throws on invalid key format', async () => {
|
||||
await expect(cache.releaseLock('invalid key!', 'token')).rejects.toThrow('Invalid lock key format');
|
||||
});
|
||||
|
||||
it('throws on invalid token format', async () => {
|
||||
await expect(cache.releaseLock('validkey', 'INVALID_TOKEN!')).rejects.toThrow('Invalid lock token format');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAndRenewTtl', () => {
|
||||
it('returns value and renews TTL', async () => {
|
||||
await cache.set('key', 'value', 10);
|
||||
vi.advanceTimersByTime(5000);
|
||||
|
||||
const result = await cache.getAndRenewTtl<string>('key', 60);
|
||||
expect(result).toBe('value');
|
||||
|
||||
vi.advanceTimersByTime(30000);
|
||||
expect(await cache.get<string>('key')).toBe('value');
|
||||
|
||||
vi.advanceTimersByTime(31000);
|
||||
expect(await cache.get('key')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for non-existent key', async () => {
|
||||
const result = await cache.getAndRenewTtl('nonexistent', 60);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for expired key', async () => {
|
||||
await cache.set('key', 'value', 1);
|
||||
vi.advanceTimersByTime(1001);
|
||||
const result = await cache.getAndRenewTtl('key', 60);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('publish', () => {
|
||||
it('does not throw (no-op in memory provider)', async () => {
|
||||
await expect(cache.publish('channel', 'message')).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Set operations', () => {
|
||||
describe('sadd', () => {
|
||||
it('adds member to a new set', async () => {
|
||||
await cache.sadd('myset', 'member1', 60);
|
||||
const members = await cache.smembers('myset');
|
||||
expect(members.has('member1')).toBe(true);
|
||||
});
|
||||
|
||||
it('adds member to existing set', async () => {
|
||||
await cache.sadd('myset', 'member1', 60);
|
||||
await cache.sadd('myset', 'member2', 60);
|
||||
const members = await cache.smembers('myset');
|
||||
expect(members.has('member1')).toBe(true);
|
||||
expect(members.has('member2')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not duplicate members', async () => {
|
||||
await cache.sadd('myset', 'member1', 60);
|
||||
await cache.sadd('myset', 'member1', 60);
|
||||
const members = await cache.smembers('myset');
|
||||
expect(members.size).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('srem', () => {
|
||||
it('removes member from set', async () => {
|
||||
await cache.sadd('myset', 'member1', 60);
|
||||
await cache.sadd('myset', 'member2', 60);
|
||||
await cache.srem('myset', 'member1');
|
||||
const members = await cache.smembers('myset');
|
||||
expect(members.has('member1')).toBe(false);
|
||||
expect(members.has('member2')).toBe(true);
|
||||
});
|
||||
|
||||
it('removes set when last member is removed', async () => {
|
||||
await cache.sadd('myset', 'member1', 60);
|
||||
await cache.srem('myset', 'member1');
|
||||
const members = await cache.smembers('myset');
|
||||
expect(members.size).toBe(0);
|
||||
});
|
||||
|
||||
it('does not throw when removing from non-existent set', async () => {
|
||||
await expect(cache.srem('nonexistent', 'member')).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('smembers', () => {
|
||||
it('returns all members of set', async () => {
|
||||
await cache.sadd('myset', 'member1', 60);
|
||||
await cache.sadd('myset', 'member2', 60);
|
||||
await cache.sadd('myset', 'member3', 60);
|
||||
const members = await cache.smembers('myset');
|
||||
expect(members.size).toBe(3);
|
||||
expect(members).toEqual(new Set(['member1', 'member2', 'member3']));
|
||||
});
|
||||
|
||||
it('returns empty set for non-existent key', async () => {
|
||||
const members = await cache.smembers('nonexistent');
|
||||
expect(members.size).toBe(0);
|
||||
});
|
||||
|
||||
it('returns empty set when TTL expires', async () => {
|
||||
await cache.sadd('myset', 'member1', 1);
|
||||
vi.advanceTimersByTime(2000);
|
||||
const members = await cache.smembers('myset');
|
||||
expect(members.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sismember', () => {
|
||||
it('returns true for existing member', async () => {
|
||||
await cache.sadd('myset', 'member1', 60);
|
||||
const result = await cache.sismember('myset', 'member1');
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for non-existing member', async () => {
|
||||
await cache.sadd('myset', 'member1', 60);
|
||||
const result = await cache.sismember('myset', 'member2');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for non-existent set', async () => {
|
||||
const result = await cache.sismember('nonexistent', 'member');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when TTL expires', async () => {
|
||||
await cache.sadd('myset', 'member1', 1);
|
||||
vi.advanceTimersByTime(2000);
|
||||
const result = await cache.sismember('myset', 'member1');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Memory limits', () => {
|
||||
it('evicts oldest entry when max size reached', async () => {
|
||||
const smallCache = new InMemoryProvider({maxSize: 3});
|
||||
try {
|
||||
await smallCache.set('key1', 'value1');
|
||||
await smallCache.set('key2', 'value2');
|
||||
await smallCache.set('key3', 'value3');
|
||||
await smallCache.set('key4', 'value4');
|
||||
|
||||
expect(await smallCache.get('key1')).toBeNull();
|
||||
expect(await smallCache.get<string>('key2')).toBe('value2');
|
||||
expect(await smallCache.get<string>('key3')).toBe('value3');
|
||||
expect(await smallCache.get<string>('key4')).toBe('value4');
|
||||
} finally {
|
||||
smallCache.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
it('evicts multiple entries as needed', async () => {
|
||||
const smallCache = new InMemoryProvider({maxSize: 2});
|
||||
try {
|
||||
await smallCache.set('key1', 'value1');
|
||||
await smallCache.set('key2', 'value2');
|
||||
await smallCache.set('key3', 'value3');
|
||||
await smallCache.set('key4', 'value4');
|
||||
await smallCache.set('key5', 'value5');
|
||||
|
||||
expect(await smallCache.get('key1')).toBeNull();
|
||||
expect(await smallCache.get('key2')).toBeNull();
|
||||
expect(await smallCache.get('key3')).toBeNull();
|
||||
expect(await smallCache.get<string>('key4')).toBe('value4');
|
||||
expect(await smallCache.get<string>('key5')).toBe('value5');
|
||||
} finally {
|
||||
smallCache.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
it('uses default max size of 10000', async () => {
|
||||
const defaultCache = new InMemoryProvider();
|
||||
try {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
await defaultCache.set(`key${i}`, `value${i}`);
|
||||
}
|
||||
expect(await defaultCache.get<string>('key0')).toBe('value0');
|
||||
} finally {
|
||||
defaultCache.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cleanup interval', () => {
|
||||
it('cleans up expired entries periodically', async () => {
|
||||
const cleanupCache = new InMemoryProvider({cleanupIntervalMs: 1000});
|
||||
try {
|
||||
await cleanupCache.set('key1', 'value1', 2);
|
||||
await cleanupCache.set('key2', 'value2', 10);
|
||||
|
||||
vi.advanceTimersByTime(3000);
|
||||
|
||||
expect(await cleanupCache.get<string>('key2')).toBe('value2');
|
||||
} finally {
|
||||
cleanupCache.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
it('cleans up expired locks', async () => {
|
||||
const cleanupCache = new InMemoryProvider({cleanupIntervalMs: 1000});
|
||||
try {
|
||||
await cleanupCache.acquireLock('resource', 1);
|
||||
vi.advanceTimersByTime(2000);
|
||||
|
||||
const newToken = await cleanupCache.acquireLock('resource', 60);
|
||||
expect(newToken).not.toBeNull();
|
||||
} finally {
|
||||
cleanupCache.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('destroy', () => {
|
||||
it('clears all data', async () => {
|
||||
await cache.set('key1', 'value1');
|
||||
await cache.set('key2', 'value2');
|
||||
await cache.sadd('set1', 'member1', 60);
|
||||
await cache.acquireLock('lock1', 60);
|
||||
|
||||
cache.destroy();
|
||||
|
||||
const newCache = new InMemoryProvider();
|
||||
try {
|
||||
expect(await newCache.get('key1')).toBeNull();
|
||||
} finally {
|
||||
newCache.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
it('stops cleanup interval', () => {
|
||||
const cleanupCache = new InMemoryProvider({cleanupIntervalMs: 1000});
|
||||
cleanupCache.destroy();
|
||||
expect(() => vi.advanceTimersByTime(5000)).not.toThrow();
|
||||
});
|
||||
|
||||
it('can be called multiple times safely', () => {
|
||||
expect(() => {
|
||||
cache.destroy();
|
||||
cache.destroy();
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('handles special characters in keys', async () => {
|
||||
const specialKey = 'key:with:colons';
|
||||
await cache.set(specialKey, 'value');
|
||||
expect(await cache.get<string>(specialKey)).toBe('value');
|
||||
});
|
||||
|
||||
it('handles unicode in values', async () => {
|
||||
const unicodeValue = 'Hello World';
|
||||
await cache.set('unicode', unicodeValue);
|
||||
expect(await cache.get<string>('unicode')).toBe(unicodeValue);
|
||||
});
|
||||
|
||||
it('handles large objects', async () => {
|
||||
const largeObj = {
|
||||
data: Array.from({length: 1000}, (_, i) => ({
|
||||
id: i,
|
||||
name: `item-${i}`,
|
||||
nested: {value: i * 2},
|
||||
})),
|
||||
};
|
||||
await cache.set('large', largeObj);
|
||||
const result = await cache.get<typeof largeObj>('large');
|
||||
expect(result?.data.length).toBe(1000);
|
||||
});
|
||||
|
||||
it('handles rapid sequential operations', async () => {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
await cache.set(`rapid-${i}`, i);
|
||||
}
|
||||
for (let i = 0; i < 100; i++) {
|
||||
expect(await cache.get<number>(`rapid-${i}`)).toBe(i);
|
||||
}
|
||||
});
|
||||
|
||||
it('handles concurrent operations', async () => {
|
||||
const promises = [];
|
||||
for (let i = 0; i < 50; i++) {
|
||||
promises.push(cache.set(`concurrent-${i}`, i));
|
||||
}
|
||||
await Promise.all(promises);
|
||||
|
||||
const getPromises = [];
|
||||
for (let i = 0; i < 50; i++) {
|
||||
getPromises.push(cache.get<number>(`concurrent-${i}`));
|
||||
}
|
||||
const results = await Promise.all(getPromises);
|
||||
expect(results).toEqual(Array.from({length: 50}, (_, i) => i));
|
||||
});
|
||||
});
|
||||
});
|
||||
905
packages/cache/src/providers/tests/KVCacheProvider.test.tsx
vendored
Normal file
905
packages/cache/src/providers/tests/KVCacheProvider.test.tsx
vendored
Normal file
@@ -0,0 +1,905 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {CacheLogger, CacheTelemetry} from '@fluxer/cache/src/CacheProviderTypes';
|
||||
import {KVCacheProvider} from '@fluxer/cache/src/providers/KVCacheProvider';
|
||||
import type {IKVPipeline, IKVProvider, IKVSubscription} from '@fluxer/kv_client/src/IKVProvider';
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
class MockKVPipeline implements IKVPipeline {
|
||||
private operations: Array<{method: string; args: Array<unknown>}> = [];
|
||||
|
||||
constructor(
|
||||
private store: Map<string, string>,
|
||||
private sets: Map<string, Set<string>>,
|
||||
private expiries: Map<string, number>,
|
||||
) {}
|
||||
|
||||
get(key: string) {
|
||||
this.operations.push({method: 'get', args: [key]});
|
||||
return this;
|
||||
}
|
||||
|
||||
set(key: string, value: string) {
|
||||
this.operations.push({method: 'set', args: [key, value]});
|
||||
return this;
|
||||
}
|
||||
|
||||
setex(key: string, ttlSeconds: number, value: string) {
|
||||
this.operations.push({method: 'setex', args: [key, ttlSeconds, value]});
|
||||
return this;
|
||||
}
|
||||
|
||||
del(key: string) {
|
||||
this.operations.push({method: 'del', args: [key]});
|
||||
return this;
|
||||
}
|
||||
|
||||
expire(key: string, ttlSeconds: number) {
|
||||
this.operations.push({method: 'expire', args: [key, ttlSeconds]});
|
||||
return this;
|
||||
}
|
||||
|
||||
sadd(key: string, ...members: Array<string>) {
|
||||
this.operations.push({method: 'sadd', args: [key, ...members]});
|
||||
return this;
|
||||
}
|
||||
|
||||
srem(key: string, ...members: Array<string>) {
|
||||
this.operations.push({method: 'srem', args: [key, ...members]});
|
||||
return this;
|
||||
}
|
||||
|
||||
zadd(key: string, score: number, value: string) {
|
||||
this.operations.push({method: 'zadd', args: [key, score, value]});
|
||||
return this;
|
||||
}
|
||||
|
||||
zrem(key: string, ...members: Array<string>) {
|
||||
this.operations.push({method: 'zrem', args: [key, ...members]});
|
||||
return this;
|
||||
}
|
||||
|
||||
mset(...args: Array<string>) {
|
||||
this.operations.push({method: 'mset', args});
|
||||
return this;
|
||||
}
|
||||
|
||||
async exec(): Promise<Array<[Error | null, unknown]>> {
|
||||
for (const op of this.operations) {
|
||||
switch (op.method) {
|
||||
case 'set':
|
||||
this.store.set(op.args[0] as string, op.args[1] as string);
|
||||
break;
|
||||
case 'setex': {
|
||||
const [key, ttl, value] = op.args as [string, number, string];
|
||||
this.store.set(key, value);
|
||||
this.expiries.set(key, Date.now() + ttl * 1000);
|
||||
break;
|
||||
}
|
||||
case 'del':
|
||||
this.store.delete(op.args[0] as string);
|
||||
break;
|
||||
case 'expire': {
|
||||
const [expKey, expTtl] = op.args as [string, number];
|
||||
if (this.store.has(expKey)) {
|
||||
this.expiries.set(expKey, Date.now() + expTtl * 1000);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'sadd': {
|
||||
const [setKey, ...members] = op.args as [string, ...Array<string>];
|
||||
let set = this.sets.get(setKey);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
this.sets.set(setKey, set);
|
||||
}
|
||||
for (const m of members) {
|
||||
set.add(m);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'mset': {
|
||||
const msetArgs = op.args as Array<string>;
|
||||
for (let i = 0; i < msetArgs.length; i += 2) {
|
||||
this.store.set(msetArgs[i], msetArgs[i + 1]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return this.operations.map(() => [null, 'OK']);
|
||||
}
|
||||
}
|
||||
|
||||
class MockKVProvider implements IKVProvider {
|
||||
private store = new Map<string, string>();
|
||||
private sets = new Map<string, Set<string>>();
|
||||
private expiries = new Map<string, number>();
|
||||
|
||||
pipeline() {
|
||||
return new MockKVPipeline(this.store, this.sets, this.expiries);
|
||||
}
|
||||
|
||||
async get(key: string): Promise<string | null> {
|
||||
return this.store.get(key) ?? null;
|
||||
}
|
||||
|
||||
async set(key: string, value: string, ...args: Array<string | number>): Promise<string | null> {
|
||||
if (args.includes('NX') && this.store.has(key)) {
|
||||
return null;
|
||||
}
|
||||
this.store.set(key, value);
|
||||
const exIdx = args.indexOf('EX');
|
||||
if (exIdx !== -1 && typeof args[exIdx + 1] === 'number') {
|
||||
this.expiries.set(key, Date.now() + (args[exIdx + 1] as number) * 1000);
|
||||
}
|
||||
return 'OK';
|
||||
}
|
||||
|
||||
async setex(key: string, ttlSeconds: number, value: string): Promise<void> {
|
||||
this.store.set(key, value);
|
||||
this.expiries.set(key, Date.now() + ttlSeconds * 1000);
|
||||
}
|
||||
|
||||
async setnx(key: string, value: string, ttlSeconds?: number): Promise<boolean> {
|
||||
if (this.store.has(key)) return false;
|
||||
this.store.set(key, value);
|
||||
if (ttlSeconds) {
|
||||
this.expiries.set(key, Date.now() + ttlSeconds * 1000);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async mget(...keys: Array<string>): Promise<Array<string | null>> {
|
||||
return keys.map((key) => this.store.get(key) ?? null);
|
||||
}
|
||||
|
||||
async mset(...args: Array<string>): Promise<void> {
|
||||
for (let i = 0; i < args.length; i += 2) {
|
||||
this.store.set(args[i], args[i + 1]);
|
||||
}
|
||||
}
|
||||
|
||||
async del(...keys: Array<string>): Promise<number> {
|
||||
let count = 0;
|
||||
for (const key of keys) {
|
||||
if (this.store.delete(key)) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
async exists(key: string): Promise<number> {
|
||||
return this.store.has(key) ? 1 : 0;
|
||||
}
|
||||
|
||||
async expire(key: string, ttlSeconds: number): Promise<number> {
|
||||
if (!this.store.has(key)) return 0;
|
||||
this.expiries.set(key, Date.now() + ttlSeconds * 1000);
|
||||
return 1;
|
||||
}
|
||||
|
||||
async ttl(key: string): Promise<number> {
|
||||
if (!this.store.has(key)) return -2;
|
||||
const expiry = this.expiries.get(key);
|
||||
if (!expiry) return -1;
|
||||
const remaining = Math.floor((expiry - Date.now()) / 1000);
|
||||
return remaining > 0 ? remaining : -2;
|
||||
}
|
||||
|
||||
async incr(key: string): Promise<number> {
|
||||
const val = parseInt(this.store.get(key) ?? '0', 10) + 1;
|
||||
this.store.set(key, String(val));
|
||||
return val;
|
||||
}
|
||||
|
||||
async getex(key: string, ttlSeconds: number): Promise<string | null> {
|
||||
const val = this.store.get(key);
|
||||
if (val !== undefined) {
|
||||
this.expiries.set(key, Date.now() + ttlSeconds * 1000);
|
||||
}
|
||||
return val ?? null;
|
||||
}
|
||||
|
||||
async getdel(key: string): Promise<string | null> {
|
||||
const val = this.store.get(key);
|
||||
this.store.delete(key);
|
||||
return val ?? null;
|
||||
}
|
||||
|
||||
async sadd(key: string, ...members: Array<string>): Promise<number> {
|
||||
let set = this.sets.get(key);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
this.sets.set(key, set);
|
||||
}
|
||||
let added = 0;
|
||||
for (const m of members) {
|
||||
if (!set.has(m)) {
|
||||
set.add(m);
|
||||
added++;
|
||||
}
|
||||
}
|
||||
return added;
|
||||
}
|
||||
|
||||
async srem(key: string, ...members: Array<string>): Promise<number> {
|
||||
const set = this.sets.get(key);
|
||||
if (!set) return 0;
|
||||
let removed = 0;
|
||||
for (const m of members) {
|
||||
if (set.delete(m)) removed++;
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
async smembers(key: string): Promise<Array<string>> {
|
||||
const set = this.sets.get(key);
|
||||
return set ? Array.from(set) : [];
|
||||
}
|
||||
|
||||
async sismember(key: string, member: string): Promise<number> {
|
||||
const set = this.sets.get(key);
|
||||
return set?.has(member) ? 1 : 0;
|
||||
}
|
||||
|
||||
async scard(key: string): Promise<number> {
|
||||
return this.sets.get(key)?.size ?? 0;
|
||||
}
|
||||
|
||||
async spop(key: string, count = 1): Promise<Array<string>> {
|
||||
const set = this.sets.get(key);
|
||||
if (!set) return [];
|
||||
const results: Array<string> = [];
|
||||
const iter = set.values();
|
||||
for (let i = 0; i < count; i++) {
|
||||
const next = iter.next();
|
||||
if (next.done) break;
|
||||
results.push(next.value);
|
||||
set.delete(next.value);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
async zadd(_key: string, ..._scoreMembers: Array<number | string>): Promise<number> {
|
||||
return 1;
|
||||
}
|
||||
|
||||
async zrem(_key: string, ..._members: Array<string>): Promise<number> {
|
||||
return 1;
|
||||
}
|
||||
|
||||
async zcard(_key: string): Promise<number> {
|
||||
return 0;
|
||||
}
|
||||
|
||||
async zrangebyscore(
|
||||
_key: string,
|
||||
_min: string | number,
|
||||
_max: string | number,
|
||||
..._args: Array<string | number>
|
||||
): Promise<Array<string>> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async rpush(_key: string, ..._values: Array<string>): Promise<number> {
|
||||
return 1;
|
||||
}
|
||||
|
||||
async lpop(_key: string, _count?: number): Promise<Array<string>> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async llen(_key: string): Promise<number> {
|
||||
return 0;
|
||||
}
|
||||
|
||||
async hset(_key: string, _field: string, _value: string): Promise<number> {
|
||||
return 1;
|
||||
}
|
||||
|
||||
async hdel(_key: string, ..._fields: Array<string>): Promise<number> {
|
||||
return 1;
|
||||
}
|
||||
|
||||
async hget(_key: string, _field: string): Promise<string | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
async hgetall(_key: string): Promise<Record<string, string>> {
|
||||
return {};
|
||||
}
|
||||
|
||||
async publish(_channel: string, _message: string): Promise<number> {
|
||||
return 1;
|
||||
}
|
||||
|
||||
duplicate(): IKVSubscription {
|
||||
return {} as IKVSubscription;
|
||||
}
|
||||
|
||||
async releaseLock(_key: string, _token: string): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
async renewSnowflakeNode(_key: string, _instanceId: string, _ttlSeconds: number): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
async tryConsumeTokens(
|
||||
_key: string,
|
||||
_requested: number,
|
||||
_maxTokens: number,
|
||||
_refillRate: number,
|
||||
_refillIntervalMs: number,
|
||||
): Promise<number> {
|
||||
return 0;
|
||||
}
|
||||
|
||||
async scheduleBulkDeletion(_queueKey: string, _secondaryKey: string, _score: number, _value: string): Promise<void> {}
|
||||
|
||||
async removeBulkDeletion(_queueKey: string, _secondaryKey: string): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
async scan(pattern: string, _count: number): Promise<Array<string>> {
|
||||
const regex = new RegExp(pattern.replace(/\*/g, '.*'));
|
||||
return Array.from(this.store.keys()).filter((k) => regex.test(k));
|
||||
}
|
||||
|
||||
multi(): IKVPipeline {
|
||||
return new MockKVPipeline(this.store, this.sets, this.expiries);
|
||||
}
|
||||
|
||||
async health(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.store.clear();
|
||||
this.sets.clear();
|
||||
this.expiries.clear();
|
||||
}
|
||||
}
|
||||
|
||||
function createNoopLogger(): CacheLogger {
|
||||
return {
|
||||
error: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
function createNoopTelemetry(): CacheTelemetry {
|
||||
return {
|
||||
recordCounter: () => {},
|
||||
recordHistogram: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
describe('KVCacheProvider', () => {
|
||||
let mockClient: MockKVProvider;
|
||||
let cache: KVCacheProvider;
|
||||
|
||||
beforeEach(() => {
|
||||
mockClient = new MockKVProvider();
|
||||
cache = new KVCacheProvider({
|
||||
client: mockClient,
|
||||
cacheName: 'test',
|
||||
logger: createNoopLogger(),
|
||||
telemetry: createNoopTelemetry(),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockClient.clear();
|
||||
});
|
||||
|
||||
describe('get and set', () => {
|
||||
it('returns null for non-existent key', async () => {
|
||||
const result = await cache.get('nonexistent');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('stores and retrieves a string value', async () => {
|
||||
await cache.set('key', 'value');
|
||||
const result = await cache.get<string>('key');
|
||||
expect(result).toBe('value');
|
||||
});
|
||||
|
||||
it('stores and retrieves an object value', async () => {
|
||||
const obj = {name: 'test', count: 42};
|
||||
await cache.set('obj', obj);
|
||||
const result = await cache.get<typeof obj>('obj');
|
||||
expect(result).toEqual(obj);
|
||||
});
|
||||
|
||||
it('stores value with TTL', async () => {
|
||||
await cache.set('key', 'value', 60);
|
||||
const result = await cache.get<string>('key');
|
||||
expect(result).toBe('value');
|
||||
});
|
||||
|
||||
it('handles invalid JSON gracefully', async () => {
|
||||
await mockClient.set('invalid', 'not-valid-json{');
|
||||
const result = await cache.get('invalid');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('deletes existing key', async () => {
|
||||
await cache.set('key', 'value');
|
||||
await cache.delete('key');
|
||||
const result = await cache.get('key');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAndDelete', () => {
|
||||
it('returns value and deletes key', async () => {
|
||||
await cache.set('key', 'value');
|
||||
const result = await cache.getAndDelete<string>('key');
|
||||
expect(result).toBe('value');
|
||||
expect(await cache.get('key')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for non-existent key', async () => {
|
||||
const result = await cache.getAndDelete('nonexistent');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('exists', () => {
|
||||
it('returns true for existing key', async () => {
|
||||
await cache.set('key', 'value');
|
||||
const result = await cache.exists('key');
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for non-existent key', async () => {
|
||||
const result = await cache.exists('nonexistent');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('expire', () => {
|
||||
it('sets TTL on existing key', async () => {
|
||||
await cache.set('key', 'value');
|
||||
await cache.expire('key', 60);
|
||||
const ttl = await cache.ttl('key');
|
||||
expect(ttl).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ttl', () => {
|
||||
it('returns TTL for key with expiry', async () => {
|
||||
await cache.set('key', 'value', 60);
|
||||
const result = await cache.ttl('key');
|
||||
expect(result).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('returns -1 for key without expiry', async () => {
|
||||
await cache.set('key', 'value');
|
||||
const result = await cache.ttl('key');
|
||||
expect(result).toBe(-1);
|
||||
});
|
||||
|
||||
it('returns -2 for non-existent key', async () => {
|
||||
const result = await cache.ttl('nonexistent');
|
||||
expect(result).toBe(-2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mget', () => {
|
||||
it('returns values for multiple keys', async () => {
|
||||
await cache.set('key1', 'value1');
|
||||
await cache.set('key2', 'value2');
|
||||
const results = await cache.mget<string>(['key1', 'key2']);
|
||||
expect(results).toEqual(['value1', 'value2']);
|
||||
});
|
||||
|
||||
it('returns null for missing keys', async () => {
|
||||
await cache.set('key1', 'value1');
|
||||
const results = await cache.mget<string>(['key1', 'missing']);
|
||||
expect(results).toEqual(['value1', null]);
|
||||
});
|
||||
|
||||
it('returns empty array for empty input', async () => {
|
||||
const results = await cache.mget([]);
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mset', () => {
|
||||
it('sets multiple keys at once', async () => {
|
||||
await cache.mset([
|
||||
{key: 'key1', value: 'value1'},
|
||||
{key: 'key2', value: 'value2'},
|
||||
]);
|
||||
expect(await cache.get<string>('key1')).toBe('value1');
|
||||
expect(await cache.get<string>('key2')).toBe('value2');
|
||||
});
|
||||
|
||||
it('handles empty array', async () => {
|
||||
await expect(cache.mset([])).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('handles mixed TTL entries', async () => {
|
||||
await cache.mset([
|
||||
{key: 'withTtl', value: 'val1', ttlSeconds: 60},
|
||||
{key: 'noTtl', value: 'val2'},
|
||||
]);
|
||||
expect(await cache.get<string>('withTtl')).toBe('val1');
|
||||
expect(await cache.get<string>('noTtl')).toBe('val2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deletePattern', () => {
|
||||
it('deletes keys matching pattern', async () => {
|
||||
await cache.set('user:1', 'user1');
|
||||
await cache.set('user:2', 'user2');
|
||||
await cache.set('session:1', 'session1');
|
||||
|
||||
const count = await cache.deletePattern('user:*');
|
||||
expect(count).toBe(2);
|
||||
});
|
||||
|
||||
it('returns 0 for no matches', async () => {
|
||||
const count = await cache.deletePattern('nonexistent:*');
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('acquireLock', () => {
|
||||
it('acquires lock successfully', async () => {
|
||||
const token = await cache.acquireLock('resource', 60);
|
||||
expect(token).not.toBeNull();
|
||||
expect(token).toMatch(/^[a-f0-9]{32}$/);
|
||||
});
|
||||
|
||||
it('throws on invalid key format', async () => {
|
||||
await expect(cache.acquireLock('invalid key!', 60)).rejects.toThrow('Invalid lock key format');
|
||||
});
|
||||
});
|
||||
|
||||
describe('releaseLock', () => {
|
||||
it('releases lock', async () => {
|
||||
const token = await cache.acquireLock('resource', 60);
|
||||
const released = await cache.releaseLock('resource', token!);
|
||||
expect(released).toBe(true);
|
||||
});
|
||||
|
||||
it('throws on invalid key format', async () => {
|
||||
await expect(cache.releaseLock('invalid key!', 'token')).rejects.toThrow('Invalid lock key format');
|
||||
});
|
||||
|
||||
it('throws on invalid token format', async () => {
|
||||
await expect(cache.releaseLock('validkey', 'INVALID!')).rejects.toThrow('Invalid lock token format');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAndRenewTtl', () => {
|
||||
it('returns value and renews TTL', async () => {
|
||||
await cache.set('key', 'value', 10);
|
||||
const result = await cache.getAndRenewTtl<string>('key', 60);
|
||||
expect(result).toBe('value');
|
||||
});
|
||||
|
||||
it('returns null for non-existent key', async () => {
|
||||
const result = await cache.getAndRenewTtl('nonexistent', 60);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('publish', () => {
|
||||
it('publishes message to channel', async () => {
|
||||
await expect(cache.publish('channel', 'message')).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Set operations', () => {
|
||||
describe('sadd', () => {
|
||||
it('adds member to set', async () => {
|
||||
await cache.sadd('myset', 'member1', 60);
|
||||
const members = await cache.smembers('myset');
|
||||
expect(members.has('member1')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('srem', () => {
|
||||
it('removes member from set', async () => {
|
||||
await mockClient.sadd('myset', 'member1');
|
||||
await cache.srem('myset', 'member1');
|
||||
const isMember = await cache.sismember('myset', 'member1');
|
||||
expect(isMember).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('smembers', () => {
|
||||
it('returns all members of set', async () => {
|
||||
await mockClient.sadd('myset', 'member1', 'member2');
|
||||
const members = await cache.smembers('myset');
|
||||
expect(members.size).toBe(2);
|
||||
expect(members.has('member1')).toBe(true);
|
||||
expect(members.has('member2')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns empty set for non-existent key', async () => {
|
||||
const members = await cache.smembers('nonexistent');
|
||||
expect(members.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sismember', () => {
|
||||
it('returns true for existing member', async () => {
|
||||
await mockClient.sadd('myset', 'member1');
|
||||
const result = await cache.sismember('myset', 'member1');
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for non-existing member', async () => {
|
||||
await mockClient.sadd('myset', 'member1');
|
||||
const result = await cache.sismember('myset', 'member2');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('telemetry', () => {
|
||||
it('records metrics on get operations', async () => {
|
||||
const telemetry = {
|
||||
recordCounter: vi.fn(),
|
||||
recordHistogram: vi.fn(),
|
||||
};
|
||||
|
||||
const telemetryCache = new KVCacheProvider({
|
||||
client: mockClient,
|
||||
cacheName: 'test',
|
||||
telemetry,
|
||||
});
|
||||
|
||||
await telemetryCache.get('key');
|
||||
|
||||
expect(telemetry.recordCounter).toHaveBeenCalledWith({
|
||||
name: 'cache.operation',
|
||||
dimensions: expect.objectContaining({
|
||||
operation: 'get',
|
||||
cache_name: 'test',
|
||||
}),
|
||||
});
|
||||
expect(telemetry.recordHistogram).toHaveBeenCalledWith({
|
||||
name: 'cache.operation_latency',
|
||||
valueMs: expect.any(Number),
|
||||
dimensions: expect.objectContaining({
|
||||
operation: 'get',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('records metrics on set operations', async () => {
|
||||
const telemetry = {
|
||||
recordCounter: vi.fn(),
|
||||
recordHistogram: vi.fn(),
|
||||
};
|
||||
|
||||
const telemetryCache = new KVCacheProvider({
|
||||
client: mockClient,
|
||||
cacheName: 'test',
|
||||
telemetry,
|
||||
});
|
||||
|
||||
await telemetryCache.set('key', 'value');
|
||||
|
||||
expect(telemetry.recordCounter).toHaveBeenCalledWith({
|
||||
name: 'cache.operation',
|
||||
dimensions: expect.objectContaining({
|
||||
operation: 'set',
|
||||
status: 'success',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('records error metrics on failure', async () => {
|
||||
const telemetry = {
|
||||
recordCounter: vi.fn(),
|
||||
recordHistogram: vi.fn(),
|
||||
};
|
||||
|
||||
const failingClient = {
|
||||
...mockClient,
|
||||
get: vi.fn().mockRejectedValue(new Error('connection error')),
|
||||
} as unknown as IKVProvider;
|
||||
|
||||
const telemetryCache = new KVCacheProvider({
|
||||
client: failingClient,
|
||||
cacheName: 'test',
|
||||
telemetry,
|
||||
});
|
||||
|
||||
await expect(telemetryCache.get('key')).rejects.toThrow('connection error');
|
||||
|
||||
expect(telemetry.recordCounter).toHaveBeenCalledWith({
|
||||
name: 'cache.operation',
|
||||
dimensions: expect.objectContaining({
|
||||
operation: 'get',
|
||||
status: 'error',
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('key type detection', () => {
|
||||
it('identifies lock keys', async () => {
|
||||
const telemetry = {
|
||||
recordCounter: vi.fn(),
|
||||
recordHistogram: vi.fn(),
|
||||
};
|
||||
|
||||
const telemetryCache = new KVCacheProvider({
|
||||
client: mockClient,
|
||||
cacheName: 'test',
|
||||
telemetry,
|
||||
});
|
||||
|
||||
await telemetryCache.get('lock:mylock');
|
||||
|
||||
expect(telemetry.recordCounter).toHaveBeenCalledWith({
|
||||
name: 'cache.operation',
|
||||
dimensions: expect.objectContaining({
|
||||
key_type: 'lock',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('identifies session keys', async () => {
|
||||
const telemetry = {
|
||||
recordCounter: vi.fn(),
|
||||
recordHistogram: vi.fn(),
|
||||
};
|
||||
|
||||
const telemetryCache = new KVCacheProvider({
|
||||
client: mockClient,
|
||||
cacheName: 'test',
|
||||
telemetry,
|
||||
});
|
||||
|
||||
await telemetryCache.get('user:123:session:abc');
|
||||
|
||||
expect(telemetry.recordCounter).toHaveBeenCalledWith({
|
||||
name: 'cache.operation',
|
||||
dimensions: expect.objectContaining({
|
||||
key_type: 'session',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('identifies user keys', async () => {
|
||||
const telemetry = {
|
||||
recordCounter: vi.fn(),
|
||||
recordHistogram: vi.fn(),
|
||||
};
|
||||
|
||||
const telemetryCache = new KVCacheProvider({
|
||||
client: mockClient,
|
||||
cacheName: 'test',
|
||||
telemetry,
|
||||
});
|
||||
|
||||
await telemetryCache.get('prefix:user:123');
|
||||
|
||||
expect(telemetry.recordCounter).toHaveBeenCalledWith({
|
||||
name: 'cache.operation',
|
||||
dimensions: expect.objectContaining({
|
||||
key_type: 'user',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults to other for unknown key types', async () => {
|
||||
const telemetry = {
|
||||
recordCounter: vi.fn(),
|
||||
recordHistogram: vi.fn(),
|
||||
};
|
||||
|
||||
const telemetryCache = new KVCacheProvider({
|
||||
client: mockClient,
|
||||
cacheName: 'test',
|
||||
telemetry,
|
||||
});
|
||||
|
||||
await telemetryCache.get('random:key');
|
||||
|
||||
expect(telemetry.recordCounter).toHaveBeenCalledWith({
|
||||
name: 'cache.operation',
|
||||
dimensions: expect.objectContaining({
|
||||
key_type: 'other',
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('logger', () => {
|
||||
it('logs JSON parse errors', async () => {
|
||||
const logger = {
|
||||
error: vi.fn(),
|
||||
};
|
||||
|
||||
const loggingCache = new KVCacheProvider({
|
||||
client: mockClient,
|
||||
cacheName: 'test',
|
||||
logger,
|
||||
});
|
||||
|
||||
await mockClient.set('invalid', 'not-valid-json{');
|
||||
await loggingCache.get('invalid');
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
value: 'not-valid-json{',
|
||||
}),
|
||||
expect.stringContaining('JSON parse error'),
|
||||
);
|
||||
});
|
||||
|
||||
it('truncates long values in error logs', async () => {
|
||||
const logger = {
|
||||
error: vi.fn(),
|
||||
};
|
||||
|
||||
const loggingCache = new KVCacheProvider({
|
||||
client: mockClient,
|
||||
cacheName: 'test',
|
||||
logger,
|
||||
});
|
||||
|
||||
const longInvalidValue = 'x'.repeat(300);
|
||||
await mockClient.set('invalid', longInvalidValue);
|
||||
await loggingCache.get('invalid');
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
value: expect.stringMatching(/^x{200}\.\.\.$/),
|
||||
}),
|
||||
expect.any(String),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('default config', () => {
|
||||
it('uses default cache name when not provided', async () => {
|
||||
const telemetry = {
|
||||
recordCounter: vi.fn(),
|
||||
recordHistogram: vi.fn(),
|
||||
};
|
||||
|
||||
const defaultCache = new KVCacheProvider({
|
||||
client: mockClient,
|
||||
telemetry,
|
||||
});
|
||||
|
||||
await defaultCache.get('key');
|
||||
|
||||
expect(telemetry.recordCounter).toHaveBeenCalledWith({
|
||||
name: 'cache.operation',
|
||||
dimensions: expect.objectContaining({
|
||||
cache_name: 'kv',
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user