Skip to content

Commit 15a5e05

Browse files
slorello89claude
andauthored
Fix RESP3 map parsing for FT.SEARCH/AGGREGATE/INFO (#570) (#571)
Newer StackExchange.Redis versions auto-negotiate RESP3, which changes FT.SEARCH/FT.AGGREGATE/FT.INFO replies from the flat RESP2 array into a map. SE.Redis collapses a map's Resp2Type to Array (flattened key/value pairs), so the existing parsers read the first map key as the document count, throwing "InvalidCastException: Could not cast to long". Detect the RESP3 map shape at the parser layer and reshape it back into the legacy RESP2 layout before the existing logic runs: - RedisReply records IsMap (Resp3Type == Map) and exposes GetMapValueOrDefault; the existing flattening is preserved. - SearchResponse.NormalizeReply and AggregationResult.NormalizeReply reshape map replies into the legacy [count, id, (score,) fields, ...] array (search) and [count, fields, ...] array (aggregation). - RedisIndexInfoAttribute flattens RESP3's nested "flags" array so SORTABLE/NOSTEM/INDEXMISSING/INDEXEMPTY are detected under both protocols. - RedisUriParser honors a "protocol" URI query arg and the REDIS_OM_PROTOCOL env var (resp2/resp3), giving users a workaround and enabling the full suite to run under RESP3. Adds deterministic unit tests that feed synthetic RESP3 maps through the search and aggregation parsers (CI runs RESP2 by default). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 3b47603 commit 15a5e05

6 files changed

Lines changed: 327 additions & 10 deletions

File tree

src/Redis.OM/Aggregation/AggregationResult.cs

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ private AggregationResult(RedisReply res)
7272
/// <returns>A set of Aggregation Results.</returns>
7373
internal static IEnumerable<AggregationResult<T>> FromRedisResult(RedisReply res)
7474
{
75-
var arr = res.ToArray();
75+
var arr = AggregationResult.NormalizeReply(res);
7676
for (var i = 1; i < arr.Length; i++)
7777
{
7878
yield return new AggregationResult<T>(arr[i]);
@@ -117,11 +117,40 @@ private AggregationResult(RedisReply res)
117117
/// <returns>an enumerable of results.</returns>
118118
public static IEnumerable<AggregationResult> FromRedisResult(RedisReply res)
119119
{
120-
var arr = res.ToArray();
120+
var arr = NormalizeReply(res);
121121
for (var i = 1; i < arr.Length; i++)
122122
{
123123
yield return new AggregationResult(arr[i]);
124124
}
125125
}
126+
127+
/// <summary>
128+
/// Normalizes an aggregation reply into the flat RESP2 layout the parsers expect. RESP2 replies
129+
/// are returned unchanged; a RESP3 map reply (negotiated automatically by newer
130+
/// StackExchange.Redis versions) is reshaped from its <c>total_results</c>/<c>results</c>
131+
/// structure into the legacy <c>[count, fields, ...]</c> array, where each <c>fields</c> entry is
132+
/// the result's <c>extra_attributes</c> map.
133+
/// </summary>
134+
/// <param name="res">The raw aggregation reply.</param>
135+
/// <returns>The reply in flat RESP2 layout.</returns>
136+
internal static RedisReply[] NormalizeReply(RedisReply res)
137+
{
138+
if (!res.IsMap)
139+
{
140+
return res.ToArray();
141+
}
142+
143+
var flattened = new List<RedisReply> { res.GetMapValueOrDefault("total_results") ?? 0L };
144+
var results = res.GetMapValueOrDefault("results");
145+
if (results is not null)
146+
{
147+
foreach (var result in results.ToArray())
148+
{
149+
flattened.Add(result.GetMapValueOrDefault("extra_attributes") ?? new RedisReply(Array.Empty<RedisReply>()));
150+
}
151+
}
152+
153+
return flattened.ToArray();
154+
}
126155
}
127156
}

src/Redis.OM/RedisIndexInfo.cs

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -292,21 +292,36 @@ public RedisIndexInfoAttribute(RedisReply redisReply)
292292
case "M": M = value; break;
293293
case "ef_construction": EfConstruction = value; break;
294294
case "WEIGHT": Weight = value; break;
295-
case "INDEXMISSING": IndexMissing = true; break;
296-
case "INDEXEMPTY": IndexEmpty = true; break;
297295
}
298296
}
299297

300-
if (responseArray.Any(x => ((string)x).Equals("NOSTEM", StringComparison.InvariantCultureIgnoreCase)))
298+
// Boolean flags are emitted as trailing top-level tokens under RESP2, but grouped
299+
// inside a nested "flags" array under RESP3. Flattening one level yields the leaf
300+
// tokens for either layout so the flag detection below is protocol-agnostic.
301+
var flagTokens = responseArray
302+
.SelectMany(x => x.ToArray())
303+
.Select(x => x.ToString(CultureInfo.InvariantCulture))
304+
.ToArray();
305+
306+
if (flagTokens.Any(x => x.Equals("NOSTEM", StringComparison.InvariantCultureIgnoreCase)))
301307
{
302308
NoStem = true;
303309
}
304310

305-
if (responseArray.Select(x => x.ToString())
306-
.Any(x => x.Equals("SORTABLE", StringComparison.InvariantCultureIgnoreCase)))
311+
if (flagTokens.Any(x => x.Equals("SORTABLE", StringComparison.InvariantCultureIgnoreCase)))
307312
{
308313
Sortable = true;
309314
}
315+
316+
if (flagTokens.Any(x => x.Equals("INDEXMISSING", StringComparison.InvariantCultureIgnoreCase)))
317+
{
318+
IndexMissing = true;
319+
}
320+
321+
if (flagTokens.Any(x => x.Equals("INDEXEMPTY", StringComparison.InvariantCultureIgnoreCase)))
322+
{
323+
IndexEmpty = true;
324+
}
310325
}
311326

312327
/// <summary>

src/Redis.OM/RedisReply.cs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,13 @@ public RedisReply(RedisResult result)
4444
_values = ((RedisResult[])result!).Select(x => new RedisReply(x)).ToArray();
4545
break;
4646
}
47+
48+
// When the connection negotiates RESP3, commands such as FT.SEARCH and FT.AGGREGATE
49+
// reply with a map rather than the flat RESP2 array. SE.Redis collapses a map's
50+
// Resp2Type to Array (the flattened, interleaved key/value pairs handled above), so the
51+
// existing array-based parsing is preserved. We additionally record that the node was a
52+
// map so response parsers can opt into the RESP3 shape via <see cref="IsMap"/>.
53+
IsMap = result.Resp3Type == ResultType.Map;
4754
}
4855

4956
/// <summary>
@@ -100,11 +107,30 @@ internal RedisReply(RedisReply[] values)
100107
_values = values;
101108
}
102109

110+
/// <summary>
111+
/// Initializes a new instance of the <see cref="RedisReply"/> class representing a RESP3 map,
112+
/// stored as a flattened, interleaved key/value array. Used to exercise the RESP3 parsing paths.
113+
/// </summary>
114+
/// <param name="values">the flattened key/value pairs.</param>
115+
/// <param name="isMap">whether the reply represents a RESP3 map.</param>
116+
internal RedisReply(RedisReply[] values, bool isMap)
117+
{
118+
_values = values;
119+
IsMap = isMap;
120+
}
121+
103122
/// <summary>
104123
/// Gets a value indicating whether the result represents an error.
105124
/// </summary>
106125
public bool Error { get; }
107126

127+
/// <summary>
128+
/// Gets a value indicating whether the reply was a RESP3 map (e.g. a RESP3 FT.SEARCH or
129+
/// FT.AGGREGATE response). The underlying values are still exposed as the flattened,
130+
/// interleaved key/value array via <see cref="ToArray"/>.
131+
/// </summary>
132+
internal bool IsMap { get; }
133+
108134
/// <summary>
109135
/// implicitly converts the reply to a double.
110136
/// </summary>
@@ -508,5 +534,29 @@ public ulong ToUInt64(IFormatProvider provider)
508534

509535
throw new InvalidCastException();
510536
}
537+
538+
/// <summary>
539+
/// Looks up a value by key, treating this reply as a RESP3 map whose values are stored as a
540+
/// flattened, interleaved key/value array.
541+
/// </summary>
542+
/// <param name="key">The map key to look up.</param>
543+
/// <returns>The value associated with <paramref name="key"/>, or <c>null</c> if absent.</returns>
544+
internal RedisReply? GetMapValueOrDefault(string key)
545+
{
546+
if (_values is null)
547+
{
548+
return null;
549+
}
550+
551+
for (var i = 0; i + 1 < _values.Length; i += 2)
552+
{
553+
if ((string)_values[i] == key)
554+
{
555+
return _values[i + 1];
556+
}
557+
}
558+
559+
return null;
560+
}
511561
}
512562
}

src/Redis.OM/RedisUriParser.cs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,19 +26,60 @@ internal static ConfigurationOptions ParseConfigFromUri(string uriString)
2626
if (string.IsNullOrEmpty(uriString))
2727
{
2828
options.EndPoints.Add("localhost:6379");
29+
ParseProtocol(options, null);
2930
return options;
3031
}
3132

3233
var uri = new Uri(uriString);
3334
ParseHost(options, uri);
3435
ParseUserInfo(options, uri);
3536
ParseQueryArguments(options, uri);
37+
ParseProtocol(options, uri);
3638
ParseDefaultDatabase(options, uri);
3739
options.Ssl = uri.Scheme == "rediss";
3840
options.AbortOnConnectFail = false;
3941
return options;
4042
}
4143

44+
/// <summary>
45+
/// Resolves the RESP protocol to negotiate. An explicit <c>protocol</c> query argument
46+
/// (<c>resp2</c>/<c>resp3</c> or <c>2</c>/<c>3</c>) takes precedence; otherwise the
47+
/// <c>REDIS_OM_PROTOCOL</c> environment variable is used as a fallback default. When neither is
48+
/// supplied the StackExchange.Redis default is left untouched.
49+
/// </summary>
50+
/// <param name="options">The configuration options to populate.</param>
51+
/// <param name="uri">The parsed URI, or <c>null</c> when none was supplied.</param>
52+
private static void ParseProtocol(ConfigurationOptions options, Uri? uri)
53+
{
54+
string? requested = null;
55+
if (uri is not null && !string.IsNullOrEmpty(uri.Query))
56+
{
57+
requested = ParseQuery(uri.Query.Substring(1))
58+
.Where(x => x.Key.ToLower() == "protocol")
59+
.Select(x => x.Value)
60+
.FirstOrDefault();
61+
}
62+
63+
requested ??= Environment.GetEnvironmentVariable("REDIS_OM_PROTOCOL");
64+
65+
if (string.IsNullOrEmpty(requested))
66+
{
67+
return;
68+
}
69+
70+
switch (requested!.Trim().ToLowerInvariant())
71+
{
72+
case "2":
73+
case "resp2":
74+
options.Protocol = RedisProtocol.Resp2;
75+
break;
76+
case "3":
77+
case "resp3":
78+
options.Protocol = RedisProtocol.Resp3;
79+
break;
80+
}
81+
}
82+
4283
private static void ParseDefaultDatabase(ConfigurationOptions options, Uri uri)
4384
{
4485
if (string.IsNullOrEmpty(uri.AbsolutePath))

src/Redis.OM/Searching/SearchResponse.cs

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ public class SearchResponse
1515
/// <param name="val">The redis response.</param>
1616
public SearchResponse(RedisReply val)
1717
{
18-
var vals = val.ToArray();
18+
var vals = NormalizeReply(val);
1919
DocumentCount = vals[0];
2020
Documents = new Dictionary<string, IDictionary<string, string>>();
2121
Scores = new Dictionary<string, double>();
@@ -80,6 +80,44 @@ public IDictionary<string, T> DocumentsAs<T>()
8080
return dict;
8181
}
8282

83+
/// <summary>
84+
/// Normalizes a search reply into the flat RESP2 layout the parsers expect. RESP2 replies are
85+
/// returned unchanged; a RESP3 map reply (negotiated automatically by newer StackExchange.Redis
86+
/// versions) is reshaped from its <c>total_results</c>/<c>results</c> structure into the legacy
87+
/// <c>[count, id, (score,) fields, ...]</c> array.
88+
/// </summary>
89+
/// <param name="val">The raw search reply.</param>
90+
/// <returns>The reply in flat RESP2 layout.</returns>
91+
internal static RedisReply[] NormalizeReply(RedisReply val)
92+
{
93+
if (!val.IsMap)
94+
{
95+
return val.ToArray();
96+
}
97+
98+
var flattened = new List<RedisReply> { val.GetMapValueOrDefault("total_results") ?? 0L };
99+
var results = val.GetMapValueOrDefault("results");
100+
if (results is not null)
101+
{
102+
foreach (var result in results.ToArray())
103+
{
104+
flattened.Add(result.GetMapValueOrDefault("id") ?? string.Empty);
105+
106+
// WITHSCORES surfaces the score as a scalar map entry; the existing parser treats it
107+
// as the metadata sitting between the id and the field payload.
108+
var score = result.GetMapValueOrDefault("score");
109+
if (score is not null)
110+
{
111+
flattened.Add(score);
112+
}
113+
114+
flattened.Add(result.GetMapValueOrDefault("extra_attributes") ?? new RedisReply(Array.Empty<RedisReply>()));
115+
}
116+
}
117+
118+
return flattened.ToArray();
119+
}
120+
83121
/// <summary>
84122
/// Walks forward over scalar metadata entries (e.g. the score emitted by WITHSCORES) that sit between a
85123
/// document's id and its field payload, returning the index of the field payload.
@@ -154,7 +192,7 @@ public SearchResponse(RedisReply val)
154192
}
155193
else
156194
{
157-
var vals = val.ToArray();
195+
var vals = SearchResponse.NormalizeReply(val);
158196
if (vals.Length == 1)
159197
{
160198
var str = vals[0].ToString();
@@ -248,7 +286,7 @@ private SearchResponse()
248286

249287
private static SearchResponse<T> PrimitiveSearchResponse(RedisReply redisReply)
250288
{
251-
var arr = redisReply.ToArray();
289+
var arr = SearchResponse.NormalizeReply(redisReply);
252290
var response = new SearchResponse<T>();
253291
response.DocumentCount = arr[0];
254292
for (var i = 1; i < arr.Count();)

0 commit comments

Comments
 (0)