diff --git a/.vscode/tasks.json b/.vscode/tasks.json index b85b3ea8b9d..3835de1c673 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -17,12 +17,19 @@ } }, { - "label": "Build src/All.sln", + "type": "dotnet", + "task": "build", + "group": "build", + "problemMatcher": [], + "label": "dotnet: build" + }, + { + "label": "Build src/All.slnx", "command": "dotnet", "type": "shell", "args": [ "build", - "src/All.sln", + "src/All.slnx", "/property:GenerateFullPaths=true", "/consoleloggerparameters:NoSummary" ], @@ -49,12 +56,12 @@ "problemMatcher": "$msCompile" }, { - "label": "Test src/All.sln", + "label": "Test src/All.slnx", "command": "dotnet", "type": "shell", "args": [ "test", - "src/All.sln", + "src/All.slnx", "--verbosity q", "/property:GenerateFullPaths=true", "/consoleloggerparameters:NoSummary" @@ -64,6 +71,6 @@ "reveal": "silent" }, "problemMatcher": "$msCompile" - }, + } ] } diff --git a/src/CookieCrumble/src/CookieCrumble.Xunit/CookieCrumble.Xunit.csproj b/src/CookieCrumble/src/CookieCrumble.Xunit/CookieCrumble.Xunit.csproj index ecc54d94a23..b9e5062e13c 100644 --- a/src/CookieCrumble/src/CookieCrumble.Xunit/CookieCrumble.Xunit.csproj +++ b/src/CookieCrumble/src/CookieCrumble.Xunit/CookieCrumble.Xunit.csproj @@ -7,7 +7,9 @@ - + + + diff --git a/src/CookieCrumble/src/CookieCrumble.Xunit3/CookieCrumble.Xunit3.csproj b/src/CookieCrumble/src/CookieCrumble.Xunit3/CookieCrumble.Xunit3.csproj index 4f920d2ee7f..caee8700f21 100644 --- a/src/CookieCrumble/src/CookieCrumble.Xunit3/CookieCrumble.Xunit3.csproj +++ b/src/CookieCrumble/src/CookieCrumble.Xunit3/CookieCrumble.Xunit3.csproj @@ -7,7 +7,8 @@ - + + diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index a3d046b0431..144732b7b7f 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -59,11 +59,16 @@ - - - - - + + + + + + + + + + diff --git a/src/GreenDonut/src/GreenDonut/BatchDataLoader.cs b/src/GreenDonut/src/GreenDonut/BatchDataLoader.cs index d4b34e2ca69..d4e47df966b 100644 --- a/src/GreenDonut/src/GreenDonut/BatchDataLoader.cs +++ b/src/GreenDonut/src/GreenDonut/BatchDataLoader.cs @@ -28,10 +28,6 @@ protected BatchDataLoader( DataLoaderOptions options) : base(batchScheduler, options) { - if (options is null) - { - throw new ArgumentNullException(nameof(options)); - } } /// diff --git a/src/GreenDonut/src/GreenDonut/BranchedDataLoader.cs b/src/GreenDonut/src/GreenDonut/BranchedDataLoader.cs new file mode 100644 index 00000000000..731feff1e96 --- /dev/null +++ b/src/GreenDonut/src/GreenDonut/BranchedDataLoader.cs @@ -0,0 +1,42 @@ +namespace GreenDonut; + +/// +/// This class represents a branched . +/// +/// +/// The type of the key. +/// +/// +/// The type of the value. +/// +public class BranchedDataLoader + : DataLoaderBase + where TKey : notnull +{ + private readonly DataLoaderBase _root; + + public BranchedDataLoader( + DataLoaderBase root, + string key) + : base(root.BatchScheduler, root.Options) + { + _root = root; + CacheKeyType = $"{root.CacheKeyType}:{key}"; + ContextData = root.ContextData; + } + + public IDataLoader Root => _root; + + protected internal override string CacheKeyType { get; } + + protected sealed override bool AllowCachePropagation => false; + + protected override bool AllowBranching => true; + + protected internal override ValueTask FetchAsync( + IReadOnlyList keys, + Memory> results, + DataLoaderFetchContext context, + CancellationToken cancellationToken) + => _root.FetchAsync(keys, results, context, cancellationToken); +} diff --git a/src/GreenDonut/src/GreenDonut/DataLoaderBase.cs b/src/GreenDonut/src/GreenDonut/DataLoaderBase.cs index a9782f649bc..2b00050be0c 100644 --- a/src/GreenDonut/src/GreenDonut/DataLoaderBase.cs +++ b/src/GreenDonut/src/GreenDonut/DataLoaderBase.cs @@ -46,9 +46,11 @@ public abstract partial class DataLoaderBase /// /// Throws if is null. /// - protected DataLoaderBase(IBatchScheduler batchScheduler, DataLoaderOptions? options = null) + protected DataLoaderBase(IBatchScheduler batchScheduler, DataLoaderOptions options) { - options ??= new DataLoaderOptions(); + ArgumentNullException.ThrowIfNull(batchScheduler); + ArgumentNullException.ThrowIfNull(options); + _diagnosticEvents = options.DiagnosticEvents ?? Default; Cache = options.Cache; _batchScheduler = batchScheduler; @@ -226,7 +228,7 @@ void Initialize() ct); } } - + /// public void SetCacheEntry(TKey key, Task value) { diff --git a/src/GreenDonut/src/GreenDonut/GroupedDataLoader.cs b/src/GreenDonut/src/GreenDonut/GroupedDataLoader.cs index 1e676ba7fca..fc61d6b28e4 100644 --- a/src/GreenDonut/src/GreenDonut/GroupedDataLoader.cs +++ b/src/GreenDonut/src/GreenDonut/GroupedDataLoader.cs @@ -25,7 +25,7 @@ public abstract class GroupedDataLoader /// protected GroupedDataLoader( IBatchScheduler batchScheduler, - DataLoaderOptions? options = null) + DataLoaderOptions options) : base(batchScheduler, options) { } @@ -92,7 +92,7 @@ public abstract class StatefulGroupedDataLoader /// protected StatefulGroupedDataLoader( IBatchScheduler batchScheduler, - DataLoaderOptions? options = null) + DataLoaderOptions options) : base(batchScheduler, options) { } diff --git a/src/GreenDonut/test/GreenDonut.Tests/DataLoader.cs b/src/GreenDonut/test/GreenDonut.Tests/DataLoader.cs index d0001f962b0..168968ffe62 100644 --- a/src/GreenDonut/test/GreenDonut.Tests/DataLoader.cs +++ b/src/GreenDonut/test/GreenDonut.Tests/DataLoader.cs @@ -3,7 +3,7 @@ namespace GreenDonut; public class DataLoader( FetchDataDelegate fetch, IBatchScheduler batchScheduler, - DataLoaderOptions? options = null) + DataLoaderOptions options) : DataLoaderBase(batchScheduler, options) where TKey : notnull { diff --git a/src/GreenDonut/test/GreenDonut.Tests/DataLoaderExtensionsTests.cs b/src/GreenDonut/test/GreenDonut.Tests/DataLoaderExtensionsTests.cs index 1f05265710a..7311146d52d 100644 --- a/src/GreenDonut/test/GreenDonut.Tests/DataLoaderExtensionsTests.cs +++ b/src/GreenDonut/test/GreenDonut.Tests/DataLoaderExtensionsTests.cs @@ -1,5 +1,7 @@ // ReSharper disable InconsistentNaming +using System.Reflection.Metadata; + namespace GreenDonut; public class DataLoaderExtensionsTests @@ -24,7 +26,7 @@ public void SetCacheEntryKeyNull() // arrange var fetch = TestHelpers.CreateFetch(); var batchScheduler = new ManualBatchScheduler(); - var loader = new DataLoader(fetch, batchScheduler); + var loader = new DataLoader(fetch, batchScheduler, new DataLoaderOptions()); var value = "Bar"; // act @@ -40,7 +42,7 @@ public void SetCacheEntryNoException() // arrange var fetch = TestHelpers.CreateFetch(); var batchScheduler = new ManualBatchScheduler(); - var loader = new DataLoader(fetch, batchScheduler); + var loader = new DataLoader(fetch, batchScheduler, new DataLoaderOptions()); var key = "Foo"; // act @@ -149,7 +151,7 @@ public void IDataLoaderSetCacheEntryNoException() // arrange var fetch = TestHelpers.CreateFetch(); var batchScheduler = new ManualBatchScheduler(); - IDataLoader loader = new DataLoader(fetch, batchScheduler); + IDataLoader loader = new DataLoader(fetch, batchScheduler, new DataLoaderOptions()); object key = "Foo"; // act diff --git a/src/GreenDonut/test/GreenDonut.Tests/DataLoaderStateTests.cs b/src/GreenDonut/test/GreenDonut.Tests/DataLoaderStateTests.cs index 71bd0da33c1..3a7497f641a 100644 --- a/src/GreenDonut/test/GreenDonut.Tests/DataLoaderStateTests.cs +++ b/src/GreenDonut/test/GreenDonut.Tests/DataLoaderStateTests.cs @@ -6,7 +6,7 @@ public static class DataLoaderStateTests public static async Task SetStateInferredKey() { // arrange - var loader = new DummyDataLoader(typeof(string).FullName!); + var loader = new DummyDataLoader(typeof(string).FullName!, new DataLoaderOptions()); // act await loader.SetState("abc").LoadAsync("def"); @@ -19,7 +19,7 @@ public static async Task SetStateInferredKey() public static async Task SetStateExplicitKey() { // arrange - var loader = new DummyDataLoader("abc"); + var loader = new DummyDataLoader("abc", new DataLoaderOptions()); // act await loader.SetState("abc", "def").LoadAsync("ghi"); @@ -32,7 +32,7 @@ public static async Task SetStateExplicitKey() public static async Task TrySetStateInferredKey() { // arrange - var loader = new DummyDataLoader(typeof(string).FullName!); + var loader = new DummyDataLoader(typeof(string).FullName!, new DataLoaderOptions()); // act await loader.SetState("abc").TrySetState("xyz").LoadAsync("def"); @@ -45,7 +45,7 @@ public static async Task TrySetStateInferredKey() public static async Task TrySetStateExplicitKey() { // arrange - var loader = new DummyDataLoader("abc"); + var loader = new DummyDataLoader("abc", new DataLoaderOptions()); // act await loader.SetState("abc", "def").TrySetState("abc", "xyz").LoadAsync("def"); @@ -58,7 +58,7 @@ public static async Task TrySetStateExplicitKey() public static async Task AddStateEnumerableInferredKey() { // arrange - var loader = new DummyDataLoader(typeof(string).FullName!); + var loader = new DummyDataLoader(typeof(string).FullName!, new DataLoaderOptions()); // act await loader.AddStateEnumerable("abc").AddStateEnumerable("xyz").LoadAsync("def"); @@ -74,7 +74,7 @@ public static async Task AddStateEnumerableInferredKey() public static async Task AddStateEnumerableExplicitKey() { // arrange - var loader = new DummyDataLoader("abc"); + var loader = new DummyDataLoader("abc", new DataLoaderOptions()); // act await loader.AddStateEnumerable("abc", "def").AddStateEnumerable("abc", "xyz").LoadAsync("def"); @@ -86,7 +86,7 @@ public static async Task AddStateEnumerableExplicitKey() item => Assert.Equal("xyz", item)); } - public class DummyDataLoader(string expectedKey, DataLoaderOptions? options = null) + public class DummyDataLoader(string expectedKey, DataLoaderOptions options) : DataLoaderBase(AutoBatchScheduler.Default, options) { public object? State { get; set; } diff --git a/src/GreenDonut/test/GreenDonut.Tests/DataLoaderTests.cs b/src/GreenDonut/test/GreenDonut.Tests/DataLoaderTests.cs index 0903b25a300..6db353d5e7e 100644 --- a/src/GreenDonut/test/GreenDonut.Tests/DataLoaderTests.cs +++ b/src/GreenDonut/test/GreenDonut.Tests/DataLoaderTests.cs @@ -15,7 +15,10 @@ public void ClearCacheNoException() var fetch = CreateFetch(); var services = new ServiceCollection() .AddScoped() - .AddDataLoader(sp => new DataLoader(fetch, sp.GetRequiredService())); + .AddDataLoader(sp => new DataLoader( + fetch, + sp.GetRequiredService(), + sp.GetRequiredService())); var scope = services.BuildServiceProvider().CreateScope(); var dataLoader = scope.ServiceProvider.GetRequiredService>(); @@ -52,7 +55,7 @@ public async Task LoadSingleKeyNull() // arrange var fetch = CreateFetch(); var batchScheduler = new ManualBatchScheduler(); - var loader = new DataLoader(fetch, batchScheduler); + var loader = new DataLoader(fetch, batchScheduler, new DataLoaderOptions()); // act Task Verify() => loader.LoadAsync(default(string)!, CancellationToken.None); @@ -67,7 +70,7 @@ public async Task LoadSingleResult() // arrange var fetch = CreateFetch("Bar"); var batchScheduler = new ManualBatchScheduler(); - var loader = new DataLoader(fetch, batchScheduler); + var loader = new DataLoader(fetch, batchScheduler, new DataLoaderOptions()); var key = "Foo"; // act @@ -85,7 +88,7 @@ public async Task LoadSingleResultTwice() // arrange var fetch = CreateFetch("Bar"); var batchScheduler = new DelayDispatcher(); - var loader = new DataLoader(fetch, batchScheduler); + var loader = new DataLoader(fetch, batchScheduler, new DataLoaderOptions()); var key = "Foo"; // first load. @@ -106,7 +109,8 @@ public async Task LoadSingleResultNoCache() var batchScheduler = new ManualBatchScheduler(); var loader = new DataLoader( fetch, - batchScheduler); + batchScheduler, + new DataLoaderOptions()); var key = "Foo"; // act @@ -124,7 +128,7 @@ public async Task LoadSingleErrorResult() // arrange var fetch = CreateFetch(); var batchScheduler = new ManualBatchScheduler(); - var loader = new DataLoader(fetch, batchScheduler); + var loader = new DataLoader(fetch, batchScheduler, new DataLoaderOptions()); var key = "Foo"; // act @@ -144,7 +148,7 @@ public async Task LoadParamsKeysNull() // arrange var fetch = CreateFetch(); var batchScheduler = new ManualBatchScheduler(); - var loader = new DataLoader(fetch, batchScheduler); + var loader = new DataLoader(fetch, batchScheduler, new DataLoaderOptions()); // act Task> Verify() => loader.LoadAsync(default(string[])!); @@ -159,7 +163,7 @@ public async Task LoadParamsZeroKeys() // arrange var fetch = TestHelpers.CreateFetch(); var batchScheduler = new ManualBatchScheduler(); - var loader = new DataLoader(fetch, batchScheduler); + var loader = new DataLoader(fetch, batchScheduler, new DataLoaderOptions()); var keys = Array.Empty(); // act @@ -178,7 +182,7 @@ public async Task LoadParamsResult() var fetch = TestHelpers .CreateFetch("Bar"); var batchScheduler = new ManualBatchScheduler(); - var loader = new DataLoader(fetch, batchScheduler); + var loader = new DataLoader(fetch, batchScheduler, new DataLoaderOptions()); var keys = new[] { "Foo", }; // act @@ -196,7 +200,7 @@ public async Task LoadCollectionKeysNull() // arrange var fetch = CreateFetch(); var batchScheduler = new ManualBatchScheduler(); - var loader = new DataLoader(fetch, batchScheduler); + var loader = new DataLoader(fetch, batchScheduler, new DataLoaderOptions()); // act Task> Verify() @@ -212,7 +216,7 @@ public async Task LoadCollectionZeroKeys() // arrange var fetch = CreateFetch(); var batchScheduler = new ManualBatchScheduler(); - var loader = new DataLoader(fetch, batchScheduler); + var loader = new DataLoader(fetch, batchScheduler, new DataLoaderOptions()); var keys = new List(); // act @@ -230,7 +234,7 @@ public async Task LoadCollectionResult() // arrange var fetch = CreateFetch("Bar"); var batchScheduler = new ManualBatchScheduler(); - var loader = new DataLoader(fetch, batchScheduler); + var loader = new DataLoader(fetch, batchScheduler, new DataLoaderOptions()); var keys = new List { "Foo", }; // act @@ -249,7 +253,8 @@ public async Task LoadCollectionResultTwice() var batchScheduler = new DelayDispatcher(); var loader = new DataLoader( fetch, - batchScheduler); + batchScheduler, + new DataLoaderOptions()); var keys = new List { "Foo", }; (await loader.LoadAsync(keys, CancellationToken.None)).MatchSnapshot(); @@ -269,7 +274,8 @@ public async Task LoadCollectionResultNoCache() var batchScheduler = new ManualBatchScheduler(); var loader = new DataLoader( fetch, - batchScheduler); + batchScheduler, + new DataLoaderOptions()); var keys = new List { "Foo", }; // act @@ -311,7 +317,7 @@ ValueTask Fetch( } var batchScheduler = new ManualBatchScheduler(); - var loader = new DataLoader(Fetch, batchScheduler); + var loader = new DataLoader(Fetch, batchScheduler, new DataLoaderOptions()); var requestKeys = new[] { "Foo", "Bar", "Baz", "Qux", }; // act @@ -356,7 +362,7 @@ ValueTask Fetch( } var batchScheduler = new ManualBatchScheduler(); - var loader = new DataLoader(Fetch, batchScheduler); + var loader = new DataLoader(Fetch, batchScheduler, new DataLoaderOptions()); var requestKeys = new[] { "Foo", "Bar", "Baz", "Qux", }; // act @@ -379,7 +385,7 @@ public async Task LoadBatchingError() // arrange var expectedException = new Exception("Foo"); var batchScheduler = new ManualBatchScheduler(); - var loader = new DataLoader(Fetch, batchScheduler); + var loader = new DataLoader(Fetch, batchScheduler, new DataLoaderOptions()); var requestKeys = new[] { "Foo", "Bar", "Baz", "Qux", }; ValueTask Fetch( @@ -503,7 +509,7 @@ public void RemoveCacheEntryKeyNull() // arrange var fetch = CreateFetch(); var batchScheduler = new ManualBatchScheduler(); - var loader = new DataLoader(fetch, batchScheduler); + var loader = new DataLoader(fetch, batchScheduler, new DataLoaderOptions()); loader.SetCacheEntry("Foo", Task.FromResult("Bar")); @@ -520,7 +526,7 @@ public void RemoveCacheEntryNoException() // arrange var fetch = CreateFetch(); var batchScheduler = new ManualBatchScheduler(); - var loader = new DataLoader(fetch, batchScheduler); + var loader = new DataLoader(fetch, batchScheduler, new DataLoaderOptions()); var key = "Foo"; // act @@ -556,7 +562,7 @@ public void SetCacheEntryKeyNull() // arrange var fetch = CreateFetch(); var batchScheduler = new ManualBatchScheduler(); - var loader = new DataLoader(fetch, batchScheduler); + var loader = new DataLoader(fetch, batchScheduler, new DataLoaderOptions()); var value = Task.FromResult("Foo"); // act @@ -572,8 +578,8 @@ public void SetCacheEntryValueNull() // arrange var fetch = CreateFetch(); var batchScheduler = new ManualBatchScheduler(); - var loader = new DataLoader(fetch, batchScheduler); - var key = "Foo"; + var loader = new DataLoader(fetch, batchScheduler, new DataLoaderOptions()); + const string key = "Foo"; // act void Verify() => loader.SetCacheEntry(key, default!); @@ -628,7 +634,7 @@ public async Task IDataLoaderLoadSingleKeyNull() // arrange var fetch = CreateFetch(); var batchScheduler = new ManualBatchScheduler(); - IDataLoader loader = new DataLoader(fetch, batchScheduler); + IDataLoader loader = new DataLoader(fetch, batchScheduler, new DataLoaderOptions()); // act Task Verify() => loader.LoadAsync(default(object)!); @@ -643,7 +649,7 @@ public async Task IDataLoaderLoadSingleResult() // arrange var fetch = CreateFetch("Bar"); var batchScheduler = new ManualBatchScheduler(); - IDataLoader loader = new DataLoader(fetch, batchScheduler); + IDataLoader loader = new DataLoader(fetch, batchScheduler, new DataLoaderOptions()); object key = "Foo"; // act @@ -661,7 +667,7 @@ public async Task IDataLoaderLoadSingleErrorResult() // arrange var fetch = CreateFetch(); var batchScheduler = new ManualBatchScheduler(); - IDataLoader loader = new DataLoader(fetch, batchScheduler); + IDataLoader loader = new DataLoader(fetch, batchScheduler, new DataLoaderOptions()); object key = "Foo"; // act @@ -683,7 +689,7 @@ public async Task IDataLoaderLoadParamsKeysNull() // arrange var fetch = CreateFetch(); var batchScheduler = new ManualBatchScheduler(); - IDataLoader loader = new DataLoader(fetch, batchScheduler); + IDataLoader loader = new DataLoader(fetch, batchScheduler, new DataLoaderOptions()); // act Task> Verify() => loader.LoadAsync(default(object[])!); @@ -698,7 +704,7 @@ public async Task IDataLoaderLoadParamsZeroKeys() // arrange var fetch = CreateFetch(); var batchScheduler = new ManualBatchScheduler(); - IDataLoader loader = new DataLoader(fetch, batchScheduler); + IDataLoader loader = new DataLoader(fetch, batchScheduler, new DataLoaderOptions()); var keys = Array.Empty(); // act @@ -714,7 +720,7 @@ public async Task IDataLoaderLoadParamsResult() // arrange var fetch = CreateFetch("Bar"); var batchScheduler = new ManualBatchScheduler(); - IDataLoader loader = new DataLoader(fetch, batchScheduler); + IDataLoader loader = new DataLoader(fetch, batchScheduler, new DataLoaderOptions()); var keys = new object[] { "Foo", }; // act @@ -732,7 +738,7 @@ public async Task IDataLoaderLoadCollectionKeysNull() // arrange var fetch = CreateFetch(); var batchScheduler = new ManualBatchScheduler(); - IDataLoader loader = new DataLoader(fetch, batchScheduler); + IDataLoader loader = new DataLoader(fetch, batchScheduler, new DataLoaderOptions()); // act Task> Verify() @@ -748,7 +754,7 @@ public async Task IDataLoaderLoadCollectionZeroKeys() // arrange var fetch = CreateFetch(); var batchScheduler = new ManualBatchScheduler(); - IDataLoader loader = new DataLoader(fetch, batchScheduler); + IDataLoader loader = new DataLoader(fetch, batchScheduler, new DataLoaderOptions()); var keys = new List(); // act @@ -764,7 +770,7 @@ public async Task IDataLoaderLoadCollectionResult() // arrange var fetch = CreateFetch("Bar"); var batchScheduler = new ManualBatchScheduler(); - IDataLoader loader = new DataLoader(fetch, batchScheduler); + IDataLoader loader = new DataLoader(fetch, batchScheduler, new DataLoaderOptions()); var keys = new List { "Foo", }; // act @@ -782,7 +788,7 @@ public void IDataLoaderRemoveCacheEntryKeyNull() // arrange var fetch = CreateFetch(); var batchScheduler = new ManualBatchScheduler(); - IDataLoader loader = new DataLoader(fetch, batchScheduler); + IDataLoader loader = new DataLoader(fetch, batchScheduler, new DataLoaderOptions()); loader.SetCacheEntry("Foo", Task.FromResult((object?)"Bar")); @@ -799,7 +805,7 @@ public void IDataLoaderRemoveCacheEntryNoException() // arrange var fetch = CreateFetch(); var batchScheduler = new ManualBatchScheduler(); - IDataLoader loader = new DataLoader(fetch, batchScheduler); + IDataLoader loader = new DataLoader(fetch, batchScheduler, new DataLoaderOptions()); object key = "Foo"; // act @@ -835,7 +841,7 @@ public void IDataLoaderSetCacheEntryKeyNull() // arrange var fetch = CreateFetch(); var batchScheduler = new ManualBatchScheduler(); - IDataLoader loader = new DataLoader(fetch, batchScheduler); + IDataLoader loader = new DataLoader(fetch, batchScheduler, new DataLoaderOptions()); var value = Task.FromResult("Foo"); // act @@ -851,7 +857,7 @@ public void IDataLoaderSetCacheEntryValueNull() // arrange var fetch = CreateFetch(); var batchScheduler = new ManualBatchScheduler(); - IDataLoader loader = new DataLoader(fetch, batchScheduler); + IDataLoader loader = new DataLoader(fetch, batchScheduler, new DataLoaderOptions()); object key = "Foo"; // act @@ -867,7 +873,7 @@ public void IDataLoaderSetCacheEntryNoException() // arrange var fetch = CreateFetch(); var batchScheduler = new ManualBatchScheduler(); - IDataLoader loader = new DataLoader(fetch, batchScheduler); + IDataLoader loader = new DataLoader(fetch, batchScheduler, new DataLoaderOptions()); object key = "Foo"; var value = Task.FromResult("Bar"); @@ -942,7 +948,7 @@ public async Task Add_Additional_Lookup_With_CacheObserver() private class TestDataLoader1( IBatchScheduler batchScheduler, - DataLoaderOptions? options = null) + DataLoaderOptions options) : DataLoaderBase(batchScheduler, options) { protected internal override ValueTask FetchAsync( @@ -965,7 +971,7 @@ private class TestDataLoader2 : DataLoaderBase { public TestDataLoader2( IBatchScheduler batchScheduler, - DataLoaderOptions? options = null) + DataLoaderOptions options) : base(batchScheduler, options) { PromiseCacheObserver diff --git a/src/GreenDonut/test/GreenDonut.Tests/DependencyInjection/DataLoaderServiceCollectionExtensionsTests.cs b/src/GreenDonut/test/GreenDonut.Tests/DependencyInjection/DataLoaderServiceCollectionExtensionsTests.cs index 61e6f918528..87422d458b5 100644 --- a/src/GreenDonut/test/GreenDonut.Tests/DependencyInjection/DataLoaderServiceCollectionExtensionsTests.cs +++ b/src/GreenDonut/test/GreenDonut.Tests/DependencyInjection/DataLoaderServiceCollectionExtensionsTests.cs @@ -17,7 +17,10 @@ public void ImplFactoryIsCalledWhenServiceIsResolved() .AddDataLoader(sp => { factoryCalled = true; - return new DataLoader(fetch, sp.GetRequiredService()); + return new DataLoader( + fetch, + sp.GetRequiredService(), + sp.GetRequiredService()); }); var scope = services.BuildServiceProvider().CreateScope(); @@ -40,7 +43,10 @@ public void InterfaceImplFactoryIsCalledWhenServiceIsResolved() .AddDataLoader, DataLoader>(sp => { factoryCalled = true; - return new DataLoader(fetch, sp.GetRequiredService()); + return new DataLoader( + fetch, + sp.GetRequiredService(), + sp.GetRequiredService()); }); var scope = services.BuildServiceProvider().CreateScope(); diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore/Extensions/HotChocolateAspNetCoreServiceCollectionExtensions.Warmup.cs b/src/HotChocolate/AspNetCore/src/AspNetCore/Extensions/HotChocolateAspNetCoreServiceCollectionExtensions.Warmup.cs index 83ea680cda1..61d0430c0e4 100644 --- a/src/HotChocolate/AspNetCore/src/AspNetCore/Extensions/HotChocolateAspNetCoreServiceCollectionExtensions.Warmup.cs +++ b/src/HotChocolate/AspNetCore/src/AspNetCore/Extensions/HotChocolateAspNetCoreServiceCollectionExtensions.Warmup.cs @@ -34,7 +34,7 @@ public static IRequestExecutorBuilder InitializeOnStartup( throw new ArgumentNullException(nameof(builder)); } - builder.Services.AddHostedService(); + builder.Services.AddHostedService(); builder.Services.AddSingleton(new WarmupSchemaTask(builder.Name, keepWarm, warmup)); return builder; } diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore/Serialization/DefaultHttpResponseFormatter.cs b/src/HotChocolate/AspNetCore/src/AspNetCore/Serialization/DefaultHttpResponseFormatter.cs index 8c21ac880c9..2f686787a14 100644 --- a/src/HotChocolate/AspNetCore/src/AspNetCore/Serialization/DefaultHttpResponseFormatter.cs +++ b/src/HotChocolate/AspNetCore/src/AspNetCore/Serialization/DefaultHttpResponseFormatter.cs @@ -168,6 +168,23 @@ public async ValueTask FormatAsync( throw ThrowHelper.Formatter_InvalidAcceptMediaType(); } + try + { + await FormatInternalAsync(response, result, proposedStatusCode, format, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // if the request is aborted we will fail gracefully. + } + } + + private async ValueTask FormatInternalAsync( + HttpResponse response, + IExecutionResult result, + HttpStatusCode? proposedStatusCode, + FormatInfo format, + CancellationToken cancellationToken) + { switch (result) { case IOperationResult operationResult: diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore/Warmup/ExecutorWarmupService.cs b/src/HotChocolate/AspNetCore/src/AspNetCore/Warmup/ExecutorWarmupService.cs deleted file mode 100644 index c518e7f8f11..00000000000 --- a/src/HotChocolate/AspNetCore/src/AspNetCore/Warmup/ExecutorWarmupService.cs +++ /dev/null @@ -1,96 +0,0 @@ -using HotChocolate.Utilities; -using Microsoft.Extensions.Hosting; - -namespace HotChocolate.AspNetCore.Warmup; - -internal class ExecutorWarmupService : BackgroundService -{ - private readonly IRequestExecutorResolver _executorResolver; - private readonly Dictionary _tasks; - private IDisposable? _eventSubscription; - private CancellationToken _stopping; - - public ExecutorWarmupService( - IRequestExecutorResolver executorResolver, - IEnumerable tasks) - { - if (tasks is null) - { - throw new ArgumentNullException(nameof(tasks)); - } - - _executorResolver = executorResolver ?? - throw new ArgumentNullException(nameof(executorResolver)); - _tasks = tasks.GroupBy(t => t.SchemaName).ToDictionary(t => t.Key, t => t.ToArray()); - } - - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - _stopping = stoppingToken; - _eventSubscription = _executorResolver.Events.Subscribe( - new WarmupObserver(name => BeginWarmup(name))); - - foreach (var task in _tasks) - { - // initialize services - var executor = await _executorResolver.GetRequestExecutorAsync(task.Key, stoppingToken); - - // execute startup task - foreach (var warmup in task.Value) - { - await warmup.ExecuteAsync(executor, stoppingToken); - } - } - } - - private void BeginWarmup(string schemaName) - { - if (_tasks.TryGetValue(schemaName, out var value) && value.Any(t => t.KeepWarm)) - { - WarmupAsync(schemaName, value, _stopping).FireAndForget(); - } - } - - private async Task WarmupAsync( - string schemaName, - WarmupSchemaTask[] tasks, - CancellationToken ct) - { - // initialize services - var executor = await _executorResolver.GetRequestExecutorAsync(schemaName, ct); - - // execute startup task - foreach (var warmup in tasks) - { - await warmup.ExecuteAsync(executor, ct); - } - } - - public override void Dispose() - { - _eventSubscription?.Dispose(); - base.Dispose(); - } - - private sealed class WarmupObserver : IObserver - { - public WarmupObserver(Action onEvicted) - { - OnEvicted = onEvicted; - } - - public Action OnEvicted { get; } - - public void OnNext(RequestExecutorEvent value) - { - if (value.Type is RequestExecutorEventType.Evicted) - { - OnEvicted(value.Name); - } - } - - public void OnError(Exception error) { } - - public void OnCompleted() { } - } -} diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore/Warmup/RequestExecutorWarmupService.cs b/src/HotChocolate/AspNetCore/src/AspNetCore/Warmup/RequestExecutorWarmupService.cs new file mode 100644 index 00000000000..76d5d1bdf04 --- /dev/null +++ b/src/HotChocolate/AspNetCore/src/AspNetCore/Warmup/RequestExecutorWarmupService.cs @@ -0,0 +1,14 @@ +using Microsoft.Extensions.Hosting; + +namespace HotChocolate.AspNetCore.Warmup; + +internal sealed class RequestExecutorWarmupService( + IRequestExecutorWarmup executorWarmup) + : IHostedService +{ + public async Task StartAsync(CancellationToken cancellationToken) + => await executorWarmup.WarmupAsync(cancellationToken).ConfigureAwait(false); + + public Task StopAsync(CancellationToken cancellationToken) + => Task.CompletedTask; +} diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore/Warmup/WarmupSchemaTask.cs b/src/HotChocolate/AspNetCore/src/AspNetCore/Warmup/WarmupSchemaTask.cs deleted file mode 100644 index 7dd8822b213..00000000000 --- a/src/HotChocolate/AspNetCore/src/AspNetCore/Warmup/WarmupSchemaTask.cs +++ /dev/null @@ -1,25 +0,0 @@ -namespace HotChocolate.AspNetCore.Warmup; - -internal sealed class WarmupSchemaTask -{ - private readonly Func? _warmup; - - public WarmupSchemaTask( - string schemaName, - bool keepWarm, - Func? warmup = null) - { - _warmup = warmup; - SchemaName = schemaName; - KeepWarm = keepWarm; - } - - public string SchemaName { get; } - - public bool KeepWarm { get; } - - public Task ExecuteAsync(IRequestExecutor executor, CancellationToken cancellationToken) - => _warmup is not null - ? _warmup.Invoke(executor, cancellationToken) - : Task.CompletedTask; -} diff --git a/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/EvictSchemaTests.cs b/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/EvictSchemaTests.cs index e74cd33d809..5c22a89274f 100644 --- a/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/EvictSchemaTests.cs +++ b/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/EvictSchemaTests.cs @@ -1,26 +1,34 @@ using HotChocolate.AspNetCore.Tests.Utilities; +using HotChocolate.Execution; +using Microsoft.Extensions.DependencyInjection; namespace HotChocolate.AspNetCore; -public class EvictSchemaTests : ServerTestBase +public class EvictSchemaTests(TestServerFactory serverFactory) : ServerTestBase(serverFactory) { - public EvictSchemaTests(TestServerFactory serverFactory) - : base(serverFactory) - { - } - [Fact] public async Task Evict_Default_Schema() { // arrange + var newExecutorCreatedResetEvent = new AutoResetEvent(false); var server = CreateStarWarsServer(); var time1 = await server.GetAsync( new ClientQueryRequest { Query = "{ time }", }); + var resolver = server.Services.GetRequiredService(); + resolver.Events.Subscribe(new RequestExecutorEventObserver(@event => + { + if (@event.Type == RequestExecutorEventType.Created) + { + newExecutorCreatedResetEvent.Set(); + } + })); + // act await server.GetAsync( new ClientQueryRequest { Query = "{ evict }", }); + newExecutorCreatedResetEvent.WaitOne(5000); // assert var time2 = await server.GetAsync( @@ -32,16 +40,27 @@ await server.GetAsync( public async Task Evict_Named_Schema() { // arrange + var newExecutorCreatedResetEvent = new AutoResetEvent(false); var server = CreateStarWarsServer(); var time1 = await server.GetAsync( new ClientQueryRequest { Query = "{ time }", }, "/evict"); + var resolver = server.Services.GetRequiredService(); + resolver.Events.Subscribe(new RequestExecutorEventObserver(@event => + { + if (@event.Type == RequestExecutorEventType.Created) + { + newExecutorCreatedResetEvent.Set(); + } + })); + // act await server.GetAsync( new ClientQueryRequest { Query = "{ evict }", }, "/evict"); + newExecutorCreatedResetEvent.WaitOne(5000); // assert var time2 = await server.GetAsync( diff --git a/src/HotChocolate/Core/src/Execution/AutoUpdateRequestExecutorProxy.cs b/src/HotChocolate/Core/src/Execution/AutoUpdateRequestExecutorProxy.cs index a7c2af5ee57..f4300cc8d8f 100644 --- a/src/HotChocolate/Core/src/Execution/AutoUpdateRequestExecutorProxy.cs +++ b/src/HotChocolate/Core/src/Execution/AutoUpdateRequestExecutorProxy.cs @@ -21,9 +21,7 @@ private AutoUpdateRequestExecutorProxy( _executorProxy = requestExecutorProxy; _executor = initialExecutor; - _executorProxy.ExecutorEvicted += (_, _) => BeginUpdateExecutor(); - - BeginUpdateExecutor(); + _executorProxy.ExecutorUpdated += (_, args) => _executor = args.Executor; } /// @@ -144,26 +142,6 @@ public Task ExecuteBatchAsync( CancellationToken cancellationToken = default) => _executor.ExecuteBatchAsync(requestBatch, cancellationToken); - private void BeginUpdateExecutor() - => UpdateExecutorAsync().FireAndForget(); - - private async ValueTask UpdateExecutorAsync() - { - await _semaphore.WaitAsync().ConfigureAwait(false); - - try - { - var executor = await _executorProxy - .GetRequestExecutorAsync(CancellationToken.None) - .ConfigureAwait(false); - _executor = executor; - } - finally - { - _semaphore.Release(); - } - } - /// public void Dispose() { diff --git a/src/HotChocolate/Core/src/Execution/Caching/PreparedOperationCacheOptions.cs b/src/HotChocolate/Core/src/Execution/Caching/PreparedOperationCacheOptions.cs new file mode 100644 index 00000000000..88beb6e8e80 --- /dev/null +++ b/src/HotChocolate/Core/src/Execution/Caching/PreparedOperationCacheOptions.cs @@ -0,0 +1,6 @@ +namespace HotChocolate.Execution.Caching; + +internal sealed class PreparedOperationCacheOptions +{ + public int Capacity { get; set; } = 100; +} diff --git a/src/HotChocolate/Core/src/Execution/DependencyInjection/InternalServiceCollectionExtensions.cs b/src/HotChocolate/Core/src/Execution/DependencyInjection/InternalServiceCollectionExtensions.cs index 53a4bac1b1f..f6f4dee078f 100644 --- a/src/HotChocolate/Core/src/Execution/DependencyInjection/InternalServiceCollectionExtensions.cs +++ b/src/HotChocolate/Core/src/Execution/DependencyInjection/InternalServiceCollectionExtensions.cs @@ -154,10 +154,8 @@ internal static IServiceCollection TryAddRequestExecutorResolver( this IServiceCollection services) { services.TryAddSingleton(); - services.TryAddSingleton( - sp => sp.GetRequiredService()); - services.TryAddSingleton( - sp => sp.GetRequiredService()); + services.TryAddSingleton(sp => sp.GetRequiredService()); + services.TryAddSingleton(sp => sp.GetRequiredService()); return services; } @@ -166,8 +164,8 @@ internal static IServiceCollection TryAddDefaultCaches( { services.TryAddSingleton( _ => new DefaultDocumentCache()); - services.TryAddSingleton( - _ => new DefaultPreparedOperationCache()); + services.TryAddSingleton( + _ => new PreparedOperationCacheOptions { Capacity = 100 }); return services; } diff --git a/src/HotChocolate/Core/src/Execution/DependencyInjection/RequestExecutorServiceCollectionExtensions.cs b/src/HotChocolate/Core/src/Execution/DependencyInjection/RequestExecutorServiceCollectionExtensions.cs index 2298d90bd82..d915dedb7d4 100644 --- a/src/HotChocolate/Core/src/Execution/DependencyInjection/RequestExecutorServiceCollectionExtensions.cs +++ b/src/HotChocolate/Core/src/Execution/DependencyInjection/RequestExecutorServiceCollectionExtensions.cs @@ -163,15 +163,6 @@ private static IRequestExecutorBuilder CreateBuilder( builder.Services.AddValidation(schemaName); - builder.Configure( - (sp, e) => - { - e.OnRequestExecutorEvictedHooks.Add( - // when ever we evict this schema we will clear the caches. - new OnRequestExecutorEvictedAction( - _ => sp.GetRequiredService().Clear())); - }); - builder.TryAddNoOpTransactionScopeHandler(); builder.TryAddTypeInterceptor(); builder.TryAddTypeInterceptor(); @@ -193,11 +184,9 @@ public static IServiceCollection AddOperationCache( this IServiceCollection services, int capacity = 100) { - services.RemoveAll(); - - services.AddSingleton( - _ => new DefaultPreparedOperationCache(capacity)); - + services.RemoveAll(); + services.AddSingleton( + _ => new PreparedOperationCacheOptions{ Capacity = capacity }); return services; } diff --git a/src/HotChocolate/Core/src/Execution/HotChocolate.Execution.csproj b/src/HotChocolate/Core/src/Execution/HotChocolate.Execution.csproj index 0980e2d578e..1942abf89c8 100644 --- a/src/HotChocolate/Core/src/Execution/HotChocolate.Execution.csproj +++ b/src/HotChocolate/Core/src/Execution/HotChocolate.Execution.csproj @@ -12,6 +12,7 @@ + diff --git a/src/HotChocolate/Core/src/Execution/IRequestExecutorWarmup.cs b/src/HotChocolate/Core/src/Execution/IRequestExecutorWarmup.cs new file mode 100644 index 00000000000..00fd7f9d68a --- /dev/null +++ b/src/HotChocolate/Core/src/Execution/IRequestExecutorWarmup.cs @@ -0,0 +1,18 @@ +namespace HotChocolate.Execution; + +/// +/// Allows to run the initial warmup for registered s. +/// +internal interface IRequestExecutorWarmup +{ + /// + /// Runs the initial warmup tasks. + /// + /// + /// The cancellation token. + /// + /// + /// Returns a task that completes once the warmup is done. + /// + Task WarmupAsync(CancellationToken cancellationToken); +} diff --git a/src/HotChocolate/Core/src/Execution/Internal/IInternalRequestExecutorResolver.cs b/src/HotChocolate/Core/src/Execution/Internal/IInternalRequestExecutorResolver.cs deleted file mode 100644 index 9be9dd64138..00000000000 --- a/src/HotChocolate/Core/src/Execution/Internal/IInternalRequestExecutorResolver.cs +++ /dev/null @@ -1,25 +0,0 @@ -namespace HotChocolate.Execution.Internal; - -/// -/// The is an internal request executor resolver that is not meant for public usage. -/// -public interface IInternalRequestExecutorResolver -{ - /// - /// Gets or creates the request executor that is associated with the - /// given configuration . - /// - /// - /// The schema name. - /// - /// - /// The cancellation token. - /// - /// - /// Returns a request executor that is associated with the - /// given configuration . - /// - ValueTask GetRequestExecutorNoLockAsync( - string? schemaName = default, - CancellationToken cancellationToken = default); -} diff --git a/src/HotChocolate/Core/src/Execution/Pipeline/OperationCacheMiddleware.cs b/src/HotChocolate/Core/src/Execution/Pipeline/OperationCacheMiddleware.cs index c18e6996397..44b4a2a9ded 100644 --- a/src/HotChocolate/Core/src/Execution/Pipeline/OperationCacheMiddleware.cs +++ b/src/HotChocolate/Core/src/Execution/Pipeline/OperationCacheMiddleware.cs @@ -10,9 +10,10 @@ internal sealed class OperationCacheMiddleware private readonly IExecutionDiagnosticEvents _diagnosticEvents; private readonly IPreparedOperationCache _operationCache; - private OperationCacheMiddleware(RequestDelegate next, + private OperationCacheMiddleware( + RequestDelegate next, [SchemaService] IExecutionDiagnosticEvents diagnosticEvents, - IPreparedOperationCache operationCache) + [SchemaService] IPreparedOperationCache operationCache) { _next = next ?? throw new ArgumentNullException(nameof(next)); @@ -64,7 +65,7 @@ public static RequestCoreMiddleware Create() => (core, next) => { var diagnosticEvents = core.SchemaServices.GetRequiredService(); - var cache = core.Services.GetRequiredService(); + var cache = core.SchemaServices.GetRequiredService(); var middleware = new OperationCacheMiddleware(next, diagnosticEvents, cache); return context => middleware.InvokeAsync(context); }; diff --git a/src/HotChocolate/Core/src/Execution/RequestExecutorProxy.cs b/src/HotChocolate/Core/src/Execution/RequestExecutorProxy.cs index 1f467f680fa..dab1e9a5cb6 100644 --- a/src/HotChocolate/Core/src/Execution/RequestExecutorProxy.cs +++ b/src/HotChocolate/Core/src/Execution/RequestExecutorProxy.cs @@ -29,7 +29,7 @@ public RequestExecutorProxy(IRequestExecutorResolver executorResolver, string sc _schemaName = schemaName; _eventSubscription = _executorResolver.Events.Subscribe( - new ExecutorObserver(EvictRequestExecutor)); + new RequestExecutorEventObserver(OnRequestExecutorEvent)); } public IRequestExecutor? CurrentExecutor => _executor; @@ -178,15 +178,19 @@ public async ValueTask GetRequestExecutorAsync( return executor; } - private void EvictRequestExecutor(string schemaName) + private void OnRequestExecutorEvent(RequestExecutorEvent @event) { - if (!_disposed && schemaName.Equals(_schemaName)) + if (_disposed || !@event.Name.Equals(_schemaName) || _executor is null) + { + return; + } + + if (@event.Type is RequestExecutorEventType.Evicted) { _semaphore.Wait(); try { - _executor = null; ExecutorEvicted?.Invoke(this, EventArgs.Empty); } finally @@ -194,6 +198,20 @@ private void EvictRequestExecutor(string schemaName) _semaphore.Release(); } } + else if (@event.Type is RequestExecutorEventType.Created) + { + _semaphore.Wait(); + + try + { + _executor = @event.Executor; + ExecutorUpdated?.Invoke(this, new RequestExecutorUpdatedEventArgs(@event.Executor)); + } + finally + { + _semaphore.Release(); + } + } } public void Dispose() @@ -206,19 +224,4 @@ public void Dispose() _disposed = true; } } - - private sealed class ExecutorObserver(Action evicted) : IObserver - { - public void OnNext(RequestExecutorEvent value) - { - if (value.Type is RequestExecutorEventType.Evicted) - { - evicted(value.Name); - } - } - - public void OnError(Exception error) { } - - public void OnCompleted() { } - } } diff --git a/src/HotChocolate/Core/src/Execution/RequestExecutorResolver.Warmup.cs b/src/HotChocolate/Core/src/Execution/RequestExecutorResolver.Warmup.cs new file mode 100644 index 00000000000..9e441a1449f --- /dev/null +++ b/src/HotChocolate/Core/src/Execution/RequestExecutorResolver.Warmup.cs @@ -0,0 +1,35 @@ +namespace HotChocolate.Execution; + +internal sealed partial class RequestExecutorResolver +{ + private bool _initialWarmupDone; + + public async Task WarmupAsync(CancellationToken cancellationToken) + { + if (_initialWarmupDone) + { + return; + } + _initialWarmupDone = true; + + // we get the schema names for schemas that have warmup tasks. + var schemasToWarmup = _warmupTasksBySchema.Keys; + var tasks = new Task[schemasToWarmup.Length]; + + for (var i = 0; i < schemasToWarmup.Length; i++) + { + // next we create an initial warmup for each schema + tasks[i] = WarmupSchemaAsync(schemasToWarmup[i], cancellationToken); + } + + // last we wait for all warmup tasks to complete. + await Task.WhenAll(tasks).ConfigureAwait(false); + + async Task WarmupSchemaAsync(string schemaName, CancellationToken cancellationToken) + { + // the actual warmup tasks are executed inlined into the executor creation. + await GetRequestExecutorAsync(schemaName, cancellationToken) + .ConfigureAwait(false); + } + } +} diff --git a/src/HotChocolate/Core/src/Execution/RequestExecutorResolver.cs b/src/HotChocolate/Core/src/Execution/RequestExecutorResolver.cs index a688733b9fd..1cdd73c7105 100644 --- a/src/HotChocolate/Core/src/Execution/RequestExecutorResolver.cs +++ b/src/HotChocolate/Core/src/Execution/RequestExecutorResolver.cs @@ -1,12 +1,14 @@ using System.Collections.Concurrent; +using System.Collections.Frozen; using System.Collections.Immutable; using System.Reflection.Metadata; +using System.Threading.Channels; using HotChocolate.Configuration; using HotChocolate.Execution; +using HotChocolate.Execution.Caching; using HotChocolate.Execution.Configuration; using HotChocolate.Execution.Errors; using HotChocolate.Execution.Instrumentation; -using HotChocolate.Execution.Internal; using HotChocolate.Execution.Options; using HotChocolate.Execution.Processing; using HotChocolate.Types; @@ -24,14 +26,17 @@ namespace HotChocolate.Execution; internal sealed partial class RequestExecutorResolver : IRequestExecutorResolver - , IInternalRequestExecutorResolver + , IRequestExecutorWarmup , IDisposable { - private readonly SemaphoreSlim _semaphore = new(1, 1); + private readonly CancellationTokenSource _cts = new(); + private readonly ConcurrentDictionary _semaphoreBySchema = new(); private readonly ConcurrentDictionary _executors = new(); + private readonly FrozenDictionary _warmupTasksBySchema; private readonly IRequestExecutorOptionsMonitor _optionsMonitor; private readonly IServiceProvider _applicationServices; private readonly EventObservable _events = new(); + private readonly ChannelWriter _executorEvictionChannelWriter; private ulong _version; private bool _disposed; @@ -40,17 +45,26 @@ internal sealed partial class RequestExecutorResolver public RequestExecutorResolver( IRequestExecutorOptionsMonitor optionsMonitor, + IEnumerable warmupSchemaTasks, IServiceProvider serviceProvider) { _optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor)); _applicationServices = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + _warmupTasksBySchema = warmupSchemaTasks.GroupBy(t => t.SchemaName) + .ToFrozenDictionary(g => g.Key, g => g.ToArray()); + + var executorEvictionChannel = Channel.CreateUnbounded(); + _executorEvictionChannelWriter = executorEvictionChannel.Writer; + + ConsumeExecutorEvictionsAsync(executorEvictionChannel.Reader, _cts.Token).FireAndForget(); + _optionsMonitor.OnChange(EvictRequestExecutor); // we register the schema eviction for application updates when hot reload is used. // Whenever a hot reload update is triggered we will evict all executors. - ApplicationUpdateHandler.RegisterForApplicationUpdate(() => EvictAllRequestExecutors()); + ApplicationUpdateHandler.RegisterForApplicationUpdate(EvictAllRequestExecutors); } public IObservable Events => _events; @@ -61,95 +75,171 @@ public async ValueTask GetRequestExecutorAsync( { schemaName ??= Schema.DefaultName; - if (!_executors.TryGetValue(schemaName, out var re)) + if (_executors.TryGetValue(schemaName, out var re)) { - await _semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + return re.Executor; + } - try - { - return await GetRequestExecutorNoLockAsync(schemaName, cancellationToken) - .ConfigureAwait(false); - } - finally + var semaphore = GetSemaphoreForSchema(schemaName); + await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + + try + { + // We check the cache again for the case that GetRequestExecutorAsync has been + // called multiple times. This should only happen, if someone calls GetRequestExecutorAsync + // themselves. Normally the RequestExecutorProxy takes care of only calling this method once. + if (_executors.TryGetValue(schemaName, out re)) { - _semaphore.Release(); + return re.Executor; } - } - return re.Executor; + var registeredExecutor = await CreateRequestExecutorAsync(schemaName, true, cancellationToken) + .ConfigureAwait(false); + + return registeredExecutor.Executor; + } + finally + { + semaphore.Release(); + } } - public async ValueTask GetRequestExecutorNoLockAsync( - string? schemaName = default, - CancellationToken cancellationToken = default) + public void EvictRequestExecutor(string? schemaName = default) { schemaName ??= Schema.DefaultName; - if (!_executors.TryGetValue(schemaName, out var registeredExecutor)) + _executorEvictionChannelWriter.TryWrite(schemaName); + } + + private async ValueTask ConsumeExecutorEvictionsAsync( + ChannelReader reader, + CancellationToken cancellationToken) + { + while (await reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false)) { - var setup = - await _optionsMonitor.GetAsync(schemaName, cancellationToken) - .ConfigureAwait(false); + while (reader.TryRead(out var schemaName)) + { + var semaphore = GetSemaphoreForSchema(schemaName); + await semaphore.WaitAsync(cancellationToken); - var context = new ConfigurationContext( - schemaName, - setup.SchemaBuilder ?? new SchemaBuilder(), - _applicationServices); + try + { + if (_executors.TryGetValue(schemaName, out var previousExecutor)) + { + await UpdateRequestExecutorAsync(schemaName, previousExecutor); + } + } + catch + { + // Ignore + } + finally + { + semaphore.Release(); + } + } + } + } - var schemaServices = - await CreateSchemaServicesAsync(context, setup, cancellationToken) - .ConfigureAwait(false); + private SemaphoreSlim GetSemaphoreForSchema(string schemaName) + => _semaphoreBySchema.GetOrAdd(schemaName, _ => new SemaphoreSlim(1, 1)); - registeredExecutor = new RegisteredExecutor( - schemaServices.GetRequiredService(), - schemaServices, - schemaServices.GetRequiredService(), - setup, - schemaServices.GetRequiredService()); + private async Task CreateRequestExecutorAsync( + string schemaName, + bool isInitialCreation, + CancellationToken cancellationToken) + { + var setup = + await _optionsMonitor.GetAsync(schemaName, cancellationToken) + .ConfigureAwait(false); + + var context = new ConfigurationContext( + schemaName, + setup.SchemaBuilder ?? new SchemaBuilder(), + _applicationServices); + + var typeModuleChangeMonitor = new TypeModuleChangeMonitor(this, context.SchemaName); - var executor = registeredExecutor.Executor; + // if there are any type modules we will register them with the + // type module change monitor. + // The module will track if type modules signal changes to the schema and + // start a schema eviction. + foreach (var typeModule in setup.TypeModules) + { + typeModuleChangeMonitor.Register(typeModule); + } - await OnRequestExecutorCreatedAsync(context, executor, setup, cancellationToken) + var schemaServices = + await CreateSchemaServicesAsync(context, setup, typeModuleChangeMonitor, cancellationToken) .ConfigureAwait(false); - registeredExecutor.DiagnosticEvents.ExecutorCreated( - schemaName, - registeredExecutor.Executor); - _executors.TryAdd(schemaName, registeredExecutor); + var registeredExecutor = new RegisteredExecutor( + schemaServices.GetRequiredService(), + schemaServices, + schemaServices.GetRequiredService(), + setup, + typeModuleChangeMonitor); - _events.RaiseEvent( - new RequestExecutorEvent( - RequestExecutorEventType.Created, - schemaName, - registeredExecutor.Executor)); + var executor = registeredExecutor.Executor; + + await OnRequestExecutorCreatedAsync(context, executor, setup, cancellationToken) + .ConfigureAwait(false); + + if (_warmupTasksBySchema.TryGetValue(schemaName, out var warmupTasks)) + { + if (!isInitialCreation) + { + warmupTasks = [.. warmupTasks.Where(t => t.KeepWarm)]; + } + + foreach (var warmupTask in warmupTasks) + { + await warmupTask.ExecuteAsync(executor, cancellationToken).ConfigureAwait(false); + } } - return registeredExecutor.Executor; + _executors[schemaName] = registeredExecutor; + + registeredExecutor.DiagnosticEvents.ExecutorCreated( + schemaName, + registeredExecutor.Executor); + + _events.RaiseEvent( + new RequestExecutorEvent( + RequestExecutorEventType.Created, + schemaName, + registeredExecutor.Executor)); + + return registeredExecutor; } - public void EvictRequestExecutor(string? schemaName = default) + private async Task UpdateRequestExecutorAsync(string schemaName, RegisteredExecutor previousExecutor) { - schemaName ??= Schema.DefaultName; + // We dispose the subscription to type updates so there will be no updates + // during the phase-out of the previous executor. + previousExecutor.TypeModuleChangeMonitor.Dispose(); + + // This will hot swap the request executor. + await CreateRequestExecutorAsync(schemaName, false, CancellationToken.None) + .ConfigureAwait(false); - if (_executors.TryRemove(schemaName, out var re)) + previousExecutor.DiagnosticEvents.ExecutorEvicted(schemaName, previousExecutor.Executor); + + try { - re.DiagnosticEvents.ExecutorEvicted(schemaName, re.Executor); + RequestExecutorEvicted?.Invoke( + this, + new RequestExecutorEvictedEventArgs(schemaName, previousExecutor.Executor)); - try - { - RequestExecutorEvicted?.Invoke( - this, - new RequestExecutorEvictedEventArgs(schemaName, re.Executor)); - _events.RaiseEvent( - new RequestExecutorEvent( - RequestExecutorEventType.Evicted, - schemaName, - re.Executor)); - } - finally - { - BeginRunEvictionEvents(re); - } + _events.RaiseEvent( + new RequestExecutorEvent( + RequestExecutorEventType.Evicted, + schemaName, + previousExecutor.Executor)); + } + finally + { + RunEvictionEvents(previousExecutor).FireAndForget(); } } @@ -157,32 +247,10 @@ private void EvictAllRequestExecutors() { foreach (var key in _executors.Keys) { - if (_executors.TryRemove(key, out var re)) - { - re.DiagnosticEvents.ExecutorEvicted(key, re.Executor); - - try - { - RequestExecutorEvicted?.Invoke( - this, - new RequestExecutorEvictedEventArgs(key, re.Executor)); - _events.RaiseEvent( - new RequestExecutorEvent( - RequestExecutorEventType.Evicted, - key, - re.Executor)); - } - finally - { - BeginRunEvictionEvents(re); - } - } + EvictRequestExecutor(key); } } - private static void BeginRunEvictionEvents(RegisteredExecutor registeredExecutor) - => RunEvictionEvents(registeredExecutor).FireAndForget(); - private static async Task RunEvictionEvents(RegisteredExecutor registeredExecutor) { try @@ -192,7 +260,7 @@ private static async Task RunEvictionEvents(RegisteredExecutor registeredExecuto finally { // we will give the request executor some grace period to finish all request - // in the pipeline + // in the pipeline. await Task.Delay(TimeSpan.FromMinutes(5)); registeredExecutor.Dispose(); } @@ -201,6 +269,7 @@ private static async Task RunEvictionEvents(RegisteredExecutor registeredExecuto private async Task CreateSchemaServicesAsync( ConfigurationContext context, RequestExecutorSetup setup, + TypeModuleChangeMonitor typeModuleChangeMonitor, CancellationToken cancellationToken) { ulong version; @@ -211,22 +280,12 @@ private async Task CreateSchemaServicesAsync( } var serviceCollection = new ServiceCollection(); - var typeModuleChangeMonitor = new TypeModuleChangeMonitor(this, context.SchemaName); var lazy = new SchemaBuilder.LazySchema(); var executorOptions = await OnConfigureRequestExecutorOptionsAsync(context, setup, cancellationToken) .ConfigureAwait(false); - // if there are any type modules we will register them with the - // type module change monitor. - // The module will track if type modules signal changes to the schema and - // start a schema eviction. - foreach (var typeModule in setup.TypeModules) - { - typeModuleChangeMonitor.Register(typeModule); - } - // we allow newer type modules to apply configurations. await typeModuleChangeMonitor.ConfigureAsync(context, cancellationToken) .ConfigureAwait(false); @@ -241,7 +300,6 @@ await typeModuleChangeMonitor.ConfigureAsync(context, cancellationToken) setup.DefaultPipelineFactory, setup.Pipeline)); - serviceCollection.AddSingleton(typeModuleChangeMonitor); serviceCollection.AddSingleton(executorOptions); serviceCollection.AddSingleton( static s => s.GetRequiredService()); @@ -252,6 +310,10 @@ await typeModuleChangeMonitor.ConfigureAsync(context, cancellationToken) serviceCollection.AddSingleton( static s => s.GetRequiredService()); + serviceCollection.AddSingleton( + _ => new DefaultPreparedOperationCache( + _applicationServices.GetRequiredService().Capacity)); + serviceCollection.AddSingleton(); serviceCollection.TryAddDiagnosticEvents(); @@ -449,9 +511,23 @@ public void Dispose() { if (!_disposed) { + // this will stop the eviction processor. + _cts.Cancel(); + + foreach (var executor in _executors.Values) + { + executor.Dispose(); + } + + foreach (var semaphore in _semaphoreBySchema.Values) + { + semaphore.Dispose(); + } + _events.Dispose(); _executors.Clear(); - _semaphore.Dispose(); + _semaphoreBySchema.Clear(); + _cts.Dispose(); _disposed = true; } } @@ -504,20 +580,11 @@ public override void OnBeforeCompleteName( } } - private sealed class TypeModuleChangeMonitor : IDisposable + private sealed class TypeModuleChangeMonitor(RequestExecutorResolver resolver, string schemaName) : IDisposable { private readonly List _typeModules = []; - private readonly RequestExecutorResolver _resolver; private bool _disposed; - public TypeModuleChangeMonitor(RequestExecutorResolver resolver, string schemaName) - { - _resolver = resolver; - SchemaName = schemaName; - } - - public string SchemaName { get; } - public void Register(ITypeModule typeModule) { typeModule.TypesChanged += EvictRequestExecutor; @@ -542,7 +609,7 @@ public IAsyncEnumerable CreateTypesAsync(IDescriptorContext c => new TypeModuleEnumerable(_typeModules, context); private void EvictRequestExecutor(object? sender, EventArgs args) - => _resolver.EvictRequestExecutor(SchemaName); + => resolver.EvictRequestExecutor(schemaName); public void Dispose() { diff --git a/src/HotChocolate/Core/src/Execution/Serialization/EventStreamResultFormatter.cs b/src/HotChocolate/Core/src/Execution/Serialization/EventStreamResultFormatter.cs index 95feceb8c91..dbd025e03f5 100644 --- a/src/HotChocolate/Core/src/Execution/Serialization/EventStreamResultFormatter.cs +++ b/src/HotChocolate/Core/src/Execution/Serialization/EventStreamResultFormatter.cs @@ -74,8 +74,11 @@ private async ValueTask FormatOperationResultAsync( MessageHelper.WriteNextMessage(_payloadFormatter, operationResult, buffer); MessageHelper.WriteCompleteMessage(buffer); - await outputStream.WriteAsync(buffer.GetInternalBuffer(), 0, buffer.Length, ct).ConfigureAwait(false); - await outputStream.FlushAsync(ct).ConfigureAwait(false); + if (!ct.IsCancellationRequested) + { + await outputStream.WriteAsync(buffer.GetInternalBuffer(), 0, buffer.Length, ct).ConfigureAwait(false); + await outputStream.FlushAsync(ct).ConfigureAwait(false); + } } catch (Exception ex) { @@ -111,8 +114,14 @@ private async ValueTask FormatResultBatchAsync( { buffer ??= new ArrayWriter(); MessageHelper.WriteNextMessage(_payloadFormatter, operationResult, buffer); + writer.Write(buffer.GetWrittenSpan()); - await writer.FlushAsync(ct).ConfigureAwait(false); + + if (!ct.IsCancellationRequested) + { + await writer.FlushAsync(ct).ConfigureAwait(false); + } + keepAlive?.Reset(); buffer.Reset(); } @@ -148,8 +157,11 @@ private async ValueTask FormatResultBatchAsync( buffer?.Dispose(); } - MessageHelper.WriteCompleteMessage(writer); - await writer.FlushAsync(ct).ConfigureAwait(false); + if (!ct.IsCancellationRequested) + { + MessageHelper.WriteCompleteMessage(writer); + await writer.FlushAsync(ct).ConfigureAwait(false); + } } private async ValueTask FormatResponseStreamAsync( @@ -165,8 +177,11 @@ private async ValueTask FormatResponseStreamAsync( await formatter.ProcessAsync(ct).ConfigureAwait(false); } - MessageHelper.WriteCompleteMessage(writer); - await writer.FlushAsync(ct).ConfigureAwait(false); + if (!ct.IsCancellationRequested) + { + MessageHelper.WriteCompleteMessage(writer); + await writer.FlushAsync(ct).ConfigureAwait(false); + } } private sealed class StreamFormatter( @@ -207,6 +222,12 @@ public async Task ProcessAsync(CancellationToken ct) } } } + catch (OperationCanceledException) + { + // if the operation was canceled we do not need to log this + // and will stop gracefully. + return; + } finally { await responseStream.DisposeAsync().ConfigureAwait(false); diff --git a/src/HotChocolate/Core/src/Execution/WarmupSchemaTask.cs b/src/HotChocolate/Core/src/Execution/WarmupSchemaTask.cs new file mode 100644 index 00000000000..ae8c8d8c990 --- /dev/null +++ b/src/HotChocolate/Core/src/Execution/WarmupSchemaTask.cs @@ -0,0 +1,16 @@ +namespace HotChocolate.Execution; + +internal sealed class WarmupSchemaTask( + string schemaName, + bool keepWarm, + Func? warmup = null) +{ + public string SchemaName { get; } = schemaName; + + public bool KeepWarm { get; } = keepWarm; + + public Task ExecuteAsync(IRequestExecutor executor, CancellationToken cancellationToken) + => warmup is not null + ? warmup.Invoke(executor, cancellationToken) + : Task.CompletedTask; +} diff --git a/src/HotChocolate/Core/src/Types/Configuration/TypeRegistry.cs b/src/HotChocolate/Core/src/Types/Configuration/TypeRegistry.cs index da331219c81..18a49690f39 100644 --- a/src/HotChocolate/Core/src/Types/Configuration/TypeRegistry.cs +++ b/src/HotChocolate/Core/src/Types/Configuration/TypeRegistry.cs @@ -162,7 +162,8 @@ public void Register(RegisteredType registeredType) _nameRefs.Add(typeDef.Name, registeredType.References[0]); } else if (registeredType.Kind == TypeKind.Scalar && - registeredType.Type is ScalarType scalar) + registeredType.Type is ScalarType scalar && + !_nameRefs.ContainsKey(scalar.Name)) { _nameRefs.Add(scalar.Name, registeredType.References[0]); } diff --git a/src/HotChocolate/Core/src/Types/IReadOnlySchemaOptions.cs b/src/HotChocolate/Core/src/Types/IReadOnlySchemaOptions.cs index 7feb9939efd..7dbf71f8146 100644 --- a/src/HotChocolate/Core/src/Types/IReadOnlySchemaOptions.cs +++ b/src/HotChocolate/Core/src/Types/IReadOnlySchemaOptions.cs @@ -199,7 +199,7 @@ public interface IReadOnlySchemaOptions /// /// Specifies if the elements of paginated root fields should be published - /// to the DataLOader promise cache. + /// to the DataLoader promise cache. /// bool PublishRootFieldPagesToPromiseCache { get; } } diff --git a/src/HotChocolate/Core/test/Execution.Tests/AutoUpdateRequestExecutorProxyTests.cs b/src/HotChocolate/Core/test/Execution.Tests/AutoUpdateRequestExecutorProxyTests.cs index 59ace2dfe58..1a421f844e7 100644 --- a/src/HotChocolate/Core/test/Execution.Tests/AutoUpdateRequestExecutorProxyTests.cs +++ b/src/HotChocolate/Core/test/Execution.Tests/AutoUpdateRequestExecutorProxyTests.cs @@ -33,6 +33,7 @@ public async Task Ensure_Executor_Is_Cached() public async Task Ensure_Executor_Is_Correctly_Swapped_When_Evicted() { // arrange + var executorUpdatedResetEvent = new AutoResetEvent(false); var resolver = new ServiceCollection() .AddGraphQL() @@ -45,30 +46,21 @@ public async Task Ensure_Executor_Is_Correctly_Swapped_When_Evicted() var updated = false; var innerProxy = new RequestExecutorProxy(resolver, Schema.DefaultName); + + var proxy = await AutoUpdateRequestExecutorProxy.CreateAsync(innerProxy); innerProxy.ExecutorEvicted += (_, _) => { evicted = true; - updated = false; + executorUpdatedResetEvent.Set(); }; innerProxy.ExecutorUpdated += (_, _) => updated = true; - var proxy = await AutoUpdateRequestExecutorProxy.CreateAsync(innerProxy); - // act var a = proxy.InnerExecutor; resolver.EvictRequestExecutor(); + executorUpdatedResetEvent.WaitOne(1000); - var i = 0; var b = proxy.InnerExecutor; - while (ReferenceEquals(a, b)) - { - await Task.Delay(100); - b = proxy.InnerExecutor; - if (i++ > 10) - { - break; - } - } // assert Assert.NotSame(a, b); diff --git a/src/HotChocolate/Core/test/Execution.Tests/Configuration/TypeModuleTests.cs b/src/HotChocolate/Core/test/Execution.Tests/Configuration/TypeModuleTests.cs index fad4e67be77..c96aeb97c6e 100644 --- a/src/HotChocolate/Core/test/Execution.Tests/Configuration/TypeModuleTests.cs +++ b/src/HotChocolate/Core/test/Execution.Tests/Configuration/TypeModuleTests.cs @@ -3,6 +3,7 @@ using HotChocolate.Types.Descriptors; using HotChocolate.Types.Descriptors.Definitions; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; namespace HotChocolate.Execution.Configuration; @@ -64,6 +65,62 @@ public async Task Use_Type_Module_From_Factory() .MatchSnapshotAsync(); } + [Fact] + public async Task Ensure_Warmups_Are_Triggered_An_Appropriate_Number_Of_Times() + { + // arrange + var typeModule = new TriggerableTypeModule(); + var warmups = 0; + var warmupResetEvent = new AutoResetEvent(false); + + var services = new ServiceCollection(); + services + .AddGraphQL() + .AddTypeModule(_ => typeModule) + .InitializeOnStartup(keepWarm: true, warmup: (_, _) => + { + warmups++; + warmupResetEvent.Set(); + return Task.CompletedTask; + }) + .AddQueryType(d => d.Field("foo").Resolve("")); + var provider = services.BuildServiceProvider(); + var warmupService = provider.GetRequiredService(); + + using var cts = new CancellationTokenSource(); + _ = Task.Run(async () => + { + await warmupService.StartAsync(CancellationToken.None); + }, cts.Token); + + var resolver = provider.GetRequiredService(); + + await resolver.GetRequestExecutorAsync(null, cts.Token); + + // act + // assert + warmupResetEvent.WaitOne(); + + Assert.Equal(1, warmups); + warmupResetEvent.Reset(); + + typeModule.TriggerChange(); + warmupResetEvent.WaitOne(); + + Assert.Equal(2, warmups); + warmupResetEvent.Reset(); + + typeModule.TriggerChange(); + warmupResetEvent.WaitOne(); + + Assert.Equal(3, warmups); + } + + private sealed class TriggerableTypeModule : TypeModule + { + public void TriggerChange() => OnTypesChanged(); + } + public class DummyTypeModule : ITypeModule { #pragma warning disable CS0067 diff --git a/src/HotChocolate/Core/test/Execution.Tests/Integration/DataLoader/UseDataLoaderTests.cs b/src/HotChocolate/Core/test/Execution.Tests/Integration/DataLoader/UseDataLoaderTests.cs index d0d704b130d..c3ef0a237fc 100644 --- a/src/HotChocolate/Core/test/Execution.Tests/Integration/DataLoader/UseDataLoaderTests.cs +++ b/src/HotChocolate/Core/test/Execution.Tests/Integration/DataLoader/UseDataLoaderTests.cs @@ -256,7 +256,7 @@ public class TestGroupedLoader : GroupedDataLoader { public TestGroupedLoader( IBatchScheduler batchScheduler, - DataLoaderOptions? options = null) + DataLoaderOptions options) : base(batchScheduler, options) { } diff --git a/src/HotChocolate/Core/test/Execution.Tests/PreparedOperationCacheTests.cs b/src/HotChocolate/Core/test/Execution.Tests/PreparedOperationCacheTests.cs new file mode 100644 index 00000000000..798d1c1d0d6 --- /dev/null +++ b/src/HotChocolate/Core/test/Execution.Tests/PreparedOperationCacheTests.cs @@ -0,0 +1,30 @@ +using HotChocolate.Execution.Caching; +using HotChocolate.Types; +using Microsoft.Extensions.DependencyInjection; + +namespace HotChocolate.Execution; + +public class PreparedOperationCacheTests +{ + [Fact] + public async Task Operation_Cache_Should_Have_Configured_Capacity() + { + // arrange + var operationCacheCapacity = 517; + var services = new ServiceCollection(); + services.AddOperationCache(operationCacheCapacity); + services + .AddGraphQL() + .AddQueryType(d => d.Field("foo").Resolve("")); + var provider = services.BuildServiceProvider(); + var resolver = provider.GetRequiredService(); + + // act + var executor = await resolver.GetRequestExecutorAsync(); + var operationCache = executor.Services.GetCombinedServices() + .GetRequiredService(); + + // assert + Assert.Equal(operationCache.Capacity, operationCacheCapacity); + } +} diff --git a/src/HotChocolate/Core/test/Execution.Tests/RequestExecutorProxyTests.cs b/src/HotChocolate/Core/test/Execution.Tests/RequestExecutorProxyTests.cs index 62a5fa30663..0bf05913747 100644 --- a/src/HotChocolate/Core/test/Execution.Tests/RequestExecutorProxyTests.cs +++ b/src/HotChocolate/Core/test/Execution.Tests/RequestExecutorProxyTests.cs @@ -31,6 +31,7 @@ public async Task Ensure_Executor_Is_Cached() public async Task Ensure_Executor_Is_Correctly_Swapped_When_Evicted() { // arrange + var executorUpdatedResetEvent = new AutoResetEvent(false); var resolver = new ServiceCollection() .AddGraphQL() @@ -46,13 +47,14 @@ public async Task Ensure_Executor_Is_Correctly_Swapped_When_Evicted() proxy.ExecutorEvicted += (sender, args) => { evicted = true; - updated = false; + executorUpdatedResetEvent.Set(); }; proxy.ExecutorUpdated += (sender, args) => updated = true; // act var a = await proxy.GetRequestExecutorAsync(CancellationToken.None); resolver.EvictRequestExecutor(); + executorUpdatedResetEvent.WaitOne(1000); var b = await proxy.GetRequestExecutorAsync(CancellationToken.None); // assert diff --git a/src/HotChocolate/Core/test/Execution.Tests/RequestExecutorResolverTests.cs b/src/HotChocolate/Core/test/Execution.Tests/RequestExecutorResolverTests.cs new file mode 100644 index 00000000000..8a98bfe527f --- /dev/null +++ b/src/HotChocolate/Core/test/Execution.Tests/RequestExecutorResolverTests.cs @@ -0,0 +1,194 @@ +using HotChocolate.Execution.Caching; +using HotChocolate.Types; +using Microsoft.Extensions.DependencyInjection; + +namespace HotChocolate.Execution; + +public class RequestExecutorResolverTests +{ + [Fact] + public async Task Operation_Cache_Should_Be_Scoped_To_Executor() + { + // arrange + var executorEvictedResetEvent = new AutoResetEvent(false); + + var resolver = new ServiceCollection() + .AddGraphQL() + .AddQueryType(d => d.Field("foo").Resolve("")) + .Services.BuildServiceProvider() + .GetRequiredService(); + + resolver.Events.Subscribe(new RequestExecutorEventObserver(@event => + { + if (@event.Type == RequestExecutorEventType.Evicted) + { + executorEvictedResetEvent.Set(); + } + })); + + // act + var firstExecutor = await resolver.GetRequestExecutorAsync(); + var firstOperationCache = firstExecutor.Services.GetCombinedServices() + .GetRequiredService(); + + resolver.EvictRequestExecutor(); + executorEvictedResetEvent.WaitOne(1000); + + var secondExecutor = await resolver.GetRequestExecutorAsync(); + var secondOperationCache = secondExecutor.Services.GetCombinedServices() + .GetRequiredService(); + + // assert + Assert.NotSame(secondOperationCache, firstOperationCache); + } + + [Fact] + public async Task Executor_Should_Only_Be_Switched_Once_It_Is_Warmed_Up() + { + // arrange + var warmupResetEvent = new AutoResetEvent(true); + var executorEvictedResetEvent = new AutoResetEvent(false); + + var resolver = new ServiceCollection() + .AddGraphQL() + .InitializeOnStartup( + keepWarm: true, + warmup: (_, _) => + { + warmupResetEvent.WaitOne(1000); + + return Task.CompletedTask; + }) + .AddQueryType(d => d.Field("foo").Resolve("")) + .Services.BuildServiceProvider() + .GetRequiredService(); + + resolver.Events.Subscribe(new RequestExecutorEventObserver(@event => + { + if (@event.Type == RequestExecutorEventType.Evicted) + { + executorEvictedResetEvent.Set(); + } + })); + + // act + // assert + var initialExecutor = await resolver.GetRequestExecutorAsync(); + warmupResetEvent.Reset(); + + resolver.EvictRequestExecutor(); + + var executorAfterEviction = await resolver.GetRequestExecutorAsync(); + + Assert.Same(initialExecutor, executorAfterEviction); + + warmupResetEvent.Set(); + executorEvictedResetEvent.WaitOne(1000); + var executorAfterWarmup = await resolver.GetRequestExecutorAsync(); + + Assert.NotSame(initialExecutor, executorAfterWarmup); + } + + [Theory] + [InlineData(false, 1)] + [InlineData(true, 2)] + public async Task WarmupSchemaTasks_Are_Applied_Correct_Number_Of_Times( + bool keepWarm, int expectedWarmups) + { + // arrange + var warmups = 0; + var executorEvictedResetEvent = new AutoResetEvent(false); + + var resolver = new ServiceCollection() + .AddGraphQL() + .InitializeOnStartup( + keepWarm: keepWarm, + warmup: (_, _) => + { + warmups++; + return Task.CompletedTask; + }) + .AddQueryType(d => d.Field("foo").Resolve("")) + .Services.BuildServiceProvider() + .GetRequiredService(); + + resolver.Events.Subscribe(new RequestExecutorEventObserver(@event => + { + if (@event.Type == RequestExecutorEventType.Evicted) + { + executorEvictedResetEvent.Set(); + } + })); + + // act + // assert + var initialExecutor = await resolver.GetRequestExecutorAsync(); + + resolver.EvictRequestExecutor(); + executorEvictedResetEvent.WaitOne(1000); + + var executorAfterEviction = await resolver.GetRequestExecutorAsync(); + + Assert.NotSame(initialExecutor, executorAfterEviction); + Assert.Equal(expectedWarmups, warmups); + } + + [Fact] + public async Task Calling_GetExecutorAsync_Multiple_Times_Only_Creates_One_Executor() + { + // arrange + var resolver = new ServiceCollection() + .AddGraphQL() + .AddQueryType(d => + { + d.Field("foo").Resolve(""); + }) + .Services.BuildServiceProvider() + .GetRequiredService(); + + // act + var executor1Task = Task.Run(async () => await resolver.GetRequestExecutorAsync()); + var executor2Task = Task.Run(async () => await resolver.GetRequestExecutorAsync()); + + var executor1 = await executor1Task; + var executor2 = await executor2Task; + + // assert + Assert.Same(executor1, executor2); + } + + [Fact] + public async Task Executor_Resolution_Should_Be_Parallel() + { + // arrange + var schema1CreationResetEvent = new AutoResetEvent(false); + + var services = new ServiceCollection(); + services + .AddGraphQL("schema1") + .AddQueryType(d => + { + schema1CreationResetEvent.WaitOne(1000); + d.Field("foo").Resolve(""); + }); + services + .AddGraphQL("schema2") + .AddQueryType(d => + { + d.Field("foo").Resolve(""); + }); + var provider = services.BuildServiceProvider(); + var resolver = provider.GetRequiredService(); + + // act + var executor1Task = Task.Run(async () => await resolver.GetRequestExecutorAsync("schema1")); + var executor2Task = Task.Run(async () => await resolver.GetRequestExecutorAsync("schema2")); + + // assert + await executor2Task; + + schema1CreationResetEvent.Set(); + + await executor1Task; + } +} diff --git a/src/HotChocolate/Core/test/Execution.Tests/WarmupRequestTests.cs b/src/HotChocolate/Core/test/Execution.Tests/WarmupRequestTests.cs index fe04071361d..f5e037f89c8 100644 --- a/src/HotChocolate/Core/test/Execution.Tests/WarmupRequestTests.cs +++ b/src/HotChocolate/Core/test/Execution.Tests/WarmupRequestTests.cs @@ -34,9 +34,9 @@ public async Task Warmup_Request_Warms_Up_Caches() // assert 1 Assert.IsType(warmupResult); - var provider = executor.Services.GetCombinedServices(); - var documentCache = provider.GetRequiredService(); - var operationCache = provider.GetRequiredService(); + var documentCache = executor.Services.GetCombinedServices() + .GetRequiredService(); + var operationCache = executor.Services.GetRequiredService(); Assert.True(documentCache.TryGetDocument(documentId, out _)); Assert.Equal(1, operationCache.Count); diff --git a/src/HotChocolate/Core/test/Types.Tests/Configuration/TypeDiscoveryTests.cs b/src/HotChocolate/Core/test/Types.Tests/Configuration/TypeDiscoveryTests.cs index c919d211bc2..f68975bb8ab 100644 --- a/src/HotChocolate/Core/test/Types.Tests/Configuration/TypeDiscoveryTests.cs +++ b/src/HotChocolate/Core/test/Types.Tests/Configuration/TypeDiscoveryTests.cs @@ -54,6 +54,19 @@ public void InferInputTypeWithComputedProperty() .MatchSnapshot(); } + [Fact] + public void Custom_LocalDate_Should_Throw_SchemaException_When_Not_Bound() + { + static void Act() => + SchemaBuilder.New() + .AddQueryType() + .Create(); + + Assert.Equal( + "The name `LocalDate` was already registered by another type.", + Assert.Throws(Act).Errors[0].Message); + } + public class QueryWithDateTime { public DateTimeOffset DateTimeOffset(DateTimeOffset time) => time; @@ -153,4 +166,14 @@ public class QueryTypeWithComputedProperty { public int Foo(InputTypeWithReadOnlyProperties arg) => arg.Property1; } + + public class QueryTypeWithCustomLocalDate + { + public LocalDate Foo() => new(); + } + + public class LocalDate + { + public DateOnly Date { get; set; } = new(); + } } diff --git a/website/src/docs/hotchocolate/v15/migrating/migrate-from-14-to-15.md b/website/src/docs/hotchocolate/v15/migrating/migrate-from-14-to-15.md index 956071560fc..42c13d641f1 100644 --- a/website/src/docs/hotchocolate/v15/migrating/migrate-from-14-to-15.md +++ b/website/src/docs/hotchocolate/v15/migrating/migrate-from-14-to-15.md @@ -50,8 +50,74 @@ Please ensure that your clients are sending date/time strings in the correct for - `DateOnly` is now bound to `LocalDateType` instead of `DateType`. - `TimeOnly` is now bound to `LocalTimeType` instead of `TimeSpanType`. +## DataLoaderOptions are now required + +Starting with Hot Chocolate 15, the `DataLoaderOptions` must be passed down to the DataLoaderBase constructor. + +```csharp +public class ProductByIdDataLoader : BatchDataLoader +{ + private readonly IServiceProvider _services; + + public ProductDataLoader1( + IBatchScheduler batchScheduler, + DataLoaderOptions options) // the options are now required ... + : base(batchScheduler, options) + { + } +} +``` + +## DataLoader Dependency Injection + +DataLoader must not be manually registered with the dependency injection and must use the extension methods provided by GreenDonut. + +```csharp +services.AddDataLoader(); +services.AddDataLoader(); +services.AddDataLoader(sp => ....); +``` + +We recommend to use the source-generated DataLoaders and let the source generator write the registration code for you. + +> If you register DataLoader manually they will be stuck in the auto-dispatch mode, which basically means that they will no longer batch. + +DataLoader are available as scoped services and can be injected like any other scoped service. + +```csharp +public class ProductService(IProductByIdDataLoader productByIdData) +{ + public async Task GetProductById(int id) + { + return await productByIdDataLoader.LoadAsync(id); + } +} +``` + # Deprecations +## GroupDataLoader + +We no longer recommend using the `GroupDataLoader`, as the same functionality can be achieved with a BatchDataLoader, which provides greater flexibility in determining the type of list returned. + +Use the following patter to replace the `GroupDataLoader`: + +```csharp +internal static class ProductDataLoader +{ + [DataLoader] + public static async Task> GetProductsByBrandIdAsync( + IReadOnlyList brandIds, + CatalogContext context, + CancellationToken cancellationToken) + => await context.Products + .Where(t => brandIds.Contains(t.BrandId)) + .GroupBy(t => t.BrandId) + .Select(t => new { t.Key, Items = t.OrderBy(p => p.Name).ToArray() }) + .ToDictionaryAsync(t => t.Key, t => t.Items, cancellationToken); +} +``` + ## AdHoc DataLoader The ad-hoc DataLoader methods on IResolverContext have been deprecated.