diff --git a/packages/time-series/lib/commands/NRANGE.spec.ts b/packages/time-series/lib/commands/NRANGE.spec.ts new file mode 100644 index 00000000000..6e4ccbb7089 --- /dev/null +++ b/packages/time-series/lib/commands/NRANGE.spec.ts @@ -0,0 +1,144 @@ +import { strict as assert } from 'node:assert'; +import testUtils, { GLOBAL } from '../test-utils'; +import NRANGE from './NRANGE'; +import { TIME_SERIES_AGGREGATION_TYPE } from './CREATERULE'; +import { TIME_SERIES_BUCKET_TIMESTAMP } from './RANGE'; +import { parseArgs } from '@redis/client/lib/commands/generic-transformers'; + +describe('TS.NRANGE', () => { + it('transformArguments (minimal)', () => { + assert.deepEqual( + parseArgs(NRANGE, ['a', 'b', 'c'], '-', '+'), + ['TS.NRANGE', '3', 'a', 'b', 'c', '-', '+'] + ); + }); + + it('transformArguments preserves key order and duplicates', () => { + assert.deepEqual( + parseArgs(NRANGE, ['k', 'k'], '-', '+'), + ['TS.NRANGE', '2', 'k', 'k', '-', '+'] + ); + }); + + it('transformArguments (all options, aggregators as separate tokens)', () => { + assert.deepEqual( + parseArgs(NRANGE, ['a', 'b', 'c'], '-', '+', { + LATEST: true, + FILTER_BY_TS: [0, 1], + FILTER_BY_VALUE: { + min: 1, + max: 2 + }, + COUNT: 1, + ALIGN: '-', + AGGREGATION: { + types: [ + TIME_SERIES_AGGREGATION_TYPE.FIRST, + TIME_SERIES_AGGREGATION_TYPE.MAX, + TIME_SERIES_AGGREGATION_TYPE.MIN + ], + timeBucket: 10000, + BUCKETTIMESTAMP: TIME_SERIES_BUCKET_TIMESTAMP.LOW, + EMPTY: true + } + }), + [ + 'TS.NRANGE', '3', 'a', 'b', 'c', '-', '+', 'LATEST', + 'FILTER_BY_TS', '0', '1', 'FILTER_BY_VALUE', '1', '2', 'COUNT', '1', + 'ALIGN', '-', 'AGGREGATION', 'FIRST', 'MAX', 'MIN', '10000', + 'BUCKETTIMESTAMP', '-', 'EMPTY' + ] + ); + }); + + testUtils.testWithClient('client.ts.nRange (raw)', async client => { + await Promise.all([ + client.ts.create('{t}:1'), + client.ts.create('{t}:2') + ]); + await Promise.all([ + client.ts.add('{t}:1', 1000, 10), + client.ts.add('{t}:1', 2000, 12), + client.ts.add('{t}:2', 1000, 13) + ]); + + assert.deepEqual( + await client.ts.nRange(['{t}:1', '{t}:2'], '-', '+'), + [ + { timestamp: 1000, values: [10, 13] }, + { timestamp: 2000, values: [12, NaN] } + ] + ); + }, { + ...GLOBAL.SERVERS.OPEN, + minimumDockerVersion: [8, 10] + }); + + testUtils.testWithClient('client.ts.nRange (aggregation)', async client => { + await Promise.all([ + client.ts.create('{t}:1'), + client.ts.create('{t}:2') + ]); + await Promise.all([ + client.ts.add('{t}:1', 1000, 10), + client.ts.add('{t}:1', 1500, 20), + client.ts.add('{t}:2', 1000, 5) + ]); + + const reply = await client.ts.nRange(['{t}:1', '{t}:2'], 0, 3000, { + AGGREGATION: { + types: [ + TIME_SERIES_AGGREGATION_TYPE.MAX, + TIME_SERIES_AGGREGATION_TYPE.MIN + ], + timeBucket: 1000 + } + }); + + assert.deepEqual(reply, [ + { timestamp: 1000, values: [20, 5] } + ]); + }, { + ...GLOBAL.SERVERS.OPEN, + minimumDockerVersion: [8, 10] + }); + + testUtils.testWithClient('client.ts.nRange (RESP2 surfaces missing cell as NaN)', async client => { + await Promise.all([ + client.ts.create('{t}:1'), + client.ts.create('{t}:2') + ]); + await Promise.all([ + client.ts.add('{t}:1', 1000, 10), + client.ts.add('{t}:1', 2000, 12), + client.ts.add('{t}:2', 1000, 13) + ]); + + assert.deepEqual( + await client.ts.nRange(['{t}:1', '{t}:2'], '-', '+'), + [ + { timestamp: 1000, values: [10, 13] }, + { timestamp: 2000, values: [12, NaN] } + ] + ); + }, { + ...GLOBAL.SERVERS.OPEN, + clientOptions: { + ...GLOBAL.SERVERS.OPEN.clientOptions, + RESP: 2 + }, + minimumDockerVersion: [8, 10] + }); + + testUtils.testWithClient('client.ts.nRange (empty result)', async client => { + await client.ts.create('{t}:1'); + + assert.deepEqual( + await client.ts.nRange(['{t}:1'], 5000, 6000), + [] + ); + }, { + ...GLOBAL.SERVERS.OPEN, + minimumDockerVersion: [8, 10] + }); +}); diff --git a/packages/time-series/lib/commands/NRANGE.ts b/packages/time-series/lib/commands/NRANGE.ts new file mode 100644 index 00000000000..599346f3125 --- /dev/null +++ b/packages/time-series/lib/commands/NRANGE.ts @@ -0,0 +1,79 @@ +import { CommandParser } from '@redis/client/dist/lib/client/parser'; +import { RedisArgument, Command } from '@redis/client/dist/lib/RESP/types'; +import { + Timestamp, + transformTimestampArgument, + TsRangeCommonOptions, + parseRangeCommonArguments, + transformPivotSamplesReply +} from './helpers'; +import { TimeSeriesBucketTimestamp } from './RANGE'; +import { TimeSeriesAggregationTypeList } from './RANGE_MULTIAGGR'; + +export interface TsNRangeOptions extends TsRangeCommonOptions { + ALIGN?: Timestamp; + AGGREGATION?: { + /** + * One aggregator per key argument; length must equal the key list length. + * Emitted as separate tokens (never the comma-joined form). + */ + types: TimeSeriesAggregationTypeList; + timeBucket: Timestamp; + BUCKETTIMESTAMP?: TimeSeriesBucketTimestamp; + EMPTY?: boolean; + }; +} + +export function parseNRangeArguments( + parser: CommandParser, + fromTimestamp: Timestamp, + toTimestamp: Timestamp, + options?: TsNRangeOptions +) { + parseRangeCommonArguments(parser, fromTimestamp, toTimestamp, options); + + if (options?.AGGREGATION) { + if (options?.ALIGN !== undefined) { + parser.push('ALIGN', transformTimestampArgument(options.ALIGN)); + } + + parser.push('AGGREGATION'); + for (const type of options.AGGREGATION.types) { + parser.push(type); + } + parser.push(transformTimestampArgument(options.AGGREGATION.timeBucket)); + + if (options.AGGREGATION.BUCKETTIMESTAMP) { + parser.push( + 'BUCKETTIMESTAMP', + options.AGGREGATION.BUCKETTIMESTAMP + ); + } + + if (options.AGGREGATION.EMPTY) { + parser.push('EMPTY'); + } + } +} + +export function transformNRangeArguments( + parser: CommandParser, + keys: Array, + fromTimestamp: Timestamp, + toTimestamp: Timestamp, + options?: TsNRangeOptions +) { + parser.pushKeysLength(keys); + parseNRangeArguments(parser, fromTimestamp, toTimestamp, options); +} + +export default { + IS_READ_ONLY: true, + parseCommand(...args: Parameters) { + const parser = args[0]; + + parser.push('TS.NRANGE'); + transformNRangeArguments(...args); + }, + transformReply: transformPivotSamplesReply +} as const satisfies Command; diff --git a/packages/time-series/lib/commands/NREVRANGE.spec.ts b/packages/time-series/lib/commands/NREVRANGE.spec.ts new file mode 100644 index 00000000000..cf38c964c06 --- /dev/null +++ b/packages/time-series/lib/commands/NREVRANGE.spec.ts @@ -0,0 +1,55 @@ +import { strict as assert } from 'node:assert'; +import testUtils, { GLOBAL } from '../test-utils'; +import NREVRANGE from './NREVRANGE'; +import { TIME_SERIES_AGGREGATION_TYPE } from './CREATERULE'; +import { parseArgs } from '@redis/client/lib/commands/generic-transformers'; + +describe('TS.NREVRANGE', () => { + it('transformArguments (minimal)', () => { + assert.deepEqual( + parseArgs(NREVRANGE, ['a', 'b', 'c'], '-', '+'), + ['TS.NREVRANGE', '3', 'a', 'b', 'c', '-', '+'] + ); + }); + + it('transformArguments (aggregators as separate tokens)', () => { + assert.deepEqual( + parseArgs(NREVRANGE, ['a', 'b'], '-', '+', { + AGGREGATION: { + types: [ + TIME_SERIES_AGGREGATION_TYPE.MIN, + TIME_SERIES_AGGREGATION_TYPE.MAX + ], + timeBucket: 1000 + } + }), + [ + 'TS.NREVRANGE', '2', 'a', 'b', '-', '+', + 'AGGREGATION', 'MIN', 'MAX', '1000' + ] + ); + }); + + testUtils.testWithClient('client.ts.nRevRange (reverse order)', async client => { + await Promise.all([ + client.ts.create('{t}:1'), + client.ts.create('{t}:2') + ]); + await Promise.all([ + client.ts.add('{t}:1', 1000, 10), + client.ts.add('{t}:1', 2000, 12), + client.ts.add('{t}:2', 1000, 13) + ]); + + assert.deepEqual( + await client.ts.nRevRange(['{t}:1', '{t}:2'], '-', '+'), + [ + { timestamp: 2000, values: [12, NaN] }, + { timestamp: 1000, values: [10, 13] } + ] + ); + }, { + ...GLOBAL.SERVERS.OPEN, + minimumDockerVersion: [8, 10] + }); +}); diff --git a/packages/time-series/lib/commands/NREVRANGE.ts b/packages/time-series/lib/commands/NREVRANGE.ts new file mode 100644 index 00000000000..ca523f6353c --- /dev/null +++ b/packages/time-series/lib/commands/NREVRANGE.ts @@ -0,0 +1,13 @@ +import { Command } from '@redis/client/dist/lib/RESP/types'; +import NRANGE, { transformNRangeArguments } from './NRANGE'; + +export default { + IS_READ_ONLY: NRANGE.IS_READ_ONLY, + parseCommand(...args: Parameters) { + const parser = args[0]; + + parser.push('TS.NREVRANGE'); + transformNRangeArguments(...args); + }, + transformReply: NRANGE.transformReply +} as const satisfies Command; diff --git a/packages/time-series/lib/commands/RANGE.ts b/packages/time-series/lib/commands/RANGE.ts index 44da30d81de..43c1357f88f 100644 --- a/packages/time-series/lib/commands/RANGE.ts +++ b/packages/time-series/lib/commands/RANGE.ts @@ -1,6 +1,6 @@ import { CommandParser } from '@redis/client/dist/lib/client/parser'; import { RedisArgument, Command } from '@redis/client/dist/lib/RESP/types'; -import { Timestamp, transformTimestampArgument, SamplesRawReply, transformSamplesReply } from './helpers'; +import { Timestamp, transformTimestampArgument, TsRangeCommonOptions, parseRangeCommonArguments, SamplesRawReply, transformSamplesReply } from './helpers'; import { TimeSeriesAggregationType } from './CREATERULE'; import { Resp2Reply } from '@redis/client/dist/lib/RESP/types'; @@ -12,14 +12,7 @@ export const TIME_SERIES_BUCKET_TIMESTAMP = { export type TimeSeriesBucketTimestamp = typeof TIME_SERIES_BUCKET_TIMESTAMP[keyof typeof TIME_SERIES_BUCKET_TIMESTAMP]; -export interface TsRangeOptions { - LATEST?: boolean; - FILTER_BY_TS?: Array; - FILTER_BY_VALUE?: { - min: number; - max: number; - }; - COUNT?: number; +export interface TsRangeOptions extends TsRangeCommonOptions { ALIGN?: Timestamp; AGGREGATION?: { ALIGN?: Timestamp; @@ -36,33 +29,7 @@ export function parseRangeArguments( toTimestamp: Timestamp, options?: TsRangeOptions ) { - parser.push( - transformTimestampArgument(fromTimestamp), - transformTimestampArgument(toTimestamp) - ); - - if (options?.LATEST) { - parser.push('LATEST'); - } - - if (options?.FILTER_BY_TS) { - parser.push('FILTER_BY_TS'); - for (const timestamp of options.FILTER_BY_TS) { - parser.push(transformTimestampArgument(timestamp)); - } - } - - if (options?.FILTER_BY_VALUE) { - parser.push( - 'FILTER_BY_VALUE', - options.FILTER_BY_VALUE.min.toString(), - options.FILTER_BY_VALUE.max.toString() - ); - } - - if (options?.COUNT !== undefined) { - parser.push('COUNT', options.COUNT.toString()); - } + parseRangeCommonArguments(parser, fromTimestamp, toTimestamp, options); if (options?.AGGREGATION) { if (options?.ALIGN !== undefined) { diff --git a/packages/time-series/lib/commands/RANGE_MULTIAGGR.ts b/packages/time-series/lib/commands/RANGE_MULTIAGGR.ts index 30cb5d53c8e..dc32ff94684 100644 --- a/packages/time-series/lib/commands/RANGE_MULTIAGGR.ts +++ b/packages/time-series/lib/commands/RANGE_MULTIAGGR.ts @@ -3,6 +3,8 @@ import { RedisArgument, Command } from '@redis/client/dist/lib/RESP/types'; import { Timestamp, transformTimestampArgument, + TsRangeCommonOptions, + parseRangeCommonArguments, MultiAggregationSamplesRawReply, transformMultiAggregationSamplesReply } from './helpers'; @@ -12,14 +14,7 @@ import { TimeSeriesBucketTimestamp } from './RANGE'; export type TimeSeriesAggregationTypeList = [TimeSeriesAggregationType, ...Array]; -export interface TsRangeMultiAggrOptions { - LATEST?: boolean; - FILTER_BY_TS?: Array; - FILTER_BY_VALUE?: { - min: number; - max: number; - }; - COUNT?: number; +export interface TsRangeMultiAggrOptions extends TsRangeCommonOptions { ALIGN?: Timestamp; AGGREGATION: { types: TimeSeriesAggregationTypeList; @@ -35,33 +30,7 @@ export function parseRangeMultiArguments( toTimestamp: Timestamp, options: TsRangeMultiAggrOptions ) { - parser.push( - transformTimestampArgument(fromTimestamp), - transformTimestampArgument(toTimestamp) - ); - - if (options.LATEST) { - parser.push('LATEST'); - } - - if (options.FILTER_BY_TS) { - parser.push('FILTER_BY_TS'); - for (const timestamp of options.FILTER_BY_TS) { - parser.push(transformTimestampArgument(timestamp)); - } - } - - if (options.FILTER_BY_VALUE) { - parser.push( - 'FILTER_BY_VALUE', - options.FILTER_BY_VALUE.min.toString(), - options.FILTER_BY_VALUE.max.toString() - ); - } - - if (options.COUNT !== undefined) { - parser.push('COUNT', options.COUNT.toString()); - } + parseRangeCommonArguments(parser, fromTimestamp, toTimestamp, options); if (options.ALIGN !== undefined) { parser.push('ALIGN', transformTimestampArgument(options.ALIGN)); diff --git a/packages/time-series/lib/commands/helpers.ts b/packages/time-series/lib/commands/helpers.ts index e5b090d86cb..7c31a441d86 100644 --- a/packages/time-series/lib/commands/helpers.ts +++ b/packages/time-series/lib/commands/helpers.ts @@ -64,6 +64,60 @@ export function transformTimestampArgument(timestamp: Timestamp): string { ).toString(); } +/** + * Options shared by every `TS.RANGE`-family command, emitted in canonical order + * before the optional `ALIGN`/`AGGREGATION` tail (which differs per command). + */ +export interface TsRangeCommonOptions { + LATEST?: boolean; + FILTER_BY_TS?: Array; + FILTER_BY_VALUE?: { + min: number; + max: number; + }; + COUNT?: number; +} + +/** + * Pushes `fromTimestamp toTimestamp [LATEST] [FILTER_BY_TS ...] [FILTER_BY_VALUE ...] [COUNT ...]`, + * the portion common to `TS.RANGE`, `TS.REVRANGE`, their multi-aggregation variants, and + * `TS.NRANGE`/`TS.NREVRANGE`. Callers append their own `ALIGN`/`AGGREGATION` tail. + */ +export function parseRangeCommonArguments( + parser: CommandParser, + fromTimestamp: Timestamp, + toTimestamp: Timestamp, + options?: TsRangeCommonOptions +) { + parser.push( + transformTimestampArgument(fromTimestamp), + transformTimestampArgument(toTimestamp) + ); + + if (options?.LATEST) { + parser.push('LATEST'); + } + + if (options?.FILTER_BY_TS) { + parser.push('FILTER_BY_TS'); + for (const timestamp of options.FILTER_BY_TS) { + parser.push(transformTimestampArgument(timestamp)); + } + } + + if (options?.FILTER_BY_VALUE) { + parser.push( + 'FILTER_BY_VALUE', + options.FILTER_BY_VALUE.min.toString(), + options.FILTER_BY_VALUE.max.toString() + ); + } + + if (options?.COUNT !== undefined) { + parser.push('COUNT', options.COUNT.toString()); + } +} + export type Labels = { [label: string]: string; }; @@ -145,6 +199,40 @@ export const transformMultiAggregationSamplesReply = { } }; +/** + * A single timestamp-major pivot row as returned by `TS.NRANGE` / `TS.NREVRANGE`: + * `[timestamp, [value_for_key_0, value_for_key_1, ...]]`. The value array preserves + * the input key order; a missing cell is surfaced as `NaN`. + */ +export type PivotSampleRawReply = TuplesReply<[ + timestamp: NumberReply, + values: ArrayReply +]>; + +export type PivotSamplesRawReply = ArrayReply; + +export const transformPivotSamplesReply = { + 2(reply: Resp2Reply) { + return (reply as unknown as UnwrapReply).map(sample => { + const [ timestamp, values ] = sample as unknown as UnwrapReply; + const unwrappedValues = values as unknown as UnwrapReply; + return { + timestamp, + values: unwrappedValues.map(value => Number(value)) + }; + }); + }, + 3(reply: PivotSamplesRawReply) { + return (reply as unknown as UnwrapReply).map(sample => { + const [ timestamp, values ] = sample as unknown as UnwrapReply; + return { + timestamp, + values + }; + }); + } +}; + // TODO: move to @redis/client? export function resp2MapToValue< RAW_VALUE extends TuplesReply<[key: BlobStringReply, ...rest: Array]>, diff --git a/packages/time-series/lib/commands/index.ts b/packages/time-series/lib/commands/index.ts index 693447f45a9..4ec9a3e3e9a 100644 --- a/packages/time-series/lib/commands/index.ts +++ b/packages/time-series/lib/commands/index.ts @@ -36,6 +36,8 @@ import RANGE_MULTIAGGR from './RANGE_MULTIAGGR'; import RANGE from './RANGE'; import REVRANGE_MULTIAGGR from './REVRANGE_MULTIAGGR'; import REVRANGE from './REVRANGE'; +import NRANGE from './NRANGE'; +import NREVRANGE from './NREVRANGE'; import { RedisCommands } from '@redis/client/dist/lib/RESP/types'; export * from './helpers'; @@ -586,5 +588,29 @@ export default { * Gets samples from a time series within a time range (in reverse order) * @param args - Arguments passed to the {@link transformRangeArguments} function */ - revRange: REVRANGE + revRange: REVRANGE, + /** + * Queries multiple time series keys over a range, returning timestamp-major pivot rows in forward order; keys must be same-slot in a cluster. + * Added since Redis 8.10. + * @param args - Arguments passed to the {@link transformNRangeArguments} function + */ + NRANGE, + /** + * Queries multiple time series keys over a range, returning timestamp-major pivot rows in forward order; keys must be same-slot in a cluster. + * Added since Redis 8.10. + * @param args - Arguments passed to the {@link transformNRangeArguments} function + */ + nRange: NRANGE, + /** + * Queries multiple time series keys over a range, returning timestamp-major pivot rows in reverse order; keys must be same-slot in a cluster. + * Added since Redis 8.10. + * @param args - Arguments passed to the {@link transformNRangeArguments} function + */ + NREVRANGE, + /** + * Queries multiple time series keys over a range, returning timestamp-major pivot rows in reverse order; keys must be same-slot in a cluster. + * Added since Redis 8.10. + * @param args - Arguments passed to the {@link transformNRangeArguments} function + */ + nRevRange: NREVRANGE } as const satisfies RedisCommands;