Skip to content
Merged
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
16 changes: 15 additions & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<!-- General information -->
<PropertyGroup>
<Authors>Ugo Lattanzi</Authors>
<VersionPrefix>13.0.0</VersionPrefix>
<VersionPrefix>13.0.1</VersionPrefix>
<!--
<VersionSuffix>pre</VersionSuffix>
-->
Expand Down Expand Up @@ -50,6 +50,20 @@ Features:
Serializer packages (pick one): System.Text.Json, Newtonsoft, MemoryPack, MsgPack, Protobuf, ServiceStack, Utf8Json.
</Description>
<PackageReleaseNotes>
v13.0.1:
- Performance: connection pool selection no longer allocates multiple ServerCounters snapshots per operation (LeastLoaded) and uses a true lock-free round-robin instead of a cryptographic RNG (RoundRobin)
- Performance: RedisDatabase instances are cached per database number instead of being allocated on every Db0..Db16 access
- Performance: HashGetAsync multi-key now issues a single HMGET instead of N parallel HGET round-trips
- Performance: UpdateExpiryAsync no longer issues a redundant EXISTS before EXPIRE (half the round-trips)
- Performance: AddAllAsync builds its payload without LINQ intermediate materializations
- Performance: eager materialization of previously lazy IEnumerable results (SetPopAsync, SortedSetRangeByScoreAsync, HashKeysAsync, SearchKeysAsync) — no more re-deserialization on repeated enumeration
- Performance: serializers (Newtonsoft, ServiceStack, MsgPack, Protobuf) no longer materialize intermediate UTF-16 strings or wrapping MemoryStreams
- Performance: compressors use pooled buffers (Snappier, Brotli, ZstdSharp) and reused Zstd contexts; Snappier.Decompress no longer duplicates the output array
- Performance: IDistributedCache adapter uses cached field arrays and batches HSET+EXPIRE in a single network write
- Fixed HashGetAllAsyncAtOneTimeAsync applying KeyPrefix to hash fields instead of the hash key, and removed a potential Lua injection through the hash key
- Fixed Utf8JsonSerializer.Deserialize throwing on null input instead of returning default
- Fixed ListGetFromRightAsync duplicated null check (dead code)

v13.0.0:
- Added HyperLogLog API: HyperLogLogAddAsync, HyperLogLogLengthAsync, HyperLogLogMergeAsync (#637)
- Added Distributed Lock API: LockTakeAsync, LockReleaseAsync, LockExtendAsync, LockQueryAsync, LockAcquireAsync with IAsyncDisposable auto-release (#638)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ internal sealed class RedisDistributedCache : IDistributedCache
private static readonly RedisValue AbsoluteExpirationField = "absexp";
private static readonly RedisValue SlidingExpirationField = "sldexp";

// Cached field arrays: a collection expression at the call-site would allocate a new array on every Get/Refresh.
// SE.Redis never mutates the array (same pattern used by Microsoft.Extensions.Caching.StackExchangeRedis).
private static readonly RedisValue[] AllFields = [DataField, AbsoluteExpirationField, SlidingExpirationField];
private static readonly RedisValue[] MetadataFields = [AbsoluteExpirationField, SlidingExpirationField];

private readonly IDatabase db;

/// <summary>
Expand Down Expand Up @@ -110,12 +115,22 @@ public async Task SetAsync(string key, byte[] value, DistributedCacheEntryOption
new(SlidingExpirationField, slidingTicks),
};

await db.HashSetAsync(key, fields).ConfigureAwait(false);

var expiry = GetExpirationTimeout(absoluteExpiration, options.SlidingExpiration);

if (expiry.HasValue)
await db.KeyExpireAsync(key, expiry.Value).ConfigureAwait(false);
{
// A batch flushes HSET + EXPIRE in a single network write instead of two sequential round-trips.
var batch = db.CreateBatch();
var setTask = batch.HashSetAsync(key, fields);
var expireTask = batch.KeyExpireAsync(key, expiry.Value);
batch.Execute();

await Task.WhenAll(setTask, expireTask).ConfigureAwait(false);
}
else
{
await db.HashSetAsync(key, fields).ConfigureAwait(false);
}
}

/// <inheritdoc/>
Expand Down Expand Up @@ -163,9 +178,9 @@ public async Task RefreshAsync(string key, CancellationToken token = default)
RedisValue[] results;

if (getData)
results = db.HashGet(key, [DataField, AbsoluteExpirationField, SlidingExpirationField]);
results = db.HashGet(key, AllFields);
else
results = db.HashGet(key, [AbsoluteExpirationField, SlidingExpirationField]);
results = db.HashGet(key, MetadataFields);

if (results[0].IsNull)
return null;
Expand All @@ -181,9 +196,9 @@ public async Task RefreshAsync(string key, CancellationToken token = default)
RedisValue[] results;

if (getData)
results = await db.HashGetAsync(key, [DataField, AbsoluteExpirationField, SlidingExpirationField]).ConfigureAwait(false);
results = await db.HashGetAsync(key, AllFields).ConfigureAwait(false);
else
results = await db.HashGetAsync(key, [AbsoluteExpirationField, SlidingExpirationField]).ConfigureAwait(false);
results = await db.HashGetAsync(key, MetadataFields).ConfigureAwait(false);

if (results[0].IsNull)
return null;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
// Copyright (c) Ugo Lattanzi. All Rights Reserved. Licensed under the MIT license. See License.txt in the project root for license information.

using System;
using System.Buffers;
using System.IO;
using System.IO.Compression;

Expand All @@ -11,6 +13,8 @@ namespace StackExchange.Redis.Extensions.Core;
/// </summary>
public class BrotliCompressor : ICompressor
{
private const int BrotliDefaultWindow = 22;

private readonly CompressionLevel compressionLevel;

/// <summary>
Expand All @@ -25,23 +29,44 @@ public BrotliCompressor(CompressionLevel compressionLevel = CompressionLevel.Fas
/// <inheritdoc/>
public byte[] Compress(byte[] data)
{
using var output = new MemoryStream();
// The one-shot encoder avoids the BrotliStream state machine and the MemoryStream growth copies:
// a single rented worst-case buffer plus the exact-size result array.
var maxLength = BrotliEncoder.GetMaxCompressedLength(data.Length);
var buffer = ArrayPool<byte>.Shared.Rent(maxLength);

using (var brotli = new BrotliStream(output, compressionLevel))
brotli.Write(data, 0, data.Length);
try
{
if (!BrotliEncoder.TryCompress(data, buffer, out var written, GetQuality(compressionLevel), BrotliDefaultWindow))
throw new InvalidOperationException("Brotli compression failed.");

return output.ToArray();
return buffer.AsSpan(0, written).ToArray();
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}

/// <inheritdoc/>
public byte[] Decompress(byte[] compressedData)
{
using var input = new MemoryStream(compressedData);
using var brotli = new BrotliStream(input, CompressionMode.Decompress);
using var output = new MemoryStream();

// Pre-sized with a typical-ratio heuristic to avoid the growth copies of an empty MemoryStream.
using var output = new MemoryStream(compressedData.Length * 4);

brotli.CopyTo(output);

return output.ToArray();
}

// Same CompressionLevel-to-quality mapping the runtime uses internally for BrotliStream.
private static int GetQuality(CompressionLevel level) => level switch
{
CompressionLevel.NoCompression => 0,
CompressionLevel.Fastest => 1,
CompressionLevel.SmallestSize => 11,
_ => 4,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ public GZipCompressor(CompressionLevel compressionLevel = CompressionLevel.Faste
/// <inheritdoc/>
public byte[] Compress(byte[] data)
{
using var output = new MemoryStream();
// Pre-sized to avoid the growth copies of an empty MemoryStream (the +64 covers the GZip header on tiny payloads).
using var output = new MemoryStream((data.Length / 2) + 64);

using (var gzip = new GZipStream(output, compressionLevel))
gzip.Write(data, 0, data.Length);
Expand All @@ -38,7 +39,9 @@ public byte[] Decompress(byte[] compressedData)
{
using var input = new MemoryStream(compressedData);
using var gzip = new GZipStream(input, CompressionMode.Decompress);
using var output = new MemoryStream();

// Pre-sized with a typical-ratio heuristic to avoid the growth copies of an empty MemoryStream.
using var output = new MemoryStream(compressedData.Length * 3);

gzip.CopyTo(output);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c) Ugo Lattanzi. All Rights Reserved. Licensed under the MIT license. See License.txt in the project root for license information.

using System;
using System.Buffers;

using Snappier;

Expand All @@ -15,11 +16,20 @@ public class SnappierCompressor : ICompressor
/// <inheritdoc/>
public byte[] Compress(byte[] data)
{
// The worst-case buffer (~1.17x input) is only transient: renting it avoids a heap (or LOH) allocation per call.
var maxLength = Snappy.GetMaxCompressedLength(data.Length);
var buffer = new byte[maxLength];
var compressedLength = Snappy.Compress(data, buffer);
var buffer = ArrayPool<byte>.Shared.Rent(maxLength);

return buffer.AsSpan(0, compressedLength).ToArray();
try
{
var compressedLength = Snappy.Compress(data, buffer);

return buffer.AsSpan(0, compressedLength).ToArray();
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}

/// <inheritdoc/>
Expand All @@ -29,6 +39,8 @@ public byte[] Decompress(byte[] compressedData)
var buffer = new byte[decompressedLength];
var actualLength = Snappy.Decompress(compressedData, buffer);

return buffer.AsSpan(0, actualLength).ToArray();
// The header-declared length matches the actual output, so the buffer can be returned as-is
// instead of being copied a second time.
return actualLength == decompressedLength ? buffer : buffer.AsSpan(0, actualLength).ToArray();
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
// Copyright (c) Ugo Lattanzi. All Rights Reserved. Licensed under the MIT license. See License.txt in the project root for license information.

using System;
using System.Buffers;
using System.Threading;

using ZstdSharp;

namespace StackExchange.Redis.Extensions.Core;
Expand All @@ -10,30 +14,50 @@ namespace StackExchange.Redis.Extensions.Core;
/// </summary>
public class ZstdSharpCompressor : ICompressor
{
private readonly int compressionLevel;
// Zstd contexts are designed for reuse and are expensive to initialize, but they are not thread-safe:
// one context per thread replaces the previous new-context-per-call pattern.
private readonly ThreadLocal<Compressor> compressor;
private readonly ThreadLocal<Decompressor> decompressor = new(static () => new Decompressor());

/// <summary>
/// Initializes a new instance of the <see cref="ZstdSharpCompressor"/> class.
/// </summary>
/// <param name="compressionLevel">The Zstd compression level (1-22). Defaults to 3 (fast).</param>
public ZstdSharpCompressor(int compressionLevel = 3)
{
this.compressionLevel = compressionLevel;
compressor = new(() => new Compressor(compressionLevel));
}

/// <inheritdoc/>
public byte[] Compress(byte[] data)
{
using var compressor = new Compressor(compressionLevel);

return compressor.Wrap(data).ToArray();
var bound = Compressor.GetCompressBound(data.Length);
var buffer = ArrayPool<byte>.Shared.Rent(bound);

try
{
var written = compressor.Value!.Wrap(data, buffer);

return buffer.AsSpan(0, written).ToArray();
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}

/// <inheritdoc/>
public byte[] Decompress(byte[] compressedData)
{
using var decompressor = new Decompressor();
var size = Decompressor.GetDecompressedSize(compressedData);

// Frames produced by Wrap always carry the content size; the fallback covers foreign frames without it.
if (size == 0 || size > int.MaxValue)
return decompressor.Value!.Unwrap(compressedData).ToArray();

var result = new byte[(int)size];
var written = decompressor.Value!.Unwrap(compressedData, result);

return decompressor.Unwrap(compressedData).ToArray();
return written == result.Length ? result : result.AsSpan(0, written).ToArray();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,17 +48,6 @@ public static void EnumerateLines(this ReadOnlySpan<char> span, ref List<InfoDet
}
}

public static bool Any<T>(this ReadOnlySpan<T> span, Predicate<T> condition)
{
for (var i = 0; i < span.Length; i++)
{
if (condition(span[i]))
return true;
}

return false;
}

public static TResult[] ToFastArray<TSource, TResult>(this ReadOnlySpan<TSource> span, Func<TSource, TResult> action)
{
if (span.IsEmpty)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,23 @@ namespace StackExchange.Redis.Extensions.Core.Extensions;

internal static class ValueLengthExtensions
{
public static IEnumerable<KeyValuePair<string, byte[]>> OfValueInListSize<T>(this IEnumerable<Tuple<string, T>> items, ISerializer serializer, uint maxValueLength)
public static KeyValuePair<RedisKey, RedisValue>[] ToRedisEntries<T>(this Tuple<string, T>[] items, ISerializer serializer, uint maxValueLength)
{
using var iterator = items.GetEnumerator();
var result = new KeyValuePair<RedisKey, RedisValue>[items.Length];
var count = 0;

while (iterator.MoveNext())
foreach (var item in items)
{
if (iterator.Current != null)
{
yield return new(
iterator.Current.Item1,
iterator.Current.Item2.SerializeItem(serializer).CheckLength(maxValueLength, iterator.Current.Item1));
}
if (item == null)
continue;

result[count++] = new(item.Item1, item.Item2.SerializeItem(serializer).CheckLength(maxValueLength, item.Item1));
}

if (count != result.Length)
Array.Resize(ref result, count);

return result;
}

public static byte[] OfValueSize<T>(this T? value, ISerializer serializer, uint maxValueLength, string key)
Expand All @@ -43,33 +47,4 @@ private static byte[] CheckLength(this byte[] byteArray, uint maxValueLength, st

return byteArray;
}

public static TSource MinBy<TSource, TKey>(
this IEnumerable<TSource> source,
Func<TSource, TKey> selector,
IComparer<TKey>? comparer = null)
{
comparer ??= Comparer<TKey>.Default;

using var sourceIterator = source.GetEnumerator();

if (!sourceIterator.MoveNext())
throw new InvalidOperationException("Sequence contains no elements");

var min = sourceIterator.Current;
var minKey = selector(min);

while (sourceIterator.MoveNext())
{
var candidate = sourceIterator.Current;
var candidateProjected = selector(candidate);
if (comparer.Compare(candidateProjected, minKey) < 0)
{
min = candidate;
minKey = candidateProjected;
}
}

return min;
}
}
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
using System;
using System.Diagnostics.CodeAnalysis;

using StackExchange.Redis.Extensions.Core.Extensions;

namespace StackExchange.Redis.Extensions.Core.Helpers;
internal static class ExceptionThrowHelper
{
public static void ThrowIfExistsNullElement<T>(ReadOnlySpan<T> argument, string paramName)
{
if (argument.Any(x => x is null))
ThrowNullElementException(paramName);
foreach (var item in argument)
{
if (item is null)
ThrowNullElementException(paramName);
}
}

[DoesNotReturn]
Expand Down
Loading
Loading