Skip to content

Commit 3a345c9

Browse files
committed
spike better RedisValue parsing logic
1 parent 16bde35 commit 3a345c9

2 files changed

Lines changed: 132 additions & 6 deletions

File tree

src/StackExchange.Redis/RespReaderExtensions.cs

Lines changed: 80 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System;
22
using System.Buffers;
33
using System.Diagnostics;
4+
using System.Runtime.CompilerServices;
45
using System.Threading.Tasks;
56
using RESPite.Messages;
67

@@ -15,12 +16,22 @@ public RedisValue ReadRedisValue()
1516
reader.DemandScalar();
1617
if (reader.IsNull) return RedisValue.Null;
1718

18-
return reader.Prefix switch
19+
switch (reader.Prefix)
1920
{
20-
RespPrefix.Boolean => reader.ReadBoolean(),
21-
RespPrefix.Integer => reader.ReadInt64(),
22-
_ => reader.ReadByteArray(),
23-
};
21+
case RespPrefix.Boolean:
22+
return reader.ReadBoolean();
23+
case RespPrefix.Integer:
24+
return reader.ReadInt64();
25+
}
26+
27+
// bulk/simple/verbatim string: for inline scalars, prefer a compact numeric storage kind when
28+
// the text is the *canonical* representation of that number, so every projection (ToString,
29+
// (byte[]), equality, hash) still round-trips byte-for-byte; otherwise keep the raw bytes
30+
if (reader.TryGetSpan(out var span) && TryReadCanonicalNumber(span, out var number))
31+
{
32+
return number;
33+
}
34+
return reader.ReadByteArray();
2435
}
2536

2637
public string DebugReadTruncatedString(int maxChars)
@@ -184,7 +195,7 @@ public static RespPrefix GetRespPrefix(ReadOnlySpan<byte> frame)
184195
RespPrefix.SimpleError => ResultType.Error,
185196
RespPrefix.Null => ResultType.Null,
186197
RespPrefix.VerbatimString => ResultType.VerbatimString,
187-
RespPrefix.Push=> ResultType.Push,
198+
RespPrefix.Push => ResultType.Push,
188199
_ => throw new ArgumentOutOfRangeException(nameof(prefix), prefix, null),
189200
};
190201
}
@@ -208,4 +219,67 @@ internal bool AnyNull()
208219
public bool IsCompletedSuccessfully => task.Status is TaskStatus.RanToCompletion;
209220
}
210221
#endif
222+
223+
private static readonly int MaxCanonicalLength = Math.Max(Format.MaxInt64TextLen, Format.MaxDoubleTextLen);
224+
225+
// Recognizes the canonical decimal text of an integer or finite double, returning a numeric-backed
226+
// RedisValue only when re-formatting the parsed value reproduces the exact input bytes. This keeps the
227+
// optimization invisible to callers: non-canonical spellings ("01234", "+5", "1.50", "1e3"), oversize
228+
// values, and the special inf/nan tokens all return false and are kept as a byte[] payload by the caller.
229+
private static bool TryReadCanonicalNumber(ReadOnlySpan<byte> span, out RedisValue value)
230+
{
231+
static bool Failure(out RedisValue value)
232+
{
233+
value = default;
234+
return false;
235+
}
236+
// integer: canonical exactly when the round-trip text length matches (rules out leading zeros, a
237+
// leading '+', "-0", trailing junk, etc.) - so no need to re-emit and compare bytes
238+
if (span.IsEmpty | span.Length > MaxCanonicalLength) return Failure(out value);
239+
240+
// restrict to *just* basic number tokens
241+
foreach (var b in span)
242+
{
243+
switch (b)
244+
{
245+
case (byte)'0':
246+
case (byte)'1':
247+
case (byte)'2':
248+
case (byte)'3':
249+
case (byte)'4':
250+
case (byte)'5':
251+
case (byte)'6':
252+
case (byte)'7':
253+
case (byte)'8':
254+
case (byte)'9':
255+
case (byte)'.':
256+
case (byte)'-':
257+
case (byte)'E':
258+
case (byte)'e':
259+
break;
260+
default:
261+
return Failure(out value);
262+
}
263+
}
264+
265+
if (Format.TryParseInt64(span, out var i64) && Format.MeasureInt64(i64) == span.Length)
266+
{
267+
value = i64;
268+
return true;
269+
}
270+
271+
if (Format.TryParseDouble(span, out var dbl))
272+
{
273+
if (dbl == 0.0) dbl = Math.Abs(dbl); // prevent problems with -0 formatting
274+
Span<byte> formatted = stackalloc byte[Format.MaxDoubleTextLen];
275+
var len = Format.FormatDouble(dbl, formatted);
276+
if (formatted.Slice(0, len).SequenceEqual(span))
277+
{
278+
value = dbl;
279+
return true;
280+
}
281+
}
282+
283+
return Failure(out value);
284+
}
211285
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
using System.Threading.Tasks;
2+
using Xunit;
3+
4+
namespace StackExchange.Redis.Tests;
5+
6+
/// <summary>
7+
/// Pins the <see cref="RedisValue.StorageType"/> that the inbound read path
8+
/// (<c>RespReader.ReadRedisValue</c>) produces for values fetched from a server: numeric-looking bulk
9+
/// strings are stored compactly as Int64/Double when they round-trip exactly, otherwise as a byte[].
10+
/// </summary>
11+
/// <remarks>
12+
/// The expected storage kind is passed by name because <see cref="RedisValue.StorageType"/> is internal and
13+
/// so cannot appear as a parameter on a public theory method.
14+
/// </remarks>
15+
public class RedisValueStorageKindTests(ITestOutputHelper output, InProcServerFixture fixture) : TestBase(output, fixture)
16+
{
17+
[Theory]
18+
// canonical integers => Int64
19+
[InlineData("1234", "Int64")]
20+
[InlineData("0", "Int64")]
21+
[InlineData("-5", "Int64")]
22+
[InlineData("9223372036854775807", "Int64")] // long.MaxValue
23+
// canonical finite doubles => Double
24+
[InlineData("0.5", "Double")]
25+
[InlineData("-2.25", "Double")]
26+
// non-canonical / non-numeric / special => kept as bytes
27+
[InlineData("01234", "ByteArray")] // leading zero
28+
[InlineData("+1234", "ByteArray")] // leading '+'
29+
[InlineData("-0", "ByteArray")] // negative zero text
30+
[InlineData("1.50", "ByteArray")] // trailing zero (non-canonical double)
31+
[InlineData("1e3", "ByteArray")] // exponent form
32+
[InlineData("inf", "ByteArray")] // special token excluded
33+
[InlineData("nan", "ByteArray")]
34+
[InlineData("99999999999999999999999", "ByteArray")] // oversize
35+
[InlineData("abc", "ByteArray")] // not numeric
36+
[InlineData("12abc", "ByteArray")] // trailing junk
37+
public async Task FetchedValue_StorageKindAndRoundTrip(string stored, string expectedKind)
38+
{
39+
await using var conn = Create();
40+
var db = conn.GetDatabase();
41+
var key = Me();
42+
db.KeyDelete(key, CommandFlags.FireAndForget);
43+
44+
db.StringSet(key, stored);
45+
var value = db.StringGet(key);
46+
47+
// the optimization must be invisible: the text projection always matches what was stored
48+
Assert.Equal(stored, (string?)value);
49+
Log($"'{stored}' => {value.Type}");
50+
Assert.Equal(expectedKind, value.Type.ToString());
51+
}
52+
}

0 commit comments

Comments
 (0)