|
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Buffers;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Caching.Hybrid.Internal;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Internal;
using Xunit.Abstractions;
namespace Microsoft.Extensions.Caching.Hybrid.Tests;
/// <summary>
/// Validate over-arching expectations of DC implementations, in particular behaviour re IBufferDistributedCache added for HybridCache.
/// </summary>
public abstract class DistributedCacheTests
{
protected DistributedCacheTests(ITestOutputHelper log)
{
Log = log;
}
protected ITestOutputHelper Log { get; }
protected abstract ValueTask ConfigureAsync(IServiceCollection services);
protected abstract bool CustomClockSupported { get; }
protected FakeTime Clock { get; } = new();
protected sealed class FakeTime : TimeProvider, ISystemClock
{
private DateTimeOffset _now = DateTimeOffset.UtcNow;
public void Reset() => _now = DateTimeOffset.UtcNow;
DateTimeOffset ISystemClock.UtcNow => _now;
public override DateTimeOffset GetUtcNow() => _now;
public void Add(TimeSpan delta) => _now += delta;
}
private async ValueTask<IServiceCollection> InitAsync()
{
Clock.Reset();
var services = new ServiceCollection();
services.AddSingleton<TimeProvider>(Clock);
services.AddSingleton<ISystemClock>(Clock);
await ConfigureAsync(services);
return services;
}
[Theory]
[InlineData(0)]
[InlineData(128)]
[InlineData(1024)]
[InlineData(16 * 1024)]
public async Task SimpleBufferRoundtrip(int size)
{
var cache = (await InitAsync()).BuildServiceProvider().GetService<IDistributedCache>();
if (cache is null)
{
Log.WriteLine("Cache is not available");
return; // inconclusive
}
var key = $"{Me()}:{size}";
cache.Remove(key);
Assert.Null(cache.Get(key));
var expected = new byte[size];
new Random().NextBytes(expected);
cache.Set(key, expected, _fiveMinutes);
var actual = cache.Get(key);
Assert.NotNull(actual);
Assert.True(expected.SequenceEqual(actual));
Log.WriteLine("Data validated");
if (CustomClockSupported)
{
Clock.Add(TimeSpan.FromMinutes(4));
actual = cache.Get(key);
Assert.NotNull(actual);
Assert.True(expected.SequenceEqual(actual));
Clock.Add(TimeSpan.FromMinutes(2));
actual = cache.Get(key);
Assert.Null(actual);
Log.WriteLine("Expiration validated");
}
else
{
Log.WriteLine("Expiration not validated - TimeProvider not supported");
}
}
[Theory]
[InlineData(0)]
[InlineData(128)]
[InlineData(1024)]
[InlineData(16 * 1024)]
public async Task SimpleBufferRoundtripAsync(int size)
{
var cache = (await InitAsync()).BuildServiceProvider().GetService<IDistributedCache>();
if (cache is null)
{
Log.WriteLine("Cache is not available");
return; // inconclusive
}
var key = $"{Me()}:{size}";
await cache.RemoveAsync(key);
Assert.Null(cache.Get(key));
var expected = new byte[size];
new Random().NextBytes(expected);
await cache.SetAsync(key, expected, _fiveMinutes);
var actual = await cache.GetAsync(key);
Assert.NotNull(actual);
Assert.True(expected.SequenceEqual(actual));
Log.WriteLine("Data validated");
if (CustomClockSupported)
{
Clock.Add(TimeSpan.FromMinutes(4));
actual = await cache.GetAsync(key);
Assert.NotNull(actual);
Assert.True(expected.SequenceEqual(actual));
Clock.Add(TimeSpan.FromMinutes(2));
actual = await cache.GetAsync(key);
Assert.Null(actual);
Log.WriteLine("Expiration validated");
}
else
{
Log.WriteLine("Expiration not validated - TimeProvider not supported");
}
}
public enum SequenceKind
{
FullArray,
PaddedArray,
CustomMemory,
MultiSegment,
}
[Theory]
[InlineData(0, SequenceKind.FullArray)]
[InlineData(128, SequenceKind.FullArray)]
[InlineData(1024, SequenceKind.FullArray)]
[InlineData(16 * 1024, SequenceKind.FullArray)]
[InlineData(0, SequenceKind.PaddedArray)]
[InlineData(128, SequenceKind.PaddedArray)]
[InlineData(1024, SequenceKind.PaddedArray)]
[InlineData(16 * 1024, SequenceKind.PaddedArray)]
[InlineData(0, SequenceKind.CustomMemory)]
[InlineData(128, SequenceKind.CustomMemory)]
[InlineData(1024, SequenceKind.CustomMemory)]
[InlineData(16 * 1024, SequenceKind.CustomMemory)]
[InlineData(0, SequenceKind.MultiSegment)]
[InlineData(128, SequenceKind.MultiSegment)]
[InlineData(1024, SequenceKind.MultiSegment)]
[InlineData(16 * 1024, SequenceKind.MultiSegment)]
public async Task ReadOnlySequenceBufferRoundtrip(int size, SequenceKind kind)
{
var cache = (await InitAsync()).BuildServiceProvider().GetService<IDistributedCache>() as IBufferDistributedCache;
if (cache is null)
{
Log.WriteLine("Cache is not available or does not support IBufferDistributedCache");
return; // inconclusive
}
var key = $"{Me()}:{size}/{kind}";
cache.Remove(key);
Assert.Null(cache.Get(key));
var payload = Invent(size, kind);
ReadOnlyMemory<byte> expected = payload.ToArray(); // simplify for testing
Assert.Equal(size, expected.Length);
cache.Set(key, payload, _fiveMinutes);
RecyclableArrayBufferWriter<byte> writer = RecyclableArrayBufferWriter<byte>.Create(int.MaxValue);
Assert.True(cache.TryGet(key, writer));
Assert.True(expected.Span.SequenceEqual(writer.GetCommittedMemory().Span));
writer.ResetInPlace();
Log.WriteLine("Data validated");
if (CustomClockSupported)
{
Clock.Add(TimeSpan.FromMinutes(4));
Assert.True(cache.TryGet(key, writer));
Assert.True(expected.Span.SequenceEqual(writer.GetCommittedMemory().Span));
writer.ResetInPlace();
Clock.Add(TimeSpan.FromMinutes(2));
Assert.False(cache.TryGet(key, writer));
Assert.Equal(0, writer.CommittedBytes);
Log.WriteLine("Expiration validated");
}
else
{
Log.WriteLine("Expiration not validated - TimeProvider not supported");
}
writer.Dispose(); // intentionally only recycle on success
}
[Theory]
[InlineData(0, SequenceKind.FullArray)]
[InlineData(128, SequenceKind.FullArray)]
[InlineData(1024, SequenceKind.FullArray)]
[InlineData(16 * 1024, SequenceKind.FullArray)]
[InlineData(0, SequenceKind.PaddedArray)]
[InlineData(128, SequenceKind.PaddedArray)]
[InlineData(1024, SequenceKind.PaddedArray)]
[InlineData(16 * 1024, SequenceKind.PaddedArray)]
[InlineData(0, SequenceKind.CustomMemory)]
[InlineData(128, SequenceKind.CustomMemory)]
[InlineData(1024, SequenceKind.CustomMemory)]
[InlineData(16 * 1024, SequenceKind.CustomMemory)]
[InlineData(0, SequenceKind.MultiSegment)]
[InlineData(128, SequenceKind.MultiSegment)]
[InlineData(1024, SequenceKind.MultiSegment)]
[InlineData(16 * 1024, SequenceKind.MultiSegment)]
public async Task ReadOnlySequenceBufferRoundtripAsync(int size, SequenceKind kind)
{
var cache = (await InitAsync()).BuildServiceProvider().GetService<IDistributedCache>() as IBufferDistributedCache;
if (cache is null)
{
Log.WriteLine("Cache is not available or does not support IBufferDistributedCache");
return; // inconclusive
}
var key = $"{Me()}:{size}/{kind}";
await cache.RemoveAsync(key);
Assert.Null(await cache.GetAsync(key));
var payload = Invent(size, kind);
ReadOnlyMemory<byte> expected = payload.ToArray(); // simplify for testing
Assert.Equal(size, expected.Length);
await cache.SetAsync(key, payload, _fiveMinutes);
RecyclableArrayBufferWriter<byte> writer = RecyclableArrayBufferWriter<byte>.Create(int.MaxValue);
Assert.True(await cache.TryGetAsync(key, writer));
Assert.True(expected.Span.SequenceEqual(writer.GetCommittedMemory().Span));
writer.ResetInPlace();
Log.WriteLine("Data validated");
if (CustomClockSupported)
{
Clock.Add(TimeSpan.FromMinutes(4));
Assert.True(await cache.TryGetAsync(key, writer));
Assert.True(expected.Span.SequenceEqual(writer.GetCommittedMemory().Span));
writer.ResetInPlace();
Clock.Add(TimeSpan.FromMinutes(2));
Assert.False(await cache.TryGetAsync(key, writer));
Assert.Equal(0, writer.CommittedBytes);
Log.WriteLine("Expiration validated");
}
else
{
Log.WriteLine("Expiration not validated - TimeProvider not supported");
}
writer.Dispose(); // intentionally only recycle on success
}
[System.Diagnostics.CodeAnalysis.SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope", Justification = "Not relevant for this test - no-op")]
private static ReadOnlySequence<byte> Invent(int size, SequenceKind kind)
{
var rand = new Random();
ReadOnlySequence<byte> payload;
switch (kind)
{
case SequenceKind.FullArray:
var arr = new byte[size];
rand.NextBytes(arr);
payload = new(arr);
break;
case SequenceKind.PaddedArray:
arr = new byte[size + 10];
rand.NextBytes(arr);
payload = new(arr, 5, arr.Length - 10);
break;
case SequenceKind.CustomMemory:
var mem = new CustomMemory(size, rand).Memory;
payload = new(mem);
break;
case SequenceKind.MultiSegment:
if (size == 0)
{
payload = default;
break;
}
if (size < 10)
{
throw new ArgumentException("small segments not considered"); // a pain to construct
}
CustomSegment first = new(10, rand, null); // we'll take the last 3 of this 10
CustomSegment second = new(size - 7, rand, first); // we'll take all of this one
CustomSegment third = new(10, rand, second); // we'll take the first 4 of this 10
payload = new(first, 7, third, 4);
break;
default:
throw new ArgumentOutOfRangeException(nameof(kind));
}
// now validate what we expect of that payload
Assert.Equal(size, payload.Length);
switch (kind)
{
case SequenceKind.CustomMemory or SequenceKind.MultiSegment when size == 0:
Assert.True(payload.IsSingleSegment);
Assert.True(MemoryMarshal.TryGetArray(payload.First, out _));
break;
case SequenceKind.MultiSegment:
Assert.False(payload.IsSingleSegment);
break;
case SequenceKind.CustomMemory:
Assert.True(payload.IsSingleSegment);
Assert.False(MemoryMarshal.TryGetArray(payload.First, out _));
break;
case SequenceKind.FullArray:
Assert.True(payload.IsSingleSegment);
Assert.True(MemoryMarshal.TryGetArray(payload.First, out var segment));
Assert.Equal(0, segment.Offset);
Assert.NotNull(segment.Array);
Assert.Equal(size, segment.Count);
Assert.Equal(size, segment.Array.Length);
break;
case SequenceKind.PaddedArray:
Assert.True(payload.IsSingleSegment);
Assert.True(MemoryMarshal.TryGetArray(payload.First, out segment));
Assert.NotEqual(0, segment.Offset);
Assert.NotNull(segment.Array);
Assert.Equal(size, segment.Count);
Assert.NotEqual(size, segment.Array.Length);
break;
}
return payload;
}
private class CustomSegment : ReadOnlySequenceSegment<byte>
{
public CustomSegment(int size, Random? rand, CustomSegment? previous)
{
var arr = new byte[size + 10];
rand?.NextBytes(arr);
Memory = new(arr, 5, arr.Length - 10);
if (previous is not null)
{
RunningIndex = previous.RunningIndex + previous.Memory.Length;
previous.Next = this;
}
}
}
private class CustomMemory : MemoryManager<byte>
{
private readonly byte[] _data;
public CustomMemory(int size, Random? rand = null)
{
_data = new byte[size + 10];
rand?.NextBytes(_data);
}
public override Span<byte> GetSpan() => new(_data, 5, _data.Length - 10);
public override MemoryHandle Pin(int elementIndex = 0) => throw new NotSupportedException();
public override void Unpin() => throw new NotSupportedException();
protected override void Dispose(bool disposing)
{
}
protected override bool TryGetArray(out ArraySegment<byte> segment)
{
segment = default;
return false;
}
}
private static readonly DistributedCacheEntryOptions _fiveMinutes
= new() { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5) };
protected static string Me([CallerMemberName] string caller = "") => caller;
}
|