Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
5 changes: 5 additions & 0 deletions src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -1 +1,6 @@
#nullable enable
abstract StackExchange.Redis.RedisValue.EqualityComparer.Equals(StackExchange.Redis.RedisValue x, StackExchange.Redis.RedisValue y) -> bool
abstract StackExchange.Redis.RedisValue.EqualityComparer.GetHashCode(StackExchange.Redis.RedisValue obj) -> int
StackExchange.Redis.RedisValue.EqualityComparer
static StackExchange.Redis.RedisValue.EqualityComparer.Binary.get -> StackExchange.Redis.RedisValue.EqualityComparer!
static StackExchange.Redis.RedisValue.EqualityComparer.Default.get -> StackExchange.Redis.RedisValue.EqualityComparer!
203 changes: 203 additions & 0 deletions src/StackExchange.Redis/RedisValue.EqualityComparer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
using System;
using System.Buffers;
using System.Collections;
using System.Collections.Generic;
using System.IO.Hashing;
using System.Text;

namespace StackExchange.Redis
{
public readonly partial struct RedisValue
{
/// <summary>
/// Ways of testing <see cref="RedisValue"/> instances for equality.
/// </summary>
/// <remarks>
/// <para>
/// Equality only. There is deliberately no ordering counterpart: a total order over
/// <see cref="RedisValue"/> would have to choose between the numeric, textual and raw-byte readings of
/// a value, and those disagree for the same logical value. Redis does not define one either - sorted
/// sets order by score, and <c>ZRANGEBYLEX</c> orders member bytes rather than values.
/// </para>
/// <para>
/// Derivation is closed. Anything wanting its own rules should implement
/// <see cref="IEqualityComparer{T}"/> directly - it never needed this base class - and keeping the
/// hierarchy closed leaves room to add members here later without breaking anyone.
/// </para>
/// </remarks>
public abstract class EqualityComparer : IEqualityComparer<RedisValue>, IEqualityComparer
{
private protected EqualityComparer() { }

/// <summary>
/// Matches <see cref="RedisValue"/>'s own equality, where a blob equals the text it decodes to.
/// </summary>
public static EqualityComparer Default { get; } = new DefaultComparer();

/// <summary>
/// Compares the UTF-8 form of values without decoding it into text, which is markedly cheaper for
/// large values.
/// </summary>
/// <remarks>
/// <para>
/// Agrees with <see cref="Default"/> wherever a value's UTF-8 form round-trips, which is to say
/// for all well-formed text. It differs where that fails, and either side can be the cause:
/// </para>
/// <list type="bullet">
/// <item><description>
/// A string holding an unpaired surrogate encodes to the same bytes as one holding U+FFFD, so
/// this calls those equal where <see cref="Default"/> does not.
/// </description></item>
/// <item><description>
/// A blob that is not canonical UTF-8 decodes to U+FFFD but does not re-encode to itself - the
/// single byte <c>0xFF</c>, say - so this calls it distinct from the text U+FFFD where
/// <see cref="Default"/> calls them equal.
/// </description></item>
/// </list>
/// <para>
/// Since both its equality and its hashing read those same bytes, it is self-consistent - which
/// is what makes the byte reading safe here and not on <see cref="RedisValue"/> itself, whose
/// <see cref="RedisValue.GetHashCode()"/> hashes the decoded text.
/// </para>
/// <para>
/// Hashing is not resistant to deliberate collision-finding - it is chosen for speed, unlike the
/// framework's string hashing. Do not use it to key on values an untrusted party controls.
/// </para>
/// </remarks>
public static EqualityComparer Binary { get; } = new BinaryComparer();

/// <inheritdoc/>
public abstract bool Equals(RedisValue x, RedisValue y);

/// <inheritdoc/>
public abstract int GetHashCode(RedisValue obj);

// The untyped API accepts anything RedisValue itself would accept from object - string, byte[],
// the numeric types and so on - matching Equals(object) rather than demanding a boxed RedisValue.
bool IEqualityComparer.Equals(object? x, object? y)
{
if (ReferenceEquals(x, y)) return true; // also covers both-null

var left = TryParse(x, out var leftValid);
var right = TryParse(y, out var rightValid);
return leftValid && rightValid && Equals(left, right);
}

int IEqualityComparer.GetHashCode(object obj)
{
var value = TryParse(obj, out var valid);

// anything we cannot read is never equal to anything under Equals above, so its own hash is
// as good as any: it only has to be stable
return valid ? GetHashCode(value) : obj.GetHashCode();
}

private sealed class DefaultComparer : EqualityComparer
{
public override bool Equals(RedisValue x, RedisValue y) => x == y;

public override int GetHashCode(RedisValue obj) => obj.GetHashCode();
}

private sealed class BinaryComparer : EqualityComparer
{
/// <summary>Per-process entropy, so hash codes are not predictable between runs.</summary>
private static readonly long Seed = BitConverter.ToInt64(Guid.NewGuid().ToByteArray(), 0);

private const int StackLimit = 256;

public override bool Equals(RedisValue x, RedisValue y)
{
if (x.IsNull || y.IsNull) return x.IsNull && y.IsNull;

// byte-backed on both sides: the bytes are already there, so no copy is needed
if (IsBlob(x.Type) && IsBlob(y.Type)) return BlobSequenceEqual(x, y);

// A string against a contiguous blob is the case worth caring about: encode the string a
// chunk at a time straight onto the blob's own bytes, so a mismatch near the front stops
// there instead of after both sides have been written out in full. Deliberately ahead of
// any length check - measuring the string's UTF8 length means walking all of it, which
// costs more than the comparison usually does.
if (x.Type == StorageType.String && IsContiguousBlob(y.Type)) return StringEqualsBytes(x.RawString(), y.UnsafeRawSpan(out _));
if (y.Type == StorageType.String && IsContiguousBlob(x.Type)) return StringEqualsBytes(y.RawString(), x.UnsafeRawSpan(out _));

int length = x.GetByteCount();
if (length != y.GetByteCount()) return false;
if (length == 0) return true;

byte[]? leasedX = null, leasedY = null;
Span<byte> bytesX = length <= StackLimit ? stackalloc byte[StackLimit] : (leasedX = ArrayPool<byte>.Shared.Rent(length));
Span<byte> bytesY = length <= StackLimit ? stackalloc byte[StackLimit] : (leasedY = ArrayPool<byte>.Shared.Rent(length));

x.CopyTo(bytesX);
y.CopyTo(bytesY);
bool equal = bytesX.Slice(0, length).SequenceEqual(bytesY.Slice(0, length));

if (leasedX is not null) ArrayPool<byte>.Shared.Return(leasedX);
if (leasedY is not null) ArrayPool<byte>.Shared.Return(leasedY);
return equal;
}

private static bool IsContiguousBlob(StorageType type)
=> type is StorageType.ByteArray or StorageType.MemoryManager or StorageType.ShortBlob;

/// <summary>
/// Compares a string's UTF-8 form against bytes, encoding it a chunk at a time so that a
/// mismatch costs only the chunk that contains it.
/// </summary>
private static bool StringEqualsBytes(string s, scoped ReadOnlySpan<byte> utf8)
{
const int ChunkChars = 512;
Span<byte> buffer = stackalloc byte[ChunkChars * MaxUtf8BytesPerChar];

var chars = s.AsSpan();
while (!chars.IsEmpty)
{
var take = Math.Min(ChunkChars, chars.Length);

// never split a surrogate pair: the encoder would emit U+FFFD for each half, which is
// not what encoding the whole string would have produced
if (take < chars.Length && char.IsHighSurrogate(chars[take - 1])) take++;

var written = Encoding.UTF8.GetBytes(chars.Slice(0, take), buffer);
if (written > utf8.Length || !buffer.Slice(0, written).SequenceEqual(utf8.Slice(0, written))) return false;

chars = chars.Slice(take);
utf8 = utf8.Slice(written);
}
return utf8.IsEmpty;
}

/// <summary>Worst case UTF-8 bytes for a single char (a lone surrogate becomes U+FFFD).</summary>
private const int MaxUtf8BytesPerChar = 3;

public override int GetHashCode(RedisValue obj)
{
if (obj.IsNull) return -1;

switch (obj.Type)
{
case StorageType.ByteArray or StorageType.MemoryManager or StorageType.ShortBlob:
return Fold(XxHash3.HashToUInt64(obj.UnsafeRawSpan(out _), Seed));
case StorageType.Sequence:
var sequence = obj.RawSequence();
if (sequence.IsSingleSegment) return Fold(XxHash3.HashToUInt64(sequence.First.Span, Seed));
break; // multi-segment: fall through to the copy below rather than streaming
}

int length = obj.GetByteCount();
if (length == 0) return 0;

byte[]? leased = null;
Span<byte> bytes = length <= StackLimit ? stackalloc byte[StackLimit] : (leased = ArrayPool<byte>.Shared.Rent(length));
obj.CopyTo(bytes);
var hash = Fold(XxHash3.HashToUInt64(bytes.Slice(0, length), Seed));
if (leased is not null) ArrayPool<byte>.Shared.Return(leased);
return hash;
}

private static int Fold(ulong hash) => unchecked((int)hash ^ (int)(hash >> 32));
}
}
}
}
2 changes: 1 addition & 1 deletion src/StackExchange.Redis/RedisValue.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ namespace StackExchange.Redis
/// Represents values that can be stored in redis.
/// </summary>
[StructLayout(LayoutKind.Explicit)]
public readonly struct RedisValue : IEquatable<RedisValue>, IComparable<RedisValue>, IComparable, IConvertible
public readonly partial struct RedisValue : IEquatable<RedisValue>, IComparable<RedisValue>, IComparable, IConvertible
{
// Maximum payload that fits in an inline short-blob (packed into the overlapped int64 field).
internal const int MaxInlineBytes = sizeof(long);
Expand Down
1 change: 1 addition & 0 deletions src/StackExchange.Redis/StackExchange.Redis.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@

<Compile Update="BitFieldOperation.*.cs" DependentUpon="BitFieldOperation.cs" />
<Compile Update="HotKeys.*.cs" DependentUpon="HotKeys.cs" />
<Compile Update="RedisValue.*.cs" DependentUpon="RedisValue.cs" />
</ItemGroup>

<ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,18 @@ public void Setup()
[Benchmark]
public int HashString() => _string.GetHashCode();

/// <summary>The opt-in byte comparer on the same mixed case: no decode at all.</summary>
[Benchmark]
public bool BinaryStringVsByteArray() => RedisValue.EqualityComparer.Binary.Equals(_string, _byteArray);

/// <summary>The opt-in byte comparer, blob against blob.</summary>
[Benchmark]
public bool BinaryBlobVsBlob() => RedisValue.EqualityComparer.Binary.Equals(_other, _byteArray);

/// <summary>Hashing a blob through the byte comparer: raw bytes, never decoded.</summary>
[Benchmark]
public int BinaryHashBlob() => RedisValue.EqualityComparer.Binary.GetHashCode(_byteArray);

private sealed class Segment : ReadOnlySequenceSegment<byte>
{
public Segment(ReadOnlyMemory<byte> value, Segment? head)
Expand Down
134 changes: 134 additions & 0 deletions tests/StackExchange.Redis.Tests/RedisValueEquivalencyTests.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
using System;
using System.Buffers;
using System.Collections;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Text;
using Xunit;
Expand Down Expand Up @@ -766,4 +768,136 @@ public EquivalenceSegment(ReadOnlyMemory<byte> value, EquivalenceSegment? head)
}
}
}

[Fact]
public void DefaultComparer_MatchesTheTypesOwnEquality()
{
var comparer = RedisValue.EqualityComparer.Default;
foreach (var blob in EquivalenceBlobs())
{
RedisValue asBlob = blob;
foreach (var s in EquivalenceStrings())
{
RedisValue asString = s;
var because = $"'{Escape(s)}' vs {BitConverter.ToString(blob)}";

Assert.True((asString == asBlob) == comparer.Equals(asString, asBlob), because);
Assert.Equal(asString.GetHashCode(), comparer.GetHashCode(asString));
Assert.Equal(asBlob.GetHashCode(), comparer.GetHashCode(asBlob));
}
}
}

[Fact]
public void BinaryComparer_AgreesWithDefaultOnWellFormedText()
{
var binary = RedisValue.EqualityComparer.Binary;
foreach (var blob in EquivalenceBlobs())
{
RedisValue asBlob = blob;
foreach (var s in EquivalenceStrings())
{
// The two readings may differ exactly where UTF8 does not round-trip, and either side can be
// the culprit: a string holding an unpaired surrogate, or a blob that is not canonical UTF8
// (0xFF decodes to U+FFFD but re-encodes to EF BF BD). Everywhere else they must agree.
if (Encoding.UTF8.GetString(Encoding.UTF8.GetBytes(s)) != s) continue;
if (!Encoding.UTF8.GetBytes(Encoding.UTF8.GetString(blob)).AsSpan().SequenceEqual(blob)) continue;

RedisValue asString = s;
Assert.True(
(asString == asBlob) == binary.Equals(asString, asBlob),
$"'{Escape(s)}' vs {BitConverter.ToString(blob)}");
}
}
}

[Fact]
public void BinaryComparer_DivergesOnlyWhereUtf8DoesNotRoundTrip()
{
var binary = RedisValue.EqualityComparer.Binary;

// a lone surrogate encodes to the same bytes as U+FFFD, so Binary calls them equal and the default
// does not - this is the documented divergence, pinned so it cannot drift silently
RedisValue loneSurrogate = new string([(char)0xD800]);
RedisValue replacement = "�";

Assert.False(loneSurrogate == replacement);
Assert.True(binary.Equals(loneSurrogate, replacement));

// and Binary stays self-consistent about it: equal means same hash
Assert.Equal(binary.GetHashCode(loneSurrogate), binary.GetHashCode(replacement));

// the other direction, where the *blob* is not canonical UTF8: 0xFF decodes to U+FFFD, so the default
// calls it equal to that text, while Binary sees FF against EF BF BD and does not
RedisValue invalidBlob = new byte[] { 0xFF };
Assert.True(replacement == invalidBlob);
Assert.False(binary.Equals(replacement, invalidBlob));
}

[Fact]
public void BinaryComparer_EqualValuesShareHashCodes()
{
var binary = RedisValue.EqualityComparer.Binary;
foreach (var blob in EquivalenceBlobs())
{
RedisValue asBlob = blob;
RedisValue asSegmented = blob.Length >= 2 ? Segmented(blob, blob.Length / 2) : asBlob;

foreach (var s in EquivalenceStrings())
{
RedisValue asString = s;
if (binary.Equals(asString, asBlob))
{
Assert.True(
binary.GetHashCode(asString) == binary.GetHashCode(asBlob),
$"equal under Binary must share a hash: '{Escape(s)}' vs {BitConverter.ToString(blob)}");
}
}

// representation must not matter: the same bytes, contiguous or segmented
Assert.True(binary.Equals(asBlob, asSegmented));
Assert.Equal(binary.GetHashCode(asBlob), binary.GetHashCode(asSegmented));
}
}

[Theory]
[InlineData("abc")]
[InlineData("42")]
public void Comparers_UntypedApiAcceptsWhateverRedisValueAccepts(string value)
{
foreach (var comparer in new[] { RedisValue.EqualityComparer.Default, RedisValue.EqualityComparer.Binary })
{
var untyped = (IEqualityComparer)comparer;
object asString = value;
object asBytes = Encoding.UTF8.GetBytes(value);
object asRedisValue = (RedisValue)value;

Assert.True(untyped.Equals(asString, asBytes));
Assert.True(untyped.Equals(asString, asRedisValue));
Assert.True(untyped.Equals(asBytes, asRedisValue));

Assert.Equal(untyped.GetHashCode(asString), untyped.GetHashCode(asBytes));
Assert.Equal(untyped.GetHashCode(asString), untyped.GetHashCode(asRedisValue));

// something it cannot read is not equal to anything, but is still reflexive and stable
object unreadable = new object();
Assert.False(untyped.Equals(unreadable, asString));
Assert.True(untyped.Equals(unreadable, unreadable));
Assert.Equal(unreadable.GetHashCode(), untyped.GetHashCode(unreadable));
}
}

[Fact]
public void BinaryComparer_WorksAsADictionaryComparer()
{
var dictionary = new Dictionary<RedisValue, string>(RedisValue.EqualityComparer.Binary)
{
{ "alpha", "one" },
{ Encoding.UTF8.GetBytes("beta"), "two" },
};

Assert.Equal("one", dictionary[Encoding.UTF8.GetBytes("alpha")]);
Assert.Equal("two", dictionary["beta"]);
Assert.False(dictionary.ContainsKey("gamma"));
}
}
Loading