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
Next Next commit
Add support for warnings in FT.SEARCH, AGGREGATE and HYBRID commands …
…with timeout integration tests
  • Loading branch information
uglide committed Jul 10, 2026
commit 73adcf5f9611a3d474443130badfcd34364b02df
3 changes: 3 additions & 0 deletions src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,6 @@ NRedisStack.TimeSeriesCommandsAsync.MRangeAsync(NRedisStack.DataTypes.TimeStamp
NRedisStack.TimeSeriesCommandsAsync.MRevRangeAsync(NRedisStack.DataTypes.TimeStamp fromTimeStamp, NRedisStack.DataTypes.TimeStamp toTimeStamp, System.Collections.Generic.IReadOnlyCollection<string!>! filter, bool latest = false, System.Collections.Generic.IReadOnlyCollection<NRedisStack.DataTypes.TimeStamp>? filterByTs = null, (long, long)? filterByValue = null, bool? withLabels = null, System.Collections.Generic.IReadOnlyCollection<string!>? selectLabels = null, long? count = null, NRedisStack.DataTypes.TimeStamp? align = null, NRedisStack.TsAggregations aggregation = default(NRedisStack.TsAggregations), long? timeBucket = null, NRedisStack.Literals.Enums.TsBucketTimestamps? bt = null, bool empty = false, (string!, NRedisStack.Literals.Enums.TsReduce)? groupbyTuple = null) -> System.Threading.Tasks.Task<System.Collections.Generic.IReadOnlyList<(string! key, System.Collections.Generic.IReadOnlyList<NRedisStack.DataTypes.TimeSeriesLabel!>! labels, System.Collections.Generic.IReadOnlyList<NRedisStack.DataTypes.TimeSeriesTuple!>! values)>!>!
NRedisStack.TimeSeriesCommandsAsync.RangeAsync(string! key, NRedisStack.DataTypes.TimeStamp fromTimeStamp, NRedisStack.DataTypes.TimeStamp toTimeStamp, bool latest = false, System.Collections.Generic.IReadOnlyCollection<NRedisStack.DataTypes.TimeStamp>? filterByTs = null, (long, long)? filterByValue = null, long? count = null, NRedisStack.DataTypes.TimeStamp? align = null, NRedisStack.TsAggregations aggregation = default(NRedisStack.TsAggregations), long? timeBucket = null, NRedisStack.Literals.Enums.TsBucketTimestamps? bt = null, bool empty = false) -> System.Threading.Tasks.Task<System.Collections.Generic.IReadOnlyList<NRedisStack.DataTypes.TimeSeriesTuple!>!>!
NRedisStack.TimeSeriesCommandsAsync.RevRangeAsync(string! key, NRedisStack.DataTypes.TimeStamp fromTimeStamp, NRedisStack.DataTypes.TimeStamp toTimeStamp, bool latest = false, System.Collections.Generic.IReadOnlyCollection<NRedisStack.DataTypes.TimeStamp>? filterByTs = null, (long, long)? filterByValue = null, long? count = null, NRedisStack.DataTypes.TimeStamp? align = null, NRedisStack.TsAggregations aggregation = default(NRedisStack.TsAggregations), long? timeBucket = null, NRedisStack.Literals.Enums.TsBucketTimestamps? bt = null, bool empty = false) -> System.Threading.Tasks.Task<System.Collections.Generic.IReadOnlyList<NRedisStack.DataTypes.TimeSeriesTuple!>!>!
NRedisStack.Search.SearchResult.Warnings.get -> System.Collections.Generic.List<string!>!
Comment thread
uglide marked this conversation as resolved.
Resolved
NRedisStack.Search.AggregationResult.Warnings.get -> System.Collections.Generic.List<string!>!
NRedisStack.Search.HybridSearchResult.Warnings.get -> System.Collections.Generic.List<string!>!
23 changes: 23 additions & 0 deletions src/NRedisStack/ResponseParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1289,6 +1289,29 @@ internal static T[] ParseSearchResultsMap<T>(RedisResult result, Func<string[],
return results;
}

// Extracts the top-level "warning" field from a RESP3 FT.SEARCH / FT.AGGREGATE reply map.
// Warnings sit alongside "results"/"total_results" as a (possibly empty) array of strings.
internal static List<string> ParseWarnings(RedisResult root)
{
var arr = (RedisResult[])root!;
for (int i = 0; i + 1 < arr.Length; i += 2)
{
if ((string)arr[i]! == "warning" && arr[i + 1].Resp3Type is ResultType.Array)
{
var raw = (RedisResult[])arr[i + 1]!;
var warnings = new List<string>(raw.Length);
foreach (var warning in raw)
{
warnings.Add(warning.ToString());
}

return warnings;
}
}

return [];
}

internal static JsonType[] ParseJsonTypeArray(RedisResult result)
{
// RESP3 adds a layer of wrapping
Expand Down
8 changes: 8 additions & 0 deletions src/NRedisStack/Search/AggregationResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@ internal WithCursorAggregationResult(string indexName, RedisResult result, long

public long CursorId { get; }

/// <summary>
/// Warnings reported by the server for this query (for example when a query times out under the
/// <c>return</c>/<c>return-strict</c> on-timeout policy and partial results are returned).
/// Only populated on RESP3; on RESP2 FT.AGGREGATE does not carry warnings and this list is empty.
/// </summary>
public List<string> Warnings { get; } = [];
Comment thread
uglide marked this conversation as resolved.
Resolved

internal AggregationResult(RedisResult result, long cursorId = -1)
{
var arr = (RedisResult[])result!;
Expand All @@ -35,6 +42,7 @@ internal AggregationResult(RedisResult result, long cursorId = -1)
{
results = ResponseParser.ParseSearchResultsMap(result, ParseRecordFromMap, out long totalResults);
// TotalResults = totalResults; // we ignore this in RESP3 mode for consistent API behaviour
Warnings = ResponseParser.ParseWarnings(result);
}
else
{
Expand Down
14 changes: 8 additions & 6 deletions src/NRedisStack/Search/HybridSearchResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,16 +30,14 @@ internal static HybridSearchResult Parse(RedisResult? result)
case ResultKey.ExecutionTime:
obj.ExecutionTime = TimeSpan.FromSeconds((double)value);
break;
/* // defer Warnings until we've seen examples
case ResultKey.Warnings when value.Length > 0:
var warnings = new string[value.Length];
var warnings = new List<string>(value.Length);
for (int j = 0; j < value.Length; j++)
{
warnings[j] = value[j].ToString();
warnings.Add(value[j].ToString());
}
obj.Warnings = warnings;
break;
*/
case ResultKey.Results when value.Length > 0:
obj._rawResults = value.ToArray();
break;
Expand Down Expand Up @@ -118,8 +116,12 @@ private enum ResultKey
/// </summary>
public TimeSpan ExecutionTime { get; private set; }

// not exposing this until I've seen it being used
internal string[] Warnings { get; private set; } = [];
/// <summary>
/// Warnings reported by the server for this query (for example a timeout warning under the
/// <c>return</c>/<c>return-strict</c> on-timeout policy). FT.HYBRID reports warnings on both
/// RESP2 and RESP3.
/// </summary>
public List<string> Warnings { get; private set; } = [];

private RedisResult[] _rawResults = [];
private Document[]? _docResults;
Expand Down
8 changes: 8 additions & 0 deletions src/NRedisStack/Search/SearchResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ public class SearchResult
public long TotalResults { get; }
public List<Document> Documents { get; }

/// <summary>
/// Warnings reported by the server for this query (for example when a query times out under the
/// <c>return</c>/<c>return-strict</c> on-timeout policy and partial results are returned).
/// Only populated on RESP3; on RESP2 FT.SEARCH does not carry warnings and this list is empty.
/// </summary>
public List<string> Warnings { get; } = [];

/// <summary>
/// Converts the documents to a list of json strings. only works on a json documents index.
/// </summary>
Expand Down Expand Up @@ -61,6 +68,7 @@ internal SearchResult(RedisResult root, bool hasContent, bool hasScores, bool ha
return Document.Load(id, score, payload, fields, scoreExplained);
}, out long totalResults));
TotalResults = totalResults;
Warnings = ResponseParser.ParseWarnings(root);
}
else // RESP2
{
Expand Down
71 changes: 71 additions & 0 deletions tests/NRedisStack.Tests/Search/HybridSearchIntegrationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -432,4 +432,75 @@ static void WriteEscaped(ReadOnlySpan<byte> span, StringBuilder sb)
sb.Append('"');
}
}

// Number of documents indexed by the on-timeout test. Enough that the hybrid query cannot
// complete within the 1ms per-query timeout, so the on-timeout policy is guaranteed to kick in.
private const int TimeoutDocCount = 1_000;

// Sets the global search-on-timeout policy on every primary. On a cluster the policy must be
// present on all shards (and the coordinator) for the timeout behaviour to be consistent.
private static void SetSearchOnTimeout(IConnectionMultiplexer muxer, string value)
{
foreach (var endpoint in muxer.GetEndPoints())
{
var server = muxer.GetServer(endpoint);
if (!server.IsReplica)
{
server.ConfigSet("search-on-timeout", value);
}
}
}

// FT.HYBRID counterpart of the FT.SEARCH/FT.AGGREGATE on-timeout tests: under the `return`
// policy a timed-out query must return (possibly partial) results plus a warning rather than
// failing. FT.HYBRID reports warnings on both RESP2 and RESP3, so no protocol skip is needed.
// See CAE-3003.
[SkipIfRedisTheory(Comparison.LessThan, "8.9.0", deferVersionCheck: true)]
[MemberData(nameof(EndpointsFixture.Env.AllEnvironments), MemberType = typeof(EndpointsFixture.Env))]
public async Task TestHybridOnTimeoutReturnPopulatesWarnings(string endpointId)
{
#if NET
var api = await CreateIndexAsync(endpointId, populate: false);
if (api.IsNull) return;

// Bulk-load with fire-and-forget so many documents load quickly, then Ping to make sure all
// writes have been processed (and therefore indexed) before we query.
var rand = new Random(12345);
using var vectorData = VectorData.Lease<Half>(V1DIM);
for (int i = 0; i < TimeoutDocCount; i++)
{
foreach (ref Half val in vectorData.Span)
{
val = (Half)rand.NextDouble();
}

HashEntry[] entry =
[
new("text1", $"hello world {i}"),
new("vector1", vectorData.AsRedisValue())
];
api.DB.HashSet($"{api.Index}_e{i}", entry, flags: CommandFlags.FireAndForget);
}

api.DB.Ping();

// Ensure the RETURN policy is active on every primary. A stored vector guarantees the query
// vector matches the field type/dimension.
SetSearchOnTimeout(api.DB.Multiplexer, "return");

var hash = (await api.DB.HashGetAllAsync($"{api.Index}_e0")).ToDictionary(k => k.Name, v => v.Value);
var vec = (byte[])hash["vector1"]!;
var query = new HybridSearchQuery()
.Search("hello world")
.VectorSearch("@vector1", VectorData.Raw(vec))
.Timeout(TimeSpan.FromMilliseconds(1));

var result = api.FT.HybridSearch(api.Index, query);
Assert.NotNull(result);
Assert.NotEmpty(result.Warnings);
Assert.Contains(result.Warnings, w => w.ToLowerInvariant().Contains("timeout"));
#else
await Task.CompletedTask;
#endif
}
}
143 changes: 143 additions & 0 deletions tests/NRedisStack.Tests/Search/SearchTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4046,4 +4046,147 @@ public async Task TestDocumentLoadWithDB_Issue352(string endpointId)
// Without fix for Issue352, document load in this case fails %100 with my local test runs,, and %100 success with fixed version.
// The results in pipeline should be the same.
}

// Number of documents indexed by the on-timeout tests. Large enough that scanning, scoring and
// sorting them cannot complete within the 1ms per-query timeout, so the query engine's
// on-timeout policy is guaranteed to kick in.
private const int TimeoutDocCount = 1_000;

// Sets the global search-on-timeout policy on every primary. On a cluster the policy must be
// present on all shards (and the coordinator) for the timeout behaviour to be consistent, so we
// broadcast rather than target a single node.
private static void SetSearchOnTimeout(IConnectionMultiplexer muxer, string value)
{
foreach (var endpoint in muxer.GetEndPoints())
{
var server = muxer.GetServer(endpoint);
if (!server.IsReplica)
{
server.ConfigSet("search-on-timeout", value);
}
}
}

private void PopulateTimeoutIndex(IDatabase db, SearchCommands ft)
{
Schema sc = new();
sc.AddTextField("title");
sc.AddNumericField("n", sortable: true);
ft.Create(index, FTCreateParams.CreateParams(), sc);

// Bulk-load with fire-and-forget so 1k documents load quickly, then Ping to make sure all
// writes have been processed (and therefore indexed) before we query.
for (int i = 0; i < TimeoutDocCount; i++)
{
db.HashSet($"tdoc:{i}",
new HashEntry[] { new("title", $"hello world {i}"), new("n", i) },
flags: CommandFlags.FireAndForget);
}

db.Ping();
}

// A query heavy enough that it cannot finish within a 1ms timeout: it matches every document and
// forces a full scan, scoring and sort over the whole index.
private static Query TimingOutQuery() =>
new Query("hello world").SetWithScores().SetSortBy("n", ascending: false).Limit(0, TimeoutDocCount).Timeout(1);

// With the `fail` on-timeout policy the server returns an error on a timed-out search instead of
// partial results. The client must surface that error (as a RedisServerException) rather than
// swallowing it into an empty/partial result, and it must not be automatically retried.
// See CAE-3003 (initiative RED-132340: "Timeout guardrails").
[SkipIfRedisTheory(Comparison.LessThan, "8.9.0")]
[MemberData(nameof(EndpointsFixture.Env.AllEnvironments), MemberType = typeof(EndpointsFixture.Env))]
public void TestSearchOnTimeoutFailReturnsError(string endpointId)
{
SkipClusterPre8(endpointId);
IDatabase db = GetCleanDatabase(endpointId);
var ft = db.FT();
PopulateTimeoutIndex(db, ft);

// Use the unified CONFIG SET (search-on-timeout); it is a global server setting.
SetSearchOnTimeout(db.Multiplexer, "fail");
try
{
var ex = Assert.Throws<RedisServerException>(() => ft.Search(index, TimingOutQuery()));
Assert.Contains("timeout", ex.Message.ToLowerInvariant());
}
finally
{
// Restore the default policy so we don't leak the FAIL setting into other tests.
SetSearchOnTimeout(db.Multiplexer, "return");
}
}

// With the `return` on-timeout policy the server returns a (possibly partial) result together
// with a warning instead of failing. The query must not throw, and the timeout must be reported
// via SearchResult.Warnings. Warnings are only carried on RESP3 for FT.SEARCH. See CAE-3003.
[SkipIfRedisTheory(Comparison.LessThan, "8.9.0")]
[MemberData(nameof(EndpointsFixture.Env.AllEnvironments), MemberType = typeof(EndpointsFixture.Env))]
public void TestSearchOnTimeoutReturnPopulatesWarnings(string endpointId)
{
SkipClusterPre8(endpointId);
Assert.SkipUnless(TestContext.Current.GetRunProtocol().IsResp3(),
"FT.SEARCH warnings are only returned on RESP3");

IDatabase db = GetCleanDatabase(endpointId);
var ft = db.FT();
PopulateTimeoutIndex(db, ft);

SetSearchOnTimeout(db.Multiplexer, "return");

var result = ft.Search(index, TimingOutQuery());
Assert.NotNull(result);
Assert.NotEmpty(result.Warnings);
Assert.Contains(result.Warnings, w => w.ToLowerInvariant().Contains("timeout"));
}

// A heavy FT.AGGREGATE that cannot finish within a 1ms timeout: it matches every document and
// sorts the whole index.
private static AggregationRequest TimingOutAggregation() =>
new AggregationRequest("hello world").SortBy(SortedField.Desc("@n")).Timeout(1);

// FT.AGGREGATE counterpart of TestSearchOnTimeoutFailReturnsError. See CAE-3003.
[SkipIfRedisTheory(Comparison.LessThan, "8.9.0")]
[MemberData(nameof(EndpointsFixture.Env.AllEnvironments), MemberType = typeof(EndpointsFixture.Env))]
public void TestAggregateOnTimeoutFailReturnsError(string endpointId)
{
SkipClusterPre8(endpointId);
IDatabase db = GetCleanDatabase(endpointId);
var ft = db.FT();
PopulateTimeoutIndex(db, ft);

SetSearchOnTimeout(db.Multiplexer, "fail");
try
{
var ex = Assert.Throws<RedisServerException>(() => ft.Aggregate(index, TimingOutAggregation()));
Assert.Contains("timeout", ex.Message.ToLowerInvariant());
}
finally
{
SetSearchOnTimeout(db.Multiplexer, "return");
}
}

// FT.AGGREGATE counterpart of TestSearchOnTimeoutReturnPopulatesWarnings. FT.AGGREGATE warnings
// are only carried on RESP3. See CAE-3003.
[SkipIfRedisTheory(Comparison.LessThan, "8.9.0")]
[MemberData(nameof(EndpointsFixture.Env.AllEnvironments), MemberType = typeof(EndpointsFixture.Env))]
public void TestAggregateOnTimeoutReturnPopulatesWarnings(string endpointId)
{
SkipClusterPre8(endpointId);
Assert.SkipUnless(TestContext.Current.GetRunProtocol().IsResp3(),
"FT.AGGREGATE warnings are only returned on RESP3");

IDatabase db = GetCleanDatabase(endpointId);
var ft = db.FT();
PopulateTimeoutIndex(db, ft);

SetSearchOnTimeout(db.Multiplexer, "return");

var result = ft.Aggregate(index, TimingOutAggregation());
Assert.NotNull(result);
Assert.NotEmpty(result.Warnings);
Assert.Contains(result.Warnings, w => w.ToLowerInvariant().Contains("timeout"));
}
}