Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

FileCacheLayer Overhaul #203

Merged
merged 3 commits into from
Jun 16, 2022
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
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand All @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
}
}
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -17,6 +14,6 @@ public class JsonFileCacheLayer : FileCacheLayer, ICacheLayer
/// Creates a <see cref="JsonFileCacheLayer"/>, using the given <paramref name="directoryPath"/> as the location to store the cache.
/// </summary>
/// <param name="directoryPath"></param>
public JsonFileCacheLayer(string directoryPath) : base(NewtonsoftJsonCacheSerializer.Instance, directoryPath) { }
public JsonFileCacheLayer(string directoryPath) : base(new FileCacheLayerOptions(directoryPath, NewtonsoftJsonCacheSerializer.Instance)) { }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,6 @@ public class ProtobufFileCacheLayer : FileCacheLayer
/// Creates a <see cref="ProtobufFileCacheLayer"/>, using the given <paramref name="directoryPath"/> as the location to store the cache.
/// </summary>
/// <param name="directoryPath"></param>
public ProtobufFileCacheLayer(string directoryPath) : base(ProtobufCacheSerializer.Instance, directoryPath) { }
public ProtobufFileCacheLayer(string directoryPath) : base(new FileCacheLayerOptions(directoryPath, ProtobufCacheSerializer.Instance)) { }
}
}
57 changes: 57 additions & 0 deletions src/CacheTower/Internal/MD5HashUtility.cs
Original file line number Diff line number Diff line change
@@ -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<char> value)
{
var encoding = Encoding.UTF8;
var bytesRequired = encoding.GetByteCount(value);
Span<byte> bytes = stackalloc byte[bytesRequired];
encoding.GetBytes(value, bytes);

Span<byte> 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);
}
}
123 changes: 49 additions & 74 deletions src/CacheTower/Providers/FileSystem/FileCacheLayer.cs
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<string?, ManifestEntry>? CacheManifest { get; set; }
private ConcurrentDictionary<string?, AsyncReaderWriterLock> FileLock { get; }
private readonly ConcurrentDictionary<string?, AsyncReaderWriterLock> FileLock;

/// <summary>
/// Initialises the file cache layer with the given <paramref name="directoryPath"/>.
/// Initialises the <see cref="FileCacheLayer"/> with the provided <paramref name="options"/>.
/// </summary>
/// <param name="serializer">The serializer to use for the data.</param>
/// <param name="directoryPath">The directory to store the cache.</param>
public FileCacheLayer(ICacheSerializer serializer, string directoryPath)
/// <param name="options">Various options that control the behaviour of the <see cref="FileCacheLayer"/>.</param>
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<string?, AsyncReaderWriterLock>(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<T?> DeserializeFileAsync<T>(string path)
Expand All @@ -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<T>(memStream);
return Options.Serializer.Deserialize<T>(memStream);
}

private async Task SerializeFileAsync<T>(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);
}
Expand All @@ -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<string?, ManifestEntry>();
Expand All @@ -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);
Expand All @@ -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<char> cacheKey)
{
var encoding = Encoding.UTF8;
var bytesRequired = encoding.GetByteCount(cacheKey);
Span<byte> bytes = stackalloc byte[bytesRequired];
encoding.GetBytes(cacheKey, bytes);

Span<byte> 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);
}

/// <inheritdoc/>
public async ValueTask CleanupAsync()
{
await TryLoadManifestAsync();

var currentTime = DateTime.UtcNow;
var currentTime = DateTimeProvider.Now;
foreach (var cachePair in CacheManifest!)
{
var manifestEntry = cachePair.Value;
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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<T>(path);
Expand Down Expand Up @@ -304,7 +274,7 @@ public async ValueTask SetAsync<T>(string cacheKey, CacheEntry<T> cacheEntry)
else
{
manifestEntry = new(
GetFileName(cacheKey!),
MD5HashUtility.ComputeHash(cacheKey!),
cacheEntry.Expiry
);
}
Expand All @@ -314,7 +284,7 @@ public async ValueTask SetAsync<T>(string cacheKey, CacheEntry<T> 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);
}
}
Expand All @@ -330,9 +300,14 @@ public async ValueTask DisposeAsync()
return;
}

ManifestSavingCancellationTokenSource.Cancel();
if (!BackgroundManifestSavingTask.IsFaulted)
{
await BackgroundManifestSavingTask;
}
await SaveManifestAsync();

ManifestLock.Dispose();
FileNameHashAlgorithm.Dispose();

Disposed = true;
}
Expand Down
39 changes: 39 additions & 0 deletions src/CacheTower/Providers/FileSystem/FileCacheLayerOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
using System;

namespace CacheTower.Providers.FileSystem;

/// <summary>
/// Options for controlling a <see cref="FileCacheLayer"/>.
/// </summary>
/// <param name="DirectoryPath">The directory to store the cache in.</param>
/// <param name="Serializer">The serializer to use for the data.</param>
public record struct FileCacheLayerOptions(
string DirectoryPath,
ICacheSerializer Serializer
)
{
/// <summary>
/// The default manifest save interval of 5 minutes.
/// </summary>
public static readonly TimeSpan DefaultManifestSaveInterval = TimeSpan.FromMinutes(5);

/// <summary>
/// The time interval controlling how often the cache manifest is saved to disk.
/// </summary>
public TimeSpan ManifestSaveInterval { get; init; } = DefaultManifestSaveInterval;

/// <summary>
/// Options for controlling a <see cref="FileCacheLayer"/>.
/// </summary>
/// <param name="directoryPath">The directory to store the cache in.</param>
/// <param name="serializer">The serializer to use for the data.</param>
/// <param name="manifestSaveInterval">The time interval controlling how often the cache manifest is saved to disk.</param>
public FileCacheLayerOptions(
string directoryPath,
ICacheSerializer serializer,
TimeSpan manifestSaveInterval
) : this(directoryPath, serializer)
{
ManifestSaveInterval = manifestSaveInterval;
}
}
Loading