From 849e46e67103eeef2806095f66eaf7e112fcc72e Mon Sep 17 00:00:00 2001 From: Jonathan Bout Date: Sat, 18 Jan 2025 23:40:59 +0100 Subject: [PATCH 1/2] Optimize a lot of little but repeated things --- .../StringBuilderConversionBenchmarks.cs | 39 +++++ docker-compose.yml | 2 +- extensions/Redis/CustomRedisCacheService.cs | 135 ++++-------------- src/core/Endpoints/CDNEndpoints.cs | 5 +- src/core/Helpers/InternalExtensions.cs | 34 ++++- src/core/Helpers/StringBuilderExtensions.cs | 58 ++++++++ .../Caching/Implementations/CacheManager.cs | 2 +- .../Caching/Implementations/InMemoryCache.cs | 9 +- .../Implementations/IndexGenerator.cs | 9 +- src/core/SystemFiles/logo-old.ico | Bin 0 -> 23462 bytes src/core/SystemFiles/logo.ico | Bin 23462 -> 15086 bytes src/standalone/AdditionalEndpoints.cs | 35 +++++ src/standalone/Program.cs | 18 ++- src/standalone/Properties/launchSettings.json | 3 +- tests/load/Program.cs | 45 ++++-- tests/unit/StringBuilderExtensionTests.cs | 49 +++++++ 16 files changed, 294 insertions(+), 149 deletions(-) create mode 100644 benchmarks/StringBuilderConversionBenchmarks.cs create mode 100644 src/core/Helpers/StringBuilderExtensions.cs create mode 100644 src/core/SystemFiles/logo-old.ico create mode 100644 src/standalone/AdditionalEndpoints.cs create mode 100644 tests/unit/StringBuilderExtensionTests.cs diff --git a/benchmarks/StringBuilderConversionBenchmarks.cs b/benchmarks/StringBuilderConversionBenchmarks.cs new file mode 100644 index 0000000..2185400 --- /dev/null +++ b/benchmarks/StringBuilderConversionBenchmarks.cs @@ -0,0 +1,39 @@ +using BenchmarkDotNet.Attributes; +using SimpleCDN.Helpers; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace SimpleCDN.Benchmarks +{ + [MemoryDiagnoser(false)] + public class StringBuilderConversionBenchmarks + { + public StringBuilder[] GetData() + { + Random random = new(293); + StringBuilder sb = new(); + for (int i = 0; i < 1000; i++) + { + sb.Append(i).Append(random.GetItems("abcdefghijklmnopqrstuvwxyz", 10)); + } + return [sb]; + } + + [ArgumentsSource(nameof(GetData))] + [Benchmark] + public byte[] Manual(StringBuilder sb) + { + return sb.ToByteArray(Encoding.UTF8); + } + + [ArgumentsSource(nameof(GetData))] + [Benchmark] + public byte[] BuiltIn(StringBuilder sb) + { + return Encoding.UTF8.GetBytes(sb.ToString()); + } + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 879c5bc..870aaf9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,4 +17,4 @@ services: environment: - ASPNETCORE_ENVIRONMENT=Development - Cache__Redis__ConnectionString=redis:6379 # Only needed if Cache__Type=Redis - - Cache__Type=Redis # Redis, InMemory, or Disabled + - Cache__Type=InMemory # Redis, InMemory, or Disabled diff --git a/extensions/Redis/CustomRedisCacheService.cs b/extensions/Redis/CustomRedisCacheService.cs index aebb5c7..9f7e8a4 100644 --- a/extensions/Redis/CustomRedisCacheService.cs +++ b/extensions/Redis/CustomRedisCacheService.cs @@ -3,142 +3,63 @@ using Microsoft.Extensions.Options; using SimpleCDN.Configuration; using StackExchange.Redis; +using StackExchange.Redis.KeyspaceIsolation; +using System.Collections.Concurrent; using System.Diagnostics; namespace SimpleCDN.Extensions.Redis { - internal sealed class CustomRedisCacheService : IDistributedCache, IAsyncDisposable + internal sealed class CustomRedisCacheService : IDistributedCache { - private readonly IOptionsMonitor _redisOptions; - private readonly IOptionsMonitor _cacheOptions; - private readonly ObjectAccessBalancer _redisConnectionMultiplexers; - private readonly ILogger _logger; + private readonly IOptionsMonitor options; + private readonly IOptionsMonitor cacheOptions; - private IDatabase Database => _redisConnectionMultiplexers.Next().GetDatabase(); - private TimeSpan MaxAge => TimeSpan.FromMinutes(_cacheOptions.CurrentValue.MaxAge); + private readonly ConnectionMultiplexer _redisConnection; - DateTimeOffset lastTimeout = DateTimeOffset.MaxValue; - ushort multiplexerTargetCount = 1; - - public CustomRedisCacheService(IOptionsMonitor redisOptions, IOptionsMonitor cacheOptions, ILogger logger) + public CustomRedisCacheService(IOptionsMonitor options, IOptionsMonitor cacheOptions) { - _redisOptions = redisOptions; - _cacheOptions = cacheOptions; - _logger = logger; - - // create the instance balancer with the instance factory - _redisConnectionMultiplexers = new( - () => ConnectionMultiplexer.Connect( - _redisOptions.CurrentValue.ConnectionString, - options => options.ClientName = redisOptions.CurrentValue.ClientName), - instance => instance.IsConnected && !instance.IsConnecting); + this.options = options; + this.cacheOptions = cacheOptions; + _redisConnection = ConnectionMultiplexer.Connect(Configuration.ConnectionString, + config => config.ClientName = Configuration.ClientName); } - public ValueTask DisposeAsync() => _redisConnectionMultiplexers.DisposeAsync(); + private RedisCacheConfiguration Configuration => options.CurrentValue; + private CacheConfiguration CacheConfiguration => cacheOptions.CurrentValue; + + private IDatabase Database => _redisConnection.GetDatabase().WithKeyPrefix(Configuration.KeyPrefix + "::"); public byte[]? Get(string key) { - return Get(key, _redisConnectionMultiplexers.Next()); + return Database.StringGetSetExpiry(key, TimeSpan.FromMinutes(CacheConfiguration.MaxAge)); } - private byte[]? Get(string key, ConnectionMultiplexer multiplexer, int retryCount = 0) + public async Task GetAsync(string key, CancellationToken token = default) { - const int maxRetries = 3; - if (retryCount > maxRetries) - return null; - - try - { - // keep track of how long the operation takes, - - var start = Stopwatch.GetTimestamp(); - IDatabase database = multiplexer.GetDatabase(); - - RedisValue result = database.StringGet(key); - if (result.HasValue) - { - // reset the expiration time to have a sliding expiration - database.KeyExpire(key, MaxAge); - } - - TimeSpan elapsed = Stopwatch.GetElapsedTime(start); - - // if the operation took more than 100ms, create a new multiplexer - if (elapsed.TotalMilliseconds > 100) - { - CreateNewMultiplexer(); - } else - { - // no timeout occurred, check if we can reduce the number of multiplexers - SyncMultiplexers(); - } - - return result; - } catch (RedisTimeoutException) - { - // timeout occured, create a new multiplexer and use that to try again, up to 3 times - _logger.LogError("Redis timeout exception occurred. Trying to reconnect ({count}/{maxRetries}).", retryCount, maxRetries); - - return Get(key, CreateNewMultiplexer(), retryCount + 1); - } + return await Database.StringGetSetExpiryAsync(key, TimeSpan.FromMinutes(CacheConfiguration.MaxAge)); } - /// - /// Create a new multiplexer and set the last timeout to now. - /// - private ConnectionMultiplexer CreateNewMultiplexer() - { - lastTimeout = DateTimeOffset.UtcNow; - return _redisConnectionMultiplexers.AddInstance(); - } + public void Refresh(string key) { } - public Task GetAsync(string key, CancellationToken token = default) + public Task RefreshAsync(string key, CancellationToken token = default) => token.IsCancellationRequested ? Task.FromCanceled(token) : Task.CompletedTask; + public void Remove(string key) { - if (token.IsCancellationRequested) - return Task.FromCanceled(token); - return Task.FromResult(Get(key)); + Database.KeyDelete(key); } - public void Refresh(string key) { } - public Task RefreshAsync(string key, CancellationToken token = default) => Task.CompletedTask; - public void Remove(string key) => Database.StringGetDelete(key); - public Task RemoveAsync(string key, CancellationToken token = default) => Database.StringGetDeleteAsync(key); - - public void Set(string key, byte[] value, DistributedCacheEntryOptions options) + public Task RemoveAsync(string key, CancellationToken token = default) { - Database.StringSet(key, value, MaxAge, flags: CommandFlags.FireAndForget); + return Database.KeyDeleteAsync(key); } - public async Task SetAsync(string key, byte[] value, DistributedCacheEntryOptions options, CancellationToken token = default) + public void Set(string key, byte[] value, DistributedCacheEntryOptions options) { - await Database.StringSetAsync(key, value, MaxAge, flags: CommandFlags.FireAndForget); + Database.StringSet(key, value, TimeSpan.FromMinutes(CacheConfiguration.MaxAge)); } - private readonly SemaphoreSlim _syncLock = new(1, 1); - /// - /// If no timeouts have occurred in the last 15 seconds, reduce the number of multiplexers by 1. - /// - private void SyncMultiplexers() + public Task SetAsync(string key, byte[] value, DistributedCacheEntryOptions options, CancellationToken token = default) { - if (_syncLock.CurrentCount == 0) - return; - _syncLock.Wait(); - Task.Run(() => - { - if (DateTimeOffset.Now - lastTimeout > TimeSpan.FromSeconds(15)) - { - multiplexerTargetCount--; - if (multiplexerTargetCount < 1) - multiplexerTargetCount = 1; - } - while (_redisConnectionMultiplexers.Count > multiplexerTargetCount) - { - _redisConnectionMultiplexers.RemoveOneInstance(); - } - }) - // release the lock when the task is done. - // ContinueWith is equivalent to finally in this case. - .ContinueWith(_ => _syncLock.Release()); + return Database.StringSetAsync(key, value, TimeSpan.FromMinutes(CacheConfiguration.MaxAge)); } } } diff --git a/src/core/Endpoints/CDNEndpoints.cs b/src/core/Endpoints/CDNEndpoints.cs index 2f7b7cb..b0462e6 100644 --- a/src/core/Endpoints/CDNEndpoints.cs +++ b/src/core/Endpoints/CDNEndpoints.cs @@ -23,7 +23,10 @@ public static class CDNEndpoints /// /// Maps the SimpleCDN endpoints to the provided builder. /// - public static IEndpointRouteBuilder MapSimpleCDN(this IEndpointRouteBuilder builder) + /// + /// The after mapping the SimpleCDN endpoints. + /// + public static T MapSimpleCDN(this T builder) where T : IEndpointRouteBuilder { #if DEBUG // cache debug view. Only available in debug builds, as it exposes internal cache data diff --git a/src/core/Helpers/InternalExtensions.cs b/src/core/Helpers/InternalExtensions.cs index 150b51b..9d17a7a 100644 --- a/src/core/Helpers/InternalExtensions.cs +++ b/src/core/Helpers/InternalExtensions.cs @@ -1,4 +1,5 @@ -using System.Text.RegularExpressions; +using System.Text; +using System.Text.RegularExpressions; namespace SimpleCDN.Helpers { @@ -116,12 +117,33 @@ public static void Normalize(ref this Span path) /// /// Sanitizes a string for use in log messages, by replacing all whitespace (including newlines, tabs, ...) with a single space. /// - public static string ForLog(this string? input) => input is null ? "" : WhitespaceRegex().Replace(input, " "); + public static string ForLog(this string? input) => ForLog(input.AsSpan()); - public static string ForLog(this ReadOnlySpan input) => ForLog(input.ToString()); - - [GeneratedRegex(@"\s+", RegexOptions.Multiline | RegexOptions.Compiled)] - private static partial Regex WhitespaceRegex(); + public static string ForLog(this ReadOnlySpan input) + { + if (input.IsEmpty) + { + return ""; + } + var builder = new StringBuilder(input.Length); + bool lastWasWhitespace = false; + foreach (char c in input) + { + if (char.IsWhiteSpace(c)) + { + if (!lastWasWhitespace) + { + builder.Append(' '); + lastWasWhitespace = true; + } + } else + { + lastWasWhitespace = false; + builder.Append(c); + } + } + return builder.ToString(); + } public static bool HasAnyFlag(this T enumValue, T flag) where T : Enum, IConvertible { diff --git a/src/core/Helpers/StringBuilderExtensions.cs b/src/core/Helpers/StringBuilderExtensions.cs new file mode 100644 index 0000000..2a8bcb5 --- /dev/null +++ b/src/core/Helpers/StringBuilderExtensions.cs @@ -0,0 +1,58 @@ +using System.Diagnostics; +using System.Text; + +namespace SimpleCDN.Helpers +{ + internal static class StringBuilderExtensions + { + /// + /// Converts the StringBuilder to a byte array using the specified encoding. + /// This is more efficient than calling `Encoding.GetBytes(sb.ToString())`, + /// because it avoids creating a copy of the string. + /// + /// The StringBuilder to convert. + /// The encoding to use. If not specified, will be used. + public static byte[] ToByteArray(this StringBuilder sb, Encoding? encoding = null) + { + // to make sure we only have to allocate the byte array once, we need to calculate the total length first + // a downside of this approach is that we need to iterate over the chunks twice, but as the benchmark shows, + // this method is still faster than using `Encoding.GetBytes(sb.ToString())` for large strings + + encoding ??= Encoding.UTF8; + + + // if the encoding is single-byte, we don't need to iterate over the chunks twice, + // as the length of the byte array will be the same as the length of the string. + int totalLength = sb.Length; + + if (!encoding.IsSingleByte) + { + StringBuilder.ChunkEnumerator firstChunksEnumerator = sb.GetChunks(); + totalLength = 0; + while (firstChunksEnumerator.MoveNext()) + { + totalLength += encoding.GetByteCount(firstChunksEnumerator.Current.Span); + } + } + + // now that we know the total length, we can allocate the byte array + var bytes = new byte[totalLength]; + // use a span for faster and easier slicing + Span bytesSpan = bytes.AsSpan(); + + // allocate the second enumerator + StringBuilder.ChunkEnumerator chunksEnumerator = sb.GetChunks(); + int bytesOffset = 0; + + // iterate over the chunks again, and copy them to the allocated byte array + while (chunksEnumerator.MoveNext()) + { + bytesOffset += encoding.GetBytes(chunksEnumerator.Current.Span, bytesSpan[bytesOffset..]); + } + + Debug.Assert(bytesOffset == totalLength); + + return bytes; + } + } +} diff --git a/src/core/Services/Caching/Implementations/CacheManager.cs b/src/core/Services/Caching/Implementations/CacheManager.cs index e8c7b3e..ff54bd3 100644 --- a/src/core/Services/Caching/Implementations/CacheManager.cs +++ b/src/core/Services/Caching/Implementations/CacheManager.cs @@ -61,7 +61,7 @@ public bool TryGetValue(string key, [NotNullWhen(true)] out CachedFile? value) #endif if (bytes is null || bytes.Length == 0) { - _logger.LogDebug("Cache MISS for {Key} in {Duration:0} ms", key, elapsed.TotalMilliseconds); + _logger.LogInformation("Cache MISS for {Key} in {Duration:0} ms", key, elapsed.TotalMilliseconds); value = null; return false; } diff --git a/src/core/Services/Caching/Implementations/InMemoryCache.cs b/src/core/Services/Caching/Implementations/InMemoryCache.cs index 13d6725..ab2cc2b 100644 --- a/src/core/Services/Caching/Implementations/InMemoryCache.cs +++ b/src/core/Services/Caching/Implementations/InMemoryCache.cs @@ -19,6 +19,7 @@ internal class InMemoryCache(IOptionsMonitor options private readonly ConcurrentDictionary _dictionary = new(StringComparer.OrdinalIgnoreCase); public long MaxSize => _options.CurrentValue.MaxSize * 1000; // convert from kB to B private readonly ILogger _logger = logger; + public long Size => _dictionary.Values.Sum(wrapper => (long)wrapper.Size); public int Count => _dictionary.Count; @@ -29,12 +30,9 @@ internal class InMemoryCache(IOptionsMonitor options { if (_dictionary.TryGetValue(key, out ValueWrapper? wrapper)) { - _logger.LogDebug("Cache HIT {key}", key.ForLog()); return wrapper.Value; } - _logger.LogDebug("Cache MISS {key}", key.ForLog()); - return null; } @@ -86,6 +84,11 @@ public Task SetAsync(string key, byte[] value, DistributedCacheEntryOptions opti return Task.CompletedTask; } + internal void Clear() + { + _dictionary.Clear(); + } + #region Automated Purging private CancellationTokenSource? _backgroundCTS; private IDisposable? _optionsOnChange; diff --git a/src/core/Services/Implementations/IndexGenerator.cs b/src/core/Services/Implementations/IndexGenerator.cs index 87f9759..06cfd4b 100644 --- a/src/core/Services/Implementations/IndexGenerator.cs +++ b/src/core/Services/Implementations/IndexGenerator.cs @@ -12,6 +12,8 @@ namespace SimpleCDN.Services.Implementations { internal class IndexGenerator(IOptionsMonitor options, ILogger logger, ICDNContext context) : IIndexGenerator { + const string ROBOTS_META = ""; + private readonly IOptionsMonitor _options = options; private readonly ILogger _logger = logger; private readonly ICDNContext _context = context; @@ -22,6 +24,7 @@ internal class IndexGenerator(IOptionsMonitor options, ILogger ReturnSpecialDirectories = false, RecurseSubdirectories = false }; + public byte[]? GenerateIndex(string absolutePath, string rootRelativePath) { if (!Directory.Exists(absolutePath)) @@ -29,9 +32,7 @@ internal class IndexGenerator(IOptionsMonitor options, ILogger var directory = new DirectoryInfo(absolutePath); - var index = new StringBuilder(); - - var robotsMeta = _options.CurrentValue.BlockRobots ? "" : ""; + var robotsMeta = _options.CurrentValue.BlockRobots ? ROBOTS_META : string.Empty; // if the path is a single slash, we want to remove it // to show "Index of /" instead of "Index of //" @@ -40,6 +41,7 @@ internal class IndexGenerator(IOptionsMonitor options, ILogger rootRelativePath = ""; } + var index = new StringBuilder(); index.Append( $$""" @@ -47,6 +49,7 @@ internal class IndexGenerator(IOptionsMonitor options, ILogger {{robotsMeta}} + diff --git a/src/core/SystemFiles/logo-old.ico b/src/core/SystemFiles/logo-old.ico new file mode 100644 index 0000000000000000000000000000000000000000..0ca239f704ad677b42dd15e6317740bf958f7001 GIT binary patch literal 23462 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 == CacheType.InMemory) + { + app.MapGet("/" + GlobalConstants.SystemFilesRelativePath + "/server/cache/clear", (HttpContext ctx, ICacheImplementationResolver cacheResolver) => + { + ctx.Response.Headers.Clear(); + + if (cacheResolver.Implementation is InMemoryCache imc) + { + imc.Clear(); + GC.Collect(); + return Results.Ok(); + } + return Results.NotFound(); + }); + } +#endif + // health check endpoint + app.MapGet("/" + GlobalConstants.SystemFilesRelativePath + "/server/health", () => "healthy"); + + app.MapGet("/favicon.ico", () => Results.Redirect("/" + GlobalConstants.SystemFilesRelativePath + "/logo.ico", true)); + + return app; + } + } +} diff --git a/src/standalone/Program.cs b/src/standalone/Program.cs index e33940c..18d7135 100644 --- a/src/standalone/Program.cs +++ b/src/standalone/Program.cs @@ -1,5 +1,8 @@ +using Microsoft.Extensions.Configuration; using SimpleCDN.Configuration; using SimpleCDN.Endpoints; +using SimpleCDN.Services.Caching; +using SimpleCDN.Services.Caching.Implementations; namespace SimpleCDN.Standalone { @@ -21,16 +24,11 @@ private static void Main(string[] args) builder.Services.AddSimpleCDN() .MapConfiguration(builder.Configuration); - WebApplication app = builder.Build(); - - app.MapSimpleCDN(); - - // health check endpoint - app.MapGet("/" + GlobalConstants.SystemFilesRelativePath + "/server/health", () => "healthy"); - - app.MapGet("/favicon.ico", () => Results.Redirect("/" + GlobalConstants.SystemFilesRelativePath + "/logo.ico", true)); - - app.Run(); + builder + .Build() + .MapSimpleCDN() + .MapAdditionalEndpoints() + .Run(); } } } diff --git a/src/standalone/Properties/launchSettings.json b/src/standalone/Properties/launchSettings.json index dd94ec8..334f5bf 100644 --- a/src/standalone/Properties/launchSettings.json +++ b/src/standalone/Properties/launchSettings.json @@ -2,8 +2,7 @@ "profiles": { "http": { "commandName": "Project", - "commandLineArgs": "--data-root ..", - "launchBrowser": true, + "commandLineArgs": "--CDN:DataRoot ../..", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" }, diff --git a/tests/load/Program.cs b/tests/load/Program.cs index 6dacd99..a52dad7 100644 --- a/tests/load/Program.cs +++ b/tests/load/Program.cs @@ -13,20 +13,13 @@ static class Program static async Task Main(string[] args) { Console.Clear(); - if (args.Length != 2) + if (args.Length != 1) { - Console.WriteLine("Usage: SimpleCDN.Tests.Load "); + Console.WriteLine("Usage: SimpleCDN.Tests.Load "); return; } - var url = args[0]; - if (!int.TryParse(args[1], out var requestsPerSecond)) - { - Console.WriteLine("Invalid number of requests."); - return; - } - - if (!Uri.TryCreate(url, UriKind.Absolute, out Uri? uriResult)) + if (!Uri.TryCreate(args[0], UriKind.Absolute, out Uri? url)) { Console.WriteLine("Invalid URL."); return; @@ -34,7 +27,7 @@ static async Task Main(string[] args) HttpClient CreateClient() => new() { - BaseAddress = uriResult, + BaseAddress = url, DefaultRequestHeaders = { { "User-Agent", "SimpleCDN Load Testing" } } @@ -64,10 +57,24 @@ void Render() lock (_consoleLock) { + double average; + int max; + int min; + + if (durations.IsEmpty) + { + average = max = min = 0; + } else + { + average = durations.Average(); + max = durations.Max(); + min = durations.Min(); + } + Console.SetCursorPosition(left, top); - Console.WriteLine("Average request duration: {0:0.##} ms ", durations.Average()); - Console.WriteLine("Max request duration: {0:0.##} ms ", durations.Max()); - Console.WriteLine("Min request duration: {0:0.##} ms ", durations.Min()); + Console.WriteLine("Average request duration: {0:0.##} ms ", average); + Console.WriteLine("Max request duration: {0:0.##} ms ", max); + Console.WriteLine("Min request duration: {0:0.##} ms ", min); Console.WriteLine("Total requests: {0} ", durations.Count); Console.WriteLine("Failed requests: {0} ", errors); Console.WriteLine(" "); @@ -81,7 +88,10 @@ void Render() } } - await Parallel.ForEachAsync(new InfiniteEnumerable(), async (_, ct) => + await Parallel.ForEachAsync(new InfiniteEnumerable(), new ParallelOptions + { + MaxDegreeOfParallelism = Environment.ProcessorCount * 2 + }, async (_, ct) => { using HttpClient client = CreateClient(); var start = Stopwatch.GetTimestamp(); @@ -96,6 +106,11 @@ await Parallel.ForEachAsync(new InfiniteEnumerable(), async (_, ct) => static double Percentile(this ConcurrentBag durations, double percentile) { + if (durations.IsEmpty) + { + return 0; + } + var sorted = durations.Order().ToArray(); int index = (int)(percentile * sorted.Length); return sorted[index]; diff --git a/tests/unit/StringBuilderExtensionTests.cs b/tests/unit/StringBuilderExtensionTests.cs new file mode 100644 index 0000000..87c7eef --- /dev/null +++ b/tests/unit/StringBuilderExtensionTests.cs @@ -0,0 +1,49 @@ +using SimpleCDN.Helpers; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace SimpleCDN.Tests.Unit +{ + public class StringBuilderExtensionTests + { + public record TestInput(string[] Parts, Encoding Encoding) + { + public override string ToString() => $"{Parts.Length} parts, {Encoding.EncodingName}"; + } + + internal static TestInput[] ToByteArray_HasSameResultAs_Manual_TestCases() + { + return [ + new TestInput(["Hello, ", "world!"], Encoding.UTF8), + new TestInput(["Hello, ", "world!"], Encoding.Unicode), + new TestInput(["Hello, ", "world!"], Encoding.ASCII), + new TestInput(["Hello, ", "world!"], Encoding.UTF32), + new TestInput(["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"], Encoding.UTF8), + new TestInput(["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"], Encoding.Unicode), + new TestInput(["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"], Encoding.ASCII), + new TestInput(["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"], Encoding.UTF32) + ]; + } + + [Test] + [TestCaseSource(nameof(ToByteArray_HasSameResultAs_Manual_TestCases))] + public void ToByteArray_HasSameResultAs_Manual(TestInput input) + { + (string[] parts, Encoding encoding) = input; + + var sb = new StringBuilder(); + foreach (var part in parts) + { + sb.Append(part); + } + + var manual = sb.ToByteArray(encoding); + var builtIn = encoding.GetBytes(sb.ToString()); + + Assert.That(manual, Is.EqualTo(builtIn)); + } + } +} From 625b39d15d975c80930449134b534c5e60c31741 Mon Sep 17 00:00:00 2001 From: Jonathan Bout Date: Tue, 21 Jan 2025 23:18:58 +0100 Subject: [PATCH 2/2] More extensive benchmarks, cache bug fix - also test different encodings - properly prefix system files in the cache --- .../StringBuilderConversionBenchmarks.cs | 26 ++- docker-compose.yml | 2 +- extensions/Redis/CustomRedisCacheService.cs | 2 +- extensions/Redis/ObjectAccessBalancer.cs | 165 ------------------ extensions/Redis/README.md | 2 +- extensions/Redis/RedisCacheConfiguration.cs | 4 +- .../Caching/Implementations/CacheManager.cs | 4 +- .../Implementations/SystemFileReader.cs | 9 +- src/standalone/AdditionalEndpoints.cs | 11 +- tests/unit/StringBuilderExtensionTests.cs | 32 ++-- 10 files changed, 64 insertions(+), 193 deletions(-) delete mode 100644 extensions/Redis/ObjectAccessBalancer.cs diff --git a/benchmarks/StringBuilderConversionBenchmarks.cs b/benchmarks/StringBuilderConversionBenchmarks.cs index 2185400..b8ce326 100644 --- a/benchmarks/StringBuilderConversionBenchmarks.cs +++ b/benchmarks/StringBuilderConversionBenchmarks.cs @@ -14,26 +14,40 @@ public class StringBuilderConversionBenchmarks public StringBuilder[] GetData() { Random random = new(293); - StringBuilder sb = new(); + StringBuilder longStringBuilder = new(); for (int i = 0; i < 1000; i++) { - sb.Append(i).Append(random.GetItems("abcdefghijklmnopqrstuvwxyz", 10)); + longStringBuilder.AppendFormat("Test {0}: ", i).Append(random.GetItems("abcdefghijklmnopqrstuvwxyz", 10)); } - return [sb]; + + StringBuilder shortStringBuilder = new(); + for (int i = 0; i < 10; i++) + { + shortStringBuilder.AppendFormat("Test {0}: ", i).Append(random.GetItems("abcdefghijklmnopqrstuvwxyz", 10)); + } + return [longStringBuilder, shortStringBuilder]; + } + + public Encoding[] GetEncodings() + { + return [Encoding.UTF8, Encoding.ASCII]; } + [ParamsSource(nameof(GetEncodings))] + public Encoding CurrentEncoding { get; set; } = Encoding.UTF8; + [ArgumentsSource(nameof(GetData))] [Benchmark] public byte[] Manual(StringBuilder sb) { - return sb.ToByteArray(Encoding.UTF8); + return sb.ToByteArray(CurrentEncoding); } [ArgumentsSource(nameof(GetData))] [Benchmark] public byte[] BuiltIn(StringBuilder sb) { - return Encoding.UTF8.GetBytes(sb.ToString()); + return CurrentEncoding.GetBytes(sb.ToString()); } - } +} } diff --git a/docker-compose.yml b/docker-compose.yml index 870aaf9..879c5bc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,4 +17,4 @@ services: environment: - ASPNETCORE_ENVIRONMENT=Development - Cache__Redis__ConnectionString=redis:6379 # Only needed if Cache__Type=Redis - - Cache__Type=InMemory # Redis, InMemory, or Disabled + - Cache__Type=Redis # Redis, InMemory, or Disabled diff --git a/extensions/Redis/CustomRedisCacheService.cs b/extensions/Redis/CustomRedisCacheService.cs index 9f7e8a4..f350fac 100644 --- a/extensions/Redis/CustomRedisCacheService.cs +++ b/extensions/Redis/CustomRedisCacheService.cs @@ -27,7 +27,7 @@ public CustomRedisCacheService(IOptionsMonitor options, private RedisCacheConfiguration Configuration => options.CurrentValue; private CacheConfiguration CacheConfiguration => cacheOptions.CurrentValue; - private IDatabase Database => _redisConnection.GetDatabase().WithKeyPrefix(Configuration.KeyPrefix + "::"); + private IDatabase Database => _redisConnection.GetDatabase().WithKeyPrefix(Configuration.KeyPrefix); public byte[]? Get(string key) { diff --git a/extensions/Redis/ObjectAccessBalancer.cs b/extensions/Redis/ObjectAccessBalancer.cs deleted file mode 100644 index cdffd8d..0000000 --- a/extensions/Redis/ObjectAccessBalancer.cs +++ /dev/null @@ -1,165 +0,0 @@ -namespace SimpleCDN.Extensions.Redis -{ - /// - /// Balances access to a collection of instances of . - /// - /// How an instance should be created. - /// A function that determines if an instance is healthy. - internal sealed class ObjectAccessBalancer(Func instanceFactory, Func? healthCheck = null) : IDisposable, IAsyncDisposable - { - private readonly List _instances = []; - private T? _lastDeactivatedInstance; - private readonly Func _instanceFactory = instanceFactory; - private readonly Func? _healthCheck = healthCheck; - - private int _index; -#if NET9_0_OR_GREATER - private readonly Lock _lock = new(); -#else - private readonly object _lock = new(); -#endif - /// - /// The number of instances currently in use in the balancer. - /// - public int Count => _instances.Count; - - /// - /// The highest number of instances that have been in use in the balancer at the same time. - /// - public int HighestCount { get; private set; } = 1; - - /// - /// Gets the next instance in the balancer. - /// - public T Next() - { - lock (_lock) - { - // if there is no health check, just return the next instance - if (_healthCheck is null) - { - if (++_index >= _instances.Count) - _index = 0; - return _instances[_index]; - } - - // if there is a health check, find the next healthy instance - T? instance = default; - var startIndex = _index; - while (_instances.Count > 0) - { - if (++_index >= _instances.Count) - _index = 0; - - instance = _instances[_index]; - - if (!_healthCheck.Invoke(instance)) - { - // if the instance is unhealthy, Dispose it if it implements IDisposable, - // and remove it from the list - if (instance is IDisposable disposable) - disposable.Dispose(); - if (instance is IAsyncDisposable asyncDisposable) - asyncDisposable.DisposeAsync().AsTask(); - _instances.RemoveAt(_index); - } else - { - break; - } - } - - if (instance is null || instance.Equals(default(T)) || _instances.Count == 0) - { - // all instances are unhealthy, clear the list and add a new instance - instance = AddInstance(); - } - - return instance; - } - } - - /// - /// Adds a new instance to the balancer. - /// - public T AddInstance() - { - lock (_lock) - { - if (_lastDeactivatedInstance is not null) - { - _instances.Add(_lastDeactivatedInstance); - _lastDeactivatedInstance = default; - } else - { - _instances.Add(_instanceFactory()); - } - - if (_instances.Count > HighestCount) - HighestCount = _instances.Count; - return _instances[^1]; - } - } - - /// - /// Removes the last instance added to the balancer, unless there is only one instance. - /// The instance is not removed completely until you call this method again. - /// Until then, calling will re-add the removed instance. - /// - public void RemoveOneInstance() - { - lock (_lock) - { - if (_instances.Count > 0) - { - if (_lastDeactivatedInstance is IDisposable last) - last.Dispose(); - _lastDeactivatedInstance = _instances[^1]; - _instances.RemoveAt(_instances.Count - 1); - } - } - } - - /// - /// If implements , disposes all instances. - ///
- /// You do not need to call this method if your instances do not implement . - ///
- public void Dispose() - { - if (typeof(IDisposable).IsAssignableFrom(typeof(T))) - { - foreach (T instance in _instances) - { - (instance as IDisposable)?.Dispose(); - } - if (_lastDeactivatedInstance is IDisposable deactivated) - deactivated.Dispose(); - } - } - - /// - /// If implements , disposes all instances asynchronously. - /// Also, if implements , disposes all instances synchronously. - ///
- /// You do not need to call this method if your instances do not implement . - ///
- public async ValueTask DisposeAsync() - { - if (typeof(IAsyncDisposable).IsAssignableFrom(typeof(T))) - { - var tasks = new List(); - foreach (T instance in _instances) - { - if (instance is IAsyncDisposable asyncDisposable) - tasks.Add(asyncDisposable.DisposeAsync().AsTask()); - } - if (_lastDeactivatedInstance is IAsyncDisposable asyncDeactivated) - tasks.Add(asyncDeactivated.DisposeAsync().AsTask()); - - await Task.WhenAll(tasks); - } - - Dispose(); - } - } -} diff --git a/extensions/Redis/README.md b/extensions/Redis/README.md index abc5fa4..e10257b 100644 --- a/extensions/Redis/README.md +++ b/extensions/Redis/README.md @@ -19,4 +19,4 @@ var cdnBuilder = builder.Services.AddSimpleCDN(); - `options.ConnectionString`: The configuration string for the Redis server. This is a required property. - `options.ClientName`: How the client should be identified to Redis. Default is `SimpleCDN`. This value can't contain whitespace. -- `options.KeyPrefix`: A string to prepend to all keys SimpleCDN inserts. Default is `SimpleCDN`. An empty value is allowed, meaning no prefix is added. +- `options.KeyPrefix`: A string to prepend to all keys SimpleCDN inserts. Default is `SimpleCDN::`. An empty value is allowed, meaning no prefix is added. diff --git a/extensions/Redis/RedisCacheConfiguration.cs b/extensions/Redis/RedisCacheConfiguration.cs index bd76207..206cd3f 100644 --- a/extensions/Redis/RedisCacheConfiguration.cs +++ b/extensions/Redis/RedisCacheConfiguration.cs @@ -18,9 +18,9 @@ public class RedisCacheConfiguration public string ClientName { get; set; } = "SimpleCDN"; /// - /// A prefix to be added to all keys stored in Redis. Default is SimpleCDN. An empty value is allowed. + /// A prefix to be added to all keys stored in Redis. Default is SimpleCDN::. An empty value is allowed. /// - public string KeyPrefix { get; set; } = "SimpleCDN"; + public string KeyPrefix { get; set; } = "SimpleCDN::"; /// /// Validates the configuration settings. diff --git a/src/core/Services/Caching/Implementations/CacheManager.cs b/src/core/Services/Caching/Implementations/CacheManager.cs index ff54bd3..a70650f 100644 --- a/src/core/Services/Caching/Implementations/CacheManager.cs +++ b/src/core/Services/Caching/Implementations/CacheManager.cs @@ -48,8 +48,10 @@ public bool TryGetValue(string key, [NotNullWhen(true)] out CachedFile? value) { if (_durations.Count == int.MaxValue - 10) { + // if we are about to run out of space, // remove about half of the durations to make room for new ones. - // this has the nice side effect of newer values being more important + // this has the nice side effect of newer values having a greater + // impact on the end result _durations.RemoveAll(_ => Random.Shared.NextDouble() > .5); } diff --git a/src/core/Services/Implementations/SystemFileReader.cs b/src/core/Services/Implementations/SystemFileReader.cs index 21624ac..607396f 100644 --- a/src/core/Services/Implementations/SystemFileReader.cs +++ b/src/core/Services/Implementations/SystemFileReader.cs @@ -8,6 +8,7 @@ namespace SimpleCDN.Services.Implementations internal class SystemFileReader(ILogger logger, ICacheManager cache) : ISystemFileReader { const string SystemFilesNamespace = "SimpleCDN.SystemFiles"; + const string CacheKeyPrefix = "/" + GlobalConstants.SystemFilesRelativePath + "::"; private readonly ILogger _logger = logger; private readonly ICacheManager _cache = cache; @@ -18,20 +19,22 @@ internal class SystemFileReader(ILogger logger, ICacheManager { var requestPathString = requestPath.TrimStart('/').ToString(); + var cacheKey = CacheKeyPrefix + requestPathString; + _logger.LogDebug("Requesting system file '{path}'", requestPathString.ForLog()); IFileInfo fileInfo = _systemFilesProvider.GetFileInfo(requestPathString); if (!fileInfo.Exists || fileInfo.IsDirectory) { - _cache.TryRemove(requestPathString); + _cache.TryRemove(cacheKey); _logger.LogDebug("System file '{path}' does not exist", requestPathString); return null; } - if (_cache.TryGetValue(requestPathString, out CachedFile? cachedFile) && cachedFile.LastModified >= fileInfo.LastModified) + if (_cache.TryGetValue(cacheKey, out CachedFile? cachedFile) && cachedFile.LastModified >= fileInfo.LastModified) { _logger.LogDebug("Serving system file '{path}' from cache", requestPathString); return new CDNFile(cachedFile.Content, cachedFile.MimeType.ToContentTypeString(), cachedFile.LastModified, cachedFile.Compression); @@ -62,7 +65,7 @@ internal class SystemFileReader(ILogger logger, ICacheManager DateTimeOffset lastModified = fileInfo.LastModified; // unchecked cast is safe because we know the file is small enough - _cache.CacheFile(requestPathString, content, unchecked((int)originalLength), lastModified, mediaType, compression); + _cache.CacheFile(cacheKey, content, unchecked((int)originalLength), lastModified, mediaType, compression); return new CDNFile(content, mediaType.ToContentTypeString(), lastModified, compression); } diff --git a/src/standalone/AdditionalEndpoints.cs b/src/standalone/AdditionalEndpoints.cs index 265f39c..c38df62 100644 --- a/src/standalone/AdditionalEndpoints.cs +++ b/src/standalone/AdditionalEndpoints.cs @@ -10,13 +10,20 @@ public static WebApplication MapAdditionalEndpoints(this WebApplication app) #if DEBUG if (app.Configuration.GetSection("Cache:Type").Get() == CacheType.InMemory) { - app.MapGet("/" + GlobalConstants.SystemFilesRelativePath + "/server/cache/clear", (HttpContext ctx, ICacheImplementationResolver cacheResolver) => + // if the cache used is the in-memory implementation, add an endpoint to clear it + // as there is no other way to clear it without restarting the server, + // opposed to for example the Redis implementation which has the Redis CLI + app.MapGet("/" + GlobalConstants.SystemFilesRelativePath + "/server/cache/clear", (ICacheImplementationResolver cacheResolver) => { - ctx.Response.Headers.Clear(); + // TODO: currently, the browser makes a request to favicon.ico after the cache is cleared, + // which means there is a new cache entry created for the favicon.ico file. + // This is not a problem, but it would be nice to prevent this from happening. if (cacheResolver.Implementation is InMemoryCache imc) { imc.Clear(); + // force garbage collection to make sure all memory + // used by the cached files is properly released GC.Collect(); return Results.Ok(); } diff --git a/tests/unit/StringBuilderExtensionTests.cs b/tests/unit/StringBuilderExtensionTests.cs index 87c7eef..40bfe40 100644 --- a/tests/unit/StringBuilderExtensionTests.cs +++ b/tests/unit/StringBuilderExtensionTests.cs @@ -9,22 +9,27 @@ namespace SimpleCDN.Tests.Unit { public class StringBuilderExtensionTests { - public record TestInput(string[] Parts, Encoding Encoding) + public record TestInput(string[] Parts, (string, object[])[] formattedParts, Encoding Encoding) { public override string ToString() => $"{Parts.Length} parts, {Encoding.EncodingName}"; } - internal static TestInput[] ToByteArray_HasSameResultAs_Manual_TestCases() + private static TestInput[] ToByteArray_HasSameResultAs_Manual_TestCases() { + (string, object[])[] formattedParts = [("Test {0}", [new { aa = "bb" }])]; + string[] shortArray = ["Hello, ", "world!"]; + string[] longArray = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", + "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z"]; + return [ - new TestInput(["Hello, ", "world!"], Encoding.UTF8), - new TestInput(["Hello, ", "world!"], Encoding.Unicode), - new TestInput(["Hello, ", "world!"], Encoding.ASCII), - new TestInput(["Hello, ", "world!"], Encoding.UTF32), - new TestInput(["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"], Encoding.UTF8), - new TestInput(["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"], Encoding.Unicode), - new TestInput(["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"], Encoding.ASCII), - new TestInput(["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"], Encoding.UTF32) + new TestInput(shortArray, [], Encoding.UTF8), + new TestInput(shortArray, [], Encoding.Unicode), + new TestInput(shortArray, [], Encoding.ASCII), + new TestInput(shortArray, [], Encoding.UTF32), + new TestInput(longArray, formattedParts, Encoding.UTF8), + new TestInput(longArray, formattedParts, Encoding.Unicode), + new TestInput(longArray, formattedParts, Encoding.ASCII), + new TestInput(longArray, formattedParts, Encoding.UTF32) ]; } @@ -32,7 +37,7 @@ internal static TestInput[] ToByteArray_HasSameResultAs_Manual_TestCases() [TestCaseSource(nameof(ToByteArray_HasSameResultAs_Manual_TestCases))] public void ToByteArray_HasSameResultAs_Manual(TestInput input) { - (string[] parts, Encoding encoding) = input; + (string[] parts, (string, object[])[] formattedParts, Encoding encoding) = input; var sb = new StringBuilder(); foreach (var part in parts) @@ -40,6 +45,11 @@ public void ToByteArray_HasSameResultAs_Manual(TestInput input) sb.Append(part); } + foreach ((string format, object[] args) in formattedParts) + { + sb.AppendFormat(format, args); + } + var manual = sb.ToByteArray(encoding); var builtIn = encoding.GetBytes(sb.ToString());