From 5f152e4cf664b071d56c06075a07380606e17455 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 18 May 2026 20:08:50 +0000 Subject: [PATCH 1/3] Initial plan From 3c68428791fc91f18c6e5ed97168002c62cbcebc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 18 May 2026 20:20:17 +0000 Subject: [PATCH 2/3] [http-client-csharp] Add Tier 1 in-memory response cache to playground-server Agent-Logs-Url: https://github.com/microsoft/typespec/sessions/693fd6ec-31da-4c6f-a4be-4324b946a6f4 Co-authored-by: jorgerangel-msft <102122018+jorgerangel-msft@users.noreply.github.com> --- .../GenerationCacheTests.cs | 174 ++++++++++++++++++ .../playground-server.Tests.csproj | 22 +++ .../playground-server/GenerationCache.cs | 109 +++++++++++ .../playground-server/Program.cs | 44 ++++- 4 files changed, 347 insertions(+), 2 deletions(-) create mode 100644 packages/http-client-csharp/playground-server.Tests/GenerationCacheTests.cs create mode 100644 packages/http-client-csharp/playground-server.Tests/playground-server.Tests.csproj create mode 100644 packages/http-client-csharp/playground-server/GenerationCache.cs 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..90cacd509f6 --- /dev/null +++ b/packages/http-client-csharp/playground-server.Tests/GenerationCacheTests.cs @@ -0,0 +1,174 @@ +// 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() + { + // Item 3 acceptance: a deploy bumps the version and must invalidate. + 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() + { + // SizeLimit is bytes; each entry's Size is its body length. + // Fill past the cap and trigger compaction. + using var backing = new MemoryCache(new MemoryCacheOptions { SizeLimit = 1024, CompactionPercentage = 0.5 }); + var cache = new MemoryGenerationCache(backing); + + // Each entry is 256 bytes -> fits 4 entries before pressure. + var payload = new byte[256]; + for (int i = 0; i < 8; i++) + { + cache.Set("k" + i, new CachedGenerationResponse(payload, "application/json")); + } + // Force a full synchronous compaction to make eviction deterministic in the test. + backing.Compact(0.0); + + // Total cache should not exceed the cap. + 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 _)); + + // Wait past the sliding window without touching the entry. + 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..f6c9508586a --- /dev/null +++ b/packages/http-client-csharp/playground-server/GenerationCache.cs @@ -0,0 +1,109 @@ +// 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 for the playground server. +/// Stored as the already-serialized JSON bytes plus content type so cache hits +/// can short-circuit the entire generation pipeline. +/// +public sealed record CachedGenerationResponse(byte[] Body, string ContentType); + +/// +/// Container-local cache for /generate responses. See Item 3 of the playground +/// perf design: this is Tier 1 (in-memory) only. Identical requests within a +/// container short-circuit the dotnet sub-process invocation. +/// +public interface IGenerationCache +{ + /// Look up a previously cached response. + bool TryGet(string key, out CachedGenerationResponse? value); + + /// Store a response, sized by its body length. + void Set(string key, CachedGenerationResponse value); +} + +/// +/// IMemoryCache-backed implementation of . +/// Entry size is the response body length in bytes; total cache size is +/// capped via on the underlying cache. +/// +public sealed class MemoryGenerationCache : IGenerationCache +{ + /// Default cache size cap: 256 MB of response bodies. + public const long DefaultSizeLimitBytes = 256L * 1024 * 1024; + + /// Default sliding expiration for an entry. + 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); + + // Size is in bytes; an entry will be evicted under SizeLimit pressure + // via the IMemoryCache compaction algorithm (LRU-ish, by priority). + 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. Includes + /// so 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. + // e.g. "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..3441cb5b546 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,12 @@ } builder.Services.AddCors(); +builder.Services.AddMemoryCache(options => +{ + // Tier 1 cache cap (Item 3 of playground perf plan): 256 MB of response bodies. + options.SizeLimit = MemoryGenerationCache.DefaultSizeLimitBytes; +}); +builder.Services.AddSingleton(); builder.Services.AddRateLimiter(options => { options.RejectionStatusCode = 429; @@ -86,6 +94,23 @@ Console.WriteLine($"Generator DLL: {generatorPath}"); } +// Capture the generator's assembly file version at startup so it can be folded +// into the cache key. A new deploy with a different binary therefore 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 +135,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 +166,18 @@ return Results.StatusCode(503); } + // Tier 1 cache lookup: identical (generator, codeModel, configuration, version) + // requests reuse the previously serialized response and skip the dotnet + // sub-process entirely. + 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 +269,12 @@ } } - return Results.Json( + // Serialize once so we can both cache and return the same bytes. + 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 { From 48ddd6c94f63c2d3157911695334a628a7455ea0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 18 May 2026 22:46:43 +0000 Subject: [PATCH 3/3] Remove issue-pertaining comments and redundant inline comments Agent-Logs-Url: https://github.com/microsoft/typespec/sessions/656e84a4-17f1-4c10-87de-4f80c2df8412 Co-authored-by: jorgerangel-msft <102122018+jorgerangel-msft@users.noreply.github.com> --- .../GenerationCacheTests.cs | 7 ----- .../playground-server/GenerationCache.cs | 27 ++++++------------- .../playground-server/Program.cs | 10 ++----- 3 files changed, 10 insertions(+), 34 deletions(-) diff --git a/packages/http-client-csharp/playground-server.Tests/GenerationCacheTests.cs b/packages/http-client-csharp/playground-server.Tests/GenerationCacheTests.cs index 90cacd509f6..a8d6e912fb9 100644 --- a/packages/http-client-csharp/playground-server.Tests/GenerationCacheTests.cs +++ b/packages/http-client-csharp/playground-server.Tests/GenerationCacheTests.cs @@ -66,7 +66,6 @@ public void ComputeKey_ChangesWhenConfigurationChanges() [Test] public void ComputeKey_ChangesWhenGeneratorVersionChanges() { - // Item 3 acceptance: a deploy bumps the version and must invalidate. var a = MemoryGenerationCache.ComputeKey("gen", "m", "c", "1.0.0"); var b = MemoryGenerationCache.ComputeKey("gen", "m", "c", "1.0.1"); Assert.AreNotEqual(a, b); @@ -141,21 +140,16 @@ public void Constructor_ThrowsOnNullBackingCache() [Test] public void SizeLimit_EvictsEntriesUnderPressure() { - // SizeLimit is bytes; each entry's Size is its body length. - // Fill past the cap and trigger compaction. using var backing = new MemoryCache(new MemoryCacheOptions { SizeLimit = 1024, CompactionPercentage = 0.5 }); var cache = new MemoryGenerationCache(backing); - // Each entry is 256 bytes -> fits 4 entries before pressure. var payload = new byte[256]; for (int i = 0; i < 8; i++) { cache.Set("k" + i, new CachedGenerationResponse(payload, "application/json")); } - // Force a full synchronous compaction to make eviction deterministic in the test. backing.Compact(0.0); - // Total cache should not exceed the cap. Assert.LessOrEqual(backing.Count * 256, 1024); } @@ -166,7 +160,6 @@ public async Task SlidingExpiration_EvictsAfterIdle() cache.Set("k", MakeResponse("x")); Assert.IsTrue(cache.TryGet("k", out _)); - // Wait past the sliding window without touching the entry. await Task.Delay(200); Assert.IsFalse(cache.TryGet("k", out var value)); Assert.IsNull(value); diff --git a/packages/http-client-csharp/playground-server/GenerationCache.cs b/packages/http-client-csharp/playground-server/GenerationCache.cs index f6c9508586a..a6d45ac7e2a 100644 --- a/packages/http-client-csharp/playground-server/GenerationCache.cs +++ b/packages/http-client-csharp/playground-server/GenerationCache.cs @@ -8,37 +8,28 @@ namespace PlaygroundServer; /// -/// Cached generator response for the playground server. -/// Stored as the already-serialized JSON bytes plus content type so cache hits -/// can short-circuit the entire generation pipeline. +/// 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 cache for /generate responses. See Item 3 of the playground -/// perf design: this is Tier 1 (in-memory) only. Identical requests within a -/// container short-circuit the dotnet sub-process invocation. +/// Container-local in-memory cache for /generate responses. /// public interface IGenerationCache { - /// Look up a previously cached response. bool TryGet(string key, out CachedGenerationResponse? value); - /// Store a response, sized by its body length. void Set(string key, CachedGenerationResponse value); } /// -/// IMemoryCache-backed implementation of . -/// Entry size is the response body length in bytes; total cache size is -/// capped via on the underlying cache. +/// -backed implementation of . /// public sealed class MemoryGenerationCache : IGenerationCache { - /// Default cache size cap: 256 MB of response bodies. public const long DefaultSizeLimitBytes = 256L * 1024 * 1024; - /// Default sliding expiration for an entry. public static readonly TimeSpan DefaultSlidingExpiration = TimeSpan.FromHours(1); private readonly IMemoryCache _cache; @@ -66,8 +57,6 @@ public void Set(string key, CachedGenerationResponse value) ArgumentNullException.ThrowIfNull(value); ArgumentNullException.ThrowIfNull(value.Body); - // Size is in bytes; an entry will be evicted under SizeLimit pressure - // via the IMemoryCache compaction algorithm (LRU-ish, by priority). var size = Math.Max(1, value.Body.LongLength); var entryOptions = new MemoryCacheEntryOptions { @@ -79,8 +68,8 @@ public void Set(string key, CachedGenerationResponse value) } /// - /// Build a content-addressed cache key. Includes - /// so a deploy of a new generator binary implicitly invalidates the cache. + /// 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) { @@ -89,8 +78,8 @@ public static string ComputeKey(string generatorName, string codeModel, string c ArgumentNullException.ThrowIfNull(configuration); ArgumentNullException.ThrowIfNull(generatorVersion); - // Length-prefix each component so concatenation is unambiguous. - // e.g. "Foo" + "BarBaz" must not collide with "FooBar" + "Baz". + // 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); diff --git a/packages/http-client-csharp/playground-server/Program.cs b/packages/http-client-csharp/playground-server/Program.cs index 3441cb5b546..ae33633701f 100644 --- a/packages/http-client-csharp/playground-server/Program.cs +++ b/packages/http-client-csharp/playground-server/Program.cs @@ -40,7 +40,6 @@ builder.Services.AddCors(); builder.Services.AddMemoryCache(options => { - // Tier 1 cache cap (Item 3 of playground perf plan): 256 MB of response bodies. options.SizeLimit = MemoryGenerationCache.DefaultSizeLimitBytes; }); builder.Services.AddSingleton(); @@ -94,9 +93,8 @@ Console.WriteLine($"Generator DLL: {generatorPath}"); } -// Capture the generator's assembly file version at startup so it can be folded -// into the cache key. A new deploy with a different binary therefore implicitly -// invalidates every previously cached response. +// 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 { @@ -166,9 +164,6 @@ return Results.StatusCode(503); } - // Tier 1 cache lookup: identical (generator, codeModel, configuration, version) - // requests reuse the previously serialized response and skip the dotnet - // sub-process entirely. var cacheKey = MemoryGenerationCache.ComputeKey(generatorName, body.CodeModel!, body.Configuration!, generatorVersion); if (cache.TryGet(cacheKey, out var cached) && cached is not null) { @@ -269,7 +264,6 @@ } } - // Serialize once so we can both cache and return the same bytes. var responseBytes = JsonSerializer.SerializeToUtf8Bytes( new GenerateResponse(files), GenerateJsonContext.Default.GenerateResponse);