From 2d5e2bada3daf6f8ea3fe6ed4d8026b381369991 Mon Sep 17 00:00:00 2001 From: andy-stark-redis <164213578+andy-stark-redis@users.noreply.github.com> Date: Wed, 29 Oct 2025 08:51:55 +0000 Subject: [PATCH 01/24] docs: DOC-5841 added index/query doc page examples (#3109) --- doctests/home-query.js | 205 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 doctests/home-query.js diff --git a/doctests/home-query.js b/doctests/home-query.js new file mode 100644 index 00000000000..b5b5bd5ba50 --- /dev/null +++ b/doctests/home-query.js @@ -0,0 +1,205 @@ +// EXAMPLE: js_home_query +// BINDER_ID nodejs-js_home_query +// REMOVE_START +import assert from "node:assert"; +// REMOVE_END +// STEP_START import +import { + createClient, + SCHEMA_FIELD_TYPE, + FT_AGGREGATE_GROUP_BY_REDUCERS, + FT_AGGREGATE_STEPS, +} from 'redis'; +// STEP_END + +// STEP_START create_data +const user1 = { + name: 'Paul John', + email: 'paul.john@example.com', + age: 42, + city: 'London' +}; + +const user2 = { + name: 'Eden Zamir', + email: 'eden.zamir@example.com', + age: 29, + city: 'Tel Aviv' +}; + +const user3 = { + name: 'Paul Zamir', + email: 'paul.zamir@example.com', + age: 35, + city: 'Tel Aviv' +}; +// STEP_END + +// STEP_START connect +const client = await createClient(); +await client.connect(); +// STEP_END + +// STEP_START cleanup_json +await client.ft.dropIndex('idx:users', { DD: true }).then(() => {}, () => {}); +// STEP_END + +// STEP_START create_index +await client.ft.create('idx:users', { + '$.name': { + type: SCHEMA_FIELD_TYPE.TEXT, + AS: 'name' + }, + '$.city': { + type: SCHEMA_FIELD_TYPE.TEXT, + AS: 'city' + }, + '$.age': { + type: SCHEMA_FIELD_TYPE.NUMERIC, + AS: 'age' + } +}, { + ON: 'JSON', + PREFIX: 'user:' +}); +// STEP_END + +// STEP_START add_data +const [user1Reply, user2Reply, user3Reply] = await Promise.all([ + client.json.set('user:1', '$', user1), + client.json.set('user:2', '$', user2), + client.json.set('user:3', '$', user3) +]); +// STEP_END +// REMOVE_START +assert.equal(user1Reply, 'OK'); +assert.equal(user2Reply, 'OK'); +assert.equal(user3Reply, 'OK'); +// REMOVE_END + +// STEP_START query1 +let findPaulResult = await client.ft.search('idx:users', 'Paul @age:[30 40]'); + +console.log(findPaulResult.total); // >>> 1 + +findPaulResult.documents.forEach(doc => { + console.log(`ID: ${doc.id}, name: ${doc.value.name}, age: ${doc.value.age}`); +}); +// >>> ID: user:3, name: Paul Zamir, age: 35 +// STEP_END +// REMOVE_START +assert.equal(findPaulResult.total, 1); +assert.equal(findPaulResult.documents[0].id, 'user:3'); +// REMOVE_END + +// STEP_START query2 +let citiesResult = await client.ft.search('idx:users', '*',{ + RETURN: 'city' +}); + +console.log(citiesResult.total); // >>> 3 + +citiesResult.documents.forEach(cityDoc => { + console.log(cityDoc.value); +}); +// >>> { city: 'London' } +// >>> { city: 'Tel Aviv' } +// >>> { city: 'Tel Aviv' } +// STEP_END +// REMOVE_START +assert.equal(citiesResult.total, 3); +citiesResult.documents.sort((a, b) => a.value.city.localeCompare(b.value.city)); +assert.deepEqual(citiesResult.documents.map(doc => doc.value.city), [ + 'London', + 'Tel Aviv', + 'Tel Aviv' +]); +// REMOVE_END + +// STEP_START query3 +let aggResult = await client.ft.aggregate('idx:users', '*', { + STEPS: [{ + type: FT_AGGREGATE_STEPS.GROUPBY, + properties: '@city', + REDUCE: [{ + type: FT_AGGREGATE_GROUP_BY_REDUCERS.COUNT, + AS: 'count' + }] + }] +}); + +console.log(aggResult.total); // >>> 2 + +aggResult.results.forEach(result => { + console.log(`${result.city} - ${result.count}`); +}); +// >>> London - 1 +// >>> Tel Aviv - 2 +// STEP_END +// REMOVE_START +assert.equal(aggResult.total, 2); +aggResult.results.sort((a, b) => a.city.localeCompare(b.city)); +assert.deepEqual(aggResult.results.map(result => result.city), [ + 'London', + 'Tel Aviv' +]); +assert.deepEqual(aggResult.results.map(result => result.count), [ + 1, + 2 +]); +// REMOVE_END + +// STEP_START cleanup_hash +await client.ft.dropIndex('hash-idx:users', { DD: true }).then(() => {}, () => {}); +// STEP_END + +// STEP_START create_hash_index +await client.ft.create('hash-idx:users', { + 'name': { + type: SCHEMA_FIELD_TYPE.TEXT + }, + 'city': { + type: SCHEMA_FIELD_TYPE.TEXT + }, + 'age': { + type: SCHEMA_FIELD_TYPE.NUMERIC + } +}, { + ON: 'HASH', + PREFIX: 'huser:' +}); +// STEP_END + +// STEP_START add_hash_data +const [huser1Reply, huser2Reply, huser3Reply] = await Promise.all([ + client.hSet('huser:1', user1), + client.hSet('huser:2', user2), + client.hSet('huser:3', user3) +]); +// STEP_END +// REMOVE_START +assert.equal(huser1Reply, 4); +assert.equal(huser2Reply, 4); +assert.equal(huser3Reply, 4); +// REMOVE_END + +// STEP_START query1_hash +let findPaulHashResult = await client.ft.search( + 'hash-idx:users', 'Paul @age:[30 40]' +); + +console.log(findPaulHashResult.total); // >>> 1 + +findPaulHashResult.documents.forEach(doc => { + console.log(`ID: ${doc.id}, name: ${doc.value.name}, age: ${doc.value.age}`); +}); +// >>> ID: huser:3, name: Paul Zamir, age: 35 +// STEP_END +// REMOVE_START +assert.equal(findPaulHashResult.total, 1); +assert.equal(findPaulHashResult.documents[0].id, 'huser:3'); +// REMOVE_END + +// STEP_START close +await client.quit(); +// STEP_END From 9c9a9732fba7854e33cce04c6500fdc7220d2157 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Thu, 30 Oct 2025 13:16:56 +0200 Subject: [PATCH 02/24] chore(tests): bump test container version 8.4-RC1-pre (#3115) --- .github/workflows/tests.yml | 2 +- packages/bloom/lib/test-utils.ts | 2 +- packages/client/lib/sentinel/test-util.ts | 2 +- packages/client/lib/test-utils.ts | 2 +- packages/entraid/lib/test-utils.ts | 2 +- packages/json/lib/test-utils.ts | 2 +- packages/search/lib/test-utils.ts | 2 +- packages/test-utils/lib/test-utils.ts | 2 +- packages/time-series/lib/test-utils.ts | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 45ada77197f..2e3a91f2c8b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -22,7 +22,7 @@ jobs: fail-fast: false matrix: node-version: ["18", "20", "22"] - redis-version: ["rs-7.4.0-v1", "8.0.2", "8.2", "8.4-M01-pre"] + redis-version: ["rs-7.4.0-v1", "8.0.2", "8.2", "8.4-M01-pre", "8.4-RC1-pre"] steps: - uses: actions/checkout@v4 with: diff --git a/packages/bloom/lib/test-utils.ts b/packages/bloom/lib/test-utils.ts index 2bad3e07617..908c063059d 100644 --- a/packages/bloom/lib/test-utils.ts +++ b/packages/bloom/lib/test-utils.ts @@ -4,7 +4,7 @@ import RedisBloomModules from '.'; export default TestUtils.createFromConfig({ dockerImageName: 'redislabs/client-libs-test', dockerImageVersionArgument: 'redis-version', - defaultDockerVersion: '8.4-M01-pre' + defaultDockerVersion: '8.4-RC1-pre' }); export const GLOBAL = { diff --git a/packages/client/lib/sentinel/test-util.ts b/packages/client/lib/sentinel/test-util.ts index 6998b31c7ff..70c551b843a 100644 --- a/packages/client/lib/sentinel/test-util.ts +++ b/packages/client/lib/sentinel/test-util.ts @@ -174,7 +174,7 @@ export class SentinelFramework extends DockerBase { this.#testUtils = TestUtils.createFromConfig({ dockerImageName: 'redislabs/client-libs-test', dockerImageVersionArgument: 'redis-version', - defaultDockerVersion: '8.4-M01-pre' + defaultDockerVersion: '8.4-RC1-pre' }); this.#nodeMap = new Map>>>(); this.#sentinelMap = new Map>>>(); diff --git a/packages/client/lib/test-utils.ts b/packages/client/lib/test-utils.ts index e9998f1350e..bcb7ae5c9ba 100644 --- a/packages/client/lib/test-utils.ts +++ b/packages/client/lib/test-utils.ts @@ -9,7 +9,7 @@ import RedisBloomModules from '@redis/bloom'; const utils = TestUtils.createFromConfig({ dockerImageName: 'redislabs/client-libs-test', dockerImageVersionArgument: 'redis-version', - defaultDockerVersion: '8.4-M01-pre' + defaultDockerVersion: '8.4-RC1-pre' }); export default utils; diff --git a/packages/entraid/lib/test-utils.ts b/packages/entraid/lib/test-utils.ts index add48e79d74..43c511f6eee 100644 --- a/packages/entraid/lib/test-utils.ts +++ b/packages/entraid/lib/test-utils.ts @@ -6,7 +6,7 @@ import { EntraidCredentialsProvider } from './entraid-credentials-provider'; export const testUtils = TestUtils.createFromConfig({ dockerImageName: 'redislabs/client-libs-test', dockerImageVersionArgument: 'redis-version', - defaultDockerVersion: '8.4-M01-pre' + defaultDockerVersion: '8.4-RC1-pre' }); const DEBUG_MODE_ARGS = testUtils.isVersionGreaterThan([7]) ? diff --git a/packages/json/lib/test-utils.ts b/packages/json/lib/test-utils.ts index 41e743b7132..a2733ab4924 100644 --- a/packages/json/lib/test-utils.ts +++ b/packages/json/lib/test-utils.ts @@ -4,7 +4,7 @@ import RedisJSON from '.'; export default TestUtils.createFromConfig({ dockerImageName: 'redislabs/client-libs-test', dockerImageVersionArgument: 'redis-version', - defaultDockerVersion: '8.4-M01-pre' + defaultDockerVersion: '8.4-RC1-pre' }); export const GLOBAL = { diff --git a/packages/search/lib/test-utils.ts b/packages/search/lib/test-utils.ts index 035ae29dd01..69f3cfa250e 100644 --- a/packages/search/lib/test-utils.ts +++ b/packages/search/lib/test-utils.ts @@ -5,7 +5,7 @@ import { RespVersions } from '@redis/client'; export default TestUtils.createFromConfig({ dockerImageName: 'redislabs/client-libs-test', dockerImageVersionArgument: 'redis-version', - defaultDockerVersion: '8.4-M01-pre' + defaultDockerVersion: '8.4-RC1-pre' }); export const GLOBAL = { diff --git a/packages/test-utils/lib/test-utils.ts b/packages/test-utils/lib/test-utils.ts index 7a172f6c4de..a008089c67d 100644 --- a/packages/test-utils/lib/test-utils.ts +++ b/packages/test-utils/lib/test-utils.ts @@ -3,7 +3,7 @@ import TestUtils from './index' export const testUtils = TestUtils.createFromConfig({ dockerImageName: 'redislabs/client-libs-test', dockerImageVersionArgument: 'redis-version', - defaultDockerVersion: '8.4-M01-pre' + defaultDockerVersion: '8.4-RC1-pre' }); diff --git a/packages/time-series/lib/test-utils.ts b/packages/time-series/lib/test-utils.ts index 0275da9cf2c..008994d85a8 100644 --- a/packages/time-series/lib/test-utils.ts +++ b/packages/time-series/lib/test-utils.ts @@ -4,7 +4,7 @@ import TimeSeries from '.'; export default TestUtils.createFromConfig({ dockerImageName: 'redislabs/client-libs-test', dockerImageVersionArgument: 'redis-version', - defaultDockerVersion: '8.4-M01-pre' + defaultDockerVersion: '8.4-RC1-pre' }); export const GLOBAL = { From 96a8a847f66ac5d60c621d46fc2ff21c579460d5 Mon Sep 17 00:00:00 2001 From: Hristo Temelski Date: Fri, 31 Oct 2025 13:09:04 +0200 Subject: [PATCH 03/24] feat(search): add hybrid search command (#3119) --- packages/search/lib/commands/HYBRID.spec.ts | 379 ++++++++++++++++++++ packages/search/lib/commands/HYBRID.ts | 377 +++++++++++++++++++ packages/search/lib/commands/index.ts | 3 + 3 files changed, 759 insertions(+) create mode 100644 packages/search/lib/commands/HYBRID.spec.ts create mode 100644 packages/search/lib/commands/HYBRID.ts diff --git a/packages/search/lib/commands/HYBRID.spec.ts b/packages/search/lib/commands/HYBRID.spec.ts new file mode 100644 index 00000000000..624c1b9c910 --- /dev/null +++ b/packages/search/lib/commands/HYBRID.spec.ts @@ -0,0 +1,379 @@ +import { strict as assert } from 'node:assert'; +import testUtils, { GLOBAL } from '../test-utils'; +import HYBRID from './HYBRID'; +import { BasicCommandParser } from '@redis/client/lib/client/parser'; + +describe('FT.HYBRID', () => { + describe('parseCommand', () => { + it('minimal command', () => { + const parser = new BasicCommandParser(); + HYBRID.parseCommand(parser, 'index'); + assert.deepEqual( + parser.redisArgs, + ['FT.HYBRID', 'index', '2', 'DIALECT', '2'] + ); + }); + + it('with count expressions', () => { + const parser = new BasicCommandParser(); + HYBRID.parseCommand(parser, 'index', { + countExpressions: 3 + }); + assert.deepEqual( + parser.redisArgs, + ['FT.HYBRID', 'index', '3', 'DIALECT', '2'] + ); + }); + + it('with SEARCH expression', () => { + const parser = new BasicCommandParser(); + HYBRID.parseCommand(parser, 'index', { + SEARCH: { + query: '@description: bikes' + } + }); + assert.deepEqual( + parser.redisArgs, + ['FT.HYBRID', 'index', '2', 'SEARCH', '@description: bikes', 'DIALECT', '2'] + ); + }); + + it('with SEARCH expression and SCORER', () => { + const parser = new BasicCommandParser(); + HYBRID.parseCommand(parser, 'index', { + SEARCH: { + query: '@description: bikes', + SCORER: { + algorithm: 'TFIDF.DOCNORM', + params: ['param1', 'param2'] + }, + YIELD_SCORE_AS: 'search_score' + } + }); + assert.deepEqual( + parser.redisArgs, + [ + 'FT.HYBRID', 'index', '2', 'SEARCH', '@description: bikes', + 'SCORER', 'TFIDF.DOCNORM', 'param1', 'param2', + 'YIELD_SCORE_AS', 'search_score', 'DIALECT', '2' + ] + ); + }); + + it('with VSIM expression and KNN method', () => { + const parser = new BasicCommandParser(); + HYBRID.parseCommand(parser, 'index', { + VSIM: { + field: '@vector_field', + vectorData: 'BLOB_DATA', + method: { + KNN: { + K: 10, + EF_RUNTIME: 50, + YIELD_DISTANCE_AS: 'vector_dist' + } + } + } + }); + assert.deepEqual( + parser.redisArgs, + [ + 'FT.HYBRID', 'index', '2', 'VSIM', '@vector_field', 'BLOB_DATA', + 'KNN', '1', 'K', '10', 'EF_RUNTIME', '50', 'YIELD_DISTANCE_AS', 'vector_dist', + 'DIALECT', '2' + ] + ); + }); + + it('with VSIM expression and RANGE method', () => { + const parser = new BasicCommandParser(); + HYBRID.parseCommand(parser, 'index', { + VSIM: { + field: '@vector_field', + vectorData: 'BLOB_DATA', + method: { + RANGE: { + RADIUS: 0.5, + EPSILON: 0.01, + YIELD_DISTANCE_AS: 'vector_dist' + } + } + } + }); + assert.deepEqual( + parser.redisArgs, + [ + 'FT.HYBRID', 'index', '2', 'VSIM', '@vector_field', 'BLOB_DATA', + 'RANGE', '1', 'RADIUS', '0.5', 'EPSILON', '0.01', 'YIELD_DISTANCE_AS', 'vector_dist', + 'DIALECT', '2' + ] + ); + }); + + it('with VSIM expression and FILTER', () => { + const parser = new BasicCommandParser(); + HYBRID.parseCommand(parser, 'index', { + VSIM: { + field: '@vector_field', + vectorData: 'BLOB_DATA', + FILTER: { + expression: '@category:{bikes}', + POLICY: 'BATCHES', + BATCHES: { + BATCH_SIZE: 100 + } + }, + YIELD_SCORE_AS: 'vsim_score' + } + }); + assert.deepEqual( + parser.redisArgs, + [ + 'FT.HYBRID', 'index', '2', 'VSIM', '@vector_field', 'BLOB_DATA', + 'FILTER', '@category:{bikes}', 'POLICY', 'BATCHES', 'BATCHES', 'BATCH_SIZE', '100', + 'YIELD_SCORE_AS', 'vsim_score', 'DIALECT', '2' + ] + ); + }); + + it('with RRF COMBINE method', () => { + const parser = new BasicCommandParser(); + HYBRID.parseCommand(parser, 'index', { + COMBINE: { + method: { + RRF: { + count: 2, + WINDOW: 10, + CONSTANT: 60 + } + }, + YIELD_SCORE_AS: 'combined_score' + } + }); + assert.deepEqual( + parser.redisArgs, + [ + 'FT.HYBRID', 'index', '2', 'COMBINE', 'RRF', '2', 'WINDOW', '10', 'CONSTANT', '60', + 'YIELD_SCORE_AS', 'combined_score', 'DIALECT', '2' + ] + ); + }); + + it('with LINEAR COMBINE method', () => { + const parser = new BasicCommandParser(); + HYBRID.parseCommand(parser, 'index', { + COMBINE: { + method: { + LINEAR: { + count: 2, + ALPHA: 0.7, + BETA: 0.3 + } + } + } + }); + assert.deepEqual( + parser.redisArgs, + [ + 'FT.HYBRID', 'index', '2', 'COMBINE', 'LINEAR', '2', 'ALPHA', '0.7', 'BETA', '0.3', + 'DIALECT', '2' + ] + ); + }); + + it('with LOAD, SORTBY, and LIMIT', () => { + const parser = new BasicCommandParser(); + HYBRID.parseCommand(parser, 'index', { + LOAD: ['field1', 'field2'], + SORTBY: { + count: 1, + fields: [ + { field: 'score', direction: 'DESC' } + ] + }, + LIMIT: { + offset: 0, + num: 10 + } + }); + assert.deepEqual( + parser.redisArgs, + [ + 'FT.HYBRID', 'index', '2', 'LOAD', '2', 'field1', 'field2', + 'SORTBY', '1', 'score', 'DESC', 'LIMIT', '0', '10', 'DIALECT', '2' + ] + ); + }); + + it('with GROUPBY and REDUCE', () => { + const parser = new BasicCommandParser(); + HYBRID.parseCommand(parser, 'index', { + GROUPBY: { + fields: ['@category'], + REDUCE: { + function: 'COUNT', + count: 0, + args: [] + } + } + }); + assert.deepEqual( + parser.redisArgs, + [ + 'FT.HYBRID', 'index', '2', 'GROUPBY', '1', '@category', 'REDUCE', 'COUNT', '0', + 'DIALECT', '2' + ] + ); + }); + + it('with APPLY', () => { + const parser = new BasicCommandParser(); + HYBRID.parseCommand(parser, 'index', { + APPLY: { + expression: '@score * 2', + AS: 'double_score' + } + }); + assert.deepEqual( + parser.redisArgs, + ['FT.HYBRID', 'index', '2', 'APPLY', '@score * 2', 'AS', 'double_score', 'DIALECT', '2'] + ); + }); + + it('with FILTER and post-processing', () => { + const parser = new BasicCommandParser(); + HYBRID.parseCommand(parser, 'index', { + FILTER: '@price:[100 500]' + }); + assert.deepEqual( + parser.redisArgs, + ['FT.HYBRID', 'index', '2', 'FILTER', '@price:[100 500]', 'DIALECT', '2'] + ); + }); + + it('with PARAMS', () => { + const parser = new BasicCommandParser(); + HYBRID.parseCommand(parser, 'index', { + PARAMS: { + query_vector: 'BLOB_DATA', + min_price: 100 + } + }); + assert.deepEqual( + parser.redisArgs, + [ + 'FT.HYBRID', 'index', '2', 'PARAMS', '4', 'query_vector', 'BLOB_DATA', 'min_price', '100', + 'DIALECT', '2' + ] + ); + }); + + it('with EXPLAINSCORE and TIMEOUT', () => { + const parser = new BasicCommandParser(); + HYBRID.parseCommand(parser, 'index', { + EXPLAINSCORE: true, + TIMEOUT: 5000 + }); + assert.deepEqual( + parser.redisArgs, + ['FT.HYBRID', 'index', '2', 'EXPLAINSCORE', 'TIMEOUT', '5000', 'DIALECT', '2'] + ); + }); + + it('with WITHCURSOR', () => { + const parser = new BasicCommandParser(); + HYBRID.parseCommand(parser, 'index', { + WITHCURSOR: { + COUNT: 100, + MAXIDLE: 300000 + } + }); + assert.deepEqual( + parser.redisArgs, + [ + 'FT.HYBRID', 'index', '2', 'WITHCURSOR', 'COUNT', '100', 'MAXIDLE', '300000', + 'DIALECT', '2' + ] + ); + }); + + it('complete example with all options', () => { + const parser = new BasicCommandParser(); + HYBRID.parseCommand(parser, 'index', { + countExpressions: 2, + SEARCH: { + query: '@description: bikes', + SCORER: { + algorithm: 'TFIDF.DOCNORM' + }, + YIELD_SCORE_AS: 'text_score' + }, + VSIM: { + field: '@vector_field', + vectorData: '$query_vector', + method: { + KNN: { + K: 5 + } + }, + YIELD_SCORE_AS: 'vector_score' + }, + COMBINE: { + method: { + RRF: { + count: 2, + CONSTANT: 60 + } + }, + YIELD_SCORE_AS: 'final_score' + }, + LOAD: ['description', 'price'], + SORTBY: { + count: 1, + fields: [{ field: 'final_score', direction: 'DESC' }] + }, + LIMIT: { + offset: 0, + num: 10 + }, + PARAMS: { + query_vector: 'BLOB_DATA' + } + }); + assert.deepEqual( + parser.redisArgs, + [ + 'FT.HYBRID', 'index', '2', + 'SEARCH', '@description: bikes', 'SCORER', 'TFIDF.DOCNORM', 'YIELD_SCORE_AS', 'text_score', + 'VSIM', '@vector_field', '$query_vector', 'KNN', '1', 'K', '5', 'YIELD_SCORE_AS', 'vector_score', + 'COMBINE', 'RRF', '2', 'CONSTANT', '60', 'YIELD_SCORE_AS', 'final_score', + 'LOAD', '2', 'description', 'price', + 'SORTBY', '1', 'final_score', 'DESC', + 'LIMIT', '0', '10', + 'PARAMS', '2', 'query_vector', 'BLOB_DATA', + 'DIALECT', '2' + ] + ); + }); + + it('with custom DIALECT', () => { + const parser = new BasicCommandParser(); + HYBRID.parseCommand(parser, 'index', { + DIALECT: 3 + }); + assert.deepEqual( + parser.redisArgs, + ['FT.HYBRID', 'index', '2', 'DIALECT', '3'] + ); + }); + }); + + // Integration tests would need to be added when RediSearch supports FT.HYBRID + // For now, we'll skip them as this is a new command that may not be available yet + describe.skip('client.ft.hybrid', () => { + testUtils.testWithClient('basic hybrid search', async client => { + // This would require a test index and data setup + // similar to how other FT commands are tested + }, GLOBAL.SERVERS.OPEN); + }); +}); \ No newline at end of file diff --git a/packages/search/lib/commands/HYBRID.ts b/packages/search/lib/commands/HYBRID.ts new file mode 100644 index 00000000000..b94e7196dad --- /dev/null +++ b/packages/search/lib/commands/HYBRID.ts @@ -0,0 +1,377 @@ +import { CommandParser } from '@redis/client/dist/lib/client/parser'; +import { RedisArgument, Command, ReplyUnion } from '@redis/client/dist/lib/RESP/types'; +import { RedisVariadicArgument, parseOptionalVariadicArgument } from '@redis/client/dist/lib/commands/generic-transformers'; +import { DEFAULT_DIALECT } from '../dialect/default'; +import { FtSearchParams, parseParamsArgument } from './SEARCH'; + +export interface FtHybridSearchExpression { + query: RedisArgument; + SCORER?: { + algorithm: RedisArgument; + params?: Array; + }; + YIELD_SCORE_AS?: RedisArgument; +} + +export interface FtHybridVectorMethod { + KNN?: { + K: number; + EF_RUNTIME?: number; + YIELD_DISTANCE_AS?: RedisArgument; + }; + RANGE?: { + RADIUS: number; + EPSILON?: number; + YIELD_DISTANCE_AS?: RedisArgument; + }; +} + +export interface FtHybridVectorExpression { + field: RedisArgument; + vectorData: RedisArgument; + method?: FtHybridVectorMethod; + FILTER?: { + expression: RedisArgument; + POLICY?: 'ADHOC' | 'BATCHES' | 'ACORN'; + BATCHES?: { + BATCH_SIZE: number; + }; + }; + YIELD_SCORE_AS?: RedisArgument; +} + +export interface FtHybridCombineMethod { + RRF?: { + count: number; + WINDOW?: number; + CONSTANT?: number; + }; + LINEAR?: { + count: number; + ALPHA?: number; + BETA?: number; + }; + FUNCTION?: RedisArgument; +} + +export interface FtHybridOptions { + countExpressions?: number; + SEARCH?: FtHybridSearchExpression; + VSIM?: FtHybridVectorExpression; + COMBINE?: { + method: FtHybridCombineMethod; + YIELD_SCORE_AS?: RedisArgument; + }; + LOAD?: RedisVariadicArgument; + GROUPBY?: { + fields: RedisVariadicArgument; + REDUCE?: { + function: RedisArgument; + count: number; + args: Array; + }; + }; + APPLY?: { + expression: RedisArgument; + AS: RedisArgument; + }; + SORTBY?: { + count: number; + fields: Array<{ + field: RedisArgument; + direction?: 'ASC' | 'DESC'; + }>; + }; + FILTER?: RedisArgument; + LIMIT?: { + offset: number | RedisArgument; + num: number | RedisArgument; + }; + PARAMS?: FtSearchParams; + EXPLAINSCORE?: boolean; + TIMEOUT?: number; + WITHCURSOR?: { + COUNT?: number; + MAXIDLE?: number; + }; + DIALECT?: number; +} + +function parseSearchExpression(parser: CommandParser, search: FtHybridSearchExpression) { + parser.push('SEARCH', search.query); + + if (search.SCORER) { + parser.push('SCORER', search.SCORER.algorithm); + if (search.SCORER.params) { + parser.push(...search.SCORER.params); + } + } + + if (search.YIELD_SCORE_AS) { + parser.push('YIELD_SCORE_AS', search.YIELD_SCORE_AS); + } +} + +function parseVectorExpression(parser: CommandParser, vsim: FtHybridVectorExpression) { + parser.push('VSIM', vsim.field, vsim.vectorData); + + if (vsim.method) { + if (vsim.method.KNN) { + const knn = vsim.method.KNN; + parser.push('KNN', '1', 'K', knn.K.toString()); + + if (knn.EF_RUNTIME !== undefined) { + parser.push('EF_RUNTIME', knn.EF_RUNTIME.toString()); + } + + if (knn.YIELD_DISTANCE_AS) { + parser.push('YIELD_DISTANCE_AS', knn.YIELD_DISTANCE_AS); + } + } + + if (vsim.method.RANGE) { + const range = vsim.method.RANGE; + parser.push('RANGE', '1', 'RADIUS', range.RADIUS.toString()); + + if (range.EPSILON !== undefined) { + parser.push('EPSILON', range.EPSILON.toString()); + } + + if (range.YIELD_DISTANCE_AS) { + parser.push('YIELD_DISTANCE_AS', range.YIELD_DISTANCE_AS); + } + } + } + + if (vsim.FILTER) { + parser.push('FILTER', vsim.FILTER.expression); + + if (vsim.FILTER.POLICY) { + parser.push('POLICY', vsim.FILTER.POLICY); + + if (vsim.FILTER.POLICY === 'BATCHES' && vsim.FILTER.BATCHES) { + parser.push('BATCHES', 'BATCH_SIZE', vsim.FILTER.BATCHES.BATCH_SIZE.toString()); + } + } + } + + if (vsim.YIELD_SCORE_AS) { + parser.push('YIELD_SCORE_AS', vsim.YIELD_SCORE_AS); + } +} + +function parseCombineMethod(parser: CommandParser, combine: FtHybridOptions['COMBINE']) { + if (!combine) return; + + parser.push('COMBINE'); + + if (combine.method.RRF) { + const rrf = combine.method.RRF; + parser.push('RRF', rrf.count.toString()); + + if (rrf.WINDOW !== undefined) { + parser.push('WINDOW', rrf.WINDOW.toString()); + } + + if (rrf.CONSTANT !== undefined) { + parser.push('CONSTANT', rrf.CONSTANT.toString()); + } + } + + if (combine.method.LINEAR) { + const linear = combine.method.LINEAR; + parser.push('LINEAR', linear.count.toString()); + + if (linear.ALPHA !== undefined) { + parser.push('ALPHA', linear.ALPHA.toString()); + } + + if (linear.BETA !== undefined) { + parser.push('BETA', linear.BETA.toString()); + } + } + + if (combine.method.FUNCTION) { + parser.push('FUNCTION', combine.method.FUNCTION); + } + + if (combine.YIELD_SCORE_AS) { + parser.push('YIELD_SCORE_AS', combine.YIELD_SCORE_AS); + } +} + +function parseHybridOptions(parser: CommandParser, options?: FtHybridOptions) { + if (!options) return; + + if (options.SEARCH) { + parseSearchExpression(parser, options.SEARCH); + } + + if (options.VSIM) { + parseVectorExpression(parser, options.VSIM); + } + + if (options.COMBINE) { + parseCombineMethod(parser, options.COMBINE); + } + + parseOptionalVariadicArgument(parser, 'LOAD', options.LOAD); + + if (options.GROUPBY) { + parseOptionalVariadicArgument(parser, 'GROUPBY', options.GROUPBY.fields); + + if (options.GROUPBY.REDUCE) { + parser.push('REDUCE', options.GROUPBY.REDUCE.function, options.GROUPBY.REDUCE.count.toString()); + parser.push(...options.GROUPBY.REDUCE.args); + } + } + + if (options.APPLY) { + parser.push('APPLY', options.APPLY.expression, 'AS', options.APPLY.AS); + } + + if (options.SORTBY) { + parser.push('SORTBY', options.SORTBY.count.toString()); + for (const sortField of options.SORTBY.fields) { + parser.push(sortField.field); + if (sortField.direction) { + parser.push(sortField.direction); + } + } + } + + if (options.FILTER) { + parser.push('FILTER', options.FILTER); + } + + if (options.LIMIT) { + parser.push('LIMIT', options.LIMIT.offset.toString(), options.LIMIT.num.toString()); + } + + parseParamsArgument(parser, options.PARAMS); + + if (options.EXPLAINSCORE) { + parser.push('EXPLAINSCORE'); + } + + if (options.TIMEOUT !== undefined) { + parser.push('TIMEOUT', options.TIMEOUT.toString()); + } + + if (options.WITHCURSOR) { + parser.push('WITHCURSOR'); + + if (options.WITHCURSOR.COUNT !== undefined) { + parser.push('COUNT', options.WITHCURSOR.COUNT.toString()); + } + + if (options.WITHCURSOR.MAXIDLE !== undefined) { + parser.push('MAXIDLE', options.WITHCURSOR.MAXIDLE.toString()); + } + } + + if (options?.DIALECT) { + parser.push('DIALECT', options.DIALECT.toString()); + } +} + +export default { + NOT_KEYED_COMMAND: true, + IS_READ_ONLY: true, + /** + * Performs a hybrid search combining multiple search expressions. + * Supports multiple SEARCH and VECTOR expressions with various fusion methods. + * + * @param parser - The command parser + * @param index - The index name to search + * @param options - Hybrid search options including: + * - countExpressions: Number of expressions (default 2) + * - SEARCH: Text search expression with optional scoring + * - VSIM: Vector similarity expression with KNN/RANGE methods + * - COMBINE: Fusion method (RRF, LINEAR, FUNCTION) + * - Post-processing operations: LOAD, GROUPBY, APPLY, SORTBY, FILTER + * - Tunable options: LIMIT, PARAMS, EXPLAINSCORE, TIMEOUT, WITHCURSOR + */ + parseCommand(parser: CommandParser, index: RedisArgument, options?: FtHybridOptions) { + parser.push('FT.HYBRID', index); + + if (options?.countExpressions !== undefined) { + parser.push(options.countExpressions.toString()); + } else { + parser.push('2'); // Default to 2 expressions + } + + parseHybridOptions(parser, options); + + // Always add DIALECT at the end if not already added + if (!options?.DIALECT) { + parser.push('DIALECT', DEFAULT_DIALECT); + } + }, + transformReply: { + 2: (reply: any): any => { + // Check if this is a cursor reply: [[results...], cursorId] + if (Array.isArray(reply) && reply.length === 2 && typeof reply[1] === 'number') { + // This is a cursor reply + const [searchResults, cursor] = reply; + const transformedResults = transformHybridSearchResults(searchResults); + + return { + ...transformedResults, + cursor + }; + } else { + // Normal reply without cursor + return transformHybridSearchResults(reply); + } + }, + 3: undefined as unknown as () => ReplyUnion + }, + unstableResp3: true +} as const satisfies Command; + +function transformHybridSearchResults(reply: any) { + // Similar structure to FT.SEARCH reply transformation + const withoutDocuments = reply.length > 2 && !Array.isArray(reply[2]); + + const documents = []; + let i = 1; + while (i < reply.length) { + documents.push({ + id: reply[i++], + value: withoutDocuments ? Object.create(null) : documentValue(reply[i++]) + }); + } + + return { + total: reply[0], + documents + }; +} + +function documentValue(tuples: any) { + const message = Object.create(null); + + if (!tuples) { + return message; + } + + let i = 0; + while (i < tuples.length) { + const key = tuples[i++]; + const value = tuples[i++]; + + if (key === '$') { // might be a JSON reply + try { + Object.assign(message, JSON.parse(value)); + continue; + } catch { + // set as a regular property if not a valid JSON + } + } + + message[key] = value; + } + + return message; +} diff --git a/packages/search/lib/commands/index.ts b/packages/search/lib/commands/index.ts index 7aa3f061bf7..53030be1ef6 100644 --- a/packages/search/lib/commands/index.ts +++ b/packages/search/lib/commands/index.ts @@ -16,6 +16,7 @@ import DICTDUMP from './DICTDUMP'; import DROPINDEX from './DROPINDEX'; import EXPLAIN from './EXPLAIN'; import EXPLAINCLI from './EXPLAINCLI'; +import HYBRID from './HYBRID'; import INFO from './INFO'; import PROFILESEARCH from './PROFILE_SEARCH'; import PROFILEAGGREGATE from './PROFILE_AGGREGATE'; @@ -82,6 +83,8 @@ export default { explain: EXPLAIN, EXPLAINCLI, explainCli: EXPLAINCLI, + HYBRID, + hybrid: HYBRID, INFO, info: INFO, PROFILESEARCH, From 130e88d45c4b4dc04c8f9da24944e61d3e1211c5 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Mon, 3 Nov 2025 11:08:17 +0200 Subject: [PATCH 04/24] chore: proxy improvements (#3121) * introduce global interceptors * move proxy stuff to new folder * implement resp framer * properly handle request/response and push * add global interceptor --- packages/test-utils/lib/index.ts | 2 +- .../test-utils/lib/proxy/redis-proxy-spec.ts | 315 ++++++++ .../test-utils/lib/{ => proxy}/redis-proxy.ts | 119 ++- .../test-utils/lib/proxy/resp-framer-spec.ts | 735 ++++++++++++++++++ packages/test-utils/lib/proxy/resp-framer.ts | 167 ++++ packages/test-utils/lib/proxy/resp-queue.ts | 43 + packages/test-utils/lib/redis-proxy-spec.ts | 167 ---- 7 files changed, 1347 insertions(+), 201 deletions(-) create mode 100644 packages/test-utils/lib/proxy/redis-proxy-spec.ts rename packages/test-utils/lib/{ => proxy}/redis-proxy.ts (76%) create mode 100644 packages/test-utils/lib/proxy/resp-framer-spec.ts create mode 100644 packages/test-utils/lib/proxy/resp-framer.ts create mode 100644 packages/test-utils/lib/proxy/resp-queue.ts delete mode 100644 packages/test-utils/lib/redis-proxy-spec.ts diff --git a/packages/test-utils/lib/index.ts b/packages/test-utils/lib/index.ts index 5f339e9a426..1a9d1c9845a 100644 --- a/packages/test-utils/lib/index.ts +++ b/packages/test-utils/lib/index.ts @@ -26,7 +26,7 @@ import { hideBin } from 'yargs/helpers'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { RedisProxy, getFreePortNumber } from './redis-proxy'; +import { RedisProxy, getFreePortNumber } from './proxy/redis-proxy'; interface TestUtilsConfig { /** diff --git a/packages/test-utils/lib/proxy/redis-proxy-spec.ts b/packages/test-utils/lib/proxy/redis-proxy-spec.ts new file mode 100644 index 00000000000..f86d96d014c --- /dev/null +++ b/packages/test-utils/lib/proxy/redis-proxy-spec.ts @@ -0,0 +1,315 @@ +import { strict as assert } from 'node:assert'; +import { Buffer } from 'node:buffer'; +import { testUtils, GLOBAL } from '../test-utils'; +import { InterceptorDescription, RedisProxy } from './redis-proxy'; +import type { RedisClientType } from '@redis/client/lib/client/index.js'; + +describe('RedisSocketProxy', function () { + testUtils.testWithClient('basic proxy functionality', async (client: RedisClientType) => { + const socketOptions = client?.options?.socket; + //@ts-ignore + assert(socketOptions?.port, 'Test requires a TCP connection to Redis'); + + const proxyPort = 50000 + Math.floor(Math.random() * 10000); + const proxy = new RedisProxy({ + listenHost: '127.0.0.1', + listenPort: proxyPort, + //@ts-ignore + targetPort: socketOptions.port, + //@ts-ignore + targetHost: socketOptions.host || '127.0.0.1', + enableLogging: true + }); + + const proxyEvents = { + connections: [] as any[], + dataTransfers: [] as any[] + }; + + proxy.on('connection', (connectionInfo) => { + proxyEvents.connections.push(connectionInfo); + }); + + proxy.on('data', (connectionId, direction, data) => { + proxyEvents.dataTransfers.push({ connectionId, direction, dataLength: data.length }); + }); + + try { + await proxy.start(); + + const proxyClient = client.duplicate({ + socket: { + port: proxyPort, + host: '127.0.0.1' + }, + }); + + await proxyClient.connect(); + + const stats = proxy.getStats(); + assert.equal(stats.activeConnections, 1, 'Should have one active connection'); + assert.equal(proxyEvents.connections.length, 1, 'Should have recorded one connection event'); + + const pingResult = await proxyClient.ping(); + assert.equal(pingResult, 'PONG', 'Client should be able to communicate with Redis through the proxy'); + + const clientToServerTransfers = proxyEvents.dataTransfers.filter(t => t.direction === 'client->server'); + const serverToClientTransfers = proxyEvents.dataTransfers.filter(t => t.direction === 'server->client'); + + assert(clientToServerTransfers.length > 0, 'Should have client->server data transfers'); + assert(serverToClientTransfers.length > 0, 'Should have server->client data transfers'); + + const testKey = `test:proxy:${Date.now()}`; + const testValue = 'proxy-test-value'; + + await proxyClient.set(testKey, testValue); + const retrievedValue = await proxyClient.get(testKey); + assert.equal(retrievedValue, testValue, 'Should be able to set and get values through proxy'); + + proxyClient.destroy(); + + + } finally { + await proxy.stop(); + } + }, GLOBAL.SERVERS.OPEN_RESP_3); + + testUtils.testWithProxiedClient('custom message injection via proxy client', + async (proxiedClient: RedisClientType, proxy: RedisProxy) => { + const customMessageTransfers: any[] = []; + + proxy.on('data', (connectionId, direction, data) => { + if (direction === 'server->client') { + customMessageTransfers.push({ connectionId, dataLength: data.length, data }); + } + }); + + + const stats = proxy.getStats(); + assert.equal(stats.activeConnections, 1, 'Should have one active connection'); + + // Send a resp3 push + const customMessage = Buffer.from('>4\r\n$6\r\nMOVING\r\n:1\r\n:2\r\n$6\r\nhost:3\r\n'); + + const sendResults = proxy.sendToAllClients(customMessage); + assert.equal(sendResults.length, 1, 'Should send to one client'); + assert.equal(sendResults[0].success, true, 'Custom message send should succeed'); + + + const customMessageFound = customMessageTransfers.find(transfer => + transfer.dataLength === customMessage.length + ); + assert(customMessageFound, 'Should have recorded the custom message transfer'); + + assert.equal(customMessageFound.dataLength, customMessage.length, + 'Custom message length should match'); + + const pingResult = await proxiedClient.ping(); + assert.equal(pingResult, 'PONG', 'Client should be able to communicate with Redis through the proxy'); + + }, GLOBAL.SERVERS.OPEN_RESP_3); + + describe("Middleware", () => { + testUtils.testWithProxiedClient( + "Modify request/response via middleware", + async ( + proxiedClient: RedisClientType, + proxy: RedisProxy, + ) => { + + // Intercept PING commands and modify the response + const pingInterceptor: InterceptorDescription = { + name: `ping`, + fn: async (data, next) => { + if (data.includes('PING')) { + return Buffer.from("+PINGINTERCEPTED\r\n"); + } + return next(data); + } + }; + + // Only intercept GET responses and double numeric values + // Does not modify other commands or non-numeric GET responses + const doubleNumberGetInterceptor: InterceptorDescription = { + name: `double-number-get`, + fn: async (data, next) => { + const response = await next(data); + + // Not a GET command, return original response + if (!data.includes("GET")) return response; + + const value = (response.toString().split("\r\n"))[1]; + const number = Number(value); + // Not a number, return original response + if(isNaN(number)) return response; + + const doubled = String(number * 2); + return Buffer.from(`$${doubled.length}\r\n${doubled}\r\n`); + } + }; + + proxy.setGlobalInterceptors([ pingInterceptor, doubleNumberGetInterceptor ]) + + const pingResponse = await proxiedClient.ping(); + assert.equal(pingResponse, 'PINGINTERCEPTED', 'Response should be modified by middleware'); + + await proxiedClient.set('foo', 1); + const getResponse1 = await proxiedClient.get('foo'); + assert.equal(getResponse1, '2', 'GET response should be doubled for numbers by middleware'); + + await proxiedClient.set('bar', 'Hi'); + const getResponse2 = await proxiedClient.get('bar'); + assert.equal(getResponse2, 'Hi', 'GET response should not be modified for strings by middleware'); + + await proxiedClient.hSet('baz', 'foo', 'dictvalue'); + const hgetResponse = await proxiedClient.hGet('baz', 'foo'); + assert.equal(hgetResponse, 'dictvalue', 'HGET response should not be modified by middleware'); + + }, + GLOBAL.SERVERS.OPEN_RESP_3, + ); + + testUtils.testWithProxiedClient( + "Stats reflect middleware activity", + async ( + proxiedClient: RedisClientType, + proxy: RedisProxy, + ) => { + const PING = `ping`; + const SKIPPED = `skipped`; + proxy.setGlobalInterceptors([ + { + name: PING, + matchLimit: 3, + fn: async (data, next, state) => { + state.invokeCount++; + if(state.matchCount === state.matchLimit) return next(data); + if (data.includes("PING")) { + state.matchCount++; + return Buffer.from("+PINGINTERCEPTED\r\n"); + } + return next(data); + }, + }, + { + name: SKIPPED, + fn: async (data, next, state) => { + state.invokeCount++; + state.matchCount++; + // This interceptor does not match anything + return next(data); + }, + }, + ]); + + await proxiedClient.ping(); + await proxiedClient.ping(); + await proxiedClient.ping(); + + let stats = proxy.getStats(); + let pingInterceptor = stats.globalInterceptors.find( + (i) => i.name === PING, + ); + assert.ok(pingInterceptor, "PING interceptor stats should be present"); + assert.equal(pingInterceptor.invokeCount, 3); + assert.equal(pingInterceptor.matchCount, 3); + + let skipInterceptor = stats.globalInterceptors.find( + (i) => i.name === SKIPPED, + ); + assert.ok(skipInterceptor, "SKIPPED interceptor stats should be present"); + assert.equal(skipInterceptor.invokeCount, 0); + assert.equal(skipInterceptor.matchCount, 0); + + await proxiedClient.set("foo", "bar"); + await proxiedClient.get("foo"); + + stats = proxy.getStats(); + pingInterceptor = stats.globalInterceptors.find( + (i) => i.name === PING, + ); + assert.ok(pingInterceptor, "PING interceptor stats should be present"); + assert.equal(pingInterceptor.invokeCount, 5); + assert.equal(pingInterceptor.matchCount, 3); + + await proxiedClient.ping(); + + stats = proxy.getStats(); + pingInterceptor = stats.globalInterceptors.find( + (i) => i.name === PING, + ); + assert.ok(pingInterceptor, "PING interceptor stats should be present"); + assert.equal(pingInterceptor.invokeCount, 6); + assert.equal(pingInterceptor.matchCount, 3, 'Should not match more than limit'); + + skipInterceptor = stats.globalInterceptors.find( + (i) => i.name === SKIPPED, + ); + assert.ok(skipInterceptor, "PING interceptor stats should be present"); + assert.equal(skipInterceptor.invokeCount, 3); + assert.equal(skipInterceptor.matchCount, 3); + }, + GLOBAL.SERVERS.OPEN_RESP_3, + ); + + testUtils.testWithProxiedClient( + "Middleware is given exactly one RESP message at a time", + async ( + proxiedClient: RedisClientType, + proxy: RedisProxy, + ) => { + proxy.setGlobalInterceptors([ + { + name: `ping`, + fn: async (data, next, state) => { + state.invokeCount++; + if (data.equals(Buffer.from("*1\r\n$4\r\nPING\r\n"))) { + state.matchCount++; + } + return next(data); + }, + }, + ]); + + await Promise.all([proxiedClient.ping(), proxiedClient.ping()]); + + const stats = proxy.getStats(); + const pingInterceptor = stats.globalInterceptors.find( + (i) => i.name === `ping`, + ); + assert.ok(pingInterceptor, "PING interceptor stats should be present"); + assert.equal(pingInterceptor.invokeCount, 2); + assert.equal(pingInterceptor.matchCount, 2); + }, + GLOBAL.SERVERS.OPEN_RESP_3, + ); + + testUtils.testWithProxiedClient( + "Proxy passes through push messages", + async ( + proxiedClient: RedisClientType, + proxy: RedisProxy, + ) => { + let resolve: (value: string) => void; + const promise = new Promise((rs) => { resolve = rs; }); + await proxiedClient.subscribe("test-push-channel", (message) => { + resolve(message); + }); + + await proxiedClient.publish("test-push-channel", "hello"); + const result = await promise; + assert.equal(result, "hello", "Should receive push message through proxy"); + }, + { + ...GLOBAL.SERVERS.OPEN_RESP_3, + clientOptions: { + maintNotifications: 'disabled', + disableClientInfo: true, + RESP: 3 + } + }, + ); + }); + + +}); diff --git a/packages/test-utils/lib/redis-proxy.ts b/packages/test-utils/lib/proxy/redis-proxy.ts similarity index 76% rename from packages/test-utils/lib/redis-proxy.ts rename to packages/test-utils/lib/proxy/redis-proxy.ts index a4ea605285f..40dca2c7176 100644 --- a/packages/test-utils/lib/redis-proxy.ts +++ b/packages/test-utils/lib/proxy/redis-proxy.ts @@ -1,5 +1,7 @@ import * as net from 'net'; import { EventEmitter } from 'events'; +import RespFramer from './resp-framer'; +import RespQueue from './resp-queue'; interface ProxyConfig { readonly listenPort: number; @@ -10,17 +12,21 @@ interface ProxyConfig { readonly enableLogging?: boolean; } -interface ConnectionInfo { +interface ConnectionInfoCommon { readonly id: string; readonly clientAddress: string; readonly clientPort: number; readonly connectedAt: Date; } -interface ActiveConnection extends ConnectionInfo { +interface ConnectionInfo extends ConnectionInfoCommon { + readonly interceptors: InterceptorState[]; +} + +interface ActiveConnection extends ConnectionInfoCommon { readonly clientSocket: net.Socket; readonly serverSocket: net.Socket; - inflightRequestsCount: number + interceptors: Interceptor[]; } type SendResult = @@ -33,6 +39,7 @@ interface ProxyStats { readonly activeConnections: number; readonly totalConnections: number; readonly connections: readonly ConnectionInfo[]; + readonly globalInterceptors: InterceptorState[]; } interface ProxyEvents { @@ -50,16 +57,35 @@ interface ProxyEvents { 'close': () => void; } -export type Interceptor = (data: Buffer) => Promise; -export type InterceptorFunction = (data: Buffer, next: Interceptor) => Promise; -type InterceptorInitializer = (init: Interceptor) => Interceptor; +export type Next = (data: Buffer) => Promise; + +export type InterceptorFunction = (data: Buffer, next: Next, state: InterceptorState) => Promise; + +export interface InterceptorDescription { + name: string; + matchLimit?: number; + fn: InterceptorFunction; +} + +export interface InterceptorState { + name: string; + matchLimit?: number; + invokeCount: number; + matchCount: number; +} + +interface Interceptor { + name: string; + state: InterceptorState; + fn: InterceptorFunction; +} export class RedisProxy extends EventEmitter { private readonly server: net.Server; public readonly config: Required; private readonly connections: Map; private isRunning: boolean; - private interceptorInitializer: InterceptorInitializer = (init) => init; + private globalInterceptors: Interceptor[] = []; constructor(config: ProxyConfig) { super(); @@ -119,11 +145,32 @@ export class RedisProxy extends EventEmitter { }); } - public setInterceptors(interceptors: Array) { - this.interceptorInitializer = (init) => interceptors.reduceRight( - (next, mw) => (data) => mw(data, next), - init - ); + private makeInterceptor(description: InterceptorDescription): Interceptor { + const { name, fn, matchLimit } = description; + return { + name, + fn, + state: { + name, + matchCount: 0, + invokeCount: 0, + matchLimit, + }, + }; + } + + public setGlobalInterceptors( + interceptorDescriptions: Array, + ) { + const interceptors: Interceptor[] = interceptorDescriptions.map(this.makeInterceptor); + this.globalInterceptors = interceptors; + } + + public addGlobalInterceptor( + interceptorDescription: InterceptorDescription, + ) { + const interceptor = this.makeInterceptor(interceptorDescription); + this.globalInterceptors = [interceptor, ...this.globalInterceptors.filter(i => i.name !== interceptor.name)]; } public getStats(): ProxyStats { @@ -132,12 +179,14 @@ export class RedisProxy extends EventEmitter { return { activeConnections: connections.length, totalConnections: connections.length, + globalInterceptors: this.globalInterceptors.map(i => i.state), connections: connections.map((conn) => ({ id: conn.id, clientAddress: conn.clientAddress, clientPort: conn.clientPort, connectedAt: conn.connectedAt, - })) + interceptors: conn.interceptors.map(i => i.state) + })), }; } @@ -246,7 +295,7 @@ export class RedisProxy extends EventEmitter { connectedAt: new Date(), clientSocket, serverSocket, - inflightRequestsCount: 0 + interceptors: [], }; this.connections.set(connectionId, connectionInfo); @@ -259,33 +308,39 @@ export class RedisProxy extends EventEmitter { this.emit('connection', connectionInfo); }); - clientSocket.on('data', async (data) => { - this.emit('data', connectionId, 'client->server', data); + /** + * + * client -> clientSocket -> clientRespFramer -> interceptors -> queue -> serverSocket -> server + * client <- clientSocket <- interceptors <- response | queue <- serverRespFramer <- serverSocket <- server + * client <- clientSocket <- push | + */ + const clientRespFramer = new RespFramer(); + const respQueue = new RespQueue(serverSocket); - connectionInfo.inflightRequestsCount++; + clientRespFramer.on('message', async (data) => { // next1 -> next2 -> ... -> last -> server // next1 <- next2 <- ... <- last <- server - const last = (data: Buffer): Promise => { - return new Promise((resolve, reject) => { - serverSocket.write(data); - serverSocket.once('data', (data) => { - connectionInfo.inflightRequestsCount--; - assert(connectionInfo.inflightRequestsCount >= 0, `inflightRequestsCount for connection ${connectionId} went below zero`); - this.emit('data', connectionId, 'server->client', data); - resolve(data); - }); - serverSocket.once('error', reject); - }); + const last = async (data: Buffer): Promise => { + this.emit('data', connectionId, 'client->server', data); + const response = await respQueue.request(data); + return response; }; - const interceptorChain = this.interceptorInitializer(last); + const interceptorChain = connectionInfo.interceptors.concat(this.globalInterceptors).reduceRight( + (next, interceptor) => (data) => + interceptor.fn(data, next, interceptor.state), + last, + ); + const response = await interceptorChain(data); + this.emit('data', connectionId, 'server->client', response); clientSocket.write(response); }); - serverSocket.on('data', (data) => { - if (connectionInfo.inflightRequestsCount > 0) return; + clientSocket.on('data', data => clientRespFramer.write(data)); + + respQueue.on('push', (data) => { this.emit('data', connectionId, 'server->client', data); clientSocket.write(data); }); @@ -310,7 +365,6 @@ export class RedisProxy extends EventEmitter { }); serverSocket.on('error', (error) => { - if (connectionInfo.inflightRequestsCount > 0) return; this.log(`Server error for connection ${connectionId}: ${error.message}`); this.emit('error', error, connectionId); clientSocket.destroy(); @@ -344,7 +398,6 @@ export class RedisProxy extends EventEmitter { } } import { createServer } from 'net'; -import assert from 'node:assert'; export function getFreePortNumber(): Promise { return new Promise((resolve, reject) => { diff --git a/packages/test-utils/lib/proxy/resp-framer-spec.ts b/packages/test-utils/lib/proxy/resp-framer-spec.ts new file mode 100644 index 00000000000..1fd0c7bc366 --- /dev/null +++ b/packages/test-utils/lib/proxy/resp-framer-spec.ts @@ -0,0 +1,735 @@ +import { strict as assert } from 'node:assert'; +import RespFramer from './resp-framer'; + +describe('RespFramer - RESP2', () => { + it('should emit a simple string message', async () => { + const framer = new RespFramer(); + const expected = Buffer.from('+OK\r\n'); + + const messagePromise = new Promise((resolve) => { + framer.once('message', resolve); + }); + + framer.write(expected); + const message = await messagePromise; + assert.deepEqual(message, expected); + }); + + it('should emit an error message', async () => { + const framer = new RespFramer(); + const expected = Buffer.from('-ERR unknown command\r\n'); + + const messagePromise = new Promise((resolve) => { + framer.once('message', resolve); + }); + + framer.write(expected); + const message = await messagePromise; + assert.deepEqual(message, expected); + }); + + it('should emit an integer message', async () => { + const framer = new RespFramer(); + const expected = Buffer.from(':1000\r\n'); + + const messagePromise = new Promise((resolve) => { + framer.once('message', resolve); + }); + + framer.write(expected); + const message = await messagePromise; + assert.deepEqual(message, expected); + }); + + it('should emit a bulk string message', async () => { + const framer = new RespFramer(); + const expected = Buffer.from('$6\r\nfoobar\r\n'); + + const messagePromise = new Promise((resolve) => { + framer.once('message', resolve); + }); + + framer.write(expected); + const message = await messagePromise; + assert.deepEqual(message, expected); + }); + + it('should emit a null bulk string', async () => { + const framer = new RespFramer(); + const expected = Buffer.from('$-1\r\n'); + + const messagePromise = new Promise((resolve) => { + framer.once('message', resolve); + }); + + framer.write(expected); + const message = await messagePromise; + assert.deepEqual(message, expected); + }); + + it('should emit an array message', async () => { + const framer = new RespFramer(); + const expected = Buffer.from('*2\r\n$3\r\nfoo\r\n$3\r\nbar\r\n'); + + const messagePromise = new Promise((resolve) => { + framer.once('message', resolve); + }); + + framer.write(expected); + const message = await messagePromise; + assert.deepEqual(message, expected); + }); + + it('should emit a null array', async () => { + const framer = new RespFramer(); + const expected = Buffer.from('*-1\r\n'); + + const messagePromise = new Promise((resolve) => { + framer.once('message', resolve); + }); + + framer.write(expected); + const message = await messagePromise; + assert.deepEqual(message, expected); + }); + + it('should emit nested arrays', async () => { + const framer = new RespFramer(); + const expected = Buffer.from('*2\r\n*2\r\n$3\r\nfoo\r\n$3\r\nbar\r\n*1\r\n$3\r\nbaz\r\n'); + + const messagePromise = new Promise((resolve) => { + framer.once('message', resolve); + }); + + framer.write(expected); + const message = await messagePromise; + assert.deepEqual(message, expected); + }); + + it('should handle multiple complete messages', async () => { + const framer = new RespFramer(); + const messages = [ + Buffer.from('+OK\r\n'), + Buffer.from(':42\r\n'), + Buffer.from('$3\r\nfoo\r\n') + ]; + const combined = Buffer.concat(messages); + const received: Buffer[] = []; + + const messagesPromise = new Promise((resolve) => { + framer.on('message', (message) => { + received.push(message); + if (received.length === 3) { + resolve(received); + } + }); + }); + + framer.write(combined); + const result = await messagesPromise; + assert.equal(result.length, messages.length); + messages.forEach((expected, i) => { + assert.deepEqual(result[i], expected); + }); + }); + + it('should handle partial messages across multiple writes', async () => { + const framer = new RespFramer(); + const fullMessage = Buffer.from('$6\r\nfoobar\r\n'); + const part1 = fullMessage.subarray(0, 5); + const part2 = fullMessage.subarray(5); + + const messagePromise = new Promise((resolve) => { + framer.once('message', resolve); + }); + + framer.write(part1); + framer.write(part2); + const message = await messagePromise; + assert.deepEqual(message, fullMessage); + }); + + it('should handle array split across multiple writes', async () => { + const framer = new RespFramer(); + const fullMessage = Buffer.from('*2\r\n$3\r\nfoo\r\n$3\r\nbar\r\n'); + const part1 = fullMessage.subarray(0, 10); + const part2 = fullMessage.subarray(10); + + const messagePromise = new Promise((resolve) => { + framer.once('message', resolve); + }); + + framer.write(part1); + framer.write(part2); + const message = await messagePromise; + assert.deepEqual(message, fullMessage); + }); + + it('should handle empty array', async () => { + const framer = new RespFramer(); + const expected = Buffer.from('*0\r\n'); + + const messagePromise = new Promise((resolve) => { + framer.once('message', resolve); + }); + + framer.write(expected); + const message = await messagePromise; + assert.deepEqual(message, expected); + }); + + it('should handle empty bulk string', async () => { + const framer = new RespFramer(); + const expected = Buffer.from('$0\r\n\r\n'); + + const messagePromise = new Promise((resolve) => { + framer.once('message', resolve); + }); + + framer.write(expected); + const message = await messagePromise; + assert.deepEqual(message, expected); + }); + + it('should handle mixed message types in sequence', async () => { + const framer = new RespFramer(); + const messages = [ + Buffer.from('+PONG\r\n'), + Buffer.from('$3\r\nGET\r\n'), + Buffer.from('*3\r\n$3\r\nSET\r\n$3\r\nkey\r\n$5\r\nvalue\r\n'), + Buffer.from(':123\r\n'), + Buffer.from('-Error\r\n') + ]; + const received: Buffer[] = []; + + const messagesPromise = new Promise((resolve) => { + framer.on('message', (message) => { + received.push(message); + if (received.length === messages.length) { + resolve(received); + } + }); + }); + + messages.forEach(msg => framer.write(msg)); + const result = await messagesPromise; + assert.equal(result.length, messages.length); + messages.forEach((expected, i) => { + assert.deepEqual(result[i], expected); + }); + }); + + it('should handle bulk string containing \\r\\n in the data', async () => { + const framer = new RespFramer(); + const expected = Buffer.from('$12\r\nhello\r\nworld\r\n'); + + const messagePromise = new Promise((resolve) => { + framer.once('message', resolve); + }); + + framer.write(expected); + const message = await messagePromise; + assert.deepEqual(message, expected); + }); + + it('should handle bulk string with binary data including null bytes', async () => { + const framer = new RespFramer(); + const binaryData = Buffer.from([0x00, 0x01, 0x02, 0xff, 0xfe]); + const expected = Buffer.concat([ + Buffer.from('$5\r\n'), + binaryData, + Buffer.from('\r\n') + ]); + + const messagePromise = new Promise((resolve) => { + framer.once('message', resolve); + }); + + framer.write(expected); + const message = await messagePromise; + assert.deepEqual(message, expected); + }); + + it('should handle array with bulk strings containing \\r\\n', async () => { + const framer = new RespFramer(); + const expected = Buffer.from('*2\r\n$5\r\nfoo\r\n\r\n$5\r\nbar\r\n\r\n'); + + const messagePromise = new Promise((resolve) => { + framer.once('message', resolve); + }); + + framer.write(expected); + const message = await messagePromise; + assert.deepEqual(message, expected); + }); +}); + +describe('RespFramer - RESP3', () => { + it('should emit a null message', async () => { + const framer = new RespFramer(); + const expected = Buffer.from('_\r\n'); + + const messagePromise = new Promise((resolve) => { + framer.once('message', resolve); + }); + + framer.write(expected); + const message = await messagePromise; + assert.deepEqual(message, expected); + }); + + it('should emit a boolean true message', async () => { + const framer = new RespFramer(); + const expected = Buffer.from('#t\r\n'); + + const messagePromise = new Promise((resolve) => { + framer.once('message', resolve); + }); + + framer.write(expected); + const message = await messagePromise; + assert.deepEqual(message, expected); + }); + + it('should emit a boolean false message', async () => { + const framer = new RespFramer(); + const expected = Buffer.from('#f\r\n'); + + const messagePromise = new Promise((resolve) => { + framer.once('message', resolve); + }); + + framer.write(expected); + const message = await messagePromise; + assert.deepEqual(message, expected); + }); + + it('should emit a double message', async () => { + const framer = new RespFramer(); + const expected = Buffer.from(',3.14159\r\n'); + + const messagePromise = new Promise((resolve) => { + framer.once('message', resolve); + }); + + framer.write(expected); + const message = await messagePromise; + assert.deepEqual(message, expected); + }); + + it('should emit a double infinity message', async () => { + const framer = new RespFramer(); + const expected = Buffer.from(',inf\r\n'); + + const messagePromise = new Promise((resolve) => { + framer.once('message', resolve); + }); + + framer.write(expected); + const message = await messagePromise; + assert.deepEqual(message, expected); + }); + + it('should emit a double negative infinity message', async () => { + const framer = new RespFramer(); + const expected = Buffer.from(',-inf\r\n'); + + const messagePromise = new Promise((resolve) => { + framer.once('message', resolve); + }); + + framer.write(expected); + const message = await messagePromise; + assert.deepEqual(message, expected); + }); + + it('should emit a big number message', async () => { + const framer = new RespFramer(); + const expected = Buffer.from('(3492890328409238509324850943850943825024385\r\n'); + + const messagePromise = new Promise((resolve) => { + framer.once('message', resolve); + }); + + framer.write(expected); + const message = await messagePromise; + assert.deepEqual(message, expected); + }); + + it('should emit a bulk error message', async () => { + const framer = new RespFramer(); + const expected = Buffer.from('!21\r\nSYNTAX invalid syntax\r\n'); + + const messagePromise = new Promise((resolve) => { + framer.once('message', resolve); + }); + + framer.write(expected); + const message = await messagePromise; + assert.deepEqual(message, expected); + }); + + it('should emit a verbatim string message', async () => { + const framer = new RespFramer(); + const expected = Buffer.from('=15\r\ntxt:Some string\r\n'); + + const messagePromise = new Promise((resolve) => { + framer.once('message', resolve); + }); + + framer.write(expected); + const message = await messagePromise; + assert.deepEqual(message, expected); + }); + + it('should emit a map message', async () => { + const framer = new RespFramer(); + const expected = Buffer.from('%2\r\n+first\r\n:1\r\n+second\r\n:2\r\n'); + + const messagePromise = new Promise((resolve) => { + framer.once('message', resolve); + }); + + framer.write(expected); + const message = await messagePromise; + assert.deepEqual(message, expected); + }); + + it('should emit a set message', async () => { + const framer = new RespFramer(); + const expected = Buffer.from('~3\r\n+apple\r\n+banana\r\n+cherry\r\n'); + + const messagePromise = new Promise((resolve) => { + framer.once('message', resolve); + }); + + framer.write(expected); + const message = await messagePromise; + assert.deepEqual(message, expected); + }); + + it('should emit a push message', async () => { + const framer = new RespFramer(); + const expected = Buffer.from('>3\r\n+pubsub\r\n+message\r\n+channel\r\n'); + + const messagePromise = new Promise((resolve) => { + framer.once('message', resolve); + }); + + framer.write(expected); + const message = await messagePromise; + assert.deepEqual(message, expected); + }); + + it('should emit an attribute message', async () => { + const framer = new RespFramer(); + const expected = Buffer.from('|1\r\n+key-popularity\r\n%2\r\n$1\r\na\r\n,0.1923\r\n$1\r\nb\r\n,0.0012\r\n*2\r\n:2039123\r\n:9543892\r\n'); + + const messagePromise = new Promise((resolve) => { + framer.once('message', resolve); + }); + + framer.write(expected); + const message = await messagePromise; + assert.deepEqual(message, expected); + }); + + it('should handle nested RESP3 structures', async () => { + const framer = new RespFramer(); + const expected = Buffer.from('%2\r\n$4\r\nname\r\n$5\r\nAlice\r\n$3\r\nage\r\n:30\r\n'); + + const messagePromise = new Promise((resolve) => { + framer.once('message', resolve); + }); + + framer.write(expected); + const message = await messagePromise; + assert.deepEqual(message, expected); + }); + + it('should handle empty map', async () => { + const framer = new RespFramer(); + const expected = Buffer.from('%0\r\n'); + + const messagePromise = new Promise((resolve) => { + framer.once('message', resolve); + }); + + framer.write(expected); + const message = await messagePromise; + assert.deepEqual(message, expected); + }); + + it('should handle empty set', async () => { + const framer = new RespFramer(); + const expected = Buffer.from('~0\r\n'); + + const messagePromise = new Promise((resolve) => { + framer.once('message', resolve); + }); + + framer.write(expected); + const message = await messagePromise; + assert.deepEqual(message, expected); + }); + + it('should handle map with nested arrays', async () => { + const framer = new RespFramer(); + const expected = Buffer.from('%1\r\n$4\r\ndata\r\n*2\r\n:1\r\n:2\r\n'); + + const messagePromise = new Promise((resolve) => { + framer.once('message', resolve); + }); + + framer.write(expected); + const message = await messagePromise; + assert.deepEqual(message, expected); + }); + + it('should handle set with mixed types', async () => { + const framer = new RespFramer(); + const expected = Buffer.from('~4\r\n+string\r\n:42\r\n#t\r\n_\r\n'); + + const messagePromise = new Promise((resolve) => { + framer.once('message', resolve); + }); + + framer.write(expected); + const message = await messagePromise; + assert.deepEqual(message, expected); + }); + + it('should handle RESP3 split across multiple writes', async () => { + const framer = new RespFramer(); + const fullMessage = Buffer.from('%2\r\n+key1\r\n:100\r\n+key2\r\n:200\r\n'); + const part1 = fullMessage.subarray(0, 10); + const part2 = fullMessage.subarray(10); + + const messagePromise = new Promise((resolve) => { + framer.once('message', resolve); + }); + + framer.write(part1); + framer.write(part2); + const message = await messagePromise; + assert.deepEqual(message, fullMessage); + }); + + it('should handle mixed RESP2 and RESP3 messages', async () => { + const framer = new RespFramer(); + const messages = [ + Buffer.from('*2\r\n$3\r\nGET\r\n$3\r\nkey\r\n'), + Buffer.from('%1\r\n+result\r\n$5\r\nvalue\r\n'), + Buffer.from('#t\r\n'), + Buffer.from('_\r\n'), + Buffer.from(',3.14\r\n') + ]; + const received: Buffer[] = []; + + const messagesPromise = new Promise((resolve) => { + framer.on('message', (message) => { + received.push(message); + if (received.length === messages.length) { + resolve(received); + } + }); + }); + + messages.forEach(msg => framer.write(msg)); + const result = await messagesPromise; + assert.equal(result.length, messages.length); + messages.forEach((expected, i) => { + assert.deepEqual(result[i], expected); + }); + }); + + it('should handle array with attribute metadata', async () => { + const framer = new RespFramer(); + const expected = Buffer.from('*3\r\n:1\r\n:2\r\n|1\r\n+ttl\r\n:3600\r\n:3\r\n'); + + const messagePromise = new Promise((resolve) => { + framer.once('message', resolve); + }); + + framer.write(expected); + const message = await messagePromise; + assert.deepEqual(message, expected); + }); + + it('should handle null map', async () => { + const framer = new RespFramer(); + const expected = Buffer.from('%-1\r\n'); + + const messagePromise = new Promise((resolve) => { + framer.once('message', resolve); + }); + + framer.write(expected); + const message = await messagePromise; + assert.deepEqual(message, expected); + }); + + it('should handle null set', async () => { + const framer = new RespFramer(); + const expected = Buffer.from('~-1\r\n'); + + const messagePromise = new Promise((resolve) => { + framer.once('message', resolve); + }); + + framer.write(expected); + const message = await messagePromise; + assert.deepEqual(message, expected); + }); + + it('should handle null push', async () => { + const framer = new RespFramer(); + const expected = Buffer.from('>-1\r\n'); + + const messagePromise = new Promise((resolve) => { + framer.once('message', resolve); + }); + + framer.write(expected); + const message = await messagePromise; + assert.deepEqual(message, expected); + }); + + it('should handle attribute with empty metadata', async () => { + const framer = new RespFramer(); + const expected = Buffer.from('|0\r\n:42\r\n'); + + const messagePromise = new Promise((resolve) => { + framer.once('message', resolve); + }); + + framer.write(expected); + const message = await messagePromise; + assert.deepEqual(message, expected); + }); + + it('should handle blob error with binary data', async () => { + const framer = new RespFramer(); + const binaryData = Buffer.from([0x00, 0x01, 0x02, 0xff, 0xfe]); + const expected = Buffer.concat([ + Buffer.from('!5\r\n'), + binaryData, + Buffer.from('\r\n') + ]); + + const messagePromise = new Promise((resolve) => { + framer.once('message', resolve); + }); + + framer.write(expected); + const message = await messagePromise; + assert.deepEqual(message, expected); + }); + + it('should handle verbatim string with different encoding', async () => { + const framer = new RespFramer(); + const expected = Buffer.from('=17\r\nmkd:# Hello World\r\n'); + + const messagePromise = new Promise((resolve) => { + framer.once('message', resolve); + }); + + framer.write(expected); + const message = await messagePromise; + assert.deepEqual(message, expected); + }); + + it('should handle double NaN', async () => { + const framer = new RespFramer(); + const expected = Buffer.from(',nan\r\n'); + + const messagePromise = new Promise((resolve) => { + framer.once('message', resolve); + }); + + framer.write(expected); + const message = await messagePromise; + assert.deepEqual(message, expected); + }); + + it('should handle deeply nested structures', async () => { + const framer = new RespFramer(); + const expected = Buffer.from('*2\r\n%1\r\n+key\r\n*2\r\n:1\r\n:2\r\n~2\r\n+a\r\n+b\r\n'); + + const messagePromise = new Promise((resolve) => { + framer.once('message', resolve); + }); + + framer.write(expected); + const message = await messagePromise; + assert.deepEqual(message, expected); + }); + + it('should handle push with nested map', async () => { + const framer = new RespFramer(); + const expected = Buffer.from('>2\r\n+pubsub\r\n%1\r\n+channel\r\n+news\r\n'); + + const messagePromise = new Promise((resolve) => { + framer.once('message', resolve); + }); + + framer.write(expected); + const message = await messagePromise; + assert.deepEqual(message, expected); + }); + + it('should handle attribute split across multiple writes', async () => { + const framer = new RespFramer(); + const fullMessage = Buffer.from('|1\r\n+ttl\r\n:3600\r\n+value\r\n'); + const part1 = fullMessage.subarray(0, 10); + const part2 = fullMessage.subarray(10); + + const messagePromise = new Promise((resolve) => { + framer.once('message', resolve); + }); + + framer.write(part1); + framer.write(part2); + const message = await messagePromise; + assert.deepEqual(message, fullMessage); + }); + + it('should handle map with null values', async () => { + const framer = new RespFramer(); + const expected = Buffer.from('%2\r\n+key1\r\n_\r\n+key2\r\n$-1\r\n'); + + const messagePromise = new Promise((resolve) => { + framer.once('message', resolve); + }); + + framer.write(expected); + const message = await messagePromise; + assert.deepEqual(message, expected); + }); + + it('should handle nested maps', async () => { + const framer = new RespFramer(); + const expected = Buffer.from('%1\r\n+outer\r\n%2\r\n+inner1\r\n:1\r\n+inner2\r\n:2\r\n'); + + const messagePromise = new Promise((resolve) => { + framer.once('message', resolve); + }); + + framer.write(expected); + const message = await messagePromise; + assert.deepEqual(message, expected); + }); + + it('should handle set containing arrays', async () => { + const framer = new RespFramer(); + const expected = Buffer.from('~2\r\n*2\r\n:1\r\n:2\r\n*2\r\n:3\r\n:4\r\n'); + + const messagePromise = new Promise((resolve) => { + framer.once('message', resolve); + }); + + framer.write(expected); + const message = await messagePromise; + assert.deepEqual(message, expected); + }); +}); diff --git a/packages/test-utils/lib/proxy/resp-framer.ts b/packages/test-utils/lib/proxy/resp-framer.ts new file mode 100644 index 00000000000..d92dd6fc80f --- /dev/null +++ b/packages/test-utils/lib/proxy/resp-framer.ts @@ -0,0 +1,167 @@ +// RespFramer: Frames raw Buffer data into complete RESP messages +// Accumulates incoming bytes and emits each complete RESP message as a separate Buffer + +import EventEmitter from "node:events"; + +export interface RespFramerEvents { + message: (data: Buffer) => void; + push: (data: Buffer) => void; +} + +export default class RespFramer extends EventEmitter { + private buffer: Buffer; + private offset: number; + + constructor() { + super(); + this.buffer = Buffer.alloc(0); + this.offset = 0; + } + + public write(data: Buffer) { + this.buffer = Buffer.concat([this.buffer, data]); + + while (this.offset < this.buffer.length) { + const messageEnd = this.findMessageEnd(this.buffer, this.offset); + if (messageEnd === -1) { + break; // Incomplete message + } + const message = this.buffer.subarray(this.offset, messageEnd); + this.emit("message", message); + this.offset = messageEnd; + } + + // Remove processed data from the buffer + if (this.offset > 0) { + this.buffer = this.buffer.subarray(this.offset); + this.offset = 0; + } + } + + private findMessageEnd(buffer: Buffer, start: number): number { + if (start >= buffer.length) { + return -1; + } + const prefix = String.fromCharCode(buffer[start]); + switch (prefix) { + case "+": // Simple String + case "-": // Error + case ":": // Integer + case "_": // Null + case "#": // Boolean + case ",": // Double + case "(": // Big Number + return this.findLineEnd(buffer, start); + case "$": // Bulk String + case "!": // Bulk Error + case "=": // Verbatim String + return this.findBulkStringEnd(buffer, start); + case "*": // Array + return this.findArrayEnd(buffer, start); + case "%": // Map + return this.findMapEnd(buffer, start); + case "~": // Set + case ">": // Push + return this.findArrayEnd(buffer, start); + case "|": // Attribute + return this.findAttributeEnd(buffer, start); + default: + return -1; // Unknown prefix + } + } + + private findArrayEnd(buffer: Buffer, start: number): number { + const result = this.readLength(buffer, start); + if (!result) { + return -1; + } + const { length, lineEnd } = result; + if (length === -1) { + return lineEnd; + } + let currentOffset = lineEnd; + for (let i = 0; i < length; i++) { + const elementEnd = this.findMessageEnd(buffer, currentOffset); + if (elementEnd === -1) { + return -1; + } + currentOffset = elementEnd; + } + return currentOffset; + } + + private findBulkStringEnd(buffer: Buffer, start: number): number { + const result = this.readLength(buffer, start); + if (!result) { + return -1; + } + const { length, lineEnd } = result; + if (length === -1) { + return lineEnd; + } + const totalLength = lineEnd + length + 2; + return totalLength <= buffer.length ? totalLength : -1; + } + + private findMapEnd(buffer: Buffer, start: number): number { + const result = this.readLength(buffer, start); + if (!result) { + return -1; + } + const { length, lineEnd } = result; + if (length === -1) { + return lineEnd; + } + let currentOffset = lineEnd; + for (let i = 0; i < length * 2; i++) { + const elementEnd = this.findMessageEnd(buffer, currentOffset); + if (elementEnd === -1) { + return -1; + } + currentOffset = elementEnd; + } + return currentOffset; + } + + private findAttributeEnd(buffer: Buffer, start: number): number { + const result = this.readLength(buffer, start); + if (!result) { + return -1; + } + const { length, lineEnd } = result; + let currentOffset = lineEnd; + for (let i = 0; i < length * 2; i++) { + const elementEnd = this.findMessageEnd(buffer, currentOffset); + if (elementEnd === -1) { + return -1; + } + currentOffset = elementEnd; + } + const valueEnd = this.findMessageEnd(buffer, currentOffset); + if (valueEnd === -1) { + return -1; + } + return valueEnd; + } + + private findLineEnd(buffer: Buffer, start: number): number { + const end = buffer.indexOf("\r\n", start); + return end !== -1 ? end + 2 : -1; + } + + private readLength( + buffer: Buffer, + start: number, + ): { length: number; lineEnd: number } | null { + const lineEnd = this.findLineEnd(buffer, start); + if (lineEnd === -1) { + return null; + } + const lengthLine = buffer.subarray(start + 1, lineEnd - 2).toString(); + const length = parseInt(lengthLine, 10); + if (isNaN(length)) { + return null; + } + return { length, lineEnd }; + } +} diff --git a/packages/test-utils/lib/proxy/resp-queue.ts b/packages/test-utils/lib/proxy/resp-queue.ts new file mode 100644 index 00000000000..d4c410a5419 --- /dev/null +++ b/packages/test-utils/lib/proxy/resp-queue.ts @@ -0,0 +1,43 @@ +import { EventEmitter } from "node:events"; +import RespFramer from "./resp-framer"; +import { Socket } from "node:net"; + +interface Request { + resolve: (data: Buffer) => void; + reject: (reason: any) => void; +} + +export default class RespQueue extends EventEmitter { + queue: Request[] = []; + respFramer: RespFramer = new RespFramer(); + + constructor(private serverSocket: Socket) { + super(); + this.respFramer.on("message", (msg) => this.handleMessage(msg)); + this.serverSocket.on("data", (data) => this.respFramer.write(data)); + } + + handleMessage(data: Buffer) { + const request = this.queue.shift(); + if (request) { + request.resolve(data); + } else { + this.emit("push", data); + } + } + + request(data: Buffer): Promise { + let resolve: (data: Buffer) => void; + let reject: (reason: any) => void; + + const promise = new Promise((rs, rj) => { + resolve = rs; + reject = rj; + }); + + //@ts-ignore + this.queue.push({ resolve, reject }); + this.serverSocket.write(data); + return promise; + } +} diff --git a/packages/test-utils/lib/redis-proxy-spec.ts b/packages/test-utils/lib/redis-proxy-spec.ts deleted file mode 100644 index d0a41204553..00000000000 --- a/packages/test-utils/lib/redis-proxy-spec.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { strict as assert } from 'node:assert'; -import { Buffer } from 'node:buffer'; -import { testUtils, GLOBAL } from './test-utils'; -import { InterceptorFunction, RedisProxy } from './redis-proxy'; -import type { RedisClientType } from '@redis/client/lib/client/index.js'; - -describe('RedisSocketProxy', function () { - testUtils.testWithClient('basic proxy functionality', async (client: RedisClientType) => { - const socketOptions = client?.options?.socket; - //@ts-ignore - assert(socketOptions?.port, 'Test requires a TCP connection to Redis'); - - const proxyPort = 50000 + Math.floor(Math.random() * 10000); - const proxy = new RedisProxy({ - listenHost: '127.0.0.1', - listenPort: proxyPort, - //@ts-ignore - targetPort: socketOptions.port, - //@ts-ignore - targetHost: socketOptions.host || '127.0.0.1', - enableLogging: true - }); - - const proxyEvents = { - connections: [] as any[], - dataTransfers: [] as any[] - }; - - proxy.on('connection', (connectionInfo) => { - proxyEvents.connections.push(connectionInfo); - }); - - proxy.on('data', (connectionId, direction, data) => { - proxyEvents.dataTransfers.push({ connectionId, direction, dataLength: data.length }); - }); - - try { - await proxy.start(); - - const proxyClient = client.duplicate({ - socket: { - port: proxyPort, - host: '127.0.0.1' - }, - }); - - await proxyClient.connect(); - - const stats = proxy.getStats(); - assert.equal(stats.activeConnections, 1, 'Should have one active connection'); - assert.equal(proxyEvents.connections.length, 1, 'Should have recorded one connection event'); - - const pingResult = await proxyClient.ping(); - assert.equal(pingResult, 'PONG', 'Client should be able to communicate with Redis through the proxy'); - - const clientToServerTransfers = proxyEvents.dataTransfers.filter(t => t.direction === 'client->server'); - const serverToClientTransfers = proxyEvents.dataTransfers.filter(t => t.direction === 'server->client'); - - assert(clientToServerTransfers.length > 0, 'Should have client->server data transfers'); - assert(serverToClientTransfers.length > 0, 'Should have server->client data transfers'); - - const testKey = `test:proxy:${Date.now()}`; - const testValue = 'proxy-test-value'; - - await proxyClient.set(testKey, testValue); - const retrievedValue = await proxyClient.get(testKey); - assert.equal(retrievedValue, testValue, 'Should be able to set and get values through proxy'); - - proxyClient.destroy(); - - - } finally { - await proxy.stop(); - } - }, GLOBAL.SERVERS.OPEN_RESP_3); - - testUtils.testWithProxiedClient('custom message injection via proxy client', - async (proxiedClient: RedisClientType, proxy: RedisProxy) => { - const customMessageTransfers: any[] = []; - - proxy.on('data', (connectionId, direction, data) => { - if (direction === 'server->client') { - customMessageTransfers.push({ connectionId, dataLength: data.length, data }); - } - }); - - - const stats = proxy.getStats(); - assert.equal(stats.activeConnections, 1, 'Should have one active connection'); - - // Send a resp3 push - const customMessage = Buffer.from('>4\r\n$6\r\nMOVING\r\n:1\r\n:2\r\n$6\r\nhost:3\r\n'); - - const sendResults = proxy.sendToAllClients(customMessage); - assert.equal(sendResults.length, 1, 'Should send to one client'); - assert.equal(sendResults[0].success, true, 'Custom message send should succeed'); - - - const customMessageFound = customMessageTransfers.find(transfer => - transfer.dataLength === customMessage.length - ); - assert(customMessageFound, 'Should have recorded the custom message transfer'); - - assert.equal(customMessageFound.dataLength, customMessage.length, - 'Custom message length should match'); - - const pingResult = await proxiedClient.ping(); - assert.equal(pingResult, 'PONG', 'Client should be able to communicate with Redis through the proxy'); - - }, GLOBAL.SERVERS.OPEN_RESP_3); - - describe("Middleware", () => { - testUtils.testWithProxiedClient( - "Modify request/response via middleware", - async ( - proxiedClient: RedisClientType, - proxy: RedisProxy, - ) => { - - // Intercept PING commands and modify the response - const pingInterceptor: InterceptorFunction = async (data, next) => { - if (data.includes('PING')) { - return Buffer.from("+PINGINTERCEPTED\r\n"); - } - return next(data); - }; - - // Only intercept GET responses and double numeric values - // Does not modify other commands or non-numeric GET responses - const doubleNumberGetInterceptor: InterceptorFunction = async (data, next) => { - const response = await next(data); - - // Not a GET command, return original response - if (!data.includes("GET")) return response; - - const value = (response.toString().split("\r\n"))[1]; - const number = Number(value); - // Not a number, return original response - if(isNaN(number)) return response; - - const doubled = String(number * 2); - return Buffer.from(`$${doubled.length}\r\n${doubled}\r\n`); - }; - - proxy.setInterceptors([ pingInterceptor, doubleNumberGetInterceptor ]) - - const pingResponse = await proxiedClient.ping(); - assert.equal(pingResponse, 'PINGINTERCEPTED', 'Response should be modified by middleware'); - - await proxiedClient.set('foo', 1); - const getResponse1 = await proxiedClient.get('foo'); - assert.equal(getResponse1, '2', 'GET response should be doubled for numbers by middleware'); - - await proxiedClient.set('bar', 'Hi'); - const getResponse2 = await proxiedClient.get('bar'); - assert.equal(getResponse2, 'Hi', 'GET response should not be modified for strings by middleware'); - - await proxiedClient.hSet('baz', 'foo', 'dictvalue'); - const hgetResponse = await proxiedClient.hGet('baz', 'foo'); - assert.equal(hgetResponse, 'dictvalue', 'HGET response should not be modified by middleware'); - - }, - GLOBAL.SERVERS.OPEN_RESP_3, - ); - }); - -}); From 5a0a06df698b7ac0e88c7c69cb8e996cb53e0992 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Mon, 3 Nov 2025 11:59:49 +0200 Subject: [PATCH 05/24] feat(xreadgroup): add claim attribute (#3122) * feat(xreadgroup): add claim attribute the CLAIM attribute can be used to instruct redis to return PEL ( Pending Entries List ) entries with their respective deliveries and ms since last delivery * remove m01 from test matrix * add jsdoc --- .github/workflows/tests.yml | 2 +- .../client/lib/commands/XREADGROUP.spec.ts | 100 +++++++++++++----- packages/client/lib/commands/XREADGROUP.ts | 9 +- .../lib/commands/generic-transformers.ts | 44 ++++---- 4 files changed, 106 insertions(+), 49 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2e3a91f2c8b..bf16dd8decf 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -22,7 +22,7 @@ jobs: fail-fast: false matrix: node-version: ["18", "20", "22"] - redis-version: ["rs-7.4.0-v1", "8.0.2", "8.2", "8.4-M01-pre", "8.4-RC1-pre"] + redis-version: ["rs-7.4.0-v1", "8.0.2", "8.2", "8.4-RC1-pre"] steps: - uses: actions/checkout@v4 with: diff --git a/packages/client/lib/commands/XREADGROUP.spec.ts b/packages/client/lib/commands/XREADGROUP.spec.ts index acc7cc2dea9..39c7c70d678 100644 --- a/packages/client/lib/commands/XREADGROUP.spec.ts +++ b/packages/client/lib/commands/XREADGROUP.spec.ts @@ -93,6 +93,33 @@ describe('XREADGROUP', () => { ['XREADGROUP', 'GROUP', 'group', 'consumer', 'COUNT', '1', 'BLOCK', '0', 'NOACK', 'STREAMS', 'key', '0-0'] ); }); + + it('with CLAIM', () => { + assert.deepEqual( + parseArgs(XREADGROUP, 'group', 'consumer', { + key: 'key', + id: '0-0' + }, { + CLAIM: 100 + }), + ['XREADGROUP', 'GROUP', 'group', 'consumer', 'CLAIM', '100', 'STREAMS', 'key', '0-0'] + ); + }); + + it('with COUNT, BLOCK, NOACK, CLAIM', () => { + assert.deepEqual( + parseArgs(XREADGROUP, 'group', 'consumer', { + key: 'key', + id: '0-0' + }, { + COUNT: 1, + BLOCK: 0, + NOACK: true, + CLAIM: 100 + }), + ['XREADGROUP', 'GROUP', 'group', 'consumer', 'COUNT', '1', 'BLOCK', '0', 'NOACK', 'CLAIM', '100', 'STREAMS', 'key', '0-0'] + ); + }); }); testUtils.testAll('xReadGroup - null', async client => { @@ -156,35 +183,54 @@ describe('XREADGROUP', () => { cluster: GLOBAL.CLUSTERS.OPEN }); - testUtils.testWithClient('client.xReadGroup should throw with resp3 and unstableResp3: false', async client => { - assert.throws( - () => client.xReadGroup('group', 'consumer', { - key: 'key', - id: '>' + testUtils.testAll('xReadGroup - without CLAIM should not include delivery fields', async client => { + const [, id] = await Promise.all([ + client.xGroupCreate('key', 'group', '$', { + MKSTREAM: true }), - { - message: 'Some RESP3 results for Redis Query Engine responses may change. Refer to the readme for guidance' - } - ); - }, { - ...GLOBAL.SERVERS.OPEN, - clientOptions: { - RESP: 3 - } - }); + client.xAdd('key', '*', { field: 'value' }) + ]); - testUtils.testWithClient('client.xReadGroup should not throw with resp3 and unstableResp3: true', async client => { - assert.doesNotThrow( - () => client.xReadGroup('group', 'consumer', { - key: 'key', - id: '>' - }) - ); + const readGroupReply = await client.xReadGroup('group', 'consumer', { + key: 'key', + id: '>' + }); + + assert.ok(readGroupReply); + assert.equal(readGroupReply[0].messages[0].millisElapsedFromDelivery, undefined); + assert.equal(readGroupReply[0].messages[0].deliveriesCounter, undefined); }, { - ...GLOBAL.SERVERS.OPEN, - clientOptions: { - RESP: 3, - unstableResp3: true - } + client: GLOBAL.SERVERS.OPEN, + cluster: GLOBAL.CLUSTERS.OPEN }); + + testUtils.testWithClientIfVersionWithinRange([[8,4], 'LATEST'],'xReadGroup - with CLAIM should include delivery fields', async client => { + const [, id] = await Promise.all([ + client.xGroupCreate('key', 'group', '$', { + MKSTREAM: true + }), + client.xAdd('key', '*', { field: 'value' }) + ]); + + // First read to add message to PEL + await client.xReadGroup('group', 'consumer', { + key: 'key', + id: '>' + }); + + // Read with CLAIM to get delivery fields + const readGroupReply = await client.xReadGroup('group', 'consumer2', { + key: 'key', + id: '>' + }, { + CLAIM: 0 + }); + + assert.ok(readGroupReply); + assert.equal(readGroupReply[0].messages[0].id, id); + assert.ok(readGroupReply[0].messages[0].millisElapsedFromDelivery !== undefined); + assert.ok(readGroupReply[0].messages[0].deliveriesCounter !== undefined); + assert.equal(typeof readGroupReply[0].messages[0].millisElapsedFromDelivery, 'number'); + assert.equal(typeof readGroupReply[0].messages[0].deliveriesCounter, 'number'); + }, GLOBAL.SERVERS.OPEN); }); diff --git a/packages/client/lib/commands/XREADGROUP.ts b/packages/client/lib/commands/XREADGROUP.ts index b274aab95fe..d177c2e4864 100644 --- a/packages/client/lib/commands/XREADGROUP.ts +++ b/packages/client/lib/commands/XREADGROUP.ts @@ -5,15 +5,17 @@ import { transformStreamsMessagesReplyResp2 } from './generic-transformers'; /** * Options for the XREADGROUP command - * + * * @property COUNT - Limit the number of entries returned per stream * @property BLOCK - Milliseconds to block waiting for new entries (0 for indefinite) * @property NOACK - Skip adding the message to the PEL (Pending Entries List) + * @property CLAIM - Prepend PEL entries that are at least this many milliseconds old */ export interface XReadGroupOptions { COUNT?: number; BLOCK?: number; NOACK?: boolean; + CLAIM?: number; } export default { @@ -50,6 +52,10 @@ export default { parser.push('NOACK'); } + if (options?.CLAIM !== undefined) { + parser.push('CLAIM', options.CLAIM.toString()); + } + pushXReadStreams(parser, streams); }, /** @@ -59,5 +65,4 @@ export default { 2: transformStreamsMessagesReplyResp2, 3: undefined as unknown as () => ReplyUnion }, - unstableResp3: true, } as const satisfies Command; diff --git a/packages/client/lib/commands/generic-transformers.ts b/packages/client/lib/commands/generic-transformers.ts index 022339e4bb7..56e99c28deb 100644 --- a/packages/client/lib/commands/generic-transformers.ts +++ b/packages/client/lib/commands/generic-transformers.ts @@ -46,7 +46,7 @@ export function transformStringDoubleArgument(num: RedisArgument | number): Redi export const transformDoubleReply = { 2: (reply: BlobStringReply, preserve?: any, typeMapping?: TypeMapping): DoubleReply => { const double = typeMapping ? typeMapping[RESP_TYPES.DOUBLE] : undefined; - + switch (double) { case String: { return reply as unknown as DoubleReply; @@ -58,13 +58,13 @@ export const transformDoubleReply = { case 'inf': case '+inf': ret = Infinity; - + case '-inf': ret = -Infinity; - + case 'nan': ret = NaN; - + default: ret = Number(reply); } @@ -98,7 +98,7 @@ export function createTransformNullableDoubleReplyResp2Func(preserve?: any, type export const transformNullableDoubleReply = { 2: (reply: BlobStringReply | NullReply, preserve?: any, typeMapping?: TypeMapping) => { if (reply === null) return null; - + return transformDoubleReply[2](reply as BlobStringReply, preserve, typeMapping); }, 3: undefined as unknown as () => DoubleReply | NullReply @@ -514,19 +514,25 @@ export function parseArgs(command: Command, ...args: Array): CommandArgumen export type StreamMessageRawReply = TuplesReply<[ id: BlobStringReply, - message: ArrayReply + message: ArrayReply, + millisElapsedFromDelivery?: NumberReply, + deliveriesCounter?: NumberReply ]>; export type StreamMessageReply = { id: BlobStringReply, message: MapReply, + millisElapsedFromDelivery?: number + deliveriesCounter?: number }; export function transformStreamMessageReply(typeMapping: TypeMapping | undefined, reply: StreamMessageRawReply): StreamMessageReply { - const [ id, message ] = reply as unknown as UnwrapReply; + const [ id, message, millisElapsedFromDelivery, deliveriesCounter ] = reply as unknown as UnwrapReply; return { id: id, - message: transformTuplesReply(message, undefined, typeMapping) + message: transformTuplesReply(message, undefined, typeMapping), + ...(millisElapsedFromDelivery !== undefined ? { millisElapsedFromDelivery: Number(millisElapsedFromDelivery) } : {}), + ...(deliveriesCounter !== undefined ? { deliveriesCounter: Number(deliveriesCounter) } : {}) }; } @@ -557,7 +563,7 @@ export function transformStreamsMessagesReplyResp2( reply: UnwrapReply, preserve?: any, typeMapping?: TypeMapping -): StreamsMessagesReply | NullReply { +): StreamsMessagesReply | NullReply { // FUTURE: resposne type if resp3 was working, reverting to old v4 for now //: MapReply | NullReply { if (reply === null) return null as unknown as NullReply; @@ -569,13 +575,13 @@ export function transformStreamsMessagesReplyResp2( for (let i=0; i < reply.length; i++) { const stream = reply[i] as unknown as UnwrapReply; - + const name = stream[0]; const rawMessages = stream[1]; - + ret.set(name.toString(), transformStreamMessagesReply(rawMessages, typeMapping)); } - + return ret as unknown as MapReply; } case Array: { @@ -583,11 +589,11 @@ export function transformStreamsMessagesReplyResp2( for (let i=0; i < reply.length; i++) { const stream = reply[i] as unknown as UnwrapReply; - + const name = stream[0]; const rawMessages = stream[1]; - - ret.push(name); + + ret.push(name); ret.push(transformStreamMessagesReply(rawMessages, typeMapping)); } @@ -598,13 +604,13 @@ export function transformStreamsMessagesReplyResp2( for (let i=0; i < reply.length; i++) { const stream = reply[i] as unknown as UnwrapReply; - + const name = stream[0] as unknown as UnwrapReply; const rawMessages = stream[1]; - + ret[name.toString()] = transformStreamMessagesReply(rawMessages); } - + return ret as unknown as MapReply; } */ @@ -630,7 +636,7 @@ type StreamsMessagesRawReply3 = MapReply): MapReply | NullReply { if (reply === null) return null as unknown as NullReply; - + if (reply instanceof Map) { const ret = new Map(); From 2fdb6def45129a8f0dc9609fc7e351193991da84 Mon Sep 17 00:00:00 2001 From: Pavel Pashov <60297174+PavelPashov@users.noreply.github.com> Date: Mon, 3 Nov 2025 13:53:01 +0200 Subject: [PATCH 06/24] feat(client): add CAS/CAD, DELEX, DIGEST support (#3123) * feat: add digest command and tests * feat: add delex command and tests * feat: add more conditional options to SET update tests --- packages/client/lib/commands/DELEX.spec.ts | 81 +++++++++++++++++++++ packages/client/lib/commands/DELEX.ts | 60 +++++++++++++++ packages/client/lib/commands/DIGEST.spec.ts | 35 +++++++++ packages/client/lib/commands/DIGEST.ts | 17 +++++ packages/client/lib/commands/SET.spec.ts | 26 +++++++ packages/client/lib/commands/SET.ts | 20 ++++- packages/client/lib/commands/index.ts | 6 ++ 7 files changed, 244 insertions(+), 1 deletion(-) create mode 100644 packages/client/lib/commands/DELEX.spec.ts create mode 100644 packages/client/lib/commands/DELEX.ts create mode 100644 packages/client/lib/commands/DIGEST.spec.ts create mode 100644 packages/client/lib/commands/DIGEST.ts diff --git a/packages/client/lib/commands/DELEX.spec.ts b/packages/client/lib/commands/DELEX.spec.ts new file mode 100644 index 00000000000..ec948801d6e --- /dev/null +++ b/packages/client/lib/commands/DELEX.spec.ts @@ -0,0 +1,81 @@ +import { strict as assert } from "node:assert"; +import DELEX, { DelexCondition } from "./DELEX"; +import { parseArgs } from "./generic-transformers"; +import testUtils, { GLOBAL } from "../test-utils"; + +describe("DELEX", () => { + describe("transformArguments", () => { + it("no condition", () => { + assert.deepEqual(parseArgs(DELEX, "key"), ["DELEX", "key"]); + }); + + it("with condition", () => { + assert.deepEqual( + parseArgs(DELEX, "key", { + condition: DelexCondition.IFEQ, + matchValue: "some-value", + }), + ["DELEX", "key", "IFEQ", "some-value"] + ); + }); + }); + + testUtils.testAll( + "non-existing key", + async (client) => { + assert.equal(await client.delEx("key{tag}"), 0); + }, + { + client: { ...GLOBAL.SERVERS.OPEN, minimumDockerVersion: [8, 4] }, + cluster: { ...GLOBAL.CLUSTERS.OPEN, minimumDockerVersion: [8, 4] }, + } + ); + + testUtils.testAll( + "non-existing key with condition", + async (client) => { + assert.equal( + await client.delEx("key{tag}", { + condition: DelexCondition.IFDEQ, + matchValue: "digest", + }), + 0 + ); + }, + { + client: { ...GLOBAL.SERVERS.OPEN, minimumDockerVersion: [8, 4] }, + cluster: { ...GLOBAL.CLUSTERS.OPEN, minimumDockerVersion: [8, 4] }, + } + ); + + testUtils.testAll( + "existing key no condition", + async (client) => { + await client.set("key{tag}", "value"); + assert.equal(await client.delEx("key{tag}"), 1); + }, + { + client: { ...GLOBAL.SERVERS.OPEN, minimumDockerVersion: [8, 4] }, + cluster: { ...GLOBAL.CLUSTERS.OPEN, minimumDockerVersion: [8, 4] }, + } + ); + + testUtils.testAll( + "existing key and condition", + async (client) => { + await client.set("key{tag}", "some-value"); + + assert.equal( + await client.delEx("key{tag}", { + condition: DelexCondition.IFEQ, + matchValue: "some-value", + }), + 1 + ); + }, + { + client: { ...GLOBAL.SERVERS.OPEN, minimumDockerVersion: [8, 4] }, + cluster: { ...GLOBAL.CLUSTERS.OPEN, minimumDockerVersion: [8, 4] }, + } + ); +}); diff --git a/packages/client/lib/commands/DELEX.ts b/packages/client/lib/commands/DELEX.ts new file mode 100644 index 00000000000..e32c292f225 --- /dev/null +++ b/packages/client/lib/commands/DELEX.ts @@ -0,0 +1,60 @@ +import { CommandParser } from "../client/parser"; +import { NumberReply, Command, RedisArgument } from "../RESP/types"; + +export const DelexCondition = { + /** + * Delete if value equals match-value. + */ + IFEQ: "IFEQ", + /** + * Delete if value does not equal match-value. + */ + IFNE: "IFNE", + /** + * Delete if value digest equals match-digest. + */ + IFDEQ: "IFDEQ", + /** + * Delete if value digest does not equal match-digest. + */ + IFDNE: "IFDNE", +} as const; + +type DelexCondition = (typeof DelexCondition)[keyof typeof DelexCondition]; + +export default { + IS_READ_ONLY: false, + /** + * Conditionally removes the specified key based on value or digest comparison. + * + * @param parser - The Redis command parser + * @param key - Key to delete + */ + parseCommand( + parser: CommandParser, + key: RedisArgument, + options?: { + /** + * The condition to apply when deleting the key. + * - `IFEQ` - Delete if value equals match-value + * - `IFNE` - Delete if value does not equal match-value + * - `IFDEQ` - Delete if value digest equals match-digest + * - `IFDNE` - Delete if value digest does not equal match-digest + */ + condition: DelexCondition; + /** + * The value or digest to compare against + */ + matchValue: RedisArgument; + } + ) { + parser.push("DELEX"); + parser.pushKey(key); + + if (options) { + parser.push(options.condition); + parser.push(options.matchValue); + } + }, + transformReply: undefined as unknown as () => NumberReply<1 | 0>, +} as const satisfies Command; diff --git a/packages/client/lib/commands/DIGEST.spec.ts b/packages/client/lib/commands/DIGEST.spec.ts new file mode 100644 index 00000000000..d89ba9d05a0 --- /dev/null +++ b/packages/client/lib/commands/DIGEST.spec.ts @@ -0,0 +1,35 @@ +import { strict as assert } from "node:assert"; +import DIGEST from "./DIGEST"; +import { parseArgs } from "./generic-transformers"; +import testUtils, { GLOBAL } from "../test-utils"; + +describe("DIGEST", () => { + describe("transformArguments", () => { + it("digest", () => { + assert.deepEqual(parseArgs(DIGEST, "key"), ["DIGEST", "key"]); + }); + }); + + testUtils.testAll( + "existing key", + async (client) => { + await client.set("key{tag}", "value"); + assert.equal(typeof await client.digest("key{tag}"), "string"); + }, + { + client: { ...GLOBAL.SERVERS.OPEN, minimumDockerVersion: [8, 4] }, + cluster: { ...GLOBAL.CLUSTERS.OPEN, minimumDockerVersion: [8, 4] }, + } + ); + + testUtils.testAll( + "non-existing key", + async (client) => { + assert.equal(await client.digest("key{tag}"), null); + }, + { + client: { ...GLOBAL.SERVERS.OPEN, minimumDockerVersion: [8, 4] }, + cluster: { ...GLOBAL.CLUSTERS.OPEN, minimumDockerVersion: [8, 4] }, + } + ); +}); diff --git a/packages/client/lib/commands/DIGEST.ts b/packages/client/lib/commands/DIGEST.ts new file mode 100644 index 00000000000..249bb78740f --- /dev/null +++ b/packages/client/lib/commands/DIGEST.ts @@ -0,0 +1,17 @@ +import { CommandParser } from "../client/parser"; +import { Command, RedisArgument, SimpleStringReply } from "../RESP/types"; + +export default { + IS_READ_ONLY: true, + /** + * Returns the XXH3 hash of a string value. + * + * @param parser - The Redis command parser + * @param key - Key to get the digest of + */ + parseCommand(parser: CommandParser, key: RedisArgument) { + parser.push("DIGEST"); + parser.pushKey(key); + }, + transformReply: undefined as unknown as () => SimpleStringReply, +} as const satisfies Command; diff --git a/packages/client/lib/commands/SET.spec.ts b/packages/client/lib/commands/SET.spec.ts index b8aa57fe77b..61fe9863209 100644 --- a/packages/client/lib/commands/SET.spec.ts +++ b/packages/client/lib/commands/SET.spec.ts @@ -1,3 +1,4 @@ + import { strict as assert } from 'node:assert'; import testUtils, { GLOBAL } from '../test-utils'; import SET from './SET'; @@ -127,6 +128,16 @@ describe('SET', () => { ['SET', 'key', 'value', 'XX'] ); }); + + it('with IFDEQ condition', () => { + assert.deepEqual( + parseArgs(SET, 'key', 'value', { + condition: 'IFDEQ', + matchValue: 'some-value' + }), + ['SET', 'key', 'value', 'IFDEQ', 'some-value'] + ); + }); }); it('with GET', () => { @@ -162,4 +173,19 @@ describe('SET', () => { client: GLOBAL.SERVERS.OPEN, cluster: GLOBAL.CLUSTERS.OPEN }); + + testUtils.testAll('set with IFEQ', async client => { + await client.set('key{tag}', 'some-value'); + + assert.equal( + await client.set('key{tag}', 'some-value', { + condition: 'IFEQ', + matchValue: 'some-value' + }), + 'OK' + ); + }, { + client: { ...GLOBAL.SERVERS.OPEN, minimumDockerVersion: [8, 4] }, + cluster: { ...GLOBAL.CLUSTERS.OPEN, minimumDockerVersion: [8, 4] }, + }); }); diff --git a/packages/client/lib/commands/SET.ts b/packages/client/lib/commands/SET.ts index d1384255679..16a2a0216c6 100644 --- a/packages/client/lib/commands/SET.ts +++ b/packages/client/lib/commands/SET.ts @@ -29,7 +29,22 @@ export interface SetOptions { */ KEEPTTL?: boolean; - condition?: 'NX' | 'XX'; + /** + * Condition for setting the key: + * - `NX` - Set if key does not exist + * - `XX` - Set if key already exists + * - `IFEQ` - Set if current value equals match-value (since 8.4, requires `matchValue`) + * - `IFNE` - Set if current value does not equal match-value (since 8.4, requires `matchValue`) + * - `IFDEQ` - Set if current value digest equals match-digest (since 8.4, requires `matchValue`) + * - `IFDNE` - Set if current value digest does not equal match-digest (since 8.4, requires `matchValue`) + */ + condition?: 'NX' | 'XX' | 'IFEQ' | 'IFNE' | 'IFDEQ' | 'IFDNE'; + + /** + * Value or digest to compare against. Required when using `IFEQ`, `IFNE`, `IFDEQ`, or `IFDNE` conditions. + */ + matchValue?: RedisArgument; + /** * @deprecated Use `{ condition: 'NX' }` instead. */ @@ -82,6 +97,9 @@ export default { if (options?.condition) { parser.push(options.condition); + if (options?.matchValue !== undefined) { + parser.push(options.matchValue); + } } else if (options?.NX) { parser.push('NX'); } else if (options?.XX) { diff --git a/packages/client/lib/commands/index.ts b/packages/client/lib/commands/index.ts index 54ede43d011..27d3d329eda 100644 --- a/packages/client/lib/commands/index.ts +++ b/packages/client/lib/commands/index.ts @@ -84,6 +84,8 @@ import DBSIZE from './DBSIZE'; import DECR from './DECR'; import DECRBY from './DECRBY'; import DEL from './DEL'; +import DELEX from './DELEX'; +import DIGEST from './DIGEST'; import DUMP from './DUMP'; import ECHO from './ECHO'; import EVAL_RO from './EVAL_RO'; @@ -543,6 +545,10 @@ export default { decrBy: DECRBY, DEL, del: DEL, + DELEX, + delEx: DELEX, + DIGEST, + digest: DIGEST, DUMP, dump: DUMP, ECHO, From 38bfaa7c909dd9dcfebb94b486452e2ff8f145d8 Mon Sep 17 00:00:00 2001 From: Pavel Pashov <60297174+PavelPashov@users.noreply.github.com> Date: Mon, 3 Nov 2025 13:53:18 +0200 Subject: [PATCH 07/24] feat(client): add msetex command and tests for it (#3116) --- packages/client/lib/commands/MSETEX.spec.ts | 393 ++++++++++++++++++++ packages/client/lib/commands/MSETEX.ts | 143 +++++++ packages/client/lib/commands/index.ts | 3 + 3 files changed, 539 insertions(+) create mode 100644 packages/client/lib/commands/MSETEX.spec.ts create mode 100644 packages/client/lib/commands/MSETEX.ts diff --git a/packages/client/lib/commands/MSETEX.spec.ts b/packages/client/lib/commands/MSETEX.spec.ts new file mode 100644 index 00000000000..20eea82136f --- /dev/null +++ b/packages/client/lib/commands/MSETEX.spec.ts @@ -0,0 +1,393 @@ +import { strict as assert } from "node:assert"; +import testUtils, { GLOBAL } from "../test-utils"; +import MSETEX, { ExpirationMode, SetMode } from "./MSETEX"; +import { parseArgs } from "./generic-transformers"; + +describe("MSETEX", () => { + describe("transformArguments", () => { + it("single key-value pair as array", () => { + assert.deepEqual(parseArgs(MSETEX, ["key1", "value1"]), [ + "MSETEX", + "1", + "key1", + "value1", + ]); + }); + + it("array of key value pairs", () => { + assert.deepEqual( + parseArgs(MSETEX, [ + "key1", + "value1", + "key2", + "value2", + "key3", + "value3", + ]), + ["MSETEX", "3", "key1", "value1", "key2", "value2", "key3", "value3"] + ); + }); + + it("array of tuples", () => { + assert.deepEqual( + parseArgs(MSETEX, [ + ["key1", "value1"], + ["key2", "value2"], + ]), + ["MSETEX", "2", "key1", "value1", "key2", "value2"] + ); + }); + + it("object of key value pairs", () => { + assert.deepEqual( + parseArgs(MSETEX, { + key1: "value1", + key2: "value2", + }), + ["MSETEX", "2", "key1", "value1", "key2", "value2"] + ); + }); + + it("with EX expiration", () => { + assert.deepEqual( + parseArgs( + MSETEX, + { + key1: "value1", + key2: "value2", + }, + { + expiration: { + type: ExpirationMode.EX, + value: 1, + }, + } + ), + ["MSETEX", "2", "key1", "value1", "key2", "value2", "EX", "1"] + ); + }); + + it("with NX set mode", () => { + assert.deepEqual( + parseArgs( + MSETEX, + [ + ["key1", "value1"], + ["key2", "value2"], + ], + { + mode: SetMode.NX, + } + ), + ["MSETEX", "2", "key1", "value1", "key2", "value2", "NX"] + ); + }); + + it("with XX set mode and PX expiration", () => { + assert.deepEqual( + parseArgs(MSETEX, ["key1", "value1", "key2", "value2"], { + mode: SetMode.XX, + expiration: { + type: ExpirationMode.PX, + value: 1, + }, + }), + ["MSETEX", "2", "key1", "value1", "key2", "value2", "XX", "PX", "1"] + ); + }); + + it("with EXAT Date expiration", () => { + assert.deepEqual( + parseArgs( + MSETEX, + { + key1: "value1", + key2: "value2", + }, + { + expiration: { + type: ExpirationMode.EXAT, + value: new Date("2025-10-28T11:23:36.203Z"), + }, + } + ), + [ + "MSETEX", + "2", + "key1", + "value1", + "key2", + "value2", + "EXAT", + "1761650616", + ] + ); + }); + + it("with EXAT numeric expiration", () => { + assert.deepEqual( + parseArgs( + MSETEX, + [ + ["key1", "value1"], + ["key2", "value2"], + ], + { + expiration: { + type: ExpirationMode.EXAT, + value: 1761650616, + }, + } + ), + [ + "MSETEX", + "2", + "key1", + "value1", + "key2", + "value2", + "EXAT", + "1761650616", + ] + ); + }); + + it("with PXAT Date expiration", () => { + assert.deepEqual( + parseArgs(MSETEX, ["key1", "value1", "key2", "value2"], { + expiration: { + type: ExpirationMode.PXAT, + value: new Date("2025-10-28T11:23:36.203Z"), + }, + }), + [ + "MSETEX", + "2", + "key1", + "value1", + "key2", + "value2", + "PXAT", + "1761650616203", + ] + ); + }); + + it("with PXAT numeric expiration", () => { + assert.deepEqual( + parseArgs( + MSETEX, + { + key1: "value1", + key2: "value2", + }, + { + expiration: { + type: ExpirationMode.PXAT, + value: 1761650616203, + }, + } + ), + [ + "MSETEX", + "2", + "key1", + "value1", + "key2", + "value2", + "PXAT", + "1761650616203", + ] + ); + }); + + it("with KEEPTTL expiration", () => { + assert.deepEqual( + parseArgs(MSETEX, ["key1", "value1", "key2", "value2"], { + expiration: { + type: ExpirationMode.KEEPTTL, + }, + }), + ["MSETEX", "2", "key1", "value1", "key2", "value2", "KEEPTTL"] + ); + }); + + it("with empty expiration object", () => { + assert.deepEqual( + parseArgs( + MSETEX, + [ + ["key1", "value1"], + ["key2", "value2"], + ], + { + expiration: {}, + } + ), + ["MSETEX", "2", "key1", "value1", "key2", "value2"] + ); + }); + }); + + testUtils.testAll( + "basic mSetEx", + async (client) => { + assert.equal( + await client.mSetEx(["{key}1", "value1", "{key}2", "value2"]), + 1 + ); + }, + { + client: { ...GLOBAL.SERVERS.OPEN, minimumDockerVersion: [8, 4] }, + cluster: { ...GLOBAL.CLUSTERS.OPEN, minimumDockerVersion: [8, 4] }, + } + ); + + testUtils.testAll( + "mSetEx with XX", + async (client) => { + const keyValuePairs = { + "{key}1": "value1", + "{key}2": "value2", + }; + + const keysDoNotExist = await client.mSetEx(keyValuePairs, { + mode: SetMode.XX, + }); + + assert.equal(keysDoNotExist, 0); + + await client.mSet(keyValuePairs); + + const keysExist = await client.mSetEx(keyValuePairs, { + mode: SetMode.XX, + }); + + assert.equal(keysExist, 1); + }, + { + client: { ...GLOBAL.SERVERS.OPEN, minimumDockerVersion: [8, 4] }, + cluster: { ...GLOBAL.CLUSTERS.OPEN, minimumDockerVersion: [8, 4] }, + } + ); + + testUtils.testAll( + "mSetEx with NX", + async (client) => { + const keyValuePairs = [ + ["{key}1", "value1"], + ["{key}2", "value2"], + ] as Array<[string, string]>; + + const firstAttempt = await client.mSetEx(keyValuePairs, { + mode: SetMode.NX, + }); + + assert.equal(firstAttempt, 1); + + const secondAttempt = await client.mSetEx(keyValuePairs, { + mode: SetMode.NX, + }); + + assert.equal(secondAttempt, 0); + }, + { + client: { ...GLOBAL.SERVERS.OPEN, minimumDockerVersion: [8, 4] }, + cluster: { ...GLOBAL.CLUSTERS.OPEN, minimumDockerVersion: [8, 4] }, + } + ); + + testUtils.testAll( + "mSetEx with PX expiration", + async (client) => { + assert.equal( + await client.mSetEx( + [ + ["{key}1", "value1"], + ["{key}2", "value2"], + ], + { + expiration: { + type: ExpirationMode.PX, + value: 500, + }, + } + ), + 1 + ); + }, + { + client: { ...GLOBAL.SERVERS.OPEN, minimumDockerVersion: [8, 4] }, + cluster: { ...GLOBAL.CLUSTERS.OPEN, minimumDockerVersion: [8, 4] }, + } + ); + + testUtils.testAll( + "mSetEx with EXAT expiration", + async (client) => { + assert.equal( + await client.mSetEx( + [ + ["{key}1", "value1"], + ["{key}2", "value2"], + ], + { + expiration: { + type: ExpirationMode.EXAT, + value: new Date(Date.now() + 10000), + }, + } + ), + 1 + ); + }, + { + client: { ...GLOBAL.SERVERS.OPEN, minimumDockerVersion: [8, 4] }, + cluster: { ...GLOBAL.CLUSTERS.OPEN, minimumDockerVersion: [8, 4] }, + } + ); + + testUtils.testAll( + "mSetEx with KEEPTTL expiration", + async (client) => { + assert.equal( + await client.mSetEx(["{key}1", "value1", "{key}2", "value2"], { + expiration: { + type: ExpirationMode.KEEPTTL, + }, + }), + 1 + ); + }, + { + client: { ...GLOBAL.SERVERS.OPEN, minimumDockerVersion: [8, 4] }, + cluster: { ...GLOBAL.CLUSTERS.OPEN, minimumDockerVersion: [8, 4] }, + } + ); + + testUtils.testAll( + "mSetEx with all options", + async (client) => { + assert.equal( + await client.mSetEx( + { + "{key}1": "value1", + "{key}2": "value2", + }, + { + expiration: { + type: ExpirationMode.PXAT, + value: Date.now() + 10000, + }, + mode: SetMode.NX, + } + ), + 1 + ); + }, + { + client: { ...GLOBAL.SERVERS.OPEN, minimumDockerVersion: [8, 4] }, + cluster: { ...GLOBAL.CLUSTERS.OPEN, minimumDockerVersion: [8, 4] }, + } + ); +}); diff --git a/packages/client/lib/commands/MSETEX.ts b/packages/client/lib/commands/MSETEX.ts new file mode 100644 index 00000000000..ee32eb6edb4 --- /dev/null +++ b/packages/client/lib/commands/MSETEX.ts @@ -0,0 +1,143 @@ +import { CommandParser } from "../client/parser"; +import { NumberReply, Command, RedisArgument } from "../RESP/types"; +import { transformEXAT, transformPXAT } from "./generic-transformers"; +import { MSetArguments } from "./MSET"; + +export const SetMode = { + /** + * Only set if all keys exist + */ + XX: "XX", + /** + * Only set if none of the keys exist + */ + NX: "NX", +} as const; + +export type SetMode = (typeof SetMode)[keyof typeof SetMode]; + +export const ExpirationMode = { + /** + * Relative expiration (seconds) + */ + EX: "EX", + /** + * Relative expiration (milliseconds) + */ + PX: "PX", + /** + * Absolute expiration (Unix timestamp in seconds) + */ + EXAT: "EXAT", + /** + * Absolute expiration (Unix timestamp in milliseconds) + */ + PXAT: "PXAT", + /** + * Keep existing TTL + */ + KEEPTTL: "KEEPTTL", +} as const; + +export type ExpirationMode = + (typeof ExpirationMode)[keyof typeof ExpirationMode]; + +type SetConditionOption = typeof SetMode.XX | typeof SetMode.NX; + +type ExpirationOption = + | { type: typeof ExpirationMode.EX; value: number } + | { type: typeof ExpirationMode.PX; value: number } + | { type: typeof ExpirationMode.EXAT; value: number | Date } + | { type: typeof ExpirationMode.PXAT; value: number | Date } + | { type: typeof ExpirationMode.KEEPTTL }; + +export function parseMSetExArguments( + parser: CommandParser, + keyValuePairs: MSetArguments +) { + let tuples: Array<[RedisArgument, RedisArgument]> = []; + + if (Array.isArray(keyValuePairs)) { + if (keyValuePairs.length == 0) { + throw new Error("empty keyValuePairs Argument"); + } + if (Array.isArray(keyValuePairs[0])) { + tuples = keyValuePairs as Array<[RedisArgument, RedisArgument]>; + } else { + const arr = keyValuePairs as Array; + for (let i = 0; i < arr.length; i += 2) { + tuples.push([arr[i], arr[i + 1]]); + } + } + } else { + for (const tuple of Object.entries(keyValuePairs)) { + tuples.push([tuple[0], tuple[1]]); + } + } + + // Push the number of keys + parser.push(tuples.length.toString()); + + for (const tuple of tuples) { + parser.pushKey(tuple[0]); + parser.push(tuple[1]); + } +} + +export default { + /** + * Constructs the MSETEX command. + * + * Atomically sets multiple string keys with a shared expiration in a single operation. + * + * @param parser - The command parser + * @param keyValuePairs - Key-value pairs to set (array of tuples, flat array, or object) + * @param options - Configuration for expiration and set modes + * @see https://redis.io/commands/msetex/ + */ + parseCommand( + parser: CommandParser, + keyValuePairs: MSetArguments, + options?: { + expiration?: ExpirationOption; + mode?: SetConditionOption; + } + ) { + parser.push("MSETEX"); + + // Push number of keys and key-value pairs before the options + parseMSetExArguments(parser, keyValuePairs); + + if (options?.mode) { + parser.push(options.mode); + } + + if (options?.expiration) { + switch (options.expiration.type) { + case ExpirationMode.EXAT: + parser.push( + ExpirationMode.EXAT, + transformEXAT(options.expiration.value) + ); + break; + case ExpirationMode.PXAT: + parser.push( + ExpirationMode.PXAT, + transformPXAT(options.expiration.value) + ); + break; + case ExpirationMode.KEEPTTL: + parser.push(ExpirationMode.KEEPTTL); + break; + case ExpirationMode.EX: + case ExpirationMode.PX: + parser.push( + options.expiration.type, + options.expiration.value?.toString() + ); + break; + } + } + }, + transformReply: undefined as unknown as () => NumberReply<0 | 1>, +} as const satisfies Command; diff --git a/packages/client/lib/commands/index.ts b/packages/client/lib/commands/index.ts index 27d3d329eda..39a2ea91fdf 100644 --- a/packages/client/lib/commands/index.ts +++ b/packages/client/lib/commands/index.ts @@ -206,6 +206,7 @@ import MODULE_LOAD from './MODULE_LOAD'; import MODULE_UNLOAD from './MODULE_UNLOAD'; import MOVE from './MOVE'; import MSET from './MSET'; +import MSETEX from './MSETEX'; import MSETNX from './MSETNX'; import OBJECT_ENCODING from './OBJECT_ENCODING'; import OBJECT_FREQ from './OBJECT_FREQ'; @@ -788,6 +789,8 @@ export default { move: MOVE, MSET, mSet: MSET, + MSETEX, + mSetEx: MSETEX, MSETNX, mSetNX: MSETNX, OBJECT_ENCODING, From dae47b48206e1153e05c8480194dee9fdbd1e986 Mon Sep 17 00:00:00 2001 From: Trofymenko Vladyslav Date: Mon, 3 Nov 2025 14:05:58 +0200 Subject: [PATCH 08/24] feat(client): add latency histogram (#3099) * add latency histogram command, tests (##1955) --- .../lib/commands/LATENCY_HISTOGRAM.spec.ts | 93 +++++++++++++++++++ .../client/lib/commands/LATENCY_HISTOGRAM.ts | 46 +++++++++ packages/client/lib/commands/index.ts | 3 + 3 files changed, 142 insertions(+) create mode 100644 packages/client/lib/commands/LATENCY_HISTOGRAM.spec.ts create mode 100644 packages/client/lib/commands/LATENCY_HISTOGRAM.ts diff --git a/packages/client/lib/commands/LATENCY_HISTOGRAM.spec.ts b/packages/client/lib/commands/LATENCY_HISTOGRAM.spec.ts new file mode 100644 index 00000000000..225410e0288 --- /dev/null +++ b/packages/client/lib/commands/LATENCY_HISTOGRAM.spec.ts @@ -0,0 +1,93 @@ +import assert from "node:assert/strict"; +import testUtils, { GLOBAL } from "../test-utils"; +import LATENCY_HISTOGRAM from "./LATENCY_HISTOGRAM"; +import { parseArgs } from "./generic-transformers"; + +describe("LATENCY HISTOGRAM", () => { + describe("transformArguments", () => { + it("filtered by command set", () => { + assert.deepEqual(parseArgs(LATENCY_HISTOGRAM, "set"), [ + "LATENCY", + "HISTOGRAM", + "set", + ]); + }); + + it("unfiltered", () => { + assert.deepEqual(parseArgs(LATENCY_HISTOGRAM), [ + "LATENCY", + "HISTOGRAM", + ]); + }); + }); + + describe("RESP 2", () => { + testUtils.testWithClient( + "unfiltered list", + async (client) => { + await client.configResetStat(); + await Promise.all([ + client.lPush("push-key", "hello "), + client.set("set-key", "world!"), + ]); + const histogram = await client.latencyHistogram(); + const commands = ["config|resetstat", "set", "lpush"]; + for (const command of commands) { + assert.ok(typeof histogram[command]["calls"], "number"); + } + }, + GLOBAL.SERVERS.OPEN, + ); + + testUtils.testWithClient( + "filtered by a command list", + async (client) => { + await client.configSet("latency-monitor-threshold", "100"); + await client.set("set-key", "hello"); + const histogram = await client.latencyHistogram("set"); + assert.ok(typeof histogram.set["calls"], "number"); + }, + GLOBAL.SERVERS.OPEN, + ); + }); + + describe("RESP 3", () => { + testUtils.testWithClient( + "unfiltered list", + async (client) => { + await client.configResetStat(); + await Promise.all([ + client.lPush("push-key", "hello "), + client.set("set-key", "world!"), + ]); + const histogram = await client.latencyHistogram(); + const commands = ["config|resetstat", "set", "lpush"]; + for (const command of commands) { + assert.ok(typeof histogram[command]["calls"], "number"); + } + }, + { + ...GLOBAL.SERVERS.OPEN, + clientOptions: { + RESP: 3, + }, + }, + ); + + testUtils.testWithClient( + "filtered by a command list", + async (client) => { + await client.configSet("latency-monitor-threshold", "100"); + await client.set("set-key", "hello"); + const histogram = await client.latencyHistogram("set"); + assert.ok(typeof histogram.set["calls"], "number"); + }, + { + ...GLOBAL.SERVERS.OPEN, + clientOptions: { + RESP: 3, + }, + }, + ); + }); +}); \ No newline at end of file diff --git a/packages/client/lib/commands/LATENCY_HISTOGRAM.ts b/packages/client/lib/commands/LATENCY_HISTOGRAM.ts new file mode 100644 index 00000000000..4c3faef1e22 --- /dev/null +++ b/packages/client/lib/commands/LATENCY_HISTOGRAM.ts @@ -0,0 +1,46 @@ +import { CommandParser } from '../client/parser'; +import { Command } from '../RESP/types'; +import { transformTuplesToMap } from './generic-transformers'; + +type RawHistogram = [string, number, string, number[]]; + +type Histogram = Record; +}>; + +const id = (n: number) => n; + +export default { + CACHEABLE: false, + IS_READ_ONLY: true, + /** + * Constructs the LATENCY HISTOGRAM command + * + * @param parser - The command parser + * @param commands - The list of redis commands to get histogram for + * @see https://redis.io/docs/latest/commands/latency-histogram/ + */ + parseCommand(parser: CommandParser, ...commands: string[]) { + const args = ['LATENCY', 'HISTOGRAM']; + if (commands.length !== 0) { + args.push(...commands); + } + parser.push(...args); + }, + transformReply: { + 2: (reply: (string | RawHistogram)[]): Histogram => { + const result: Histogram = {}; + if (reply.length === 0) return result; + for (let i = 1; i < reply.length; i += 2) { + const histogram = reply[i] as RawHistogram; + result[reply[i - 1] as string] = { + calls: histogram[1], + histogram_usec: transformTuplesToMap(histogram[3], id), + }; + } + return result; + }, + 3: undefined as unknown as () => Histogram, + } +} as const satisfies Command; diff --git a/packages/client/lib/commands/index.ts b/packages/client/lib/commands/index.ts index 39a2ea91fdf..590b3f32d12 100644 --- a/packages/client/lib/commands/index.ts +++ b/packages/client/lib/commands/index.ts @@ -364,6 +364,7 @@ import VREM from './VREM'; import VSETATTR from './VSETATTR'; import VSIM from './VSIM'; import VSIM_WITHSCORES from './VSIM_WITHSCORES'; +import LATENCY_HISTOGRAM from './LATENCY_HISTOGRAM'; export { CLIENT_KILL_FILTERS, @@ -722,6 +723,8 @@ export default { latencyGraph: LATENCY_GRAPH, LATENCY_HISTORY, latencyHistory: LATENCY_HISTORY, + LATENCY_HISTOGRAM, + latencyHistogram: LATENCY_HISTOGRAM, LATENCY_LATEST, latencyLatest: LATENCY_LATEST, LATENCY_RESET, From 568d60dbaaff0df5d13f3576cd99b55a90255879 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Mon, 3 Nov 2025 17:22:03 +0200 Subject: [PATCH 09/24] chore(tests): bump test container version 8.4-RC1-pre.2 (#3126) --- .github/workflows/tests.yml | 2 +- packages/bloom/lib/test-utils.ts | 2 +- packages/client/lib/sentinel/test-util.ts | 2 +- packages/client/lib/test-utils.ts | 2 +- packages/entraid/lib/test-utils.ts | 2 +- packages/json/lib/test-utils.ts | 2 +- packages/search/lib/test-utils.ts | 2 +- packages/test-utils/lib/test-utils.ts | 2 +- packages/time-series/lib/test-utils.ts | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index bf16dd8decf..c9c1ff56532 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -22,7 +22,7 @@ jobs: fail-fast: false matrix: node-version: ["18", "20", "22"] - redis-version: ["rs-7.4.0-v1", "8.0.2", "8.2", "8.4-RC1-pre"] + redis-version: ["rs-7.4.0-v1", "8.0.2", "8.2", "8.4-RC1-pre.2"] steps: - uses: actions/checkout@v4 with: diff --git a/packages/bloom/lib/test-utils.ts b/packages/bloom/lib/test-utils.ts index 908c063059d..7ecf3e0e399 100644 --- a/packages/bloom/lib/test-utils.ts +++ b/packages/bloom/lib/test-utils.ts @@ -4,7 +4,7 @@ import RedisBloomModules from '.'; export default TestUtils.createFromConfig({ dockerImageName: 'redislabs/client-libs-test', dockerImageVersionArgument: 'redis-version', - defaultDockerVersion: '8.4-RC1-pre' + defaultDockerVersion: '8.4-RC1-pre.2' }); export const GLOBAL = { diff --git a/packages/client/lib/sentinel/test-util.ts b/packages/client/lib/sentinel/test-util.ts index 70c551b843a..294bc8ae4f2 100644 --- a/packages/client/lib/sentinel/test-util.ts +++ b/packages/client/lib/sentinel/test-util.ts @@ -174,7 +174,7 @@ export class SentinelFramework extends DockerBase { this.#testUtils = TestUtils.createFromConfig({ dockerImageName: 'redislabs/client-libs-test', dockerImageVersionArgument: 'redis-version', - defaultDockerVersion: '8.4-RC1-pre' + defaultDockerVersion: '8.4-RC1-pre.2' }); this.#nodeMap = new Map>>>(); this.#sentinelMap = new Map>>>(); diff --git a/packages/client/lib/test-utils.ts b/packages/client/lib/test-utils.ts index bcb7ae5c9ba..1c49e92d868 100644 --- a/packages/client/lib/test-utils.ts +++ b/packages/client/lib/test-utils.ts @@ -9,7 +9,7 @@ import RedisBloomModules from '@redis/bloom'; const utils = TestUtils.createFromConfig({ dockerImageName: 'redislabs/client-libs-test', dockerImageVersionArgument: 'redis-version', - defaultDockerVersion: '8.4-RC1-pre' + defaultDockerVersion: '8.4-RC1-pre.2' }); export default utils; diff --git a/packages/entraid/lib/test-utils.ts b/packages/entraid/lib/test-utils.ts index 43c511f6eee..0d196120a9e 100644 --- a/packages/entraid/lib/test-utils.ts +++ b/packages/entraid/lib/test-utils.ts @@ -6,7 +6,7 @@ import { EntraidCredentialsProvider } from './entraid-credentials-provider'; export const testUtils = TestUtils.createFromConfig({ dockerImageName: 'redislabs/client-libs-test', dockerImageVersionArgument: 'redis-version', - defaultDockerVersion: '8.4-RC1-pre' + defaultDockerVersion: '8.4-RC1-pre.2' }); const DEBUG_MODE_ARGS = testUtils.isVersionGreaterThan([7]) ? diff --git a/packages/json/lib/test-utils.ts b/packages/json/lib/test-utils.ts index a2733ab4924..15c07414a49 100644 --- a/packages/json/lib/test-utils.ts +++ b/packages/json/lib/test-utils.ts @@ -4,7 +4,7 @@ import RedisJSON from '.'; export default TestUtils.createFromConfig({ dockerImageName: 'redislabs/client-libs-test', dockerImageVersionArgument: 'redis-version', - defaultDockerVersion: '8.4-RC1-pre' + defaultDockerVersion: '8.4-RC1-pre.2' }); export const GLOBAL = { diff --git a/packages/search/lib/test-utils.ts b/packages/search/lib/test-utils.ts index 69f3cfa250e..3a4af41f99a 100644 --- a/packages/search/lib/test-utils.ts +++ b/packages/search/lib/test-utils.ts @@ -5,7 +5,7 @@ import { RespVersions } from '@redis/client'; export default TestUtils.createFromConfig({ dockerImageName: 'redislabs/client-libs-test', dockerImageVersionArgument: 'redis-version', - defaultDockerVersion: '8.4-RC1-pre' + defaultDockerVersion: '8.4-RC1-pre.2' }); export const GLOBAL = { diff --git a/packages/test-utils/lib/test-utils.ts b/packages/test-utils/lib/test-utils.ts index a008089c67d..a48948e8485 100644 --- a/packages/test-utils/lib/test-utils.ts +++ b/packages/test-utils/lib/test-utils.ts @@ -3,7 +3,7 @@ import TestUtils from './index' export const testUtils = TestUtils.createFromConfig({ dockerImageName: 'redislabs/client-libs-test', dockerImageVersionArgument: 'redis-version', - defaultDockerVersion: '8.4-RC1-pre' + defaultDockerVersion: '8.4-RC1-pre.2' }); diff --git a/packages/time-series/lib/test-utils.ts b/packages/time-series/lib/test-utils.ts index 008994d85a8..6158498743b 100644 --- a/packages/time-series/lib/test-utils.ts +++ b/packages/time-series/lib/test-utils.ts @@ -4,7 +4,7 @@ import TimeSeries from '.'; export default TestUtils.createFromConfig({ dockerImageName: 'redislabs/client-libs-test', dockerImageVersionArgument: 'redis-version', - defaultDockerVersion: '8.4-RC1-pre' + defaultDockerVersion: '8.4-RC1-pre.2' }); export const GLOBAL = { From 100c0394dcfed56c5dca95f270f71df00697e879 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Tue, 4 Nov 2025 10:25:27 +0200 Subject: [PATCH 10/24] fix(release): bump dist/package.json version (#3125) Currently, the release process incorrectly leaves the dist/package.json's version to be the old version. This in turn makes the client setinfo lib-ver command to send wrong version to redis. Fix: use the release-it/bumper package to update dist/package.json with the correct version upon release. fixes: #3118 --- packages/client/.release-it.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/client/.release-it.json b/packages/client/.release-it.json index fe1a6ad0d0d..71cf5107f9b 100644 --- a/packages/client/.release-it.json +++ b/packages/client/.release-it.json @@ -9,5 +9,13 @@ "commitMessage": "Release ${tagName}", "tagAnnotation": "Release ${tagName}", "commitArgs": "--all" + }, + "plugins": { + "@release-it/bumper": { + "out": { + "file": "dist/package.json", + "path": "version" + } + } } } From b1c39fe02f6d30913e5784f18dd35af34eac33b1 Mon Sep 17 00:00:00 2001 From: Hristo Temelski Date: Mon, 10 Nov 2025 11:38:45 +0200 Subject: [PATCH 11/24] fix: Hybrid Search, minor changes, added experimental notice (#3132) * Hybrid Search, minor changes, added experimental notice * fixed twot tests --- packages/search/lib/commands/HYBRID.spec.ts | 75 +++++++-------------- packages/search/lib/commands/HYBRID.ts | 23 ++----- 2 files changed, 27 insertions(+), 71 deletions(-) diff --git a/packages/search/lib/commands/HYBRID.spec.ts b/packages/search/lib/commands/HYBRID.spec.ts index 624c1b9c910..12f1ded6dcc 100644 --- a/packages/search/lib/commands/HYBRID.spec.ts +++ b/packages/search/lib/commands/HYBRID.spec.ts @@ -10,20 +10,10 @@ describe('FT.HYBRID', () => { HYBRID.parseCommand(parser, 'index'); assert.deepEqual( parser.redisArgs, - ['FT.HYBRID', 'index', '2', 'DIALECT', '2'] + ['FT.HYBRID', 'index'] ); }); - it('with count expressions', () => { - const parser = new BasicCommandParser(); - HYBRID.parseCommand(parser, 'index', { - countExpressions: 3 - }); - assert.deepEqual( - parser.redisArgs, - ['FT.HYBRID', 'index', '3', 'DIALECT', '2'] - ); - }); it('with SEARCH expression', () => { const parser = new BasicCommandParser(); @@ -34,7 +24,7 @@ describe('FT.HYBRID', () => { }); assert.deepEqual( parser.redisArgs, - ['FT.HYBRID', 'index', '2', 'SEARCH', '@description: bikes', 'DIALECT', '2'] + ['FT.HYBRID', 'index', 'SEARCH', '@description: bikes'] ); }); @@ -53,9 +43,9 @@ describe('FT.HYBRID', () => { assert.deepEqual( parser.redisArgs, [ - 'FT.HYBRID', 'index', '2', 'SEARCH', '@description: bikes', + 'FT.HYBRID', 'index', 'SEARCH', '@description: bikes', 'SCORER', 'TFIDF.DOCNORM', 'param1', 'param2', - 'YIELD_SCORE_AS', 'search_score', 'DIALECT', '2' + 'YIELD_SCORE_AS', 'search_score' ] ); }); @@ -78,9 +68,8 @@ describe('FT.HYBRID', () => { assert.deepEqual( parser.redisArgs, [ - 'FT.HYBRID', 'index', '2', 'VSIM', '@vector_field', 'BLOB_DATA', - 'KNN', '1', 'K', '10', 'EF_RUNTIME', '50', 'YIELD_DISTANCE_AS', 'vector_dist', - 'DIALECT', '2' + 'FT.HYBRID', 'index', 'VSIM', '@vector_field', 'BLOB_DATA', + 'KNN', '1', 'K', '10', 'EF_RUNTIME', '50', 'YIELD_DISTANCE_AS', 'vector_dist' ] ); }); @@ -103,9 +92,8 @@ describe('FT.HYBRID', () => { assert.deepEqual( parser.redisArgs, [ - 'FT.HYBRID', 'index', '2', 'VSIM', '@vector_field', 'BLOB_DATA', - 'RANGE', '1', 'RADIUS', '0.5', 'EPSILON', '0.01', 'YIELD_DISTANCE_AS', 'vector_dist', - 'DIALECT', '2' + 'FT.HYBRID', 'index', 'VSIM', '@vector_field', 'BLOB_DATA', + 'RANGE', '1', 'RADIUS', '0.5', 'EPSILON', '0.01', 'YIELD_DISTANCE_AS', 'vector_dist' ] ); }); @@ -129,9 +117,9 @@ describe('FT.HYBRID', () => { assert.deepEqual( parser.redisArgs, [ - 'FT.HYBRID', 'index', '2', 'VSIM', '@vector_field', 'BLOB_DATA', + 'FT.HYBRID', 'index', 'VSIM', '@vector_field', 'BLOB_DATA', 'FILTER', '@category:{bikes}', 'POLICY', 'BATCHES', 'BATCHES', 'BATCH_SIZE', '100', - 'YIELD_SCORE_AS', 'vsim_score', 'DIALECT', '2' + 'YIELD_SCORE_AS', 'vsim_score' ] ); }); @@ -153,8 +141,8 @@ describe('FT.HYBRID', () => { assert.deepEqual( parser.redisArgs, [ - 'FT.HYBRID', 'index', '2', 'COMBINE', 'RRF', '2', 'WINDOW', '10', 'CONSTANT', '60', - 'YIELD_SCORE_AS', 'combined_score', 'DIALECT', '2' + 'FT.HYBRID', 'index', 'COMBINE', 'RRF', '2', 'WINDOW', '10', 'CONSTANT', '60', + 'YIELD_SCORE_AS', 'combined_score' ] ); }); @@ -175,8 +163,7 @@ describe('FT.HYBRID', () => { assert.deepEqual( parser.redisArgs, [ - 'FT.HYBRID', 'index', '2', 'COMBINE', 'LINEAR', '2', 'ALPHA', '0.7', 'BETA', '0.3', - 'DIALECT', '2' + 'FT.HYBRID', 'index', 'COMBINE', 'LINEAR', '2', 'ALPHA', '0.7', 'BETA', '0.3' ] ); }); @@ -199,8 +186,8 @@ describe('FT.HYBRID', () => { assert.deepEqual( parser.redisArgs, [ - 'FT.HYBRID', 'index', '2', 'LOAD', '2', 'field1', 'field2', - 'SORTBY', '1', 'score', 'DESC', 'LIMIT', '0', '10', 'DIALECT', '2' + 'FT.HYBRID', 'index', 'LOAD', '2', 'field1', 'field2', + 'SORTBY', '1', 'score', 'DESC', 'LIMIT', '0', '10' ] ); }); @@ -220,8 +207,7 @@ describe('FT.HYBRID', () => { assert.deepEqual( parser.redisArgs, [ - 'FT.HYBRID', 'index', '2', 'GROUPBY', '1', '@category', 'REDUCE', 'COUNT', '0', - 'DIALECT', '2' + 'FT.HYBRID', 'index', 'GROUPBY', '1', '@category', 'REDUCE', 'COUNT', '0' ] ); }); @@ -236,7 +222,7 @@ describe('FT.HYBRID', () => { }); assert.deepEqual( parser.redisArgs, - ['FT.HYBRID', 'index', '2', 'APPLY', '@score * 2', 'AS', 'double_score', 'DIALECT', '2'] + ['FT.HYBRID', 'index', 'APPLY', '@score * 2', 'AS', 'double_score'] ); }); @@ -247,7 +233,7 @@ describe('FT.HYBRID', () => { }); assert.deepEqual( parser.redisArgs, - ['FT.HYBRID', 'index', '2', 'FILTER', '@price:[100 500]', 'DIALECT', '2'] + ['FT.HYBRID', 'index', 'FILTER', '@price:[100 500]'] ); }); @@ -262,8 +248,7 @@ describe('FT.HYBRID', () => { assert.deepEqual( parser.redisArgs, [ - 'FT.HYBRID', 'index', '2', 'PARAMS', '4', 'query_vector', 'BLOB_DATA', 'min_price', '100', - 'DIALECT', '2' + 'FT.HYBRID', 'index', 'PARAMS', '4', 'query_vector', 'BLOB_DATA', 'min_price', '100' ] ); }); @@ -276,7 +261,7 @@ describe('FT.HYBRID', () => { }); assert.deepEqual( parser.redisArgs, - ['FT.HYBRID', 'index', '2', 'EXPLAINSCORE', 'TIMEOUT', '5000', 'DIALECT', '2'] + ['FT.HYBRID', 'index', 'EXPLAINSCORE', 'TIMEOUT', '5000'] ); }); @@ -291,8 +276,7 @@ describe('FT.HYBRID', () => { assert.deepEqual( parser.redisArgs, [ - 'FT.HYBRID', 'index', '2', 'WITHCURSOR', 'COUNT', '100', 'MAXIDLE', '300000', - 'DIALECT', '2' + 'FT.HYBRID', 'index', 'WITHCURSOR', 'COUNT', '100', 'MAXIDLE', '300000' ] ); }); @@ -300,7 +284,6 @@ describe('FT.HYBRID', () => { it('complete example with all options', () => { const parser = new BasicCommandParser(); HYBRID.parseCommand(parser, 'index', { - countExpressions: 2, SEARCH: { query: '@description: bikes', SCORER: { @@ -343,29 +326,17 @@ describe('FT.HYBRID', () => { assert.deepEqual( parser.redisArgs, [ - 'FT.HYBRID', 'index', '2', + 'FT.HYBRID', 'index', 'SEARCH', '@description: bikes', 'SCORER', 'TFIDF.DOCNORM', 'YIELD_SCORE_AS', 'text_score', 'VSIM', '@vector_field', '$query_vector', 'KNN', '1', 'K', '5', 'YIELD_SCORE_AS', 'vector_score', 'COMBINE', 'RRF', '2', 'CONSTANT', '60', 'YIELD_SCORE_AS', 'final_score', 'LOAD', '2', 'description', 'price', 'SORTBY', '1', 'final_score', 'DESC', 'LIMIT', '0', '10', - 'PARAMS', '2', 'query_vector', 'BLOB_DATA', - 'DIALECT', '2' + 'PARAMS', '2', 'query_vector', 'BLOB_DATA' ] ); }); - - it('with custom DIALECT', () => { - const parser = new BasicCommandParser(); - HYBRID.parseCommand(parser, 'index', { - DIALECT: 3 - }); - assert.deepEqual( - parser.redisArgs, - ['FT.HYBRID', 'index', '2', 'DIALECT', '3'] - ); - }); }); // Integration tests would need to be added when RediSearch supports FT.HYBRID diff --git a/packages/search/lib/commands/HYBRID.ts b/packages/search/lib/commands/HYBRID.ts index b94e7196dad..c8b8ad0e9fe 100644 --- a/packages/search/lib/commands/HYBRID.ts +++ b/packages/search/lib/commands/HYBRID.ts @@ -1,7 +1,6 @@ import { CommandParser } from '@redis/client/dist/lib/client/parser'; import { RedisArgument, Command, ReplyUnion } from '@redis/client/dist/lib/RESP/types'; import { RedisVariadicArgument, parseOptionalVariadicArgument } from '@redis/client/dist/lib/commands/generic-transformers'; -import { DEFAULT_DIALECT } from '../dialect/default'; import { FtSearchParams, parseParamsArgument } from './SEARCH'; export interface FtHybridSearchExpression { @@ -55,7 +54,6 @@ export interface FtHybridCombineMethod { } export interface FtHybridOptions { - countExpressions?: number; SEARCH?: FtHybridSearchExpression; VSIM?: FtHybridVectorExpression; COMBINE?: { @@ -94,7 +92,6 @@ export interface FtHybridOptions { COUNT?: number; MAXIDLE?: number; }; - DIALECT?: number; } function parseSearchExpression(parser: CommandParser, search: FtHybridSearchExpression) { @@ -269,10 +266,6 @@ function parseHybridOptions(parser: CommandParser, options?: FtHybridOptions) { parser.push('MAXIDLE', options.WITHCURSOR.MAXIDLE.toString()); } } - - if (options?.DIALECT) { - parser.push('DIALECT', options.DIALECT.toString()); - } } export default { @@ -282,10 +275,12 @@ export default { * Performs a hybrid search combining multiple search expressions. * Supports multiple SEARCH and VECTOR expressions with various fusion methods. * + * NOTE: FT.Hybrid is still in experimental state + * It's behavioud and function signature may change` + * * @param parser - The command parser * @param index - The index name to search * @param options - Hybrid search options including: - * - countExpressions: Number of expressions (default 2) * - SEARCH: Text search expression with optional scoring * - VSIM: Vector similarity expression with KNN/RANGE methods * - COMBINE: Fusion method (RRF, LINEAR, FUNCTION) @@ -295,18 +290,8 @@ export default { parseCommand(parser: CommandParser, index: RedisArgument, options?: FtHybridOptions) { parser.push('FT.HYBRID', index); - if (options?.countExpressions !== undefined) { - parser.push(options.countExpressions.toString()); - } else { - parser.push('2'); // Default to 2 expressions - } - parseHybridOptions(parser, options); - - // Always add DIALECT at the end if not already added - if (!options?.DIALECT) { - parser.push('DIALECT', DEFAULT_DIALECT); - } + }, transformReply: { 2: (reply: any): any => { From f8841c880e5fa7f260a43f1dadfbe07fb37923a4 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Mon, 10 Nov 2025 19:34:26 +0200 Subject: [PATCH 12/24] fix(socket): prevent false-ready state when socket errors during handshake (#3128) * fix(socket): prevent false-ready state when socket errors during handshake Fixes race condition where async socket errors during connection handshake don't trigger reconnection. Validates socket state after initiator completes to catch errors swallowed by command handlers. fixes: #3108 * remove comments --- packages/client/lib/client/index.spec.ts | 89 +++++++++++++++++++++++- packages/client/lib/client/socket.ts | 10 +++ 2 files changed, 98 insertions(+), 1 deletion(-) diff --git a/packages/client/lib/client/index.spec.ts b/packages/client/lib/client/index.spec.ts index d7ce00f38ae..1056c3ed6a2 100644 --- a/packages/client/lib/client/index.spec.ts +++ b/packages/client/lib/client/index.spec.ts @@ -4,12 +4,16 @@ import RedisClient, { RedisClientOptions, RedisClientType } from '.'; import { AbortError, ClientClosedError, ClientOfflineError, ConnectionTimeoutError, DisconnectsClientError, ErrorReply, MultiErrorReply, TimeoutError, WatchError } from '../errors'; import { defineScript } from '../lua-script'; import { spy, stub } from 'sinon'; -import { once } from 'node:events'; +import EventEmitter, { once } from 'node:events'; import { MATH_FUNCTION, loadMathFunction } from '../commands/FUNCTION_LOAD.spec'; import { RESP_TYPES } from '../RESP/decoder'; import { BlobStringReply, NumberReply } from '../RESP/types'; import { SortedSetMember } from '../commands/generic-transformers'; import { CommandParser } from './parser'; +import { RedisSocketOptions } from './socket'; +import { getFreePortNumber } from '@redis/test-utils/lib/proxy/redis-proxy'; +import { createClient } from '../../'; +import net from 'node:net' export const SQUARE_SCRIPT = defineScript({ SCRIPT: @@ -1008,6 +1012,89 @@ describe('Client', () => { } }, GLOBAL.SERVERS.OPEN); }); + + describe("socket errors during handshake", () => { + + it("should successfully connect when server accepts connection immediately", async () => { + const { log, client, teardown } = await setup({}, 0); + await client.connect(); + assert.deepEqual(["connect", "ready"], log); + teardown(); + }); + + it("should reconnect after multiple connection drops during handshake", async () => { + const { log, client, teardown } = await setup({}, 2); + await client.connect(); + assert.deepEqual( + [ + "connect", + "error", + "reconnecting", + "connect", + "error", + "reconnecting", + "connect", + "ready", + ], + log, + ); + teardown(); + }); + + async function setup( + socketOptions: Partial, + dropCount: number, + ) { + const port = await getFreePortNumber(); + const server = setupMockServer(dropCount); + const options = { + ...{ + socket: { + host: "localhost", + port, + }, + ...socketOptions, + }, + }; + const client = createClient(options); + const log = setupLog(client); + await once(server.listen(port), "listening"); + return { + log, + client, + server, + teardown: async function () { + client.destroy(); + server.close(); + }, + }; + } + + function setupLog(client: EventEmitter): string[] { + const log: string[] = []; + client.on("connect", () => log.push("connect")); + client.on("ready", () => log.push("ready")); + client.on("reconnecting", () => log.push("reconnecting")); + client.on("error", () => log.push("error")); + return log; + } + + // Create a TCP server that accepts connections but immediately drops them times + // This simulates what happens when Docker container is stopped: + // - TCP connection succeeds (OS accepts it) + // - But socket is immediately destroyed, causing ECONNRESET during handshake + function setupMockServer(dropImmediately: number) { + const server = net.createServer(async (socket) => { + if (dropImmediately > 0) { + dropImmediately--; + socket.destroy(); + } + socket.write("+OK\r\n+OK\r\n"); + }); + return server; + } + + }); }); /** diff --git a/packages/client/lib/client/socket.ts b/packages/client/lib/client/socket.ts index c5569e86547..ab8e9992b66 100644 --- a/packages/client/lib/client/socket.ts +++ b/packages/client/lib/client/socket.ts @@ -220,6 +220,15 @@ export default class RedisSocket extends EventEmitter { try { await this.#initiator(); + + // Check if socket was closed/destroyed during initiator execution + if (!this.#socket || this.#socket.destroyed || !this.#socket.readable || !this.#socket.writable) { + const retryIn = this.#shouldReconnect(retries++, new SocketClosedUnexpectedlyError()); + if (typeof retryIn !== 'number') { throw retryIn; } + await setTimeout(retryIn); + this.emit('reconnecting'); + continue; + } } catch (err) { this.#socket.destroy(); this.#socket = undefined; @@ -312,6 +321,7 @@ export default class RedisSocket extends EventEmitter { }); } + write(iterable: Iterable>) { if (!this.#socket) return; From c9f8cbcad5cf55b535924180b542a6d2e9fabbc4 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Tue, 11 Nov 2025 13:03:19 +0200 Subject: [PATCH 13/24] chore: mark 8.4 features as experimental (#3134) --- packages/client/lib/commands/DELEX.ts | 3 +++ packages/client/lib/commands/DIGEST.ts | 3 +++ packages/client/lib/commands/SET.ts | 7 +++-- packages/search/lib/commands/HYBRID.ts | 37 +++++++++++++------------- 4 files changed, 30 insertions(+), 20 deletions(-) diff --git a/packages/client/lib/commands/DELEX.ts b/packages/client/lib/commands/DELEX.ts index e32c292f225..c42bb3e449c 100644 --- a/packages/client/lib/commands/DELEX.ts +++ b/packages/client/lib/commands/DELEX.ts @@ -25,6 +25,9 @@ type DelexCondition = (typeof DelexCondition)[keyof typeof DelexCondition]; export default { IS_READ_ONLY: false, /** + * + * @experimental + * * Conditionally removes the specified key based on value or digest comparison. * * @param parser - The Redis command parser diff --git a/packages/client/lib/commands/DIGEST.ts b/packages/client/lib/commands/DIGEST.ts index 249bb78740f..1e1e752e25b 100644 --- a/packages/client/lib/commands/DIGEST.ts +++ b/packages/client/lib/commands/DIGEST.ts @@ -4,6 +4,9 @@ import { Command, RedisArgument, SimpleStringReply } from "../RESP/types"; export default { IS_READ_ONLY: true, /** + * + * @experimental + * * Returns the XXH3 hash of a string value. * * @param parser - The Redis command parser diff --git a/packages/client/lib/commands/SET.ts b/packages/client/lib/commands/SET.ts index 16a2a0216c6..429a3f2bce9 100644 --- a/packages/client/lib/commands/SET.ts +++ b/packages/client/lib/commands/SET.ts @@ -33,6 +33,9 @@ export interface SetOptions { * Condition for setting the key: * - `NX` - Set if key does not exist * - `XX` - Set if key already exists + * + * @experimental + * * - `IFEQ` - Set if current value equals match-value (since 8.4, requires `matchValue`) * - `IFNE` - Set if current value does not equal match-value (since 8.4, requires `matchValue`) * - `IFDEQ` - Set if current value digest equals match-digest (since 8.4, requires `matchValue`) @@ -53,14 +56,14 @@ export interface SetOptions { * @deprecated Use `{ condition: 'XX' }` instead. */ XX?: boolean; - + GET?: boolean; } export default { /** * Constructs the SET command - * + * * @param parser - The command parser * @param key - The key to set * @param value - The value to set diff --git a/packages/search/lib/commands/HYBRID.ts b/packages/search/lib/commands/HYBRID.ts index c8b8ad0e9fe..3773659de56 100644 --- a/packages/search/lib/commands/HYBRID.ts +++ b/packages/search/lib/commands/HYBRID.ts @@ -116,11 +116,11 @@ function parseVectorExpression(parser: CommandParser, vsim: FtHybridVectorExpres if (vsim.method.KNN) { const knn = vsim.method.KNN; parser.push('KNN', '1', 'K', knn.K.toString()); - + if (knn.EF_RUNTIME !== undefined) { parser.push('EF_RUNTIME', knn.EF_RUNTIME.toString()); } - + if (knn.YIELD_DISTANCE_AS) { parser.push('YIELD_DISTANCE_AS', knn.YIELD_DISTANCE_AS); } @@ -129,11 +129,11 @@ function parseVectorExpression(parser: CommandParser, vsim: FtHybridVectorExpres if (vsim.method.RANGE) { const range = vsim.method.RANGE; parser.push('RANGE', '1', 'RADIUS', range.RADIUS.toString()); - + if (range.EPSILON !== undefined) { parser.push('EPSILON', range.EPSILON.toString()); } - + if (range.YIELD_DISTANCE_AS) { parser.push('YIELD_DISTANCE_AS', range.YIELD_DISTANCE_AS); } @@ -142,10 +142,10 @@ function parseVectorExpression(parser: CommandParser, vsim: FtHybridVectorExpres if (vsim.FILTER) { parser.push('FILTER', vsim.FILTER.expression); - + if (vsim.FILTER.POLICY) { parser.push('POLICY', vsim.FILTER.POLICY); - + if (vsim.FILTER.POLICY === 'BATCHES' && vsim.FILTER.BATCHES) { parser.push('BATCHES', 'BATCH_SIZE', vsim.FILTER.BATCHES.BATCH_SIZE.toString()); } @@ -165,11 +165,11 @@ function parseCombineMethod(parser: CommandParser, combine: FtHybridOptions['COM if (combine.method.RRF) { const rrf = combine.method.RRF; parser.push('RRF', rrf.count.toString()); - + if (rrf.WINDOW !== undefined) { parser.push('WINDOW', rrf.WINDOW.toString()); } - + if (rrf.CONSTANT !== undefined) { parser.push('CONSTANT', rrf.CONSTANT.toString()); } @@ -178,11 +178,11 @@ function parseCombineMethod(parser: CommandParser, combine: FtHybridOptions['COM if (combine.method.LINEAR) { const linear = combine.method.LINEAR; parser.push('LINEAR', linear.count.toString()); - + if (linear.ALPHA !== undefined) { parser.push('ALPHA', linear.ALPHA.toString()); } - + if (linear.BETA !== undefined) { parser.push('BETA', linear.BETA.toString()); } @@ -216,7 +216,7 @@ function parseHybridOptions(parser: CommandParser, options?: FtHybridOptions) { if (options.GROUPBY) { parseOptionalVariadicArgument(parser, 'GROUPBY', options.GROUPBY.fields); - + if (options.GROUPBY.REDUCE) { parser.push('REDUCE', options.GROUPBY.REDUCE.function, options.GROUPBY.REDUCE.count.toString()); parser.push(...options.GROUPBY.REDUCE.args); @@ -257,11 +257,11 @@ function parseHybridOptions(parser: CommandParser, options?: FtHybridOptions) { if (options.WITHCURSOR) { parser.push('WITHCURSOR'); - + if (options.WITHCURSOR.COUNT !== undefined) { parser.push('COUNT', options.WITHCURSOR.COUNT.toString()); } - + if (options.WITHCURSOR.MAXIDLE !== undefined) { parser.push('MAXIDLE', options.WITHCURSOR.MAXIDLE.toString()); } @@ -274,10 +274,11 @@ export default { /** * Performs a hybrid search combining multiple search expressions. * Supports multiple SEARCH and VECTOR expressions with various fusion methods. - * + * + * @experimental * NOTE: FT.Hybrid is still in experimental state - * It's behavioud and function signature may change` - * + * It's behaviour and function signature may change + * * @param parser - The command parser * @param index - The index name to search * @param options - Hybrid search options including: @@ -300,7 +301,7 @@ export default { // This is a cursor reply const [searchResults, cursor] = reply; const transformedResults = transformHybridSearchResults(searchResults); - + return { ...transformedResults, cursor @@ -345,7 +346,7 @@ function documentValue(tuples: any) { while (i < tuples.length) { const key = tuples[i++]; const value = tuples[i++]; - + if (key === '$') { // might be a JSON reply try { Object.assign(message, JSON.parse(value)); From bcf8d2b36b6fbcd5a0a10b2ce5b39eaf1994f473 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Wed, 12 Nov 2025 11:53:14 +0200 Subject: [PATCH 14/24] docs: extract supported Redis versions into SUPPORTED_REDIS_VERSIONS.md (#3131) --- README.md | 10 +--------- SECURITY.md | 6 +----- SUPPORTED_REDIS_VERSIONS.md | 13 +++++++++++++ 3 files changed, 15 insertions(+), 14 deletions(-) create mode 100644 SUPPORTED_REDIS_VERSIONS.md diff --git a/README.md b/README.md index e6332764e4c..b251db09123 100644 --- a/README.md +++ b/README.md @@ -304,15 +304,7 @@ The Node Redis client class is an Nodejs EventEmitter and it emits an event each Node Redis is supported with the following versions of Redis: -| Version | Supported | -| ------- | ------------------ | -| 8.2.z | :heavy_check_mark: | -| 8.0.z | :heavy_check_mark: | -| 7.4.z | :heavy_check_mark: | -| 7.2.z | :heavy_check_mark: | -| < 7.2 | :x: | - -> Node Redis should work with older versions of Redis, but it is not fully tested and we cannot offer support. +See [Supported Redis Versions](https://github.com/redis/node-redis/blob/master/SUPPORTED_REDIS_VERSIONS.md). ## Migration diff --git a/SECURITY.md b/SECURITY.md index f96aa68dc12..6c94b165d53 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,11 +4,7 @@ Node Redis is generally backwards compatible with very few exceptions, so we recommend users to always use the latest version to experience stability, performance and security. -| Version | Supported | -|---------|--------------------| -| 4.0.z | :heavy_check_mark: | -| 3.1.z | :heavy_check_mark: | -| < 3.1 | :x: | +See [Supported Redis Versions](https://github.com/redis/node-redis/blob/master/SUPPORTED_REDIS_VERSIONS.md). ## Reporting a Vulnerability diff --git a/SUPPORTED_REDIS_VERSIONS.md b/SUPPORTED_REDIS_VERSIONS.md new file mode 100644 index 00000000000..35ef0e4187e --- /dev/null +++ b/SUPPORTED_REDIS_VERSIONS.md @@ -0,0 +1,13 @@ +# Supported Redis Versions + +Node Redis is supported with the following versions of Redis: + +| Version | Supported | +| ------- | ------------------ | +| 8.2.z | :heavy_check_mark: | +| 8.0.z | :heavy_check_mark: | +| 7.4.z | :heavy_check_mark: | +| 7.2.z | :heavy_check_mark: | +| < 7.2 | :x: | + +> Node Redis should work with older versions of Redis, but it is not fully tested and we cannot offer support. From 539780caa88040b577bd6bbae8ee29315b2b67f4 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Tue, 18 Nov 2025 12:20:23 +0200 Subject: [PATCH 15/24] bump test container version 8.4-GA-pre.3 (#3136) * bump test container version 8.4-GA-pre.2 * chore(tests): bump test container version 8.4-GA-pre.3 --- .github/workflows/tests.yml | 2 +- packages/bloom/lib/test-utils.ts | 2 +- packages/client/lib/sentinel/test-util.ts | 2 +- packages/client/lib/test-utils.ts | 2 +- packages/entraid/lib/test-utils.ts | 2 +- packages/json/lib/test-utils.ts | 2 +- packages/search/lib/test-utils.ts | 2 +- packages/test-utils/lib/test-utils.ts | 2 +- packages/time-series/lib/test-utils.ts | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c9c1ff56532..7f2c9e4cace 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -22,7 +22,7 @@ jobs: fail-fast: false matrix: node-version: ["18", "20", "22"] - redis-version: ["rs-7.4.0-v1", "8.0.2", "8.2", "8.4-RC1-pre.2"] + redis-version: ["rs-7.4.0-v1", "8.0.2", "8.2", "8.4-GA-pre.3"] steps: - uses: actions/checkout@v4 with: diff --git a/packages/bloom/lib/test-utils.ts b/packages/bloom/lib/test-utils.ts index 7ecf3e0e399..9219bcb82fd 100644 --- a/packages/bloom/lib/test-utils.ts +++ b/packages/bloom/lib/test-utils.ts @@ -4,7 +4,7 @@ import RedisBloomModules from '.'; export default TestUtils.createFromConfig({ dockerImageName: 'redislabs/client-libs-test', dockerImageVersionArgument: 'redis-version', - defaultDockerVersion: '8.4-RC1-pre.2' + defaultDockerVersion: '8.4-GA-pre.3' }); export const GLOBAL = { diff --git a/packages/client/lib/sentinel/test-util.ts b/packages/client/lib/sentinel/test-util.ts index 294bc8ae4f2..7552034e553 100644 --- a/packages/client/lib/sentinel/test-util.ts +++ b/packages/client/lib/sentinel/test-util.ts @@ -174,7 +174,7 @@ export class SentinelFramework extends DockerBase { this.#testUtils = TestUtils.createFromConfig({ dockerImageName: 'redislabs/client-libs-test', dockerImageVersionArgument: 'redis-version', - defaultDockerVersion: '8.4-RC1-pre.2' + defaultDockerVersion: '8.4-GA-pre.3' }); this.#nodeMap = new Map>>>(); this.#sentinelMap = new Map>>>(); diff --git a/packages/client/lib/test-utils.ts b/packages/client/lib/test-utils.ts index 1c49e92d868..eade135c3e7 100644 --- a/packages/client/lib/test-utils.ts +++ b/packages/client/lib/test-utils.ts @@ -9,7 +9,7 @@ import RedisBloomModules from '@redis/bloom'; const utils = TestUtils.createFromConfig({ dockerImageName: 'redislabs/client-libs-test', dockerImageVersionArgument: 'redis-version', - defaultDockerVersion: '8.4-RC1-pre.2' + defaultDockerVersion: '8.4-GA-pre.3' }); export default utils; diff --git a/packages/entraid/lib/test-utils.ts b/packages/entraid/lib/test-utils.ts index 0d196120a9e..42e7bb3b9e7 100644 --- a/packages/entraid/lib/test-utils.ts +++ b/packages/entraid/lib/test-utils.ts @@ -6,7 +6,7 @@ import { EntraidCredentialsProvider } from './entraid-credentials-provider'; export const testUtils = TestUtils.createFromConfig({ dockerImageName: 'redislabs/client-libs-test', dockerImageVersionArgument: 'redis-version', - defaultDockerVersion: '8.4-RC1-pre.2' + defaultDockerVersion: '8.4-GA-pre.3' }); const DEBUG_MODE_ARGS = testUtils.isVersionGreaterThan([7]) ? diff --git a/packages/json/lib/test-utils.ts b/packages/json/lib/test-utils.ts index 15c07414a49..7148f6197f1 100644 --- a/packages/json/lib/test-utils.ts +++ b/packages/json/lib/test-utils.ts @@ -4,7 +4,7 @@ import RedisJSON from '.'; export default TestUtils.createFromConfig({ dockerImageName: 'redislabs/client-libs-test', dockerImageVersionArgument: 'redis-version', - defaultDockerVersion: '8.4-RC1-pre.2' + defaultDockerVersion: '8.4-GA-pre.3' }); export const GLOBAL = { diff --git a/packages/search/lib/test-utils.ts b/packages/search/lib/test-utils.ts index 3a4af41f99a..588a2d4df59 100644 --- a/packages/search/lib/test-utils.ts +++ b/packages/search/lib/test-utils.ts @@ -5,7 +5,7 @@ import { RespVersions } from '@redis/client'; export default TestUtils.createFromConfig({ dockerImageName: 'redislabs/client-libs-test', dockerImageVersionArgument: 'redis-version', - defaultDockerVersion: '8.4-RC1-pre.2' + defaultDockerVersion: '8.4-GA-pre.3' }); export const GLOBAL = { diff --git a/packages/test-utils/lib/test-utils.ts b/packages/test-utils/lib/test-utils.ts index a48948e8485..7ff1d2acaaa 100644 --- a/packages/test-utils/lib/test-utils.ts +++ b/packages/test-utils/lib/test-utils.ts @@ -3,7 +3,7 @@ import TestUtils from './index' export const testUtils = TestUtils.createFromConfig({ dockerImageName: 'redislabs/client-libs-test', dockerImageVersionArgument: 'redis-version', - defaultDockerVersion: '8.4-RC1-pre.2' + defaultDockerVersion: '8.4-GA-pre.3' }); diff --git a/packages/time-series/lib/test-utils.ts b/packages/time-series/lib/test-utils.ts index 6158498743b..704f435680d 100644 --- a/packages/time-series/lib/test-utils.ts +++ b/packages/time-series/lib/test-utils.ts @@ -4,7 +4,7 @@ import TimeSeries from '.'; export default TestUtils.createFromConfig({ dockerImageName: 'redislabs/client-libs-test', dockerImageVersionArgument: 'redis-version', - defaultDockerVersion: '8.4-RC1-pre.2' + defaultDockerVersion: '8.4-GA-pre.3' }); export const GLOBAL = { From ff91ecef81bd13f7bc7b0a33a955f4da5a4a1937 Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Wed, 19 Nov 2025 13:35:45 +0200 Subject: [PATCH 16/24] chore(tests): bump test container version 8.4.0 (#3139) --- .github/workflows/tests.yml | 2 +- packages/bloom/lib/test-utils.ts | 2 +- packages/client/lib/sentinel/test-util.ts | 2 +- packages/client/lib/test-utils.ts | 2 +- packages/entraid/lib/test-utils.ts | 2 +- packages/json/lib/test-utils.ts | 2 +- packages/search/lib/test-utils.ts | 2 +- packages/test-utils/lib/test-utils.ts | 2 +- packages/time-series/lib/test-utils.ts | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7f2c9e4cace..466eb752bc2 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -22,7 +22,7 @@ jobs: fail-fast: false matrix: node-version: ["18", "20", "22"] - redis-version: ["rs-7.4.0-v1", "8.0.2", "8.2", "8.4-GA-pre.3"] + redis-version: ["rs-7.4.0-v1", "8.0.2", "8.2", "8.4.0"] steps: - uses: actions/checkout@v4 with: diff --git a/packages/bloom/lib/test-utils.ts b/packages/bloom/lib/test-utils.ts index 9219bcb82fd..c31c33933c9 100644 --- a/packages/bloom/lib/test-utils.ts +++ b/packages/bloom/lib/test-utils.ts @@ -4,7 +4,7 @@ import RedisBloomModules from '.'; export default TestUtils.createFromConfig({ dockerImageName: 'redislabs/client-libs-test', dockerImageVersionArgument: 'redis-version', - defaultDockerVersion: '8.4-GA-pre.3' + defaultDockerVersion: '8.4.0' }); export const GLOBAL = { diff --git a/packages/client/lib/sentinel/test-util.ts b/packages/client/lib/sentinel/test-util.ts index 7552034e553..219ee56e318 100644 --- a/packages/client/lib/sentinel/test-util.ts +++ b/packages/client/lib/sentinel/test-util.ts @@ -174,7 +174,7 @@ export class SentinelFramework extends DockerBase { this.#testUtils = TestUtils.createFromConfig({ dockerImageName: 'redislabs/client-libs-test', dockerImageVersionArgument: 'redis-version', - defaultDockerVersion: '8.4-GA-pre.3' + defaultDockerVersion: '8.4.0' }); this.#nodeMap = new Map>>>(); this.#sentinelMap = new Map>>>(); diff --git a/packages/client/lib/test-utils.ts b/packages/client/lib/test-utils.ts index eade135c3e7..61c8a0c8852 100644 --- a/packages/client/lib/test-utils.ts +++ b/packages/client/lib/test-utils.ts @@ -9,7 +9,7 @@ import RedisBloomModules from '@redis/bloom'; const utils = TestUtils.createFromConfig({ dockerImageName: 'redislabs/client-libs-test', dockerImageVersionArgument: 'redis-version', - defaultDockerVersion: '8.4-GA-pre.3' + defaultDockerVersion: '8.4.0' }); export default utils; diff --git a/packages/entraid/lib/test-utils.ts b/packages/entraid/lib/test-utils.ts index 42e7bb3b9e7..936f811927c 100644 --- a/packages/entraid/lib/test-utils.ts +++ b/packages/entraid/lib/test-utils.ts @@ -6,7 +6,7 @@ import { EntraidCredentialsProvider } from './entraid-credentials-provider'; export const testUtils = TestUtils.createFromConfig({ dockerImageName: 'redislabs/client-libs-test', dockerImageVersionArgument: 'redis-version', - defaultDockerVersion: '8.4-GA-pre.3' + defaultDockerVersion: '8.4.0' }); const DEBUG_MODE_ARGS = testUtils.isVersionGreaterThan([7]) ? diff --git a/packages/json/lib/test-utils.ts b/packages/json/lib/test-utils.ts index 7148f6197f1..1315178f204 100644 --- a/packages/json/lib/test-utils.ts +++ b/packages/json/lib/test-utils.ts @@ -4,7 +4,7 @@ import RedisJSON from '.'; export default TestUtils.createFromConfig({ dockerImageName: 'redislabs/client-libs-test', dockerImageVersionArgument: 'redis-version', - defaultDockerVersion: '8.4-GA-pre.3' + defaultDockerVersion: '8.4.0' }); export const GLOBAL = { diff --git a/packages/search/lib/test-utils.ts b/packages/search/lib/test-utils.ts index 588a2d4df59..035879620ab 100644 --- a/packages/search/lib/test-utils.ts +++ b/packages/search/lib/test-utils.ts @@ -5,7 +5,7 @@ import { RespVersions } from '@redis/client'; export default TestUtils.createFromConfig({ dockerImageName: 'redislabs/client-libs-test', dockerImageVersionArgument: 'redis-version', - defaultDockerVersion: '8.4-GA-pre.3' + defaultDockerVersion: '8.4.0' }); export const GLOBAL = { diff --git a/packages/test-utils/lib/test-utils.ts b/packages/test-utils/lib/test-utils.ts index 7ff1d2acaaa..7ba62f78dc2 100644 --- a/packages/test-utils/lib/test-utils.ts +++ b/packages/test-utils/lib/test-utils.ts @@ -3,7 +3,7 @@ import TestUtils from './index' export const testUtils = TestUtils.createFromConfig({ dockerImageName: 'redislabs/client-libs-test', dockerImageVersionArgument: 'redis-version', - defaultDockerVersion: '8.4-GA-pre.3' + defaultDockerVersion: '8.4.0' }); diff --git a/packages/time-series/lib/test-utils.ts b/packages/time-series/lib/test-utils.ts index 704f435680d..7fd1b2b06fc 100644 --- a/packages/time-series/lib/test-utils.ts +++ b/packages/time-series/lib/test-utils.ts @@ -4,7 +4,7 @@ import TimeSeries from '.'; export default TestUtils.createFromConfig({ dockerImageName: 'redislabs/client-libs-test', dockerImageVersionArgument: 'redis-version', - defaultDockerVersion: '8.4-GA-pre.3' + defaultDockerVersion: '8.4.0' }); export const GLOBAL = { From a245ef44940d8929255559b1701f9d608964f87b Mon Sep 17 00:00:00 2001 From: Nikolay Karadzhov Date: Wed, 19 Nov 2025 14:16:52 +0200 Subject: [PATCH 17/24] fix(xreadgroup): dont parse number props (#3133) Initially, there was a bug in the server where the two additional props were returned as string instead of number. This should now be fixed in the GA, so no need to parse to Number anymore. --- packages/client/lib/commands/generic-transformers.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/client/lib/commands/generic-transformers.ts b/packages/client/lib/commands/generic-transformers.ts index 56e99c28deb..33286ea7392 100644 --- a/packages/client/lib/commands/generic-transformers.ts +++ b/packages/client/lib/commands/generic-transformers.ts @@ -522,8 +522,8 @@ export type StreamMessageRawReply = TuplesReply<[ export type StreamMessageReply = { id: BlobStringReply, message: MapReply, - millisElapsedFromDelivery?: number - deliveriesCounter?: number + millisElapsedFromDelivery?: NumberReply + deliveriesCounter?: NumberReply }; export function transformStreamMessageReply(typeMapping: TypeMapping | undefined, reply: StreamMessageRawReply): StreamMessageReply { @@ -531,8 +531,8 @@ export function transformStreamMessageReply(typeMapping: TypeMapping | undefined return { id: id, message: transformTuplesReply(message, undefined, typeMapping), - ...(millisElapsedFromDelivery !== undefined ? { millisElapsedFromDelivery: Number(millisElapsedFromDelivery) } : {}), - ...(deliveriesCounter !== undefined ? { deliveriesCounter: Number(deliveriesCounter) } : {}) + ...(millisElapsedFromDelivery !== undefined ? { millisElapsedFromDelivery } : {}), + ...(deliveriesCounter !== undefined ? { deliveriesCounter } : {}) }; } From c7c387a3a04eeb522c6e6e8e67dd30c4da36d7c6 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Wed, 19 Nov 2025 16:28:06 +0000 Subject: [PATCH 18/24] Release client@5.10.0 --- package-lock.json | 14 +++++++++++++- packages/client/package.json | 2 +- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3739530f348..936d2aa1364 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7335,7 +7335,7 @@ }, "packages/client": { "name": "@redis/client", - "version": "5.9.0", + "version": "5.10.0", "license": "MIT", "dependencies": { "cluster-key-slot": "1.1.2" @@ -7433,6 +7433,18 @@ "node": ">= 18" } }, + "packages/redis/node_modules/@redis/client": { + "version": "5.9.0", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.9.0.tgz", + "integrity": "sha512-EI0Ti5pojD2p7TmcS7RRa+AJVahdQvP/urpcSbK/K9Rlk6+dwMJTQ354pCNGCwfke8x4yKr5+iH85wcERSkwLQ==", + "license": "MIT", + "dependencies": { + "cluster-key-slot": "1.1.2" + }, + "engines": { + "node": ">= 18" + } + }, "packages/search": { "name": "@redis/search", "version": "5.9.0", diff --git a/packages/client/package.json b/packages/client/package.json index 864953de32e..dc4f4027f94 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@redis/client", - "version": "5.9.0", + "version": "5.10.0", "license": "MIT", "main": "./dist/index.js", "types": "./dist/index.d.ts", From bc6892f2a165dcdcac51565ae48af593c7a8fc0a Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Wed, 19 Nov 2025 16:28:13 +0000 Subject: [PATCH 19/24] Release bloom@5.10.0 --- package-lock.json | 16 ++++++++++++++-- packages/bloom/package.json | 4 ++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 936d2aa1364..6d0d367957c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7321,7 +7321,7 @@ }, "packages/bloom": { "name": "@redis/bloom", - "version": "5.9.0", + "version": "5.10.0", "license": "MIT", "devDependencies": { "@redis/test-utils": "*" @@ -7330,7 +7330,7 @@ "node": ">= 18" }, "peerDependencies": { - "@redis/client": "^5.9.0" + "@redis/client": "^5.10.0" } }, "packages/client": { @@ -7433,6 +7433,18 @@ "node": ">= 18" } }, + "packages/redis/node_modules/@redis/bloom": { + "version": "5.9.0", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.9.0.tgz", + "integrity": "sha512-W9D8yfKTWl4tP8lkC3MRYkMz4OfbuzE/W8iObe0jFgoRmgMfkBV+Vj38gvIqZPImtY0WB34YZkX3amYuQebvRQ==", + "license": "MIT", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@redis/client": "^5.9.0" + } + }, "packages/redis/node_modules/@redis/client": { "version": "5.9.0", "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.9.0.tgz", diff --git a/packages/bloom/package.json b/packages/bloom/package.json index 99c52f6c4e0..d5b15d04a09 100644 --- a/packages/bloom/package.json +++ b/packages/bloom/package.json @@ -1,6 +1,6 @@ { "name": "@redis/bloom", - "version": "5.9.0", + "version": "5.10.0", "license": "MIT", "main": "./dist/lib/index.js", "types": "./dist/lib/index.d.ts", @@ -13,7 +13,7 @@ "release": "release-it" }, "peerDependencies": { - "@redis/client": "^5.9.0" + "@redis/client": "^5.10.0" }, "devDependencies": { "@redis/test-utils": "*" From 5c7dbd292b05e63ac4f8452d3ff9b8100cae91e6 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Wed, 19 Nov 2025 16:28:19 +0000 Subject: [PATCH 20/24] Release json@5.10.0 --- package-lock.json | 16 ++++++++++++++-- packages/json/package.json | 4 ++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6d0d367957c..ff00a2c61ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7407,7 +7407,7 @@ }, "packages/json": { "name": "@redis/json", - "version": "5.9.0", + "version": "5.10.0", "license": "MIT", "devDependencies": { "@redis/test-utils": "*" @@ -7416,7 +7416,7 @@ "node": ">= 18" }, "peerDependencies": { - "@redis/client": "^5.9.0" + "@redis/client": "^5.10.0" } }, "packages/redis": { @@ -7457,6 +7457,18 @@ "node": ">= 18" } }, + "packages/redis/node_modules/@redis/json": { + "version": "5.9.0", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-5.9.0.tgz", + "integrity": "sha512-Bm2jjLYaXdUWPb9RaEywxnjmzw7dWKDZI4MS79mTWPV16R982jVWBj6lY2ZGelJbwxHtEVg4/FSVgYDkuO/MxA==", + "license": "MIT", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@redis/client": "^5.9.0" + } + }, "packages/search": { "name": "@redis/search", "version": "5.9.0", diff --git a/packages/json/package.json b/packages/json/package.json index ecc46160b84..9e3609a14ba 100644 --- a/packages/json/package.json +++ b/packages/json/package.json @@ -1,6 +1,6 @@ { "name": "@redis/json", - "version": "5.9.0", + "version": "5.10.0", "license": "MIT", "main": "./dist/lib/index.js", "types": "./dist/lib/index.d.ts", @@ -13,7 +13,7 @@ "release": "release-it" }, "peerDependencies": { - "@redis/client": "^5.9.0" + "@redis/client": "^5.10.0" }, "devDependencies": { "@redis/test-utils": "*" From a3b8146ffbbe6d2b3ff9f010da79aa4abbbd9ad3 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Wed, 19 Nov 2025 16:28:25 +0000 Subject: [PATCH 21/24] Release search@5.10.0 --- package-lock.json | 16 ++++++++++++++-- packages/search/package.json | 4 ++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index ff00a2c61ed..ca4c6b3ebda 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7469,9 +7469,21 @@ "@redis/client": "^5.9.0" } }, + "packages/redis/node_modules/@redis/search": { + "version": "5.9.0", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-5.9.0.tgz", + "integrity": "sha512-jdk2csmJ29DlpvCIb2ySjix2co14/0iwIT3C0I+7ZaToXgPbgBMB+zfEilSuncI2F9JcVxHki0YtLA0xX3VdpA==", + "license": "MIT", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@redis/client": "^5.9.0" + } + }, "packages/search": { "name": "@redis/search", - "version": "5.9.0", + "version": "5.10.0", "license": "MIT", "devDependencies": { "@redis/test-utils": "*" @@ -7480,7 +7492,7 @@ "node": ">= 18" }, "peerDependencies": { - "@redis/client": "^5.9.0" + "@redis/client": "^5.10.0" } }, "packages/test-utils": { diff --git a/packages/search/package.json b/packages/search/package.json index ebc4cdd5588..2ef2494015f 100644 --- a/packages/search/package.json +++ b/packages/search/package.json @@ -1,6 +1,6 @@ { "name": "@redis/search", - "version": "5.9.0", + "version": "5.10.0", "license": "MIT", "main": "./dist/lib/index.js", "types": "./dist/lib/index.d.ts", @@ -14,7 +14,7 @@ "release": "release-it" }, "peerDependencies": { - "@redis/client": "^5.9.0" + "@redis/client": "^5.10.0" }, "devDependencies": { "@redis/test-utils": "*" From 2820cd0f3866a5200a385f98c4cafbf0c3b73cdf Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Wed, 19 Nov 2025 16:28:31 +0000 Subject: [PATCH 22/24] Release time-series@5.10.0 --- package-lock.json | 16 ++++++++++++++-- packages/time-series/package.json | 4 ++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index ca4c6b3ebda..c88d35f4453 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7481,6 +7481,18 @@ "@redis/client": "^5.9.0" } }, + "packages/redis/node_modules/@redis/time-series": { + "version": "5.9.0", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-5.9.0.tgz", + "integrity": "sha512-W6ILxcyOqhnI7ELKjJXOktIg3w4+aBHugDbVpgVLPZ+YDjObis1M0v7ZzwlpXhlpwsfePfipeSK+KWNuymk52w==", + "license": "MIT", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@redis/client": "^5.9.0" + } + }, "packages/search": { "name": "@redis/search", "version": "5.10.0", @@ -7561,7 +7573,7 @@ }, "packages/time-series": { "name": "@redis/time-series", - "version": "5.9.0", + "version": "5.10.0", "license": "MIT", "devDependencies": { "@redis/test-utils": "*" @@ -7570,7 +7582,7 @@ "node": ">= 18" }, "peerDependencies": { - "@redis/client": "^5.9.0" + "@redis/client": "^5.10.0" } } } diff --git a/packages/time-series/package.json b/packages/time-series/package.json index 63717ab0a36..50620ad33d4 100644 --- a/packages/time-series/package.json +++ b/packages/time-series/package.json @@ -1,6 +1,6 @@ { "name": "@redis/time-series", - "version": "5.9.0", + "version": "5.10.0", "license": "MIT", "main": "./dist/lib/index.js", "types": "./dist/lib/index.d.ts", @@ -13,7 +13,7 @@ "release": "release-it" }, "peerDependencies": { - "@redis/client": "^5.9.0" + "@redis/client": "^5.10.0" }, "devDependencies": { "@redis/test-utils": "*" From 4255467027e76caafa64984a145a0791bd9c24c8 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Wed, 19 Nov 2025 16:28:37 +0000 Subject: [PATCH 23/24] Release entraid@5.10.0 --- package-lock.json | 4 ++-- packages/entraid/package.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index c88d35f4453..2925ddde80b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7351,7 +7351,7 @@ }, "packages/entraid": { "name": "@redis/entraid", - "version": "5.9.0", + "version": "5.10.0", "license": "MIT", "dependencies": { "@azure/identity": "^4.7.0", @@ -7370,7 +7370,7 @@ "node": ">= 18" }, "peerDependencies": { - "@redis/client": "^5.9.0" + "@redis/client": "^5.10.0" } }, "packages/entraid/node_modules/@types/node": { diff --git a/packages/entraid/package.json b/packages/entraid/package.json index 9221e8e4c20..6ab20a81a94 100644 --- a/packages/entraid/package.json +++ b/packages/entraid/package.json @@ -1,6 +1,6 @@ { "name": "@redis/entraid", - "version": "5.9.0", + "version": "5.10.0", "license": "MIT", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -22,7 +22,7 @@ "@azure/msal-node": "^2.16.1" }, "peerDependencies": { - "@redis/client": "^5.9.0" + "@redis/client": "^5.10.0" }, "devDependencies": { "@types/express": "^4.17.21", From 7c419e03c8945ab5e420e4ea0875ccb3caef94d8 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Wed, 19 Nov 2025 16:28:43 +0000 Subject: [PATCH 24/24] Release redis@5.10.0 --- package-lock.json | 72 ++++--------------------------------- packages/redis/package.json | 12 +++---- 2 files changed, 12 insertions(+), 72 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2925ddde80b..728aa74e7aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7420,79 +7420,19 @@ } }, "packages/redis": { - "version": "5.9.0", - "license": "MIT", - "dependencies": { - "@redis/bloom": "5.9.0", - "@redis/client": "5.9.0", - "@redis/json": "5.9.0", - "@redis/search": "5.9.0", - "@redis/time-series": "5.9.0" - }, - "engines": { - "node": ">= 18" - } - }, - "packages/redis/node_modules/@redis/bloom": { - "version": "5.9.0", - "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.9.0.tgz", - "integrity": "sha512-W9D8yfKTWl4tP8lkC3MRYkMz4OfbuzE/W8iObe0jFgoRmgMfkBV+Vj38gvIqZPImtY0WB34YZkX3amYuQebvRQ==", - "license": "MIT", - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "@redis/client": "^5.9.0" - } - }, - "packages/redis/node_modules/@redis/client": { - "version": "5.9.0", - "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.9.0.tgz", - "integrity": "sha512-EI0Ti5pojD2p7TmcS7RRa+AJVahdQvP/urpcSbK/K9Rlk6+dwMJTQ354pCNGCwfke8x4yKr5+iH85wcERSkwLQ==", + "version": "5.10.0", "license": "MIT", "dependencies": { - "cluster-key-slot": "1.1.2" + "@redis/bloom": "5.10.0", + "@redis/client": "5.10.0", + "@redis/json": "5.10.0", + "@redis/search": "5.10.0", + "@redis/time-series": "5.10.0" }, "engines": { "node": ">= 18" } }, - "packages/redis/node_modules/@redis/json": { - "version": "5.9.0", - "resolved": "https://registry.npmjs.org/@redis/json/-/json-5.9.0.tgz", - "integrity": "sha512-Bm2jjLYaXdUWPb9RaEywxnjmzw7dWKDZI4MS79mTWPV16R982jVWBj6lY2ZGelJbwxHtEVg4/FSVgYDkuO/MxA==", - "license": "MIT", - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "@redis/client": "^5.9.0" - } - }, - "packages/redis/node_modules/@redis/search": { - "version": "5.9.0", - "resolved": "https://registry.npmjs.org/@redis/search/-/search-5.9.0.tgz", - "integrity": "sha512-jdk2csmJ29DlpvCIb2ySjix2co14/0iwIT3C0I+7ZaToXgPbgBMB+zfEilSuncI2F9JcVxHki0YtLA0xX3VdpA==", - "license": "MIT", - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "@redis/client": "^5.9.0" - } - }, - "packages/redis/node_modules/@redis/time-series": { - "version": "5.9.0", - "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-5.9.0.tgz", - "integrity": "sha512-W6ILxcyOqhnI7ELKjJXOktIg3w4+aBHugDbVpgVLPZ+YDjObis1M0v7ZzwlpXhlpwsfePfipeSK+KWNuymk52w==", - "license": "MIT", - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "@redis/client": "^5.9.0" - } - }, "packages/search": { "name": "@redis/search", "version": "5.10.0", diff --git a/packages/redis/package.json b/packages/redis/package.json index baae8e8e761..b424f6456d7 100644 --- a/packages/redis/package.json +++ b/packages/redis/package.json @@ -1,7 +1,7 @@ { "name": "redis", "description": "A modern, high performance Redis client", - "version": "5.9.0", + "version": "5.10.0", "license": "MIT", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -13,11 +13,11 @@ "release": "release-it" }, "dependencies": { - "@redis/bloom": "5.9.0", - "@redis/client": "5.9.0", - "@redis/json": "5.9.0", - "@redis/search": "5.9.0", - "@redis/time-series": "5.9.0" + "@redis/bloom": "5.10.0", + "@redis/client": "5.10.0", + "@redis/json": "5.10.0", + "@redis/search": "5.10.0", + "@redis/time-series": "5.10.0" }, "engines": { "node": ">= 18"