1
0
mirror of https://github.com/redis/node-redis.git synced 2025-08-06 02:15:48 +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:
Bobby I.
2025-05-19 15:11:47 +03:00
committed by GitHub
parent 6f961bd715
commit f01f1014cb
25 changed files with 2330 additions and 101 deletions

View File

@@ -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();
}