Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
TS.N[REV]RANGE - support multi-aggregates (single-aggregates use `TsA…
…ggregates` via implicit conversion)
  • Loading branch information
mgravell committed Jul 23, 2026
commit 12d36fededdac3fe03734532758e33c60d45e43f
5 changes: 5 additions & 0 deletions docs/exp/NRS003.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ This diagnostic covers the library surface for Redis 8.10 time-series features,
series from the reply when the queried range and options produce no reported samples for that series.
- *Label metadata queries* (`TS.QUERYLABELS`): return the distinct label names, or the distinct values of a
chosen label, across the series matching a filter.
- *Multi-series pivot ranges* (`TS.NRANGE` / `TS.NREVRANGE`): query a range across several keys and return an
outer-join-by-timestamp pivot, one row per timestamp with one value per key (missing cells reported as NaN).
- *Batched forward reads* (`TS.READ`): read a batch of samples with timestamp at or after a cursor, in
ascending order. The blocking (`BLOCK`) form is intentionally not surfaced, as it does not compose with the
SE.Redis multiplexer.

The corresponding library features must be considered subject to change:

Expand Down
22 changes: 11 additions & 11 deletions src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt

Large diffs are not rendered by default.

10 changes: 6 additions & 4 deletions src/NRedisStack/TimeSeries/DataTypes/TimeSeriesPivotRow.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,12 @@ public TimeSeriesPivotRow(TimeStamp timestamp, IReadOnlyList<double> values)
public TimeStamp Timestamp { get; }

/// <summary>
/// One value per input key, in input-key order (<c>Values.Count == numkeys</c>). A cell is
/// <see cref="double.NaN"/> when the corresponding key has no raw sample at the row timestamp, or no data
/// for the row's aggregation bucket (indistinguishable from a stored NaN) - matching how <c>TS.RANGE</c>
/// surfaces missing values.
/// The row values, one per requested aggregator, laid out in key order then aggregator order within each
/// key. With a single aggregator per key this is one value per key (<c>Values.Count == numkeys</c>); when a
/// key requests multiple aggregators, that key contributes multiple consecutive columns, so
/// <c>Values.Count</c> is the total aggregator count across all keys. A cell is <see cref="double.NaN"/>
/// when the corresponding key has no raw sample at the row timestamp, or no data for the row's aggregation
/// bucket (indistinguishable from a stored NaN) - matching how <c>TS.RANGE</c> surfaces missing values.
/// </summary>
/// <remarks>Never <see langword="null"/>; a <c>default(TimeSeriesPivotRow)</c> reports an empty list.</remarks>
public IReadOnlyList<double> Values => _values ?? Array.Empty<double>();
Expand Down
14 changes: 10 additions & 4 deletions src/NRedisStack/TimeSeries/ITimeSeriesCommands.cs
Original file line number Diff line number Diff line change
Expand Up @@ -514,7 +514,10 @@ IReadOnlyList<TimeSeriesTuple> RevRange(string key,
/// <param name="filterByValue">Optional: filter result by value using minimum and maximum.</param>
/// <param name="count">Optional: maximum number of pivot rows to return.</param>
/// <param name="align">Optional: timestamp for alignment control for aggregation.</param>
/// <param name="aggregations">Optional: one aggregator per key (in key order) for aggregation mode.</param>
/// <param name="aggregations">Optional: one aggregator group per key (in key order) for aggregation mode.
/// Each group is a <see cref="TsAggregations"/> and may hold multiple aggregators, yielding multiple value
/// columns for that key; a single <see cref="TsAggregation"/> converts implicitly to a one-aggregator group.
/// The number of groups must equal the number of keys.</param>
/// <param name="timeBucket">Optional: time bucket for aggregation in milliseconds.</param>
/// <param name="bt">Optional: controls how bucket timestamps are reported.</param>
/// <returns>The pivot rows; missing cells are <see cref="double.NaN"/>. May be empty.</returns>
Expand All @@ -529,7 +532,7 @@ IReadOnlyList<TimeSeriesPivotRow> NRange(
(long, long)? filterByValue = null,
Comment thread
mgravell marked this conversation as resolved.
Resolved
long? count = null,
TimeStamp? align = null,
IReadOnlyList<TsAggregation>? aggregations = null,
IReadOnlyList<TsAggregations>? aggregations = null,
long? timeBucket = null,
TsBucketTimestamps? bt = null);

Expand All @@ -545,7 +548,10 @@ IReadOnlyList<TimeSeriesPivotRow> NRange(
/// <param name="filterByValue">Optional: filter result by value using minimum and maximum.</param>
/// <param name="count">Optional: maximum number of pivot rows to return.</param>
/// <param name="align">Optional: timestamp for alignment control for aggregation.</param>
/// <param name="aggregations">Optional: one aggregator per key (in key order) for aggregation mode.</param>
/// <param name="aggregations">Optional: one aggregator group per key (in key order) for aggregation mode.
/// Each group is a <see cref="TsAggregations"/> and may hold multiple aggregators, yielding multiple value
/// columns for that key; a single <see cref="TsAggregation"/> converts implicitly to a one-aggregator group.
/// The number of groups must equal the number of keys.</param>
/// <param name="timeBucket">Optional: time bucket for aggregation in milliseconds.</param>
/// <param name="bt">Optional: controls how bucket timestamps are reported.</param>
/// <returns>The pivot rows in reverse timestamp order; missing cells are <see cref="double.NaN"/>. May be empty.</returns>
Expand All @@ -560,7 +566,7 @@ IReadOnlyList<TimeSeriesPivotRow> NRevRange(
(long, long)? filterByValue = null,
long? count = null,
TimeStamp? align = null,
IReadOnlyList<TsAggregation>? aggregations = null,
IReadOnlyList<TsAggregations>? aggregations = null,
long? timeBucket = null,
TsBucketTimestamps? bt = null);

Expand Down
14 changes: 10 additions & 4 deletions src/NRedisStack/TimeSeries/ITimeSeriesCommandsAsync.cs
Original file line number Diff line number Diff line change
Expand Up @@ -513,7 +513,10 @@ Task<IReadOnlyList<TimeSeriesTuple>> RevRangeAsync(string key,
/// <param name="filterByValue">Optional: filter result by value using minimum and maximum.</param>
/// <param name="count">Optional: maximum number of pivot rows to return.</param>
/// <param name="align">Optional: timestamp for alignment control for aggregation.</param>
/// <param name="aggregations">Optional: one aggregator per key (in key order) for aggregation mode.</param>
/// <param name="aggregations">Optional: one aggregator group per key (in key order) for aggregation mode.
/// Each group is a <see cref="TsAggregations"/> and may hold multiple aggregators, yielding multiple value
/// columns for that key; a single <see cref="TsAggregation"/> converts implicitly to a one-aggregator group.
/// The number of groups must equal the number of keys.</param>
/// <param name="timeBucket">Optional: time bucket for aggregation in milliseconds.</param>
/// <param name="bt">Optional: controls how bucket timestamps are reported.</param>
/// <returns>The pivot rows; missing cells are <see cref="double.NaN"/>. May be empty.</returns>
Expand All @@ -528,7 +531,7 @@ Task<IReadOnlyList<TimeSeriesPivotRow>> NRangeAsync(
(long, long)? filterByValue = null,
long? count = null,
TimeStamp? align = null,
IReadOnlyList<TsAggregation>? aggregations = null,
IReadOnlyList<TsAggregations>? aggregations = null,
long? timeBucket = null,
TsBucketTimestamps? bt = null);

Expand All @@ -544,7 +547,10 @@ Task<IReadOnlyList<TimeSeriesPivotRow>> NRangeAsync(
/// <param name="filterByValue">Optional: filter result by value using minimum and maximum.</param>
/// <param name="count">Optional: maximum number of pivot rows to return.</param>
/// <param name="align">Optional: timestamp for alignment control for aggregation.</param>
/// <param name="aggregations">Optional: one aggregator per key (in key order) for aggregation mode.</param>
/// <param name="aggregations">Optional: one aggregator group per key (in key order) for aggregation mode.
/// Each group is a <see cref="TsAggregations"/> and may hold multiple aggregators, yielding multiple value
/// columns for that key; a single <see cref="TsAggregation"/> converts implicitly to a one-aggregator group.
/// The number of groups must equal the number of keys.</param>
/// <param name="timeBucket">Optional: time bucket for aggregation in milliseconds.</param>
/// <param name="bt">Optional: controls how bucket timestamps are reported.</param>
/// <returns>The pivot rows in reverse timestamp order; missing cells are <see cref="double.NaN"/>. May be empty.</returns>
Expand All @@ -559,7 +565,7 @@ Task<IReadOnlyList<TimeSeriesPivotRow>> NRevRangeAsync(
(long, long)? filterByValue = null,
long? count = null,
TimeStamp? align = null,
IReadOnlyList<TsAggregation>? aggregations = null,
IReadOnlyList<TsAggregations>? aggregations = null,
long? timeBucket = null,
TsBucketTimestamps? bt = null);

Expand Down
6 changes: 4 additions & 2 deletions src/NRedisStack/TimeSeries/TimeSeriesAux.cs
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,7 @@ public static List<object> BuildNRangeArgs(
(long, long)? filterByValue,
long? count,
TimeStamp? align,
IReadOnlyList<TsAggregation>? aggregations,
IReadOnlyList<TsAggregations>? aggregations,
long? timeBucket,
TsBucketTimestamps? bt)
{
Expand All @@ -372,7 +372,9 @@ public static List<object> BuildNRangeArgs(
{
args.AddAlign(align);
args.Add(TimeSeriesArgs.AGGREGATION);
foreach (var aggregation in aggregations) args.Add(aggregation.AsArg());
// one comma-joined aggregator group per key (group count must equal numkeys, enforced server-side);
// each group may itself hold multiple aggregators, producing multiple value columns for that key.
foreach (var group in aggregations) args.Add(GetAggregationArgs(group));
if (timeBucket.HasValue) args.Add(timeBucket.Value);
Comment thread
mgravell marked this conversation as resolved.
Resolved
args.AddBucketTimestamp(bt);
// EMPTY must follow the AGGREGATION clause; the server rejects it in an earlier position.
Expand Down
4 changes: 2 additions & 2 deletions src/NRedisStack/TimeSeries/TimeSeriesCommands.cs
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,7 @@ public IReadOnlyList<TimeSeriesPivotRow> NRange(
(long, long)? filterByValue = null,
long? count = null,
TimeStamp? align = null,
IReadOnlyList<TsAggregation>? aggregations = null,
IReadOnlyList<TsAggregations>? aggregations = null,
long? timeBucket = null,
TsBucketTimestamps? bt = null)
{
Expand All @@ -417,7 +417,7 @@ public IReadOnlyList<TimeSeriesPivotRow> NRevRange(
(long, long)? filterByValue = null,
long? count = null,
TimeStamp? align = null,
IReadOnlyList<TsAggregation>? aggregations = null,
IReadOnlyList<TsAggregations>? aggregations = null,
long? timeBucket = null,
TsBucketTimestamps? bt = null)
{
Expand Down
4 changes: 2 additions & 2 deletions src/NRedisStack/TimeSeries/TimeSeriesCommandsAsync.cs
Original file line number Diff line number Diff line change
Expand Up @@ -404,7 +404,7 @@ public async Task<IReadOnlyList<TimeSeriesPivotRow>> NRangeAsync(
(long, long)? filterByValue = null,
long? count = null,
TimeStamp? align = null,
IReadOnlyList<TsAggregation>? aggregations = null,
IReadOnlyList<TsAggregations>? aggregations = null,
long? timeBucket = null,
TsBucketTimestamps? bt = null)
{
Expand All @@ -423,7 +423,7 @@ public async Task<IReadOnlyList<TimeSeriesPivotRow>> NRevRangeAsync(
(long, long)? filterByValue = null,
long? count = null,
TimeStamp? align = null,
IReadOnlyList<TsAggregation>? aggregations = null,
IReadOnlyList<TsAggregations>? aggregations = null,
long? timeBucket = null,
TsBucketTimestamps? bt = null)
{
Expand Down
4 changes: 2 additions & 2 deletions src/NRedisStack/TimeSeries/TimeSeriesCommandsBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -385,7 +385,7 @@ public static SerializedCommand NRange(
(long, long)? filterByValue = null,
long? count = null,
TimeStamp? align = null,
IReadOnlyList<TsAggregation>? aggregations = null,
IReadOnlyList<TsAggregations>? aggregations = null,
long? timeBucket = null,
TsBucketTimestamps? bt = null)
{
Expand All @@ -404,7 +404,7 @@ public static SerializedCommand NRevRange(
(long, long)? filterByValue = null,
long? count = null,
TimeStamp? align = null,
IReadOnlyList<TsAggregation>? aggregations = null,
IReadOnlyList<TsAggregations>? aggregations = null,
long? timeBucket = null,
TsBucketTimestamps? bt = null)
{
Expand Down
24 changes: 24 additions & 0 deletions tests/NRedisStack.Tests/TimeSeries/TestAPI/TestNRange.cs
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,30 @@ public void TestNRangeAggregationPerKey(string endpointId)
Assert.Equal(100, rows[0].Values[2]);
}

[SkipIfRedisTheory(Is.Enterprise, Comparison.LessThan, "8.10.0")]
[MemberData(nameof(EndpointsFixture.Env.StandaloneOnly), MemberType = typeof(EndpointsFixture.Env))]
public void TestNRangeMultipleAggregatorsPerKey(string endpointId)
{
// A key may request several aggregators (comma-joined server-side), contributing multiple value
// columns; total columns == sum of aggregators across keys, not numkeys.
var db = GetCleanDatabase(endpointId);
var ts = db.TS();
var keys = CreateKeyNames(2);
ts.Add(keys[0], 1000, 10); ts.Add(keys[0], 2000, 20); // avg => 15, sum => 30
ts.Add(keys[1], 1000, 4); ts.Add(keys[1], 2000, 6); // max => 6

// key[0] gets two aggregators, key[1] gets one (implicitly widened from TsAggregation).
var rows = ts.NRange(keys, "-", "+",
aggregations: [new TsAggregations(TsAggregation.Avg, TsAggregation.Sum), TsAggregation.Max],
timeBucket: 100000);

Assert.Single(rows);
Assert.Equal(3, rows[0].Values.Count); // 2 (key[0]) + 1 (key[1]), not numkeys
Assert.Equal(15, rows[0].Values[0]); // key[0] avg
Assert.Equal(30, rows[0].Values[1]); // key[0] sum
Assert.Equal(6, rows[0].Values[2]); // key[1] max
}

[SkipIfRedisTheory(Is.Enterprise, Comparison.LessThan, "8.10.0")]
[MemberData(nameof(EndpointsFixture.Env.StandaloneOnly), MemberType = typeof(EndpointsFixture.Env))]
public void TestNRangeAggregatorCountMismatchIsServerError(string endpointId)
Expand Down
Loading