You've already forked node-redis
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:
@@ -5,11 +5,59 @@ import { RESP_TYPES } from '../RESP/decoder';
|
||||
import { WatchError } from "../errors";
|
||||
import { RedisSentinelConfig, SentinelFramework } from "./test-util";
|
||||
import { RedisSentinelEvent, RedisSentinelType, RedisSentinelClientType, RedisNode } from "./types";
|
||||
import RedisSentinel from "./index";
|
||||
import { RedisModules, RedisFunctions, RedisScripts, RespVersions, TypeMapping, NumberReply } from '../RESP/types';
|
||||
import { promisify } from 'node:util';
|
||||
import { exec } from 'node:child_process';
|
||||
import { BasicPooledClientSideCache } from '../client/cache'
|
||||
import { once } from 'node:events'
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
describe('RedisSentinel', () => {
|
||||
describe('initialization', () => {
|
||||
describe('clientSideCache validation', () => {
|
||||
const clientSideCacheConfig = { ttl: 0, maxEntries: 0 };
|
||||
const options = {
|
||||
name: 'mymaster',
|
||||
sentinelRootNodes: [
|
||||
{ host: 'localhost', port: 26379 }
|
||||
]
|
||||
};
|
||||
|
||||
it('should throw error when clientSideCache is enabled with RESP 2', () => {
|
||||
assert.throws(
|
||||
() => RedisSentinel.create({
|
||||
...options,
|
||||
clientSideCache: clientSideCacheConfig,
|
||||
RESP: 2 as const,
|
||||
}),
|
||||
new Error('Client Side Caching is only supported with RESP3')
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when clientSideCache is enabled with RESP undefined', () => {
|
||||
assert.throws(
|
||||
() => RedisSentinel.create({
|
||||
...options,
|
||||
clientSideCache: clientSideCacheConfig,
|
||||
}),
|
||||
new Error('Client Side Caching is only supported with RESP3')
|
||||
);
|
||||
});
|
||||
|
||||
it('should not throw when clientSideCache is enabled with RESP 3', () => {
|
||||
assert.doesNotThrow(() =>
|
||||
RedisSentinel.create({
|
||||
...options,
|
||||
clientSideCache: clientSideCacheConfig,
|
||||
RESP: 3 as const,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
[GLOBAL.SENTINEL.OPEN, GLOBAL.SENTINEL.PASSWORD].forEach(testOptions => {
|
||||
const passIndex = testOptions.serverArguments.indexOf('--requirepass')+1;
|
||||
let password: string | undefined = undefined;
|
||||
@@ -967,6 +1015,34 @@ describe.skip('legacy tests', () => {
|
||||
tracer.push("added node and waiting on added promise");
|
||||
await nodeAddedPromise;
|
||||
})
|
||||
|
||||
it('with client side caching', async function() {
|
||||
this.timeout(30000);
|
||||
const csc = new BasicPooledClientSideCache();
|
||||
|
||||
sentinel = frame.getSentinelClient({nodeClientOptions: {RESP: 3}, clientSideCache: csc, masterPoolSize: 5});
|
||||
await sentinel.connect();
|
||||
|
||||
await sentinel.set('x', 1);
|
||||
await sentinel.get('x');
|
||||
await sentinel.get('x');
|
||||
await sentinel.get('x');
|
||||
await sentinel.get('x');
|
||||
|
||||
assert.equal(1, csc.cacheMisses());
|
||||
assert.equal(3, csc.cacheHits());
|
||||
|
||||
const invalidatePromise = once(csc, 'invalidate');
|
||||
await sentinel.set('x', 2);
|
||||
await invalidatePromise;
|
||||
await sentinel.get('x');
|
||||
await sentinel.get('x');
|
||||
await sentinel.get('x');
|
||||
await sentinel.get('x');
|
||||
|
||||
assert.equal(csc.cacheMisses(), 2);
|
||||
assert.equal(csc.cacheHits(), 6);
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
|
@@ -16,6 +16,7 @@ import { RedisVariadicArgument } from '../commands/generic-transformers';
|
||||
import { WaitQueue } from './wait-queue';
|
||||
import { TcpNetConnectOpts } from 'node:net';
|
||||
import { RedisTcpSocketOptions } from '../client/socket';
|
||||
import { BasicPooledClientSideCache, PooledClientSideCacheProvider } from '../client/cache';
|
||||
|
||||
interface ClientInfo {
|
||||
id: number;
|
||||
@@ -301,6 +302,10 @@ export default class RedisSentinel<
|
||||
#masterClientCount = 0;
|
||||
#masterClientInfo?: ClientInfo;
|
||||
|
||||
get clientSideCache() {
|
||||
return this._self.#internal.clientSideCache;
|
||||
}
|
||||
|
||||
constructor(options: RedisSentinelOptions<M, F, S, RESP, TYPE_MAPPING>) {
|
||||
super();
|
||||
|
||||
@@ -617,7 +622,7 @@ class RedisSentinelInternal<
|
||||
|
||||
readonly #name: string;
|
||||
readonly #nodeClientOptions: RedisClientOptions<M, F, S, RESP, TYPE_MAPPING, RedisTcpSocketOptions>;
|
||||
readonly #sentinelClientOptions: RedisClientOptions<typeof RedisSentinelModule, F, S, RESP, TYPE_MAPPING, RedisTcpSocketOptions>;
|
||||
readonly #sentinelClientOptions: RedisClientOptions<typeof RedisSentinelModule, RedisFunctions, RedisScripts, RespVersions, TypeMapping, RedisTcpSocketOptions>;
|
||||
readonly #scanInterval: number;
|
||||
readonly #passthroughClientErrorEvents: boolean;
|
||||
|
||||
@@ -650,9 +655,22 @@ class RedisSentinelInternal<
|
||||
|
||||
#trace: (msg: string) => unknown = () => { };
|
||||
|
||||
#clientSideCache?: PooledClientSideCacheProvider;
|
||||
get clientSideCache() {
|
||||
return this.#clientSideCache;
|
||||
}
|
||||
|
||||
#validateOptions(options?: RedisSentinelOptions<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: RedisSentinelOptions<M, F, S, RESP, TYPE_MAPPING>) {
|
||||
super();
|
||||
|
||||
this.#validateOptions(options);
|
||||
|
||||
this.#name = options.name;
|
||||
|
||||
this.#sentinelRootNodes = Array.from(options.sentinelRootNodes);
|
||||
@@ -662,11 +680,21 @@ class RedisSentinelInternal<
|
||||
this.#scanInterval = options.scanInterval ?? 0;
|
||||
this.#passthroughClientErrorEvents = options.passthroughClientErrorEvents ?? false;
|
||||
|
||||
this.#nodeClientOptions = options.nodeClientOptions ? Object.assign({} as RedisClientOptions<M, F, S, RESP, TYPE_MAPPING, RedisTcpSocketOptions>, options.nodeClientOptions) : {};
|
||||
this.#nodeClientOptions = (options.nodeClientOptions ? {...options.nodeClientOptions} : {}) as RedisClientOptions<M, F, S, RESP, TYPE_MAPPING, RedisTcpSocketOptions>;
|
||||
if (this.#nodeClientOptions.url !== undefined) {
|
||||
throw new Error("invalid nodeClientOptions for Sentinel");
|
||||
}
|
||||
|
||||
if (options.clientSideCache) {
|
||||
if (options.clientSideCache instanceof PooledClientSideCacheProvider) {
|
||||
this.#clientSideCache = this.#nodeClientOptions.clientSideCache = options.clientSideCache;
|
||||
} else {
|
||||
const cscConfig = options.clientSideCache;
|
||||
this.#clientSideCache = this.#nodeClientOptions.clientSideCache = new BasicPooledClientSideCache(cscConfig);
|
||||
// this.#clientSideCache = this.#nodeClientOptions.clientSideCache = new PooledNoRedirectClientSideCache(cscConfig);
|
||||
}
|
||||
}
|
||||
|
||||
this.#sentinelClientOptions = options.sentinelClientOptions ? Object.assign({} as RedisClientOptions<typeof RedisSentinelModule, F, S, RESP, TYPE_MAPPING, RedisTcpSocketOptions>, options.sentinelClientOptions) : {};
|
||||
this.#sentinelClientOptions.modules = RedisSentinelModule;
|
||||
|
||||
@@ -904,6 +932,8 @@ class RedisSentinelInternal<
|
||||
|
||||
this.#isReady = false;
|
||||
|
||||
this.#clientSideCache?.onPoolClose();
|
||||
|
||||
if (this.#scanTimer) {
|
||||
clearInterval(this.#scanTimer);
|
||||
this.#scanTimer = undefined;
|
||||
@@ -952,6 +982,8 @@ class RedisSentinelInternal<
|
||||
|
||||
this.#isReady = false;
|
||||
|
||||
this.#clientSideCache?.onPoolClose();
|
||||
|
||||
if (this.#scanTimer) {
|
||||
clearInterval(this.#scanTimer);
|
||||
this.#scanTimer = undefined;
|
||||
|
@@ -188,18 +188,22 @@ export class SentinelFramework extends DockerBase {
|
||||
}
|
||||
|
||||
const options: RedisSentinelOptions<RedisModules, RedisFunctions, RedisScripts, RespVersions, TypeMapping> = {
|
||||
...opts,
|
||||
name: this.config.sentinelName,
|
||||
sentinelRootNodes: this.#sentinelList.map((sentinel) => { return { host: '127.0.0.1', port: sentinel.docker.port } }),
|
||||
passthroughClientErrorEvents: errors
|
||||
}
|
||||
|
||||
if (this.config.password !== undefined) {
|
||||
options.nodeClientOptions = {password: this.config.password};
|
||||
options.sentinelClientOptions = {password: this.config.password};
|
||||
}
|
||||
if (!options.nodeClientOptions) {
|
||||
options.nodeClientOptions = {};
|
||||
}
|
||||
options.nodeClientOptions.password = this.config.password;
|
||||
|
||||
if (opts) {
|
||||
Object.assign(options, opts);
|
||||
if (!options.sentinelClientOptions) {
|
||||
options.sentinelClientOptions = {};
|
||||
}
|
||||
options.sentinelClientOptions = {password: this.config.password};
|
||||
}
|
||||
|
||||
return RedisSentinel.create(options);
|
||||
|
@@ -4,6 +4,7 @@ import { CommandSignature, CommanderConfig, RedisFunctions, RedisModules, RedisS
|
||||
import COMMANDS from '../commands';
|
||||
import RedisSentinel, { RedisSentinelClient } from '.';
|
||||
import { RedisTcpSocketOptions } from '../client/socket';
|
||||
import { ClientSideCacheConfig, PooledClientSideCacheProvider } from '../client/cache';
|
||||
|
||||
export interface RedisNode {
|
||||
host: string;
|
||||
@@ -67,6 +68,41 @@ export interface RedisSentinelOptions<
|
||||
* When `false`, the sentinel object will wait for the first available client from the pool.
|
||||
*/
|
||||
reserveClient?: boolean;
|
||||
/**
|
||||
* 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 = createSentinel({
|
||||
* 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 = createSentinel({
|
||||
* clientSideCache: cache,
|
||||
* minimum: 5
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
clientSideCache?: PooledClientSideCacheProvider | ClientSideCacheConfig;
|
||||
}
|
||||
|
||||
export interface SentinelCommander<
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import { BasicCommandParser } from '../client/parser';
|
||||
import { ArrayReply, Command, RedisFunction, RedisScript, RespVersions, UnwrapReply } from '../RESP/types';
|
||||
import { BasicCommandParser } from '../client/parser';
|
||||
import { RedisSocketOptions, RedisTcpSocketOptions } from '../client/socket';
|
||||
import { functionArgumentsPrefix, getTransformReply, scriptArgumentsPrefix } from '../commander';
|
||||
import { NamespaceProxySentinel, NamespaceProxySentinelClient, ProxySentinel, ProxySentinelClient, RedisNode } from './types';
|
||||
|
Reference in New Issue
Block a user