diff --git a/packages/http-client-csharp/playground-server.Tests/GenerationCacheTests.cs b/packages/http-client-csharp/playground-server.Tests/GenerationCacheTests.cs new file mode 100644 index 00000000000..a8d6e912fb9 --- /dev/null +++ b/packages/http-client-csharp/playground-server.Tests/GenerationCacheTests.cs @@ -0,0 +1,167 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Memory; +using NUnit.Framework; +using PlaygroundServer; + +namespace PlaygroundServer.Tests; + +[TestFixture] +public class GenerationCacheTests +{ + private static MemoryGenerationCache CreateCache(long sizeLimit = MemoryGenerationCache.DefaultSizeLimitBytes, TimeSpan? sliding = null) + { + var memory = new MemoryCache(new MemoryCacheOptions { SizeLimit = sizeLimit }); + return new MemoryGenerationCache(memory, sliding); + } + + private static CachedGenerationResponse MakeResponse(string content) + => new(Encoding.UTF8.GetBytes(content), "application/json"); + + [Test] + public void ComputeKey_IsDeterministic_ForSameInputs() + { + var k1 = MemoryGenerationCache.ComputeKey("gen", "{\"a\":1}", "{\"b\":2}", "1.0.0"); + var k2 = MemoryGenerationCache.ComputeKey("gen", "{\"a\":1}", "{\"b\":2}", "1.0.0"); + Assert.AreEqual(k1, k2); + } + + [Test] + public void ComputeKey_ProducesSha256HexString() + { + var key = MemoryGenerationCache.ComputeKey("gen", "model", "config", "1.0.0"); + // SHA-256 hex = 64 hex chars, uppercase per Convert.ToHexString. + Assert.AreEqual(64, key.Length); + Assert.That(key, Does.Match("^[0-9A-F]{64}$")); + } + + [Test] + public void ComputeKey_ChangesWhenGeneratorNameChanges() + { + var a = MemoryGenerationCache.ComputeKey("genA", "m", "c", "v"); + var b = MemoryGenerationCache.ComputeKey("genB", "m", "c", "v"); + Assert.AreNotEqual(a, b); + } + + [Test] + public void ComputeKey_ChangesWhenCodeModelChanges() + { + var a = MemoryGenerationCache.ComputeKey("gen", "m1", "c", "v"); + var b = MemoryGenerationCache.ComputeKey("gen", "m2", "c", "v"); + Assert.AreNotEqual(a, b); + } + + [Test] + public void ComputeKey_ChangesWhenConfigurationChanges() + { + var a = MemoryGenerationCache.ComputeKey("gen", "m", "c1", "v"); + var b = MemoryGenerationCache.ComputeKey("gen", "m", "c2", "v"); + Assert.AreNotEqual(a, b); + } + + [Test] + public void ComputeKey_ChangesWhenGeneratorVersionChanges() + { + var a = MemoryGenerationCache.ComputeKey("gen", "m", "c", "1.0.0"); + var b = MemoryGenerationCache.ComputeKey("gen", "m", "c", "1.0.1"); + Assert.AreNotEqual(a, b); + } + + [Test] + public void ComputeKey_IsUnambiguousAcrossComponentBoundaries() + { + // Naive concatenation would collide here; the length prefix must prevent that. + var a = MemoryGenerationCache.ComputeKey("Foo", "Bar", "Baz", "v"); + var b = MemoryGenerationCache.ComputeKey("FooBar", "", "Baz", "v"); + var c = MemoryGenerationCache.ComputeKey("Foo", "BarBaz", "", "v"); + Assert.AreNotEqual(a, b); + Assert.AreNotEqual(a, c); + Assert.AreNotEqual(b, c); + } + + [Test] + public void ComputeKey_ThrowsOnNullArguments() + { + Assert.Throws(() => MemoryGenerationCache.ComputeKey(null!, "m", "c", "v")); + Assert.Throws(() => MemoryGenerationCache.ComputeKey("g", null!, "c", "v")); + Assert.Throws(() => MemoryGenerationCache.ComputeKey("g", "m", null!, "v")); + Assert.Throws(() => MemoryGenerationCache.ComputeKey("g", "m", "c", null!)); + } + + [Test] + public void TryGet_ReturnsFalse_WhenKeyMissing() + { + var cache = CreateCache(); + Assert.IsFalse(cache.TryGet("nope", out var value)); + Assert.IsNull(value); + } + + [Test] + public void Set_ThenTryGet_ReturnsStoredValue() + { + var cache = CreateCache(); + var response = MakeResponse("hello"); + cache.Set("k", response); + + Assert.IsTrue(cache.TryGet("k", out var value)); + Assert.IsNotNull(value); + Assert.AreEqual("application/json", value!.ContentType); + Assert.AreEqual("hello", Encoding.UTF8.GetString(value.Body)); + } + + [Test] + public void Set_OverwritesExistingEntry() + { + var cache = CreateCache(); + cache.Set("k", MakeResponse("first")); + cache.Set("k", MakeResponse("second")); + + Assert.IsTrue(cache.TryGet("k", out var value)); + Assert.AreEqual("second", Encoding.UTF8.GetString(value!.Body)); + } + + [Test] + public void Set_ThrowsOnNullValue() + { + var cache = CreateCache(); + Assert.Throws(() => cache.Set("k", null!)); + } + + [Test] + public void Constructor_ThrowsOnNullBackingCache() + { + Assert.Throws(() => new MemoryGenerationCache(null!)); + } + + [Test] + public void SizeLimit_EvictsEntriesUnderPressure() + { + using var backing = new MemoryCache(new MemoryCacheOptions { SizeLimit = 1024, CompactionPercentage = 0.5 }); + var cache = new MemoryGenerationCache(backing); + + var payload = new byte[256]; + for (int i = 0; i < 8; i++) + { + cache.Set("k" + i, new CachedGenerationResponse(payload, "application/json")); + } + backing.Compact(0.0); + + Assert.LessOrEqual(backing.Count * 256, 1024); + } + + [Test] + public async Task SlidingExpiration_EvictsAfterIdle() + { + var cache = CreateCache(sliding: TimeSpan.FromMilliseconds(50)); + cache.Set("k", MakeResponse("x")); + Assert.IsTrue(cache.TryGet("k", out _)); + + await Task.Delay(200); + Assert.IsFalse(cache.TryGet("k", out var value)); + Assert.IsNull(value); + } +} diff --git a/packages/http-client-csharp/playground-server.Tests/playground-server.Tests.csproj b/packages/http-client-csharp/playground-server.Tests/playground-server.Tests.csproj new file mode 100644 index 00000000000..0c8466c3793 --- /dev/null +++ b/packages/http-client-csharp/playground-server.Tests/playground-server.Tests.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + enable + enable + false + PlaygroundServer.Tests + + + + + + + + + + + + + + diff --git a/packages/http-client-csharp/playground-server/GenerationCache.cs b/packages/http-client-csharp/playground-server/GenerationCache.cs new file mode 100644 index 00000000000..a6d45ac7e2a --- /dev/null +++ b/packages/http-client-csharp/playground-server/GenerationCache.cs @@ -0,0 +1,98 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Security.Cryptography; +using System.Text; +using Microsoft.Extensions.Caching.Memory; + +namespace PlaygroundServer; + +/// +/// Cached generator response. Stored as the already-serialized JSON bytes plus +/// content type so cache hits can return without re-serializing. +/// +public sealed record CachedGenerationResponse(byte[] Body, string ContentType); + +/// +/// Container-local in-memory cache for /generate responses. +/// +public interface IGenerationCache +{ + bool TryGet(string key, out CachedGenerationResponse? value); + + void Set(string key, CachedGenerationResponse value); +} + +/// +/// -backed implementation of . +/// +public sealed class MemoryGenerationCache : IGenerationCache +{ + public const long DefaultSizeLimitBytes = 256L * 1024 * 1024; + + public static readonly TimeSpan DefaultSlidingExpiration = TimeSpan.FromHours(1); + + private readonly IMemoryCache _cache; + private readonly TimeSpan _slidingExpiration; + + public MemoryGenerationCache(IMemoryCache cache, TimeSpan? slidingExpiration = null) + { + _cache = cache ?? throw new ArgumentNullException(nameof(cache)); + _slidingExpiration = slidingExpiration ?? DefaultSlidingExpiration; + } + + public bool TryGet(string key, out CachedGenerationResponse? value) + { + if (_cache.TryGetValue(key, out CachedGenerationResponse? hit) && hit is not null) + { + value = hit; + return true; + } + value = null; + return false; + } + + public void Set(string key, CachedGenerationResponse value) + { + ArgumentNullException.ThrowIfNull(value); + ArgumentNullException.ThrowIfNull(value.Body); + + var size = Math.Max(1, value.Body.LongLength); + var entryOptions = new MemoryCacheEntryOptions + { + Size = size, + SlidingExpiration = _slidingExpiration, + Priority = CacheItemPriority.Normal, + }; + _cache.Set(key, value, entryOptions); + } + + /// + /// Build a content-addressed cache key. Including + /// means a deploy of a new generator binary implicitly invalidates the cache. + /// + public static string ComputeKey(string generatorName, string codeModel, string configuration, string generatorVersion) + { + ArgumentNullException.ThrowIfNull(generatorName); + ArgumentNullException.ThrowIfNull(codeModel); + ArgumentNullException.ThrowIfNull(configuration); + ArgumentNullException.ThrowIfNull(generatorVersion); + + // Length-prefix each component so concatenation is unambiguous: + // "Foo" + "BarBaz" must not collide with "FooBar" + "Baz". + var sb = new StringBuilder(generatorName.Length + codeModel.Length + configuration.Length + generatorVersion.Length + 64); + Append(sb, generatorName); + Append(sb, generatorVersion); + Append(sb, codeModel); + Append(sb, configuration); + + var bytes = Encoding.UTF8.GetBytes(sb.ToString()); + var hash = SHA256.HashData(bytes); + return Convert.ToHexString(hash); + + static void Append(StringBuilder buffer, string component) + { + buffer.Append(component.Length).Append(':').Append(component).Append('|'); + } + } +} diff --git a/packages/http-client-csharp/playground-server/Program.cs b/packages/http-client-csharp/playground-server/Program.cs index ea0b84df78c..ae33633701f 100644 --- a/packages/http-client-csharp/playground-server/Program.cs +++ b/packages/http-client-csharp/playground-server/Program.cs @@ -6,6 +6,8 @@ using System.Text.Json.Serialization; using System.Threading.RateLimiting; using Microsoft.AspNetCore.RateLimiting; +using Microsoft.Extensions.Caching.Memory; +using PlaygroundServer; const int MaxRequestBodySize = 10 * 1024 * 1024; // 10 MB const int GeneratorTimeoutSeconds = 300; @@ -36,6 +38,11 @@ } builder.Services.AddCors(); +builder.Services.AddMemoryCache(options => +{ + options.SizeLimit = MemoryGenerationCache.DefaultSizeLimitBytes; +}); +builder.Services.AddSingleton(); builder.Services.AddRateLimiter(options => { options.RejectionStatusCode = 429; @@ -86,6 +93,22 @@ Console.WriteLine($"Generator DLL: {generatorPath}"); } +// Capture the generator's assembly file version at startup so a deploy of a +// new binary implicitly invalidates every previously cached response. +string generatorVersion; +try +{ + generatorVersion = File.Exists(generatorPath) + ? (FileVersionInfo.GetVersionInfo(generatorPath).FileVersion ?? "unknown") + : "missing"; +} +catch (Exception ex) +{ + Console.Error.WriteLine($"WARNING: Failed to read generator version from {generatorPath}: {ex.Message}"); + generatorVersion = "unknown"; +} +Console.WriteLine($"Generator version: {generatorVersion}"); + app.MapGet("/health", () => { string dotnetVersion; @@ -110,7 +133,7 @@ }); }); -app.MapPost("/generate", async (HttpRequest request) => +app.MapPost("/generate", async (HttpRequest request, IGenerationCache cache) => { // Validate content type if (!request.ContentType?.StartsWith("application/json", StringComparison.OrdinalIgnoreCase) ?? true) @@ -141,6 +164,15 @@ return Results.StatusCode(503); } + var cacheKey = MemoryGenerationCache.ComputeKey(generatorName, body.CodeModel!, body.Configuration!, generatorVersion); + if (cache.TryGet(cacheKey, out var cached) && cached is not null) + { + request.HttpContext.Response.Headers["X-Cache"] = "HIT"; + return Results.Bytes(cached.Body, cached.ContentType); + } + + request.HttpContext.Response.Headers["X-Cache"] = "MISS"; + // Create a temporary working directory var tempDir = Path.Combine(Path.GetTempPath(), "tsp-playground", Guid.NewGuid().ToString("N")); var generatedDir = Path.Combine(tempDir, "src", "Generated"); @@ -232,9 +264,11 @@ } } - return Results.Json( + var responseBytes = JsonSerializer.SerializeToUtf8Bytes( new GenerateResponse(files), GenerateJsonContext.Default.GenerateResponse); + cache.Set(cacheKey, new CachedGenerationResponse(responseBytes, "application/json")); + return Results.Bytes(responseBytes, "application/json"); } finally {