diff --git a/benchmarks/CacheTower.Benchmarks/Providers/CacheLayerComparisonBenchmark.cs b/benchmarks/CacheTower.Benchmarks/Providers/CacheLayerComparisonBenchmark.cs index acb033e0..7b953878 100644 --- a/benchmarks/CacheTower.Benchmarks/Providers/CacheLayerComparisonBenchmark.cs +++ b/benchmarks/CacheTower.Benchmarks/Providers/CacheLayerComparisonBenchmark.cs @@ -125,7 +125,7 @@ public async Task MemoryCacheLayer() public async Task JsonFileCacheLayer() { var directoryPath = "CacheLayerComparison/NewtonsoftJson"; - await using (var cacheLayer = new FileCacheLayer(NewtonsoftJsonCacheSerializer.Instance, directoryPath)) + await using (var cacheLayer = new FileCacheLayer(new FileCacheLayerOptions(directoryPath, NewtonsoftJsonCacheSerializer.Instance))) { await BenchmarkWork(cacheLayer); } @@ -136,7 +136,7 @@ public async Task JsonFileCacheLayer() public async Task ProtobufFileCacheLayer() { var directoryPath = "CacheLayerComparison/Protobuf"; - await using (var cacheLayer = new FileCacheLayer(ProtobufCacheSerializer.Instance, directoryPath)) + await using (var cacheLayer = new FileCacheLayer(new FileCacheLayerOptions(directoryPath, ProtobufCacheSerializer.Instance))) { await BenchmarkWork(cacheLayer); } diff --git a/benchmarks/CacheTower.Benchmarks/Providers/FileSystem/NewtonsoftJsonFileCacheBenchmark.cs b/benchmarks/CacheTower.Benchmarks/Providers/FileSystem/NewtonsoftJsonFileCacheBenchmark.cs index bc808a6f..ff57f377 100644 --- a/benchmarks/CacheTower.Benchmarks/Providers/FileSystem/NewtonsoftJsonFileCacheBenchmark.cs +++ b/benchmarks/CacheTower.Benchmarks/Providers/FileSystem/NewtonsoftJsonFileCacheBenchmark.cs @@ -10,7 +10,7 @@ public class NewtonsoftJsonFileCacheBenchmark : BaseFileCacheLayerBenchmark public void Setup() { DirectoryPath = "FileCache/NewtonsoftJson"; - CacheLayerProvider = () => new FileCacheLayer(NewtonsoftJsonCacheSerializer.Instance, DirectoryPath); + CacheLayerProvider = () => new FileCacheLayer(new(DirectoryPath, NewtonsoftJsonCacheSerializer.Instance)); } } } diff --git a/benchmarks/CacheTower.Benchmarks/Providers/FileSystem/ProtobufFileCacheBenchmark.cs b/benchmarks/CacheTower.Benchmarks/Providers/FileSystem/ProtobufFileCacheBenchmark.cs index 12acd977..53148c58 100644 --- a/benchmarks/CacheTower.Benchmarks/Providers/FileSystem/ProtobufFileCacheBenchmark.cs +++ b/benchmarks/CacheTower.Benchmarks/Providers/FileSystem/ProtobufFileCacheBenchmark.cs @@ -10,7 +10,7 @@ public class ProtobufFileCacheBenchmark : BaseFileCacheLayerBenchmark public void Setup() { DirectoryPath = "FileCache/Protobuf"; - CacheLayerProvider = () => new FileCacheLayer(ProtobufCacheSerializer.Instance, DirectoryPath); + CacheLayerProvider = () => new FileCacheLayer(new(DirectoryPath, ProtobufCacheSerializer.Instance)); } } } diff --git a/src/CacheTower.Providers.FileSystem.Json/JsonFileCacheLayer.cs b/src/CacheTower.Providers.FileSystem.Json/JsonFileCacheLayer.cs index 9733d7ff..c6dbe97c 100644 --- a/src/CacheTower.Providers.FileSystem.Json/JsonFileCacheLayer.cs +++ b/src/CacheTower.Providers.FileSystem.Json/JsonFileCacheLayer.cs @@ -1,7 +1,4 @@ -using System.IO; -using System.Text; -using Newtonsoft.Json; -using CacheTower.Serializers.NewtonsoftJson; +using CacheTower.Serializers.NewtonsoftJson; using System; namespace CacheTower.Providers.FileSystem.Json @@ -17,6 +14,6 @@ public class JsonFileCacheLayer : FileCacheLayer, ICacheLayer /// Creates a , using the given as the location to store the cache. /// /// - public JsonFileCacheLayer(string directoryPath) : base(NewtonsoftJsonCacheSerializer.Instance, directoryPath) { } + public JsonFileCacheLayer(string directoryPath) : base(new FileCacheLayerOptions(directoryPath, NewtonsoftJsonCacheSerializer.Instance)) { } } } diff --git a/src/CacheTower.Providers.FileSystem.Protobuf/ProtobufFileCacheLayer.cs b/src/CacheTower.Providers.FileSystem.Protobuf/ProtobufFileCacheLayer.cs index 80fcd8cc..289819af 100644 --- a/src/CacheTower.Providers.FileSystem.Protobuf/ProtobufFileCacheLayer.cs +++ b/src/CacheTower.Providers.FileSystem.Protobuf/ProtobufFileCacheLayer.cs @@ -18,6 +18,6 @@ public class ProtobufFileCacheLayer : FileCacheLayer /// Creates a , using the given as the location to store the cache. /// /// - public ProtobufFileCacheLayer(string directoryPath) : base(ProtobufCacheSerializer.Instance, directoryPath) { } + public ProtobufFileCacheLayer(string directoryPath) : base(new FileCacheLayerOptions(directoryPath, ProtobufCacheSerializer.Instance)) { } } } diff --git a/src/CacheTower/Internal/MD5HashUtility.cs b/src/CacheTower/Internal/MD5HashUtility.cs new file mode 100644 index 00000000..ff33d6b2 --- /dev/null +++ b/src/CacheTower/Internal/MD5HashUtility.cs @@ -0,0 +1,57 @@ +using System; +using System.Security.Cryptography; +using System.Text; + +namespace CacheTower.Internal; + +internal static class MD5HashUtility +{ + [ThreadStatic] + private static MD5? ThreadInstance; + + private static MD5 HashAlgorithm => ThreadInstance ??= MD5.Create(); + +#if NETSTANDARD2_0 + public static unsafe string ComputeHash(string value) + { + var bytes = Encoding.UTF8.GetBytes(value); + var hashBytes = HashAlgorithm.ComputeHash(bytes); + +#elif NETSTANDARD2_1 + public static unsafe string ComputeHash(ReadOnlySpan value) + { + var encoding = Encoding.UTF8; + var bytesRequired = encoding.GetByteCount(value); + Span bytes = stackalloc byte[bytesRequired]; + encoding.GetBytes(value, bytes); + + Span hashBytes = stackalloc byte[16]; + HashAlgorithm.TryComputeHash(bytes, hashBytes, out var _); +#endif + + //Based on byte conversion implementation in BitConverter (but with the dash stripped) + //https://github.com/dotnet/coreclr/blob/fbc11ea6afdaa2fe7b9377446d6bb0bd447d5cb5/src/mscorlib/shared/System/BitConverter.cs#L409-L440 + static char GetHexValue(int i) + { + if (i < 10) + { + return (char)(i + '0'); + } + + return (char)(i - 10 + 'A'); + } + + const int charArrayLength = 32; + var charArrayPtr = stackalloc char[charArrayLength]; + + var charPtr = charArrayPtr; + for (var i = 0; i < 16; i++) + { + var hashByte = hashBytes[i]; + *charPtr++ = GetHexValue(hashByte >> 4); + *charPtr++ = GetHexValue(hashByte & 0xF); + } + + return new string(charArrayPtr, 0, charArrayLength); + } +} diff --git a/src/CacheTower/Providers/FileSystem/FileCacheLayer.cs b/src/CacheTower/Providers/FileSystem/FileCacheLayer.cs index ff27d14f..9d841c5c 100644 --- a/src/CacheTower/Providers/FileSystem/FileCacheLayer.cs +++ b/src/CacheTower/Providers/FileSystem/FileCacheLayer.cs @@ -1,10 +1,9 @@ using System; using System.Collections.Concurrent; using System.IO; -using System.Security.Cryptography; -using System.Text; using System.Threading; using System.Threading.Tasks; +using CacheTower.Internal; using Nito.AsyncEx; namespace CacheTower.Providers.FileSystem @@ -17,30 +16,45 @@ namespace CacheTower.Providers.FileSystem public class FileCacheLayer : ICacheLayer, IAsyncDisposable { private bool Disposed = false; + private readonly FileCacheLayerOptions Options; + private readonly string ManifestPath; - private ICacheSerializer Serializer { get; } - private string DirectoryPath { get; } - private string ManifestPath { get; } + private readonly CancellationTokenSource ManifestSavingCancellationTokenSource; + private readonly Task BackgroundManifestSavingTask; - private SemaphoreSlim ManifestLock { get; } = new SemaphoreSlim(1, 1); - private bool? IsManifestAvailable { get; set; } + private readonly SemaphoreSlim ManifestLock = new(1, 1); - private HashAlgorithm FileNameHashAlgorithm { get; } = MD5.Create(); + private bool? IsManifestAvailable { get; set; } private ConcurrentDictionary? CacheManifest { get; set; } - private ConcurrentDictionary FileLock { get; } + private readonly ConcurrentDictionary FileLock; /// - /// Initialises the file cache layer with the given . + /// Initialises the with the provided . /// - /// The serializer to use for the data. - /// The directory to store the cache. - public FileCacheLayer(ICacheSerializer serializer, string directoryPath) + /// Various options that control the behaviour of the . + public FileCacheLayer(FileCacheLayerOptions options) { - Serializer = serializer; - DirectoryPath = directoryPath ?? throw new ArgumentNullException(nameof(directoryPath)); - ManifestPath = Path.Combine(directoryPath, "manifest"); + Options = options; + ManifestPath = Path.Combine(options.DirectoryPath, "manifest"); FileLock = new ConcurrentDictionary(StringComparer.Ordinal); + + ManifestSavingCancellationTokenSource = new(); + BackgroundManifestSavingTask = BackgroundManifestSaving(); + } + + private async Task BackgroundManifestSaving() + { + try + { + var cancellationToken = ManifestSavingCancellationTokenSource.Token; + while (!cancellationToken.IsCancellationRequested) + { + await Task.Delay(Options.ManifestSaveInterval, cancellationToken); + await SaveManifestAsync(); + } + } + catch (OperationCanceledException) { } } private async Task DeserializeFileAsync(string path) @@ -49,14 +63,14 @@ public FileCacheLayer(ICacheSerializer serializer, string directoryPath) using var memStream = new MemoryStream((int)stream.Length); await stream.CopyToAsync(memStream); memStream.Seek(0, SeekOrigin.Begin); - return Serializer.Deserialize(memStream); + return Options.Serializer.Deserialize(memStream); } private async Task SerializeFileAsync(string path, T value) { using var stream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None, 1024); using var memStream = new MemoryStream(); - Serializer.Serialize(memStream, value); + Options.Serializer.Serialize(memStream, value); memStream.Seek(0, SeekOrigin.Begin); await memStream.CopyToAsync(stream); } @@ -78,9 +92,9 @@ private async Task TryLoadManifestAsync() } else { - if (!Directory.Exists(DirectoryPath)) + if (!Directory.Exists(Options.DirectoryPath)) { - Directory.CreateDirectory(DirectoryPath); + Directory.CreateDirectory(Options.DirectoryPath); } CacheManifest = new ConcurrentDictionary(); @@ -104,9 +118,9 @@ public async Task SaveManifestAsync() await ManifestLock.WaitAsync(); try { - if (!Directory.Exists(DirectoryPath)) + if (!Directory.Exists(Options.DirectoryPath)) { - Directory.CreateDirectory(DirectoryPath); + Directory.CreateDirectory(Options.DirectoryPath); } await SerializeFileAsync(ManifestPath, CacheManifest); @@ -117,56 +131,12 @@ public async Task SaveManifestAsync() } } -#if NETSTANDARD2_0 - private unsafe string GetFileName(string cacheKey) - { - var bytes = Encoding.UTF8.GetBytes(cacheKey); - var hashBytes = FileNameHashAlgorithm.ComputeHash(bytes); - -#elif NETSTANDARD2_1 - private unsafe string GetFileName(ReadOnlySpan cacheKey) - { - var encoding = Encoding.UTF8; - var bytesRequired = encoding.GetByteCount(cacheKey); - Span bytes = stackalloc byte[bytesRequired]; - encoding.GetBytes(cacheKey, bytes); - - Span hashBytes = stackalloc byte[16]; - FileNameHashAlgorithm.TryComputeHash(bytes, hashBytes, out var _); -#endif - - //Based on byte conversion implementation in BitConverter (but with the dash stripped) - //https://github.com/dotnet/coreclr/blob/fbc11ea6afdaa2fe7b9377446d6bb0bd447d5cb5/src/mscorlib/shared/System/BitConverter.cs#L409-L440 - static char GetHexValue(int i) - { - if (i < 10) - { - return (char)(i + '0'); - } - - return (char)(i - 10 + 'A'); - } - - var charArrayLength = 32; - var charArrayPtr = stackalloc char[charArrayLength]; - - var charPtr = charArrayPtr; - for (var i = 0; i < 16; i++) - { - var hashByte = hashBytes[i]; - *charPtr++ = GetHexValue(hashByte >> 4); - *charPtr++ = GetHexValue(hashByte & 0xF); - } - - return new string(charArrayPtr, 0, charArrayLength); - } - /// public async ValueTask CleanupAsync() { await TryLoadManifestAsync(); - var currentTime = DateTime.UtcNow; + var currentTime = DateTimeProvider.Now; foreach (var cachePair in CacheManifest!) { var manifestEntry = cachePair.Value; @@ -176,7 +146,7 @@ public async ValueTask CleanupAsync() { using (await lockObj.WriterLockAsync()) { - var path = Path.Combine(DirectoryPath, manifestEntry.FileName); + var path = Path.Combine(Options.DirectoryPath, manifestEntry.FileName); if (File.Exists(path)) { File.Delete(path); @@ -198,7 +168,7 @@ public async ValueTask EvictAsync(string cacheKey) { using (await lockObj.WriterLockAsync()) { - var path = Path.Combine(DirectoryPath, manifestEntry.FileName); + var path = Path.Combine(Options.DirectoryPath, manifestEntry.FileName); if (File.Exists(path)) { File.Delete(path); @@ -219,7 +189,7 @@ public async ValueTask FlushAsync() { using (await lockObj.WriterLockAsync()) { - var path = Path.Combine(DirectoryPath, manifestEntry.FileName); + var path = Path.Combine(Options.DirectoryPath, manifestEntry.FileName); if (File.Exists(path)) { File.Delete(path); @@ -246,7 +216,7 @@ public async ValueTask FlushAsync() //By the time we have the lock, confirm we still have a cache if (CacheManifest.ContainsKey(cacheKey)) { - var path = Path.Combine(DirectoryPath, manifestEntry.FileName); + var path = Path.Combine(Options.DirectoryPath, manifestEntry.FileName); if (File.Exists(path)) { var value = await DeserializeFileAsync(path); @@ -304,7 +274,7 @@ public async ValueTask SetAsync(string cacheKey, CacheEntry cacheEntry) else { manifestEntry = new( - GetFileName(cacheKey!), + MD5HashUtility.ComputeHash(cacheKey!), cacheEntry.Expiry ); } @@ -314,7 +284,7 @@ public async ValueTask SetAsync(string cacheKey, CacheEntry cacheEntry) using (await lockObj.WriterLockAsync()) { - var path = Path.Combine(DirectoryPath, manifestEntry.FileName); + var path = Path.Combine(Options.DirectoryPath, manifestEntry.FileName); await SerializeFileAsync(path, cacheEntry.Value); } } @@ -330,9 +300,14 @@ public async ValueTask DisposeAsync() return; } + ManifestSavingCancellationTokenSource.Cancel(); + if (!BackgroundManifestSavingTask.IsFaulted) + { + await BackgroundManifestSavingTask; + } await SaveManifestAsync(); + ManifestLock.Dispose(); - FileNameHashAlgorithm.Dispose(); Disposed = true; } diff --git a/src/CacheTower/Providers/FileSystem/FileCacheLayerOptions.cs b/src/CacheTower/Providers/FileSystem/FileCacheLayerOptions.cs new file mode 100644 index 00000000..fcde2bdc --- /dev/null +++ b/src/CacheTower/Providers/FileSystem/FileCacheLayerOptions.cs @@ -0,0 +1,39 @@ +using System; + +namespace CacheTower.Providers.FileSystem; + +/// +/// Options for controlling a . +/// +/// The directory to store the cache in. +/// The serializer to use for the data. +public record struct FileCacheLayerOptions( + string DirectoryPath, + ICacheSerializer Serializer +) +{ + /// + /// The default manifest save interval of 5 minutes. + /// + public static readonly TimeSpan DefaultManifestSaveInterval = TimeSpan.FromMinutes(5); + + /// + /// The time interval controlling how often the cache manifest is saved to disk. + /// + public TimeSpan ManifestSaveInterval { get; init; } = DefaultManifestSaveInterval; + + /// + /// Options for controlling a . + /// + /// The directory to store the cache in. + /// The serializer to use for the data. + /// The time interval controlling how often the cache manifest is saved to disk. + public FileCacheLayerOptions( + string directoryPath, + ICacheSerializer serializer, + TimeSpan manifestSaveInterval + ) : this(directoryPath, serializer) + { + ManifestSaveInterval = manifestSaveInterval; + } +} diff --git a/tests/CacheTower.Tests/Providers/FileSystem/FileCacheLayerTests.cs b/tests/CacheTower.Tests/Providers/FileSystem/FileCacheLayerTests.cs index 75ba87cb..8b35562b 100644 --- a/tests/CacheTower.Tests/Providers/FileSystem/FileCacheLayerTests.cs +++ b/tests/CacheTower.Tests/Providers/FileSystem/FileCacheLayerTests.cs @@ -41,7 +41,7 @@ public void Setup() public async Task CanLoadExistingManifest() { var testSerializer = new TestCacheSerializer(); - var cacheLayer = new FileCacheLayer(testSerializer, DirectoryPath); + var cacheLayer = new FileCacheLayer(new(DirectoryPath, testSerializer)); await using (cacheLayer) { //IsAvailableAsync triggers load of manifest which in turn creates it because it doesn't exist @@ -52,7 +52,7 @@ public async Task CanLoadExistingManifest() Assert.AreEqual(0, testSerializer.DeserializeCount); testSerializer = new TestCacheSerializer(); - cacheLayer = new FileCacheLayer(testSerializer, DirectoryPath); + cacheLayer = new FileCacheLayer(new(DirectoryPath, testSerializer)); await using (cacheLayer) { Assert.IsTrue(await cacheLayer.IsAvailableAsync("AnyKey")); @@ -64,48 +64,48 @@ public async Task CanLoadExistingManifest() [TestMethod] public async Task PersistentGetSetCache() { - await AssertPersistentGetSetCacheAsync(() => new FileCacheLayer(new NewtonsoftJsonCacheSerializer(), DirectoryPath)); + await AssertPersistentGetSetCacheAsync(() => new FileCacheLayer(new(DirectoryPath, NewtonsoftJsonCacheSerializer.Instance))); } [TestMethod] public async Task GetSetCache() { - await using var cacheLayer = new FileCacheLayer(new NewtonsoftJsonCacheSerializer(), DirectoryPath); + await using var cacheLayer = new FileCacheLayer(new(DirectoryPath, NewtonsoftJsonCacheSerializer.Instance)); await AssertGetSetCacheAsync(cacheLayer); } [TestMethod] public async Task IsCacheAvailable() { - await using var cacheLayer = new FileCacheLayer(new NewtonsoftJsonCacheSerializer(), DirectoryPath); + await using var cacheLayer = new FileCacheLayer(new(DirectoryPath, NewtonsoftJsonCacheSerializer.Instance)); await AssertCacheAvailabilityAsync(cacheLayer, true); } [TestMethod] public async Task EvictFromCache() { - await using var cacheLayer = new FileCacheLayer(new NewtonsoftJsonCacheSerializer(), DirectoryPath); + await using var cacheLayer = new FileCacheLayer(new(DirectoryPath, NewtonsoftJsonCacheSerializer.Instance)); await AssertCacheEvictionAsync(cacheLayer); } [TestMethod] public async Task FlushFromCache() { - await using var cacheLayer = new FileCacheLayer(new NewtonsoftJsonCacheSerializer(), DirectoryPath); + await using var cacheLayer = new FileCacheLayer(new(DirectoryPath, NewtonsoftJsonCacheSerializer.Instance)); await AssertCacheFlushAsync(cacheLayer); } [TestMethod] public async Task CacheCleanup() { - await using var cacheLayer = new FileCacheLayer(new NewtonsoftJsonCacheSerializer(), DirectoryPath); + await using var cacheLayer = new FileCacheLayer(new(DirectoryPath, NewtonsoftJsonCacheSerializer.Instance)); await AssertCacheCleanupAsync(cacheLayer); } [TestMethod] public async Task CachingComplexTypes() { - await using var cacheLayer = new FileCacheLayer(new NewtonsoftJsonCacheSerializer(), DirectoryPath); + await using var cacheLayer = new FileCacheLayer(new(DirectoryPath, NewtonsoftJsonCacheSerializer.Instance)); await AssertComplexTypeCachingAsync(cacheLayer); } }