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 01/15] 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 02/15] [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 03/15] 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); From 79bae62bcab1596a9ddad515dd3412352afbbb26 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 2 Jun 2026 00:28:18 +0000 Subject: [PATCH 04/15] Remove sliding expiration from generation cache Co-authored-by: JoshLove-msft <54595583+JoshLove-msft@users.noreply.github.com> --- .../GenerationCacheTests.cs | 17 ++--------------- .../playground-server/GenerationCache.cs | 7 +------ 2 files changed, 3 insertions(+), 21 deletions(-) diff --git a/packages/http-client-csharp/playground-server.Tests/GenerationCacheTests.cs b/packages/http-client-csharp/playground-server.Tests/GenerationCacheTests.cs index a8d6e912fb9..ee653039976 100644 --- a/packages/http-client-csharp/playground-server.Tests/GenerationCacheTests.cs +++ b/packages/http-client-csharp/playground-server.Tests/GenerationCacheTests.cs @@ -3,7 +3,6 @@ using System; using System.Text; -using System.Threading.Tasks; using Microsoft.Extensions.Caching.Memory; using NUnit.Framework; using PlaygroundServer; @@ -13,10 +12,10 @@ namespace PlaygroundServer.Tests; [TestFixture] public class GenerationCacheTests { - private static MemoryGenerationCache CreateCache(long sizeLimit = MemoryGenerationCache.DefaultSizeLimitBytes, TimeSpan? sliding = null) + private static MemoryGenerationCache CreateCache(long sizeLimit = MemoryGenerationCache.DefaultSizeLimitBytes) { var memory = new MemoryCache(new MemoryCacheOptions { SizeLimit = sizeLimit }); - return new MemoryGenerationCache(memory, sliding); + return new MemoryGenerationCache(memory); } private static CachedGenerationResponse MakeResponse(string content) @@ -152,16 +151,4 @@ public void SizeLimit_EvictsEntriesUnderPressure() 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/GenerationCache.cs b/packages/http-client-csharp/playground-server/GenerationCache.cs index a6d45ac7e2a..e03219d1747 100644 --- a/packages/http-client-csharp/playground-server/GenerationCache.cs +++ b/packages/http-client-csharp/playground-server/GenerationCache.cs @@ -30,15 +30,11 @@ 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) + public MemoryGenerationCache(IMemoryCache cache) { _cache = cache ?? throw new ArgumentNullException(nameof(cache)); - _slidingExpiration = slidingExpiration ?? DefaultSlidingExpiration; } public bool TryGet(string key, out CachedGenerationResponse? value) @@ -61,7 +57,6 @@ public void Set(string key, CachedGenerationResponse value) var entryOptions = new MemoryCacheEntryOptions { Size = size, - SlidingExpiration = _slidingExpiration, Priority = CacheItemPriority.Normal, }; _cache.Set(key, value, entryOptions); From e1bf9710d971275b261ab90b95b1298d1df726d4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 2 Jun 2026 00:31:12 +0000 Subject: [PATCH 05/15] Add telemetry for cache hits Co-authored-by: JoshLove-msft <54595583+JoshLove-msft@users.noreply.github.com> --- packages/http-client-csharp/playground-server/Program.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/http-client-csharp/playground-server/Program.cs b/packages/http-client-csharp/playground-server/Program.cs index e36efd20e3f..56d4a0c965c 100644 --- a/packages/http-client-csharp/playground-server/Program.cs +++ b/packages/http-client-csharp/playground-server/Program.cs @@ -201,10 +201,13 @@ void TrackGenerateEvent(string outcome) if (cache.TryGet(cacheKey, out var cached) && cached is not null) { request.HttpContext.Response.Headers["X-Cache"] = "HIT"; + telemetryProperties["cacheStatus"] = "hit"; + TrackGenerateEvent("cache_hit"); return Results.Bytes(cached.Body, cached.ContentType); } request.HttpContext.Response.Headers["X-Cache"] = "MISS"; + telemetryProperties["cacheStatus"] = "miss"; // Create a temporary working directory var tempDir = Path.Combine(Path.GetTempPath(), "tsp-playground", Guid.NewGuid().ToString("N")); From 7b32d984082cdfc648b3b8ef1352aa824af0eabb Mon Sep 17 00:00:00 2001 From: jolov Date: Mon, 1 Jun 2026 17:37:00 -0700 Subject: [PATCH 06/15] Model cache status as telemetry dimension instead of outcome Emit outcome=success with cacheStatus=hit on cache hits rather than a distinct cache_hit outcome. Misses already ride on the terminal success/error event via cacheStatus=miss, so no separate miss event is needed. This keeps request outcome and cache disposition as independent axes and avoids double-counting. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/http-client-csharp/playground-server/Program.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/http-client-csharp/playground-server/Program.cs b/packages/http-client-csharp/playground-server/Program.cs index 56d4a0c965c..c0fc39a88ac 100644 --- a/packages/http-client-csharp/playground-server/Program.cs +++ b/packages/http-client-csharp/playground-server/Program.cs @@ -202,7 +202,7 @@ void TrackGenerateEvent(string outcome) { request.HttpContext.Response.Headers["X-Cache"] = "HIT"; telemetryProperties["cacheStatus"] = "hit"; - TrackGenerateEvent("cache_hit"); + TrackGenerateEvent("success"); return Results.Bytes(cached.Body, cached.ContentType); } From ee0f170ac6be8c8d9c72d442a5a434cc5312e985 Mon Sep 17 00:00:00 2001 From: jolov Date: Mon, 1 Jun 2026 17:40:23 -0700 Subject: [PATCH 07/15] Use GenerateOutcome enum for /generate telemetry outcomes Replace the stringly-typed outcome argument of TrackGenerateEvent with a GenerateOutcome enum. ToTelemetryValue maps each member to its existing snake_case wire string so telemetry dashboards keep working. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../playground-server/GenerateOutcome.cs | 39 +++++++++++++++++++ .../playground-server/Program.cs | 25 ++++++------ 2 files changed, 52 insertions(+), 12 deletions(-) create mode 100644 packages/http-client-csharp/playground-server/GenerateOutcome.cs diff --git a/packages/http-client-csharp/playground-server/GenerateOutcome.cs b/packages/http-client-csharp/playground-server/GenerateOutcome.cs new file mode 100644 index 00000000000..8d1d168fbcc --- /dev/null +++ b/packages/http-client-csharp/playground-server/GenerateOutcome.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +namespace PlaygroundServer; + +/// +/// Terminal outcome of a /generate request, recorded on the "outcome" telemetry dimension. +/// +public enum GenerateOutcome +{ + InvalidContentType, + InvalidJson, + MissingFields, + GeneratorMissing, + Timeout, + GeneratorFailed, + Success, + Exception, +} + +public static class GenerateOutcomeExtensions +{ + /// + /// Maps an outcome to its stable telemetry string. These values are part of the telemetry + /// contract (queried in dashboards), so they must not change when the enum is refactored. + /// + public static string ToTelemetryValue(this GenerateOutcome outcome) => outcome switch + { + GenerateOutcome.InvalidContentType => "invalid_content_type", + GenerateOutcome.InvalidJson => "invalid_json", + GenerateOutcome.MissingFields => "missing_fields", + GenerateOutcome.GeneratorMissing => "generator_missing", + GenerateOutcome.Timeout => "timeout", + GenerateOutcome.GeneratorFailed => "generator_failed", + GenerateOutcome.Success => "success", + GenerateOutcome.Exception => "exception", + _ => throw new ArgumentOutOfRangeException(nameof(outcome), outcome, null), + }; +} diff --git a/packages/http-client-csharp/playground-server/Program.cs b/packages/http-client-csharp/playground-server/Program.cs index c0fc39a88ac..5d850d5209c 100644 --- a/packages/http-client-csharp/playground-server/Program.cs +++ b/packages/http-client-csharp/playground-server/Program.cs @@ -150,22 +150,23 @@ var stopwatch = Stopwatch.StartNew(); var telemetryProperties = new Dictionary(); - void TrackGenerateEvent(string outcome) + void TrackGenerateEvent(GenerateOutcome outcome) { if (telemetryClient is null) return; stopwatch.Stop(); - telemetryProperties["outcome"] = outcome; + var outcomeValue = outcome.ToTelemetryValue(); + telemetryProperties["outcome"] = outcomeValue; telemetryProperties["durationMs"] = stopwatch.Elapsed.TotalMilliseconds.ToString("F0", System.Globalization.CultureInfo.InvariantCulture); var evt = new EventTelemetry("PlaygroundGenerate"); foreach (var kvp in telemetryProperties) evt.Properties[kvp.Key] = kvp.Value; telemetryClient.TrackEvent(evt); - telemetryClient.GetMetric("PlaygroundGenerateDurationMs", "outcome").TrackValue(stopwatch.Elapsed.TotalMilliseconds, outcome); + telemetryClient.GetMetric("PlaygroundGenerateDurationMs", "outcome").TrackValue(stopwatch.Elapsed.TotalMilliseconds, outcomeValue); } // Validate content type if (!request.ContentType?.StartsWith("application/json", StringComparison.OrdinalIgnoreCase) ?? true) { - TrackGenerateEvent("invalid_content_type"); + TrackGenerateEvent(GenerateOutcome.InvalidContentType); return Results.BadRequest(new { error = "Content-Type must be application/json" }); } @@ -177,13 +178,13 @@ void TrackGenerateEvent(string outcome) } catch (JsonException) { - TrackGenerateEvent("invalid_json"); + TrackGenerateEvent(GenerateOutcome.InvalidJson); return Results.BadRequest(new { error = "Invalid JSON in request body" }); } if (body?.CodeModel is null || body?.Configuration is null) { - TrackGenerateEvent("missing_fields"); + TrackGenerateEvent(GenerateOutcome.MissingFields); return Results.BadRequest(new { error = "Missing 'codeModel' or 'configuration' fields" }); } @@ -193,7 +194,7 @@ void TrackGenerateEvent(string outcome) if (!File.Exists(generatorPath)) { - TrackGenerateEvent("generator_missing"); + TrackGenerateEvent(GenerateOutcome.GeneratorMissing); return Results.StatusCode(503); } @@ -202,7 +203,7 @@ void TrackGenerateEvent(string outcome) { request.HttpContext.Response.Headers["X-Cache"] = "HIT"; telemetryProperties["cacheStatus"] = "hit"; - TrackGenerateEvent("success"); + TrackGenerateEvent(GenerateOutcome.Success); return Results.Bytes(cached.Body, cached.ContentType); } @@ -265,7 +266,7 @@ void TrackGenerateEvent(string outcome) catch (OperationCanceledException) { process.Kill(entireProcessTree: true); - TrackGenerateEvent("timeout"); + TrackGenerateEvent(GenerateOutcome.Timeout); return Results.Json( new GenerateErrorResponse("Generator timed out", $"Process did not complete within {GeneratorTimeoutSeconds} seconds"), GenerateJsonContext.Default.GenerateErrorResponse, @@ -284,7 +285,7 @@ void TrackGenerateEvent(string outcome) $"Generator failed (exit {exitCode}): {stderrTail}", SeverityLevel.Error, telemetryProperties); - TrackGenerateEvent("generator_failed"); + TrackGenerateEvent(GenerateOutcome.GeneratorFailed); return Results.Json( new GenerateErrorResponse($"Generator failed with exit code {exitCode}", stderrTail), GenerateJsonContext.Default.GenerateErrorResponse, @@ -309,7 +310,7 @@ void TrackGenerateEvent(string outcome) } telemetryProperties["generatedFileCount"] = files.Count.ToString(System.Globalization.CultureInfo.InvariantCulture); - TrackGenerateEvent("success"); + TrackGenerateEvent(GenerateOutcome.Success); var responseBytes = JsonSerializer.SerializeToUtf8Bytes( new GenerateResponse(files), GenerateJsonContext.Default.GenerateResponse); @@ -324,7 +325,7 @@ void TrackGenerateEvent(string outcome) foreach (var kvp in telemetryProperties) exTelemetry.Properties[kvp.Key] = kvp.Value; telemetryClient.TrackException(exTelemetry); } - TrackGenerateEvent("exception"); + TrackGenerateEvent(GenerateOutcome.Exception); throw; } finally From d0dffd39c5b9584b69e604b2e0c09181a2be398a Mon Sep 17 00:00:00 2001 From: JoshLove-msft Date: Tue, 2 Jun 2026 09:24:06 -0700 Subject: [PATCH 08/15] Add ComputeKey tests with a realistic code model Adds four GenerationCacheTests cases that drive ComputeKey with a multi-KB code model/configuration shaped like real generator input: determinism, key change on a semantic code-model edit, key change on a configuration edit, and a cache round-trip. Addresses PR review feedback requesting coverage with a realistic code model. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../GenerationCacheTests.cs | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) diff --git a/packages/http-client-csharp/playground-server.Tests/GenerationCacheTests.cs b/packages/http-client-csharp/playground-server.Tests/GenerationCacheTests.cs index ee653039976..428bc649962 100644 --- a/packages/http-client-csharp/playground-server.Tests/GenerationCacheTests.cs +++ b/packages/http-client-csharp/playground-server.Tests/GenerationCacheTests.cs @@ -21,6 +21,66 @@ private static MemoryGenerationCache CreateCache(long sizeLimit = MemoryGenerati private static CachedGenerationResponse MakeResponse(string content) => new(Encoding.UTF8.GetBytes(content), "application/json"); + // A representative (multi-KB) code model payload shaped like what the generator actually receives, + // used to exercise ComputeKey against realistic input rather than tiny synthetic strings. + private const string SampleCodeModel = """ + { + "$id": "1", + "name": "PetStore", + "apiVersions": ["2024-01-01"], + "enums": [ + { + "$id": "2", + "kind": "enum", + "name": "PetKind", + "valueType": { "$id": "3", "kind": "string", "name": "string", "crossLanguageDefinitionId": "TypeSpec.string" }, + "values": [ + { "$id": "4", "kind": "enumvalue", "name": "Dog", "value": "dog" }, + { "$id": "5", "kind": "enumvalue", "name": "Cat", "value": "cat" } + ], + "isFixed": true, + "usage": "Input,Output" + } + ], + "models": [ + { + "$id": "6", + "kind": "model", + "name": "Pet", + "crossLanguageDefinitionId": "PetStore.Pet", + "usage": "Input,Output", + "properties": [ + { "$id": "7", "kind": "property", "name": "id", "serializedName": "id", "type": { "$id": "8", "kind": "int32", "name": "int32" }, "optional": false }, + { "$id": "9", "kind": "property", "name": "name", "serializedName": "name", "type": { "$id": "10", "kind": "string", "name": "string" }, "optional": false }, + { "$id": "11", "kind": "property", "name": "kind", "serializedName": "kind", "type": { "$ref": "2" }, "optional": true } + ] + } + ], + "clients": [ + { + "$id": "12", + "kind": "client", + "name": "PetStoreClient", + "namespace": "PetStore", + "operations": [ + { + "$id": "13", + "name": "getPet", + "resourceName": "Pet", + "verb": "get", + "path": "/pets/{petId}", + "responses": [{ "$id": "14", "statusCodes": [200], "bodyType": { "$ref": "6" } }] + } + ] + } + ] + } + """; + + private const string SampleConfiguration = """ + { "package-name": "PetStore", "namespace": "PetStore", "library-name": "PetStore" } + """; + [Test] public void ComputeKey_IsDeterministic_ForSameInputs() { @@ -82,6 +142,52 @@ public void ComputeKey_IsUnambiguousAcrossComponentBoundaries() Assert.AreNotEqual(b, c); } + [Test] + public void ComputeKey_WithRealisticCodeModel_IsDeterministic() + { + var k1 = MemoryGenerationCache.ComputeKey("ScmCodeModelGenerator", SampleCodeModel, SampleConfiguration, "1.0.0"); + var k2 = MemoryGenerationCache.ComputeKey("ScmCodeModelGenerator", SampleCodeModel, SampleConfiguration, "1.0.0"); + Assert.AreEqual(k1, k2); + Assert.AreEqual(64, k1.Length); + Assert.That(k1, Does.Match("^[0-9A-F]{64}$")); + } + + [Test] + public void ComputeKey_WithRealisticCodeModel_ChangesOnSemanticEdit() + { + // Flip a single property name deep in the model; the key must change. + var edited = SampleCodeModel.Replace("\"name\": \"name\"", "\"name\": \"fullName\""); + Assert.AreNotEqual(SampleCodeModel, edited, "precondition: the edit must alter the code model"); + + var original = MemoryGenerationCache.ComputeKey("ScmCodeModelGenerator", SampleCodeModel, SampleConfiguration, "1.0.0"); + var afterEdit = MemoryGenerationCache.ComputeKey("ScmCodeModelGenerator", edited, SampleConfiguration, "1.0.0"); + Assert.AreNotEqual(original, afterEdit); + } + + [Test] + public void ComputeKey_WithRealisticCodeModel_ChangesOnConfigurationEdit() + { + var editedConfig = SampleConfiguration.Replace("\"library-name\": \"PetStore\"", "\"library-name\": \"PetStoreV2\""); + Assert.AreNotEqual(SampleConfiguration, editedConfig, "precondition: the edit must alter the configuration"); + + var original = MemoryGenerationCache.ComputeKey("ScmCodeModelGenerator", SampleCodeModel, SampleConfiguration, "1.0.0"); + var afterEdit = MemoryGenerationCache.ComputeKey("ScmCodeModelGenerator", SampleCodeModel, editedConfig, "1.0.0"); + Assert.AreNotEqual(original, afterEdit); + } + + [Test] + public void ComputeKey_RealisticCodeModel_RoundTripsThroughCache() + { + var cache = CreateCache(); + var key = MemoryGenerationCache.ComputeKey("ScmCodeModelGenerator", SampleCodeModel, SampleConfiguration, "1.0.0"); + + Assert.IsFalse(cache.TryGet(key, out _)); + cache.Set(key, MakeResponse("generated")); + + Assert.IsTrue(cache.TryGet(key, out var value)); + Assert.AreEqual("generated", Encoding.UTF8.GetString(value!.Body)); + } + [Test] public void ComputeKey_ThrowsOnNullArguments() { From 4b4016f3ba04bb7426e5705d3fb282f31ee7b840 Mon Sep 17 00:00:00 2001 From: JoshLove-msft Date: Tue, 2 Jun 2026 09:27:40 -0700 Subject: [PATCH 09/15] Move sample code model to TestData files instead of inlining Follows the generator test projects' TestData convention: the realistic code model and configuration now live in TestData/GenerationCacheTests/*.json (copied to the output directory) and are loaded via a ReadTestData helper, rather than as inlined string constants in the test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../GenerationCacheTests.cs | 64 +++---------------- .../sample-codemodel.json | 51 +++++++++++++++ .../sample-configuration.json | 1 + .../playground-server.Tests.csproj | 6 ++ 4 files changed, 66 insertions(+), 56 deletions(-) create mode 100644 packages/http-client-csharp/playground-server.Tests/TestData/GenerationCacheTests/sample-codemodel.json create mode 100644 packages/http-client-csharp/playground-server.Tests/TestData/GenerationCacheTests/sample-configuration.json diff --git a/packages/http-client-csharp/playground-server.Tests/GenerationCacheTests.cs b/packages/http-client-csharp/playground-server.Tests/GenerationCacheTests.cs index 428bc649962..8e9a767c511 100644 --- a/packages/http-client-csharp/playground-server.Tests/GenerationCacheTests.cs +++ b/packages/http-client-csharp/playground-server.Tests/GenerationCacheTests.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System; +using System.IO; using System.Text; using Microsoft.Extensions.Caching.Memory; using NUnit.Framework; @@ -21,65 +22,16 @@ private static MemoryGenerationCache CreateCache(long sizeLimit = MemoryGenerati private static CachedGenerationResponse MakeResponse(string content) => new(Encoding.UTF8.GetBytes(content), "application/json"); + // Reads a file from TestData//, mirroring the generator test projects' TestData convention. + // Files are copied next to the test assembly via the csproj CopyToOutputDirectory item. + private static string ReadTestData(string fileName) + => File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "TestData", nameof(GenerationCacheTests), fileName)); + // A representative (multi-KB) code model payload shaped like what the generator actually receives, // used to exercise ComputeKey against realistic input rather than tiny synthetic strings. - private const string SampleCodeModel = """ - { - "$id": "1", - "name": "PetStore", - "apiVersions": ["2024-01-01"], - "enums": [ - { - "$id": "2", - "kind": "enum", - "name": "PetKind", - "valueType": { "$id": "3", "kind": "string", "name": "string", "crossLanguageDefinitionId": "TypeSpec.string" }, - "values": [ - { "$id": "4", "kind": "enumvalue", "name": "Dog", "value": "dog" }, - { "$id": "5", "kind": "enumvalue", "name": "Cat", "value": "cat" } - ], - "isFixed": true, - "usage": "Input,Output" - } - ], - "models": [ - { - "$id": "6", - "kind": "model", - "name": "Pet", - "crossLanguageDefinitionId": "PetStore.Pet", - "usage": "Input,Output", - "properties": [ - { "$id": "7", "kind": "property", "name": "id", "serializedName": "id", "type": { "$id": "8", "kind": "int32", "name": "int32" }, "optional": false }, - { "$id": "9", "kind": "property", "name": "name", "serializedName": "name", "type": { "$id": "10", "kind": "string", "name": "string" }, "optional": false }, - { "$id": "11", "kind": "property", "name": "kind", "serializedName": "kind", "type": { "$ref": "2" }, "optional": true } - ] - } - ], - "clients": [ - { - "$id": "12", - "kind": "client", - "name": "PetStoreClient", - "namespace": "PetStore", - "operations": [ - { - "$id": "13", - "name": "getPet", - "resourceName": "Pet", - "verb": "get", - "path": "/pets/{petId}", - "responses": [{ "$id": "14", "statusCodes": [200], "bodyType": { "$ref": "6" } }] - } - ] - } - ] - } - """; + private static string SampleCodeModel => ReadTestData("sample-codemodel.json"); - private const string SampleConfiguration = """ - { "package-name": "PetStore", "namespace": "PetStore", "library-name": "PetStore" } - """; + private static string SampleConfiguration => ReadTestData("sample-configuration.json"); [Test] public void ComputeKey_IsDeterministic_ForSameInputs() diff --git a/packages/http-client-csharp/playground-server.Tests/TestData/GenerationCacheTests/sample-codemodel.json b/packages/http-client-csharp/playground-server.Tests/TestData/GenerationCacheTests/sample-codemodel.json new file mode 100644 index 00000000000..b4c4b0ec822 --- /dev/null +++ b/packages/http-client-csharp/playground-server.Tests/TestData/GenerationCacheTests/sample-codemodel.json @@ -0,0 +1,51 @@ +{ + "$id": "1", + "name": "PetStore", + "apiVersions": ["2024-01-01"], + "enums": [ + { + "$id": "2", + "kind": "enum", + "name": "PetKind", + "valueType": { "$id": "3", "kind": "string", "name": "string", "crossLanguageDefinitionId": "TypeSpec.string" }, + "values": [ + { "$id": "4", "kind": "enumvalue", "name": "Dog", "value": "dog" }, + { "$id": "5", "kind": "enumvalue", "name": "Cat", "value": "cat" } + ], + "isFixed": true, + "usage": "Input,Output" + } + ], + "models": [ + { + "$id": "6", + "kind": "model", + "name": "Pet", + "crossLanguageDefinitionId": "PetStore.Pet", + "usage": "Input,Output", + "properties": [ + { "$id": "7", "kind": "property", "name": "id", "serializedName": "id", "type": { "$id": "8", "kind": "int32", "name": "int32" }, "optional": false }, + { "$id": "9", "kind": "property", "name": "name", "serializedName": "name", "type": { "$id": "10", "kind": "string", "name": "string" }, "optional": false }, + { "$id": "11", "kind": "property", "name": "kind", "serializedName": "kind", "type": { "$ref": "2" }, "optional": true } + ] + } + ], + "clients": [ + { + "$id": "12", + "kind": "client", + "name": "PetStoreClient", + "namespace": "PetStore", + "operations": [ + { + "$id": "13", + "name": "getPet", + "resourceName": "Pet", + "verb": "get", + "path": "/pets/{petId}", + "responses": [{ "$id": "14", "statusCodes": [200], "bodyType": { "$ref": "6" } }] + } + ] + } + ] +} diff --git a/packages/http-client-csharp/playground-server.Tests/TestData/GenerationCacheTests/sample-configuration.json b/packages/http-client-csharp/playground-server.Tests/TestData/GenerationCacheTests/sample-configuration.json new file mode 100644 index 00000000000..9fcb4e7cd06 --- /dev/null +++ b/packages/http-client-csharp/playground-server.Tests/TestData/GenerationCacheTests/sample-configuration.json @@ -0,0 +1 @@ +{ "package-name": "PetStore", "namespace": "PetStore", "library-name": "PetStore" } 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 index 0c8466c3793..b4d96540c12 100644 --- a/packages/http-client-csharp/playground-server.Tests/playground-server.Tests.csproj +++ b/packages/http-client-csharp/playground-server.Tests/playground-server.Tests.csproj @@ -19,4 +19,10 @@ + + + PreserveNewest + + + From 6543d369e8bb6e554a3d821678ebbbd5aa912a1a Mon Sep 17 00:00:00 2001 From: JoshLove-msft Date: Tue, 2 Jun 2026 09:46:27 -0700 Subject: [PATCH 10/15] Apply prettier formatting to TestData JSON Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../sample-codemodel.json | 34 ++++++++++++++++--- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/packages/http-client-csharp/playground-server.Tests/TestData/GenerationCacheTests/sample-codemodel.json b/packages/http-client-csharp/playground-server.Tests/TestData/GenerationCacheTests/sample-codemodel.json index b4c4b0ec822..b64f29a365b 100644 --- a/packages/http-client-csharp/playground-server.Tests/TestData/GenerationCacheTests/sample-codemodel.json +++ b/packages/http-client-csharp/playground-server.Tests/TestData/GenerationCacheTests/sample-codemodel.json @@ -7,7 +7,12 @@ "$id": "2", "kind": "enum", "name": "PetKind", - "valueType": { "$id": "3", "kind": "string", "name": "string", "crossLanguageDefinitionId": "TypeSpec.string" }, + "valueType": { + "$id": "3", + "kind": "string", + "name": "string", + "crossLanguageDefinitionId": "TypeSpec.string" + }, "values": [ { "$id": "4", "kind": "enumvalue", "name": "Dog", "value": "dog" }, { "$id": "5", "kind": "enumvalue", "name": "Cat", "value": "cat" } @@ -24,9 +29,30 @@ "crossLanguageDefinitionId": "PetStore.Pet", "usage": "Input,Output", "properties": [ - { "$id": "7", "kind": "property", "name": "id", "serializedName": "id", "type": { "$id": "8", "kind": "int32", "name": "int32" }, "optional": false }, - { "$id": "9", "kind": "property", "name": "name", "serializedName": "name", "type": { "$id": "10", "kind": "string", "name": "string" }, "optional": false }, - { "$id": "11", "kind": "property", "name": "kind", "serializedName": "kind", "type": { "$ref": "2" }, "optional": true } + { + "$id": "7", + "kind": "property", + "name": "id", + "serializedName": "id", + "type": { "$id": "8", "kind": "int32", "name": "int32" }, + "optional": false + }, + { + "$id": "9", + "kind": "property", + "name": "name", + "serializedName": "name", + "type": { "$id": "10", "kind": "string", "name": "string" }, + "optional": false + }, + { + "$id": "11", + "kind": "property", + "name": "kind", + "serializedName": "kind", + "type": { "$ref": "2" }, + "optional": true + } ] } ], From 9a158c988dd918054ec31764ee4464c68ef4c9e9 Mon Sep 17 00:00:00 2001 From: jolov Date: Tue, 2 Jun 2026 11:46:58 -0700 Subject: [PATCH 11/15] Address review: split cache types, configurable size limit, central package versions - Split GenerationCache.cs into CachedGenerationResponse.cs, IGenerationCache.cs and MemoryGenerationCache.cs (one public type per file). - Make the memory cache SizeLimit configurable via app settings (GenerationCache:SizeLimitBytes) with the 256 MB default, surfaced in a new appsettings.json. - Drop hardcoded package versions from playground-server.Tests.csproj and consume the central versions from generator/Packages.Data.props instead (adding a Microsoft.Extensions.Caching.Memory entry there). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../generator/Packages.Data.props | 1 + .../playground-server.Tests.csproj | 11 +++++++---- .../CachedGenerationResponse.cs | 10 ++++++++++ .../playground-server/IGenerationCache.cs | 14 ++++++++++++++ ...nerationCache.cs => MemoryGenerationCache.cs} | 16 ---------------- .../playground-server/Program.cs | 4 +++- .../playground-server/appsettings.json | 5 +++++ 7 files changed, 40 insertions(+), 21 deletions(-) create mode 100644 packages/http-client-csharp/playground-server/CachedGenerationResponse.cs create mode 100644 packages/http-client-csharp/playground-server/IGenerationCache.cs rename packages/http-client-csharp/playground-server/{GenerationCache.cs => MemoryGenerationCache.cs} (84%) create mode 100644 packages/http-client-csharp/playground-server/appsettings.json diff --git a/packages/http-client-csharp/generator/Packages.Data.props b/packages/http-client-csharp/generator/Packages.Data.props index 3ddf0a7e750..89ff84aa0ce 100644 --- a/packages/http-client-csharp/generator/Packages.Data.props +++ b/packages/http-client-csharp/generator/Packages.Data.props @@ -16,6 +16,7 @@ + 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 index b4d96540c12..d6a01bff795 100644 --- a/packages/http-client-csharp/playground-server.Tests/playground-server.Tests.csproj +++ b/packages/http-client-csharp/playground-server.Tests/playground-server.Tests.csproj @@ -9,10 +9,10 @@ - - - - + + + + @@ -25,4 +25,7 @@ + + + diff --git a/packages/http-client-csharp/playground-server/CachedGenerationResponse.cs b/packages/http-client-csharp/playground-server/CachedGenerationResponse.cs new file mode 100644 index 00000000000..446ae0e3b55 --- /dev/null +++ b/packages/http-client-csharp/playground-server/CachedGenerationResponse.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +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); diff --git a/packages/http-client-csharp/playground-server/IGenerationCache.cs b/packages/http-client-csharp/playground-server/IGenerationCache.cs new file mode 100644 index 00000000000..5c7a2c8e3e8 --- /dev/null +++ b/packages/http-client-csharp/playground-server/IGenerationCache.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +namespace PlaygroundServer; + +/// +/// Container-local in-memory cache for /generate responses. +/// +public interface IGenerationCache +{ + bool TryGet(string key, out CachedGenerationResponse? value); + + void Set(string key, CachedGenerationResponse value); +} diff --git a/packages/http-client-csharp/playground-server/GenerationCache.cs b/packages/http-client-csharp/playground-server/MemoryGenerationCache.cs similarity index 84% rename from packages/http-client-csharp/playground-server/GenerationCache.cs rename to packages/http-client-csharp/playground-server/MemoryGenerationCache.cs index e03219d1747..03996b9f45e 100644 --- a/packages/http-client-csharp/playground-server/GenerationCache.cs +++ b/packages/http-client-csharp/playground-server/MemoryGenerationCache.cs @@ -7,22 +7,6 @@ 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 . /// diff --git a/packages/http-client-csharp/playground-server/Program.cs b/packages/http-client-csharp/playground-server/Program.cs index 5d850d5209c..684d95f4967 100644 --- a/packages/http-client-csharp/playground-server/Program.cs +++ b/packages/http-client-csharp/playground-server/Program.cs @@ -50,9 +50,11 @@ : "Application Insights telemetry enabled."); builder.Services.AddCors(); +var cacheSizeLimitBytes = builder.Configuration.GetValue("GenerationCache:SizeLimitBytes") + ?? MemoryGenerationCache.DefaultSizeLimitBytes; builder.Services.AddMemoryCache(options => { - options.SizeLimit = MemoryGenerationCache.DefaultSizeLimitBytes; + options.SizeLimit = cacheSizeLimitBytes; }); builder.Services.AddSingleton(); builder.Services.AddRateLimiter(options => diff --git a/packages/http-client-csharp/playground-server/appsettings.json b/packages/http-client-csharp/playground-server/appsettings.json new file mode 100644 index 00000000000..62852bf0405 --- /dev/null +++ b/packages/http-client-csharp/playground-server/appsettings.json @@ -0,0 +1,5 @@ +{ + "GenerationCache": { + "SizeLimitBytes": 268435456 + } +} From 79fc189f667311444e9400b9907e6bd26eb4b96f Mon Sep 17 00:00:00 2001 From: jolov Date: Tue, 2 Jun 2026 12:29:22 -0700 Subject: [PATCH 12/15] Constrain 'which' catalog dependency to ^6.0.1 which@7.0.0 raised its Node engine floor to ^22.22.2 || ^24.15.0, which broke CI: the emitter pipeline's primary Node (24.x) resolves to 24.14.1 on the hosted agents, failing the engine-strict pnpm install. which@6.0.1 (engines: ^20.17.0 || >=22.9.0) is API-compatible and satisfied by every Node version in the build matrix. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pnpm-lock.yaml | 15 +++------------ pnpm-workspace.yaml | 2 +- 2 files changed, 4 insertions(+), 13 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 197f9f2f4bf..0e70a7efea7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -511,8 +511,8 @@ catalogs: specifier: ^0.26.8 version: 0.26.9 which: - specifier: ^7.0.0 - version: 7.0.0 + specifier: ^6.0.1 + version: 6.0.1 yaml: specifier: ^2.8.3 version: 2.9.0 @@ -2865,7 +2865,7 @@ importers: version: 9.0.1 which: specifier: 'catalog:' - version: 7.0.0 + version: 6.0.1 yaml: specifier: 'catalog:' version: 2.9.0 @@ -13563,11 +13563,6 @@ packages: engines: {node: ^20.17.0 || >=22.9.0} hasBin: true - which@7.0.0: - resolution: {integrity: sha512-RancgH2dmbLdHl6LRhEqvklWMgl/Hdnun0Y90KhBOLkMefg8Qa7/Zel8Sm+8HEcP6DEjzsWzpkuBQEZok58isA==} - engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} - hasBin: true - why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} @@ -26899,10 +26894,6 @@ snapshots: dependencies: isexe: 4.0.0 - which@7.0.0: - dependencies: - isexe: 4.0.0 - why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 2a8c65b9180..2a113ee4485 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -175,7 +175,7 @@ catalog: vscode-oniguruma: ^2.0.1 vscode-textmate: ^9.3.2 web-tree-sitter: ^0.26.8 - which: ^7.0.0 + which: ^6.0.1 yaml: ^2.8.3 yargs: ^18.0.0 From 7bf3519ada641736df17a56734655f12dd72145d Mon Sep 17 00:00:00 2001 From: jolov Date: Tue, 2 Jun 2026 12:36:37 -0700 Subject: [PATCH 13/15] Revert "Constrain 'which' catalog dependency to ^6.0.1" This reverts commit 79fc189f667311444e9400b9907e6bd26eb4b96f. --- pnpm-lock.yaml | 15 ++++++++++++--- pnpm-workspace.yaml | 2 +- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0e70a7efea7..197f9f2f4bf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -511,8 +511,8 @@ catalogs: specifier: ^0.26.8 version: 0.26.9 which: - specifier: ^6.0.1 - version: 6.0.1 + specifier: ^7.0.0 + version: 7.0.0 yaml: specifier: ^2.8.3 version: 2.9.0 @@ -2865,7 +2865,7 @@ importers: version: 9.0.1 which: specifier: 'catalog:' - version: 6.0.1 + version: 7.0.0 yaml: specifier: 'catalog:' version: 2.9.0 @@ -13563,6 +13563,11 @@ packages: engines: {node: ^20.17.0 || >=22.9.0} hasBin: true + which@7.0.0: + resolution: {integrity: sha512-RancgH2dmbLdHl6LRhEqvklWMgl/Hdnun0Y90KhBOLkMefg8Qa7/Zel8Sm+8HEcP6DEjzsWzpkuBQEZok58isA==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + hasBin: true + why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} @@ -26894,6 +26899,10 @@ snapshots: dependencies: isexe: 4.0.0 + which@7.0.0: + dependencies: + isexe: 4.0.0 + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 2a113ee4485..2a8c65b9180 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -175,7 +175,7 @@ catalog: vscode-oniguruma: ^2.0.1 vscode-textmate: ^9.3.2 web-tree-sitter: ^0.26.8 - which: ^6.0.1 + which: ^7.0.0 yaml: ^2.8.3 yargs: ^18.0.0 From 147c08567678604862c973c4bfcc61f3a74afa1b Mon Sep 17 00:00:00 2001 From: jolov Date: Tue, 2 Jun 2026 12:37:16 -0700 Subject: [PATCH 14/15] Use checkLatest for NodeTool to install latest 22.x/24.x The pinned major-version specs resolved to a stale cached Node on the agents (24.14.1), which failed engine-strict checks for dependencies requiring >=24.15.0. checkLatest fetches the newest matching release. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- eng/emitters/pipelines/templates/stages/emitter-stages.yml | 1 + eng/emitters/pipelines/templates/steps/build-step.yml | 1 + eng/emitters/pipelines/templates/steps/test-step.yml | 1 + 3 files changed, 3 insertions(+) diff --git a/eng/emitters/pipelines/templates/stages/emitter-stages.yml b/eng/emitters/pipelines/templates/stages/emitter-stages.yml index d3e60075e14..7d73d17a2cc 100644 --- a/eng/emitters/pipelines/templates/stages/emitter-stages.yml +++ b/eng/emitters/pipelines/templates/stages/emitter-stages.yml @@ -388,6 +388,7 @@ stages: displayName: Use Node 22.x for playground bundle inputs: versionSpec: "22.x" + checkLatest: true - script: npm ci displayName: Install emitter dependencies for playground bundle workingDirectory: $(Build.SourcesDirectory)/${{ parameters.PackagePath }} diff --git a/eng/emitters/pipelines/templates/steps/build-step.yml b/eng/emitters/pipelines/templates/steps/build-step.yml index 2352b172f38..abac27e71c6 100644 --- a/eng/emitters/pipelines/templates/steps/build-step.yml +++ b/eng/emitters/pipelines/templates/steps/build-step.yml @@ -64,6 +64,7 @@ steps: retryCountOnTaskFailure: 3 inputs: versionSpec: ${{ parameters.NodeVersion }} + checkLatest: true - task: UsePythonVersion@0 displayName: "Use Python ${{ parameters.PythonVersion }}" diff --git a/eng/emitters/pipelines/templates/steps/test-step.yml b/eng/emitters/pipelines/templates/steps/test-step.yml index c61b148b7fe..fa5b0980c22 100644 --- a/eng/emitters/pipelines/templates/steps/test-step.yml +++ b/eng/emitters/pipelines/templates/steps/test-step.yml @@ -60,6 +60,7 @@ steps: retryCountOnTaskFailure: 3 inputs: versionSpec: ${{ parameters.NodeVersion }} + checkLatest: true - task: UsePythonVersion@0 displayName: "Use Python ${{ parameters.PythonVersion }}" From e793e232468138e3552ffd719400741f0cb9ac75 Mon Sep 17 00:00:00 2001 From: jolov Date: Wed, 3 Jun 2026 09:40:36 -0700 Subject: [PATCH 15/15] Harden cache eviction test assertions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../playground-server.Tests/GenerationCacheTests.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/http-client-csharp/playground-server.Tests/GenerationCacheTests.cs b/packages/http-client-csharp/playground-server.Tests/GenerationCacheTests.cs index 8e9a767c511..60dfcee0d87 100644 --- a/packages/http-client-csharp/playground-server.Tests/GenerationCacheTests.cs +++ b/packages/http-client-csharp/playground-server.Tests/GenerationCacheTests.cs @@ -205,8 +205,12 @@ public void SizeLimit_EvictsEntriesUnderPressure() { cache.Set("k" + i, new CachedGenerationResponse(payload, "application/json")); } + backing.Compact(0.0); - Assert.LessOrEqual(backing.Count * 256, 1024); + // With a 1024-byte size limit and 8 entries of 256 bytes each, the cache must + // evict enough entries to stay within the configured budget. + Assert.That(backing.Count, Is.LessThan(8)); + Assert.That(backing.Count * 256, Is.LessThanOrEqualTo(1024)); } }