You've already forked node-redis
mirror of
https://github.com/redis/node-redis.git
synced 2025-12-15 23:55:38 +03:00
Client Side Caching (#2947)
* CSC POC ontop of Parser * add csc file that weren't merged after patch * address review comments * nits to try and fix github * last change from review * Update client-side cache and improve documentation * Add client side caching RESP3 validation * Add documentation for RESP and unstableResp3 options * Add comprehensive cache statistics The `CacheStats` class provides detailed metrics like hit/miss counts, load success/failure counts, total load time, and eviction counts. It also offers derived metrics such as hit/miss rates, load failure rate, and average load penalty. The design is inspired by Caffeine. `BasicClientSideCache` now uses a `StatsCounter` to accumulate these statistics, exposed via a new `stats()` method. The previous `cacheHits()` and `cacheMisses()` methods have been removed. A `recordStats` option (default: true) in `ClientSideCacheConfig` allows disabling statistics collection. --------- Co-authored-by: Shaya Potter <shaya@redislabs.com>
This commit is contained in:
48
packages/client/lib/cluster/cluster-slots.spec.ts
Normal file
48
packages/client/lib/cluster/cluster-slots.spec.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { RedisClusterOptions, RedisClusterClientOptions } from './index';
|
||||
import RedisClusterSlots from './cluster-slots';
|
||||
|
||||
describe('RedisClusterSlots', () => {
|
||||
describe('initialization', () => {
|
||||
|
||||
describe('clientSideCache validation', () => {
|
||||
const mockEmit = ((_event: string | symbol, ..._args: any[]): boolean => true) as EventEmitter['emit'];
|
||||
const clientSideCacheConfig = { ttl: 0, maxEntries: 0 };
|
||||
const rootNodes: Array<RedisClusterClientOptions> = [
|
||||
{ socket: { host: 'localhost', port: 30001 } }
|
||||
];
|
||||
|
||||
it('should throw error when clientSideCache is enabled with RESP 2', () => {
|
||||
assert.throws(
|
||||
() => new RedisClusterSlots({
|
||||
rootNodes,
|
||||
clientSideCache: clientSideCacheConfig,
|
||||
RESP: 2 as const,
|
||||
}, mockEmit),
|
||||
new Error('Client Side Caching is only supported with RESP3')
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when clientSideCache is enabled with RESP undefined', () => {
|
||||
assert.throws(
|
||||
() => new RedisClusterSlots({
|
||||
rootNodes,
|
||||
clientSideCache: clientSideCacheConfig,
|
||||
}, mockEmit),
|
||||
new Error('Client Side Caching is only supported with RESP3')
|
||||
);
|
||||
});
|
||||
|
||||
it('should not throw when clientSideCache is enabled with RESP 3', () => {
|
||||
assert.doesNotThrow(() =>
|
||||
new RedisClusterSlots({
|
||||
rootNodes,
|
||||
clientSideCache: clientSideCacheConfig,
|
||||
RESP: 3 as const,
|
||||
}, mockEmit)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import { ChannelListeners, PUBSUB_TYPE, PubSubTypeListeners } from '../client/pu
|
||||
import { RedisArgument, RedisFunctions, RedisModules, RedisScripts, RespVersions, TypeMapping } from '../RESP/types';
|
||||
import calculateSlot from 'cluster-key-slot';
|
||||
import { RedisSocketOptions } from '../client/socket';
|
||||
import { BasicPooledClientSideCache, PooledClientSideCacheProvider } from '../client/cache';
|
||||
|
||||
interface NodeAddress {
|
||||
host: string;
|
||||
@@ -111,6 +112,7 @@ export default class RedisClusterSlots<
|
||||
replicas = new Array<ShardNode<M, F, S, RESP, TYPE_MAPPING>>();
|
||||
readonly nodeByAddress = new Map<string, MasterNode<M, F, S, RESP, TYPE_MAPPING> | ShardNode<M, F, S, RESP, TYPE_MAPPING>>();
|
||||
pubSubNode?: PubSubNode<M, F, S, RESP, TYPE_MAPPING>;
|
||||
clientSideCache?: PooledClientSideCacheProvider;
|
||||
|
||||
#isOpen = false;
|
||||
|
||||
@@ -118,12 +120,28 @@ export default class RedisClusterSlots<
|
||||
return this.#isOpen;
|
||||
}
|
||||
|
||||
#validateOptions(options?: RedisClusterOptions<M, F, S, RESP, TYPE_MAPPING>) {
|
||||
if (options?.clientSideCache && options?.RESP !== 3) {
|
||||
throw new Error('Client Side Caching is only supported with RESP3');
|
||||
}
|
||||
}
|
||||
|
||||
constructor(
|
||||
options: RedisClusterOptions<M, F, S, RESP, TYPE_MAPPING>,
|
||||
emit: EventEmitter['emit']
|
||||
) {
|
||||
this.#validateOptions(options);
|
||||
this.#options = options;
|
||||
this.#clientFactory = RedisClient.factory(options);
|
||||
|
||||
if (options?.clientSideCache) {
|
||||
if (options.clientSideCache instanceof PooledClientSideCacheProvider) {
|
||||
this.clientSideCache = options.clientSideCache;
|
||||
} else {
|
||||
this.clientSideCache = new BasicPooledClientSideCache(options.clientSideCache)
|
||||
}
|
||||
}
|
||||
|
||||
this.#clientFactory = RedisClient.factory(this.#options);
|
||||
this.#emit = emit;
|
||||
}
|
||||
|
||||
@@ -164,6 +182,8 @@ export default class RedisClusterSlots<
|
||||
}
|
||||
|
||||
async #discover(rootNode: RedisClusterClientOptions) {
|
||||
this.clientSideCache?.clear();
|
||||
this.clientSideCache?.disable();
|
||||
try {
|
||||
const addressesInUse = new Set<string>(),
|
||||
promises: Array<Promise<unknown>> = [],
|
||||
@@ -219,6 +239,7 @@ export default class RedisClusterSlots<
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
this.clientSideCache?.enable();
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
@@ -314,6 +335,8 @@ export default class RedisClusterSlots<
|
||||
#createClient(node: ShardNode<M, F, S, RESP, TYPE_MAPPING>, readonly = node.readonly) {
|
||||
return this.#clientFactory(
|
||||
this.#clientOptionsDefaults({
|
||||
clientSideCache: this.clientSideCache,
|
||||
RESP: this.#options.RESP,
|
||||
socket: this.#getNodeAddress(node.address) ?? {
|
||||
host: node.host,
|
||||
port: node.port
|
||||
|
||||
@@ -9,11 +9,10 @@ import RedisClusterMultiCommand, { RedisClusterMultiCommandType } from './multi-
|
||||
import { PubSubListener } from '../client/pub-sub';
|
||||
import { ErrorReply } from '../errors';
|
||||
import { RedisTcpSocketOptions } from '../client/socket';
|
||||
import ASKING from '../commands/ASKING';
|
||||
import { ClientSideCacheConfig, PooledClientSideCacheProvider } from '../client/cache';
|
||||
import { BasicCommandParser } from '../client/parser';
|
||||
import { parseArgs } from '../commands/generic-transformers';
|
||||
import SingleEntryCache from '../single-entry-cache';
|
||||
|
||||
import { ASKING_CMD } from '../commands/ASKING';
|
||||
import SingleEntryCache from '../single-entry-cache'
|
||||
interface ClusterCommander<
|
||||
M extends RedisModules,
|
||||
F extends RedisFunctions,
|
||||
@@ -67,6 +66,41 @@ export interface RedisClusterOptions<
|
||||
* Useful when the cluster is running on another network
|
||||
*/
|
||||
nodeAddressMap?: NodeAddressMap;
|
||||
/**
|
||||
* Client Side Caching configuration for the pool.
|
||||
*
|
||||
* Enables Redis Servers and Clients to work together to cache results from commands
|
||||
* sent to a server. The server will notify the client when cached results are no longer valid.
|
||||
* In pooled mode, the cache is shared across all clients in the pool.
|
||||
*
|
||||
* Note: Client Side Caching is only supported with RESP3.
|
||||
*
|
||||
* @example Anonymous cache configuration
|
||||
* ```
|
||||
* const client = createCluster({
|
||||
* clientSideCache: {
|
||||
* ttl: 0,
|
||||
* maxEntries: 0,
|
||||
* evictPolicy: "LRU"
|
||||
* },
|
||||
* minimum: 5
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* @example Using a controllable cache
|
||||
* ```
|
||||
* const cache = new BasicPooledClientSideCache({
|
||||
* ttl: 0,
|
||||
* maxEntries: 0,
|
||||
* evictPolicy: "LRU"
|
||||
* });
|
||||
* const client = createCluster({
|
||||
* clientSideCache: cache,
|
||||
* minimum: 5
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
clientSideCache?: PooledClientSideCacheProvider | ClientSideCacheConfig;
|
||||
}
|
||||
|
||||
// remove once request & response policies are ready
|
||||
@@ -149,6 +183,7 @@ export default class RedisCluster<
|
||||
> extends EventEmitter {
|
||||
static #createCommand(command: Command, resp: RespVersions) {
|
||||
const transformReply = getTransformReply(command, resp);
|
||||
|
||||
return async function (this: ProxyCluster, ...args: Array<unknown>) {
|
||||
const parser = new BasicCommandParser();
|
||||
command.parseCommand(parser, ...args);
|
||||
@@ -273,6 +308,10 @@ export default class RedisCluster<
|
||||
return this._self.#slots.slots;
|
||||
}
|
||||
|
||||
get clientSideCache() {
|
||||
return this._self.#slots.clientSideCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* An array of the cluster masters.
|
||||
* Use with {@link RedisCluster.prototype.nodeClient} to get the client for a specific master node.
|
||||
@@ -390,6 +429,27 @@ export default class RedisCluster<
|
||||
// return this._commandOptionsProxy('policies', policies);
|
||||
// }
|
||||
|
||||
#handleAsk<T>(
|
||||
fn: (client: RedisClientType<M, F, S, RESP, TYPE_MAPPING>, opts?: ClusterCommandOptions) => Promise<T>
|
||||
) {
|
||||
return async (client: RedisClientType<M, F, S, RESP, TYPE_MAPPING>, options?: ClusterCommandOptions) => {
|
||||
const chainId = Symbol("asking chain");
|
||||
const opts = options ? {...options} : {};
|
||||
opts.chainId = chainId;
|
||||
|
||||
|
||||
|
||||
const ret = await Promise.all(
|
||||
[
|
||||
client.sendCommand([ASKING_CMD], {chainId: chainId}),
|
||||
fn(client, opts)
|
||||
]
|
||||
);
|
||||
|
||||
return ret[1];
|
||||
};
|
||||
}
|
||||
|
||||
async #execute<T>(
|
||||
firstKey: RedisArgument | undefined,
|
||||
isReadonly: boolean | undefined,
|
||||
@@ -399,14 +459,15 @@ export default class RedisCluster<
|
||||
const maxCommandRedirections = this.#options.maxCommandRedirections ?? 16;
|
||||
let client = await this.#slots.getClient(firstKey, isReadonly);
|
||||
let i = 0;
|
||||
let myOpts = options;
|
||||
|
||||
let myFn = fn;
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
return await fn(client, myOpts);
|
||||
return await myFn(client, options);
|
||||
} catch (err) {
|
||||
// reset to passed in options, if changed by an ask request
|
||||
myOpts = options;
|
||||
myFn = fn;
|
||||
|
||||
// TODO: error class
|
||||
if (++i > maxCommandRedirections || !(err instanceof Error)) {
|
||||
throw err;
|
||||
@@ -425,13 +486,7 @@ export default class RedisCluster<
|
||||
}
|
||||
|
||||
client = redirectTo;
|
||||
|
||||
const chainId = Symbol('Asking Chain');
|
||||
myOpts = options ? {...options} : {};
|
||||
myOpts.chainId = chainId;
|
||||
|
||||
client.sendCommand(parseArgs(ASKING), {chainId: chainId}).catch(err => { console.log(`Asking Failed: ${err}`) } );
|
||||
|
||||
myFn = this.#handleAsk(fn);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -582,10 +637,12 @@ export default class RedisCluster<
|
||||
}
|
||||
|
||||
close() {
|
||||
this._self.#slots.clientSideCache?.onPoolClose();
|
||||
return this._self.#slots.close();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this._self.#slots.clientSideCache?.onPoolClose();
|
||||
return this._self.#slots.destroy();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user