Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
5f152e4
Initial plan
Copilot May 18, 2026
3c68428
[http-client-csharp] Add Tier 1 in-memory response cache to playgroun…
Copilot May 18, 2026
48ddd6c
Remove issue-pertaining comments and redundant inline comments
Copilot May 18, 2026
087a1cb
Merge remote-tracking branch 'origin/main' into copilot/improve-cshar…
Copilot Jun 1, 2026
79bae62
Remove sliding expiration from generation cache
Copilot Jun 2, 2026
e1bf971
Add telemetry for cache hits
Copilot Jun 2, 2026
7b32d98
Model cache status as telemetry dimension instead of outcome
JoshLove-msft Jun 2, 2026
ee0f170
Use GenerateOutcome enum for /generate telemetry outcomes
JoshLove-msft Jun 2, 2026
d0dffd3
Add ComputeKey tests with a realistic code model
JoshLove-msft Jun 2, 2026
4b4016f
Move sample code model to TestData files instead of inlining
JoshLove-msft Jun 2, 2026
6543d36
Apply prettier formatting to TestData JSON
JoshLove-msft Jun 2, 2026
8ffa9f4
Merge remote-tracking branch 'upstream/main' into copilot/improve-csh…
JoshLove-msft Jun 2, 2026
9a158c9
Address review: split cache types, configurable size limit, central p…
JoshLove-msft Jun 2, 2026
79fc189
Constrain 'which' catalog dependency to ^6.0.1
JoshLove-msft Jun 2, 2026
7bf3519
Revert "Constrain 'which' catalog dependency to ^6.0.1"
JoshLove-msft Jun 2, 2026
147c085
Use checkLatest for NodeTool to install latest 22.x/24.x
JoshLove-msft Jun 2, 2026
e793e23
Harden cache eviction test assertions
JoshLove-msft Jun 3, 2026
ae50df7
Merge remote-tracking branch 'upstream/main' into copilot/improve-csh…
JoshLove-msft Jun 3, 2026
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
Expand Up @@ -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 }}
Expand Down
1 change: 1 addition & 0 deletions eng/emitters/pipelines/templates/steps/build-step.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ steps:
retryCountOnTaskFailure: 3
inputs:
versionSpec: ${{ parameters.NodeVersion }}
checkLatest: true

- task: UsePythonVersion@0
displayName: "Use Python ${{ parameters.PythonVersion }}"
Expand Down
1 change: 1 addition & 0 deletions eng/emitters/pipelines/templates/steps/test-step.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ steps:
retryCountOnTaskFailure: 3
inputs:
versionSpec: ${{ parameters.NodeVersion }}
checkLatest: true

- task: UsePythonVersion@0
displayName: "Use Python ${{ parameters.PythonVersion }}"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
<PackageReference Update="System.ComponentModel.Composition" Version="8.0.0" />
<PackageReference Update="System.ClientModel" Version="1.10.0" />
<PackageReference Update="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.3" />
<PackageReference Update="Microsoft.Extensions.Caching.Memory" Version="10.0.0" />
<PackageReference Update="System.Memory.Data" Version="10.0.3" />
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
@@ -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/<this fixture>/, 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");
Comment thread
JoshLove-msft marked this conversation as resolved.
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<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);

// 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));
}
}
Original file line number Diff line number Diff line change
@@ -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" } }]
}
]
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{ "package-name": "PetStore", "namespace": "PetStore", "library-name": "PetStore" }
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<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" />
<PackageReference Include="NUnit" />
<PackageReference Include="NUnit3TestAdapter" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" />
</ItemGroup>

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

<ItemGroup>
<None Include="TestData\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>

<!-- Imported after the PackageReference items so the central Update versions apply to them. -->
<Import Project="..\generator\Packages.Data.props" />

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

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);
Loading
Loading