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 }}" 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/GenerationCacheTests.cs b/packages/http-client-csharp/playground-server.Tests/GenerationCacheTests.cs new file mode 100644 index 00000000000..60dfcee0d87 --- /dev/null +++ b/packages/http-client-csharp/playground-server.Tests/GenerationCacheTests.cs @@ -0,0 +1,216 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.IO; +using System.Text; +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) + { + var memory = new MemoryCache(new MemoryCacheOptions { SizeLimit = sizeLimit }); + return new MemoryGenerationCache(memory); + } + + 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 static string SampleCodeModel => ReadTestData("sample-codemodel.json"); + + private static string SampleConfiguration => ReadTestData("sample-configuration.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_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() + { + 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); + + // 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)); + } +} 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..b64f29a365b --- /dev/null +++ b/packages/http-client-csharp/playground-server.Tests/TestData/GenerationCacheTests/sample-codemodel.json @@ -0,0 +1,77 @@ +{ + "$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 new file mode 100644 index 00000000000..d6a01bff795 --- /dev/null +++ b/packages/http-client-csharp/playground-server.Tests/playground-server.Tests.csproj @@ -0,0 +1,31 @@ + + + + net10.0 + enable + enable + false + PlaygroundServer.Tests + + + + + + + + + + + + + + + + PreserveNewest + + + + + + + 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/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/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/MemoryGenerationCache.cs b/packages/http-client-csharp/playground-server/MemoryGenerationCache.cs new file mode 100644 index 00000000000..03996b9f45e --- /dev/null +++ b/packages/http-client-csharp/playground-server/MemoryGenerationCache.cs @@ -0,0 +1,77 @@ +// 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; + +/// +/// -backed implementation of . +/// +public sealed class MemoryGenerationCache : IGenerationCache +{ + public const long DefaultSizeLimitBytes = 256L * 1024 * 1024; + + private readonly IMemoryCache _cache; + + public MemoryGenerationCache(IMemoryCache cache) + { + _cache = cache ?? throw new ArgumentNullException(nameof(cache)); + } + + 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, + 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 85154fa84ac..684d95f4967 100644 --- a/packages/http-client-csharp/playground-server/Program.cs +++ b/packages/http-client-csharp/playground-server/Program.cs @@ -8,6 +8,8 @@ using Microsoft.ApplicationInsights; using Microsoft.ApplicationInsights.DataContracts; using Microsoft.AspNetCore.RateLimiting; +using Microsoft.Extensions.Caching.Memory; +using PlaygroundServer; const int MaxRequestBodySize = 10 * 1024 * 1024; // 10 MB const int GeneratorTimeoutSeconds = 300; @@ -48,6 +50,13 @@ : "Application Insights telemetry enabled."); builder.Services.AddCors(); +var cacheSizeLimitBytes = builder.Configuration.GetValue("GenerationCache:SizeLimitBytes") + ?? MemoryGenerationCache.DefaultSizeLimitBytes; +builder.Services.AddMemoryCache(options => +{ + options.SizeLimit = cacheSizeLimitBytes; +}); +builder.Services.AddSingleton(); builder.Services.AddRateLimiter(options => { options.RejectionStatusCode = 429; @@ -98,6 +107,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; @@ -122,27 +147,28 @@ }); }); -app.MapPost("/generate", async (HttpRequest request, TelemetryClient? telemetryClient) => +app.MapPost("/generate", async (HttpRequest request, IGenerationCache cache, TelemetryClient? telemetryClient) => { 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" }); } @@ -154,13 +180,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" }); } @@ -170,10 +196,22 @@ void TrackGenerateEvent(string outcome) if (!File.Exists(generatorPath)) { - TrackGenerateEvent("generator_missing"); + TrackGenerateEvent(GenerateOutcome.GeneratorMissing); 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"; + telemetryProperties["cacheStatus"] = "hit"; + TrackGenerateEvent(GenerateOutcome.Success); + 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")); var generatedDir = Path.Combine(tempDir, "src", "Generated"); @@ -230,7 +268,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, @@ -249,7 +287,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, @@ -274,10 +312,12 @@ void TrackGenerateEvent(string outcome) } telemetryProperties["generatedFileCount"] = files.Count.ToString(System.Globalization.CultureInfo.InvariantCulture); - TrackGenerateEvent("success"); - return Results.Json( + TrackGenerateEvent(GenerateOutcome.Success); + var responseBytes = JsonSerializer.SerializeToUtf8Bytes( new GenerateResponse(files), GenerateJsonContext.Default.GenerateResponse); + cache.Set(cacheKey, new CachedGenerationResponse(responseBytes, "application/json")); + return Results.Bytes(responseBytes, "application/json"); } catch (Exception ex) { @@ -287,7 +327,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 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 + } +}