Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

using System;
using System.Text;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using NUnit.Framework;
using PlaygroundServer;

namespace PlaygroundServer.Tests;

[TestFixture]
public class GenerationCacheTests
{
private static MemoryGenerationCache CreateCache(long sizeLimit = MemoryGenerationCache.DefaultSizeLimitBytes, TimeSpan? sliding = null)
{
var memory = new MemoryCache(new MemoryCacheOptions { SizeLimit = sizeLimit });
return new MemoryGenerationCache(memory, sliding);
}

private static CachedGenerationResponse MakeResponse(string content)
=> new(Encoding.UTF8.GetBytes(content), "application/json");

[Test]
public void ComputeKey_IsDeterministic_ForSameInputs()
{
var k1 = MemoryGenerationCache.ComputeKey("gen", "{\"a\":1}", "{\"b\":2}", "1.0.0");
var k2 = MemoryGenerationCache.ComputeKey("gen", "{\"a\":1}", "{\"b\":2}", "1.0.0");
Assert.AreEqual(k1, k2);
}

[Test]
public void ComputeKey_ProducesSha256HexString()
{
var key = MemoryGenerationCache.ComputeKey("gen", "model", "config", "1.0.0");
// SHA-256 hex = 64 hex chars, uppercase per Convert.ToHexString.
Assert.AreEqual(64, key.Length);
Assert.That(key, Does.Match("^[0-9A-F]{64}$"));
}

[Test]
public void ComputeKey_ChangesWhenGeneratorNameChanges()
{
var a = MemoryGenerationCache.ComputeKey("genA", "m", "c", "v");
var b = MemoryGenerationCache.ComputeKey("genB", "m", "c", "v");
Assert.AreNotEqual(a, b);
}

[Test]
public void ComputeKey_ChangesWhenCodeModelChanges()
{
var a = MemoryGenerationCache.ComputeKey("gen", "m1", "c", "v");
var b = MemoryGenerationCache.ComputeKey("gen", "m2", "c", "v");
Assert.AreNotEqual(a, b);
}

[Test]
public void ComputeKey_ChangesWhenConfigurationChanges()
{
var a = MemoryGenerationCache.ComputeKey("gen", "m", "c1", "v");
var b = MemoryGenerationCache.ComputeKey("gen", "m", "c2", "v");
Assert.AreNotEqual(a, b);
}

[Test]
public void ComputeKey_ChangesWhenGeneratorVersionChanges()
{
var a = MemoryGenerationCache.ComputeKey("gen", "m", "c", "1.0.0");
var b = MemoryGenerationCache.ComputeKey("gen", "m", "c", "1.0.1");
Assert.AreNotEqual(a, b);
}

[Test]
public void ComputeKey_IsUnambiguousAcrossComponentBoundaries()
{
// Naive concatenation would collide here; the length prefix must prevent that.
var a = MemoryGenerationCache.ComputeKey("Foo", "Bar", "Baz", "v");
var b = MemoryGenerationCache.ComputeKey("FooBar", "", "Baz", "v");
var c = MemoryGenerationCache.ComputeKey("Foo", "BarBaz", "", "v");
Assert.AreNotEqual(a, b);
Assert.AreNotEqual(a, c);
Assert.AreNotEqual(b, c);
}

[Test]
public void ComputeKey_ThrowsOnNullArguments()
{
Assert.Throws<ArgumentNullException>(() => MemoryGenerationCache.ComputeKey(null!, "m", "c", "v"));
Assert.Throws<ArgumentNullException>(() => MemoryGenerationCache.ComputeKey("g", null!, "c", "v"));
Assert.Throws<ArgumentNullException>(() => MemoryGenerationCache.ComputeKey("g", "m", null!, "v"));
Assert.Throws<ArgumentNullException>(() => 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<ArgumentNullException>(() => cache.Set("k", null!));
}

[Test]
public void Constructor_ThrowsOnNullBackingCache()
{
Assert.Throws<ArgumentNullException>(() => new MemoryGenerationCache(null!));
}

[Test]
public void SizeLimit_EvictsEntriesUnderPressure()
{
using var backing = new MemoryCache(new MemoryCacheOptions { SizeLimit = 1024, CompactionPercentage = 0.5 });
var cache = new MemoryGenerationCache(backing);

var payload = new byte[256];
for (int i = 0; i < 8; i++)
{
cache.Set("k" + i, new CachedGenerationResponse(payload, "application/json"));
}
backing.Compact(0.0);

Assert.LessOrEqual(backing.Count * 256, 1024);
}

[Test]
public async Task SlidingExpiration_EvictsAfterIdle()
{
var cache = CreateCache(sliding: TimeSpan.FromMilliseconds(50));
cache.Set("k", MakeResponse("x"));
Assert.IsTrue(cache.TryGet("k", out _));

await Task.Delay(200);
Assert.IsFalse(cache.TryGet("k", out var value));
Assert.IsNull(value);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<RootNamespace>PlaygroundServer.Tests</RootNamespace>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="NUnit" Version="3.13.2" />
<PackageReference Include="NUnit3TestAdapter" Version="4.5.0" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.0" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\playground-server\playground-server.csproj" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Caching.Memory;

namespace PlaygroundServer;

/// <summary>
/// Cached generator response. Stored as the already-serialized JSON bytes plus
/// content type so cache hits can return without re-serializing.
/// </summary>
public sealed record CachedGenerationResponse(byte[] Body, string ContentType);

/// <summary>
/// Container-local in-memory cache for /generate responses.
/// </summary>
public interface IGenerationCache
{
bool TryGet(string key, out CachedGenerationResponse? value);

void Set(string key, CachedGenerationResponse value);
}

/// <summary>
/// <see cref="IMemoryCache"/>-backed implementation of <see cref="IGenerationCache"/>.
/// </summary>
public sealed class MemoryGenerationCache : IGenerationCache
{
public const long DefaultSizeLimitBytes = 256L * 1024 * 1024;

public static readonly TimeSpan DefaultSlidingExpiration = TimeSpan.FromHours(1);

private readonly IMemoryCache _cache;
private readonly TimeSpan _slidingExpiration;

public MemoryGenerationCache(IMemoryCache cache, TimeSpan? slidingExpiration = null)
{
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
_slidingExpiration = slidingExpiration ?? DefaultSlidingExpiration;
}

public bool TryGet(string key, out CachedGenerationResponse? value)
{
if (_cache.TryGetValue(key, out CachedGenerationResponse? hit) && hit is not null)
{
value = hit;
return true;
}
value = null;
return false;
}

public void Set(string key, CachedGenerationResponse value)
{
ArgumentNullException.ThrowIfNull(value);
ArgumentNullException.ThrowIfNull(value.Body);

var size = Math.Max(1, value.Body.LongLength);
var entryOptions = new MemoryCacheEntryOptions
{
Size = size,
SlidingExpiration = _slidingExpiration,
Priority = CacheItemPriority.Normal,
};
_cache.Set(key, value, entryOptions);
}

/// <summary>
/// Build a content-addressed cache key. Including <paramref name="generatorVersion"/>
/// means a deploy of a new generator binary implicitly invalidates the cache.
/// </summary>
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('|');
}
}
}
38 changes: 36 additions & 2 deletions packages/http-client-csharp/playground-server/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -36,6 +38,11 @@
}

builder.Services.AddCors();
builder.Services.AddMemoryCache(options =>
{
options.SizeLimit = MemoryGenerationCache.DefaultSizeLimitBytes;
});
builder.Services.AddSingleton<IGenerationCache, MemoryGenerationCache>();
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = 429;
Expand Down Expand Up @@ -86,6 +93,22 @@
Console.WriteLine($"Generator DLL: {generatorPath}");
}

// Capture the generator's assembly file version at startup so a deploy of a
// new binary implicitly invalidates every previously cached response.
string generatorVersion;
try
{
generatorVersion = File.Exists(generatorPath)
? (FileVersionInfo.GetVersionInfo(generatorPath).FileVersion ?? "unknown")
: "missing";
}
catch (Exception ex)
{
Console.Error.WriteLine($"WARNING: Failed to read generator version from {generatorPath}: {ex.Message}");
generatorVersion = "unknown";
}
Console.WriteLine($"Generator version: {generatorVersion}");

app.MapGet("/health", () =>
{
string dotnetVersion;
Expand All @@ -110,7 +133,7 @@
});
});

app.MapPost("/generate", async (HttpRequest request) =>
app.MapPost("/generate", async (HttpRequest request, IGenerationCache cache) =>
{
// Validate content type
if (!request.ContentType?.StartsWith("application/json", StringComparison.OrdinalIgnoreCase) ?? true)
Expand Down Expand Up @@ -141,6 +164,15 @@
return Results.StatusCode(503);
}

var cacheKey = MemoryGenerationCache.ComputeKey(generatorName, body.CodeModel!, body.Configuration!, generatorVersion);
if (cache.TryGet(cacheKey, out var cached) && cached is not null)
{
request.HttpContext.Response.Headers["X-Cache"] = "HIT";
return Results.Bytes(cached.Body, cached.ContentType);
}

request.HttpContext.Response.Headers["X-Cache"] = "MISS";

// Create a temporary working directory
var tempDir = Path.Combine(Path.GetTempPath(), "tsp-playground", Guid.NewGuid().ToString("N"));
var generatedDir = Path.Combine(tempDir, "src", "Generated");
Expand Down Expand Up @@ -232,9 +264,11 @@
}
}

return Results.Json(
var responseBytes = JsonSerializer.SerializeToUtf8Bytes(
new GenerateResponse(files),
GenerateJsonContext.Default.GenerateResponse);
cache.Set(cacheKey, new CachedGenerationResponse(responseBytes, "application/json"));
return Results.Bytes(responseBytes, "application/json");
}
finally
{
Expand Down