diff --git a/src/Trax.Api.GraphQL.PersistedOperations/Broadcasting/PersistedOperationReceiverService.cs b/src/Trax.Api.GraphQL.PersistedOperations/Broadcasting/PersistedOperationReceiverService.cs index 141dec7..8734736 100644 --- a/src/Trax.Api.GraphQL.PersistedOperations/Broadcasting/PersistedOperationReceiverService.cs +++ b/src/Trax.Api.GraphQL.PersistedOperations/Broadcasting/PersistedOperationReceiverService.cs @@ -22,6 +22,7 @@ internal sealed class PersistedOperationReceiverService : IHostedService, IAsync { private readonly PersistedOperationsOptions _options; private readonly IPersistedOperationCache _cache; + private readonly HotChocolateOperationCacheInvalidator _hcInvalidator; private readonly ILogger _logger; private IConnection? _connection; @@ -31,14 +32,17 @@ internal sealed class PersistedOperationReceiverService : IHostedService, IAsync public PersistedOperationReceiverService( PersistedOperationsOptions options, IPersistedOperationCache cache, + HotChocolateOperationCacheInvalidator hcInvalidator, ILogger logger ) { ArgumentNullException.ThrowIfNull(options); ArgumentNullException.ThrowIfNull(cache); + ArgumentNullException.ThrowIfNull(hcInvalidator); ArgumentNullException.ThrowIfNull(logger); _options = options; _cache = cache; + _hcInvalidator = hcInvalidator; _logger = logger; } @@ -117,7 +121,10 @@ private async Task OnMessageAsync(object _, BasicDeliverEventArgs ea) ); if (message is not null) + { _cache.Invalidate(message.TenantKey, message.Id); + await _hcInvalidator.InvalidateAsync(CancellationToken.None).ConfigureAwait(false); + } await _channel .BasicAckAsync(ea.DeliveryTag, multiple: false, CancellationToken.None) diff --git a/src/Trax.Api.GraphQL.PersistedOperations/Extensions/TraxGraphQLBuilderPersistedOperationsExtensions.cs b/src/Trax.Api.GraphQL.PersistedOperations/Extensions/TraxGraphQLBuilderPersistedOperationsExtensions.cs index 73eb247..0d6ac18 100644 --- a/src/Trax.Api.GraphQL.PersistedOperations/Extensions/TraxGraphQLBuilderPersistedOperationsExtensions.cs +++ b/src/Trax.Api.GraphQL.PersistedOperations/Extensions/TraxGraphQLBuilderPersistedOperationsExtensions.cs @@ -111,6 +111,11 @@ Action configure builder.ExposeOperationMutations(); builder.AddTypeExtensions(typeof(GraphQL.PersistedOperationMutations).Assembly); + // HotChocolate cache invalidator. The schema name is captured below + // inside ConfigureSchema so the invalidator can resolve the right + // executor when clearing IPreparedOperationCache. + services.AddSingleton(); + // Storage: implements both IPersistedOperationStore and the HC hot-path. services.AddSingleton(); services.AddSingleton(sp => @@ -133,6 +138,12 @@ Action configure sc.AddSingleton(sp => { var root = sp.GetRequiredService(); + // Capture the schema name on the invalidator the first + // time the schema services are built. ConfigureSchemaServices + // runs lazily during executor build, by which time the root + // provider has the singleton ready. + root.GetRequiredService() + .SetSchemaName(schema.Name); return root.GetRequiredService(); }) ); diff --git a/src/Trax.Api.GraphQL.PersistedOperations/Storage/DbPersistedOperationStorage.cs b/src/Trax.Api.GraphQL.PersistedOperations/Storage/DbPersistedOperationStorage.cs index 40a6bdb..fed69de 100644 --- a/src/Trax.Api.GraphQL.PersistedOperations/Storage/DbPersistedOperationStorage.cs +++ b/src/Trax.Api.GraphQL.PersistedOperations/Storage/DbPersistedOperationStorage.cs @@ -34,6 +34,7 @@ internal sealed class DbPersistedOperationStorage private readonly IPersistedOperationCache _cache; private readonly IPersistedOperationBroadcaster _broadcaster; private readonly IPersistedOperationValidator _validator; + private readonly HotChocolateOperationCacheInvalidator _hcInvalidator; private readonly TimeProvider _clock; private readonly ILogger _logger; @@ -43,6 +44,7 @@ public DbPersistedOperationStorage( IPersistedOperationCache cache, IPersistedOperationBroadcaster broadcaster, IPersistedOperationValidator validator, + HotChocolateOperationCacheInvalidator hcInvalidator, TimeProvider clock, ILogger logger ) @@ -52,6 +54,7 @@ ILogger logger ArgumentNullException.ThrowIfNull(cache); ArgumentNullException.ThrowIfNull(broadcaster); ArgumentNullException.ThrowIfNull(validator); + ArgumentNullException.ThrowIfNull(hcInvalidator); ArgumentNullException.ThrowIfNull(clock); ArgumentNullException.ThrowIfNull(logger); _factory = factory; @@ -59,6 +62,7 @@ ILogger logger _cache = cache; _broadcaster = broadcaster; _validator = validator; + _hcInvalidator = hcInvalidator; _clock = clock; _logger = logger; } @@ -273,6 +277,7 @@ CancellationToken ct await ctx.SaveChanges(ct).ConfigureAwait(false); _cache.Invalidate(tenantKey, id); + await _hcInvalidator.InvalidateAsync(ct).ConfigureAwait(false); await PublishAsync(tenantKey, id, PersistedOperationChangeType.Upsert, ct) .ConfigureAwait(false); @@ -330,6 +335,7 @@ await ctx await ctx.SaveChanges(ct).ConfigureAwait(false); _cache.Invalidate(tenantKey, id); + await _hcInvalidator.InvalidateAsync(ct).ConfigureAwait(false); await PublishAsync(tenantKey, id, PersistedOperationChangeType.Deactivate, ct) .ConfigureAwait(false); } @@ -379,6 +385,7 @@ await ctx await ctx.SaveChanges(ct).ConfigureAwait(false); _cache.Invalidate(tenantKey, id); + await _hcInvalidator.InvalidateAsync(ct).ConfigureAwait(false); await PublishAsync(tenantKey, id, PersistedOperationChangeType.Restore, ct) .ConfigureAwait(false); } diff --git a/src/Trax.Api.GraphQL.PersistedOperations/Storage/HotChocolateOperationCacheInvalidator.cs b/src/Trax.Api.GraphQL.PersistedOperations/Storage/HotChocolateOperationCacheInvalidator.cs new file mode 100644 index 0000000..59dfd34 --- /dev/null +++ b/src/Trax.Api.GraphQL.PersistedOperations/Storage/HotChocolateOperationCacheInvalidator.cs @@ -0,0 +1,86 @@ +using HotChocolate.Execution; +using HotChocolate.Execution.Caching; +using HotChocolate.Language; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Trax.Api.GraphQL.PersistedOperations.Storage; + +/// +/// Invalidates HotChocolate's request-pipeline caches when a persisted +/// operation document changes. Without this, HC's +/// (parsed DocumentNode keyed by persisted-op id) and +/// (compiled operation keyed by +/// {schema}-{executorVersion}-{documentId}+{operationName}) will keep +/// serving the previously cached version even after +/// IOperationDocumentStorage.TryReadAsync returns the new text. +/// +/// +/// Neither cache exposes per-id removal in HC 15.x, so this clears each +/// cache in its entirety. Persisted-operation upserts are operator-driven +/// and rare, so the cache-warm cost on the next handful of requests is +/// acceptable. +/// +internal sealed class HotChocolateOperationCacheInvalidator +{ + private readonly IServiceProvider _rootServices; + private readonly ILogger _logger; + private volatile string? _schemaName; + + public HotChocolateOperationCacheInvalidator( + IServiceProvider rootServices, + ILogger logger + ) + { + ArgumentNullException.ThrowIfNull(rootServices); + ArgumentNullException.ThrowIfNull(logger); + _rootServices = rootServices; + _logger = logger; + } + + /// + /// Schema name captured at ConfigureSchema time so we know which + /// executor to ask for. null resolves to HC's default schema. + /// + public void SetSchemaName(string? schemaName) => _schemaName = schemaName; + + /// + /// Clears both the parsed-document cache and the prepared-operation + /// cache. Safe to call from any thread. Never throws; logs and returns. + /// + public async Task InvalidateAsync(CancellationToken ct) + { + try + { + var documentCache = _rootServices.GetService(); + documentCache?.Clear(); + } + catch (Exception ex) + { + _logger.LogWarning( + ex, + "Failed to clear HotChocolate IDocumentCache during persisted-operation invalidation." + ); + } + + try + { + var resolver = _rootServices.GetService(); + if (resolver is null) + return; + + var executor = await resolver + .GetRequestExecutorAsync(_schemaName, ct) + .ConfigureAwait(false); + var prepared = executor.Schema.Services.GetService(); + prepared?.Clear(); + } + catch (Exception ex) + { + _logger.LogWarning( + ex, + "Failed to clear HotChocolate IPreparedOperationCache during persisted-operation invalidation." + ); + } + } +} diff --git a/tests/Trax.Api.Tests/PersistedOperations/IntegrationTests/DbPersistedOperationStorageTests.cs b/tests/Trax.Api.Tests/PersistedOperations/IntegrationTests/DbPersistedOperationStorageTests.cs index 793814c..1a18e06 100644 --- a/tests/Trax.Api.Tests/PersistedOperations/IntegrationTests/DbPersistedOperationStorageTests.cs +++ b/tests/Trax.Api.Tests/PersistedOperations/IntegrationTests/DbPersistedOperationStorageTests.cs @@ -49,6 +49,7 @@ public async Task SetUp() cache, _broadcaster, new NoOpPersistedOperationValidator(), + NoOpInvalidator(), TimeProvider.System, NullLogger.Instance ); @@ -283,6 +284,7 @@ public async Task Upsert_BroadcasterThrows_OperationStillSucceeds() new NoOpPersistedOperationCache(), throwing, new NoOpPersistedOperationValidator(), + NoOpInvalidator(), TimeProvider.System, NullLogger.Instance ); @@ -605,6 +607,7 @@ public async Task TryReadAsync_UsesCache_WhenConfigured() memCache, new NoOpPersistedOperationBroadcaster(), new NoOpPersistedOperationValidator(), + NoOpInvalidator(), TimeProvider.System, NullLogger.Instance ); @@ -638,6 +641,17 @@ await storage.UpsertAsync( second!.ToString().Should().Contain("hello"); } + /// + /// Invalidator over an empty service provider: HC cache lookups all + /// return null so this is effectively a no-op for tests that exercise + /// the storage outside a real HotChocolate schema. + /// + private static HotChocolateOperationCacheInvalidator NoOpInvalidator() => + new( + new ServiceCollection().BuildServiceProvider(), + NullLogger.Instance + ); + private sealed class RecordingBroadcaster : IPersistedOperationBroadcaster { public List Messages { get; } = new(); diff --git a/tests/Trax.Api.Tests/PersistedOperations/IntegrationTests/HotChocolateCacheInvalidationTests.cs b/tests/Trax.Api.Tests/PersistedOperations/IntegrationTests/HotChocolateCacheInvalidationTests.cs new file mode 100644 index 0000000..aec5797 --- /dev/null +++ b/tests/Trax.Api.Tests/PersistedOperations/IntegrationTests/HotChocolateCacheInvalidationTests.cs @@ -0,0 +1,279 @@ +using FluentAssertions; +using HotChocolate; +using HotChocolate.Execution; +using HotChocolate.Execution.Caching; +using HotChocolate.Language; +using Microsoft.Extensions.DependencyInjection; +using Trax.Api.GraphQL.PersistedOperations.Storage; +using Trax.Api.Tests.PersistedOperations.Fixtures; + +namespace Trax.Api.Tests.PersistedOperations.IntegrationTests; + +/// +/// Reproduces the hot-fix regression described in +/// PERSISTED_OPS_HOTFIX_BUG.md: re-uploading a shape-bypassed edit +/// to an existing persisted operation must serve the new document on the +/// next request without a process restart. +/// +/// +/// Before the fix, HotChocolate's (parsed +/// DocumentNode keyed by persisted-op id, root-scoped) and +/// (compiled operation keyed by +/// {schema}-{executorVersion}-{documentId}+{operationName}, +/// schema-scoped) held on to the prior document's compiled form even after +/// IOperationDocumentStorage.TryReadAsync returned the new text. +/// These tests pin both caches' behaviour so a future change cannot +/// silently regress. +/// +[TestFixture] +[Category("Integration")] +public class HotChocolateCacheInvalidationTests +{ + // Distinct documents that produce different response keys ("hello" vs + // "version"). The Greet operation name keeps the cache key stable + // across the two upserts. + private const string DocHello = "query Greet { hello }"; + private const string DocVersion = "query Greet { version }"; + + private ServiceProvider _sp = null!; + private IRequestExecutor _executor = null!; + private IPersistedOperationStore _store = null!; + + [OneTimeSetUp] + public async Task OneTimeSetUp() + { + if (!PostgresFixture.IsPostgresReachable()) + Assert.Ignore("Postgres not reachable; skipping HC cache invalidation tests."); + + _sp = await GraphQLFixture.BuildAsync(); + _executor = await GraphQLFixture.GetExecutorAsync(_sp); + _store = _sp.GetRequiredService(); + } + + [OneTimeTearDown] + public async Task OneTimeTearDown() + { + if (_sp is not null) + await _sp.DisposeAsync(); + } + + [SetUp] + public Task SetUp() => PostgresFixture.ClearAsync(); + + [Test] + public async Task Upsert_AfterFirstExecute_ServesNewDocument_OnNextExecute() + { + const string id = "hotfix_via_store_v1"; + await _store.UpsertAsync(id, DocHello, null, CancellationToken.None); + (await ExecuteByIdAsync(id)).Should().Contain("\"hello\""); + + await _store.UpsertAsync( + id, + DocVersion, + new UpsertOptions { BypassShapeDiff = true }, + CancellationToken.None + ); + + var second = await ExecuteByIdAsync(id); + second.Should().Contain("\"version\"", "the new document must take effect immediately"); + second + .Should() + .NotContain( + "\"hello\"", + "the prior document must NOT still be served from HC's cache layers" + ); + } + + [Test] + public async Task Upsert_ViaGraphQLMutation_AlsoInvalidatesHCCache() + { + const string id = "hotfix_via_mutation_v1"; + await _store.UpsertAsync(id, DocHello, null, CancellationToken.None); + (await ExecuteByIdAsync(id)).Should().Contain("\"hello\""); + + await ExecuteUploadMutationAsync(id, DocVersion, bypassShapeDiff: true); + + var after = await ExecuteByIdAsync(id); + after.Should().Contain("\"version\""); + after.Should().NotContain("\"hello\""); + } + + [Test] + public async Task DocumentCache_IsCleared_OnUpsert() + { + // Direct assertion against HC's caches. After a warm execute the + // parsed-document cache holds an entry keyed by the persisted-op + // id; after upsert it must be empty. + const string id = "doccache_v1"; + await _store.UpsertAsync(id, DocHello, null, CancellationToken.None); + await ExecuteByIdAsync(id); + + var documentCache = _sp.GetRequiredService(); + documentCache.Count.Should().BeGreaterThan(0, "the warm execute must have populated it"); + + await _store.UpsertAsync( + id, + DocVersion, + new UpsertOptions { BypassShapeDiff = true }, + CancellationToken.None + ); + + documentCache + .Count.Should() + .Be(0, "Upsert must clear IDocumentCache so the new document is parsed fresh"); + } + + [Test] + public async Task PreparedOperationCache_IsCleared_OnUpsert() + { + const string id = "prepcache_v1"; + await _store.UpsertAsync(id, DocHello, null, CancellationToken.None); + await ExecuteByIdAsync(id); + + var prepared = _executor.Schema.Services.GetRequiredService(); + prepared + .Count.Should() + .BeGreaterThan(0, "the warm execute must have populated the prepared-op cache"); + + await _store.UpsertAsync( + id, + DocVersion, + new UpsertOptions { BypassShapeDiff = true }, + CancellationToken.None + ); + + prepared + .Count.Should() + .Be(0, "Upsert must clear IPreparedOperationCache so the new doc is compiled fresh"); + } + + [Test] + public async Task Deactivate_InvalidatesCache_SoStaleDocumentIsNotServed() + { + const string id = "deact_cache_v1"; + await _store.UpsertAsync(id, DocHello, null, CancellationToken.None); + (await ExecuteByIdAsync(id)).Should().Contain("\"hello\""); + + await _store.DeactivateAsync(id, null, "test", CancellationToken.None); + + // After deactivation the storage returns null. If the HC caches + // weren't cleared the prior compiled operation would still serve. + // The persisted-operation pipeline rejects when no document is + // found OR when it is not active. + var json = await ExecuteByIdAsync(id); + json.Should() + .NotContain( + "\"hello\"", + "deactivated operation must not be served from the prepared-op cache" + ); + } + + [Test] + public async Task Restore_InvalidatesCache_AndRestoredDocumentRuns() + { + const string id = "restore_cache_v1"; + await _store.UpsertAsync(id, DocHello, null, CancellationToken.None); + await _store.DeactivateAsync(id, null, "test", CancellationToken.None); + + // Confirm the executor cannot serve while deactivated. + (await ExecuteByIdAsync(id)) + .Should() + .NotContain("\"hello\""); + + await _store.RestoreAsync(id, null, CancellationToken.None); + + (await ExecuteByIdAsync(id)).Should().Contain("\"hello\""); + } + + [Test] + public async Task Upsert_WithoutBypass_AndShapePreservingChange_StillSwapsDocument() + { + // A genuinely different document text with an identical fingerprint + // (same response keys, same selection structure). Equivalent to the + // "rewrite" case the bug report calls out: shape preserved, body + // changed, no bypass needed. + const string id = "shapepreserve_v1"; + const string original = "query Greet { hello }"; + const string rewrite = "query Greet { hello # rewritten\n}"; + + await _store.UpsertAsync(id, original, null, CancellationToken.None); + (await ExecuteByIdAsync(id)).Should().Contain("\"hello\""); + + await _store.UpsertAsync(id, rewrite, null, CancellationToken.None); + + var stored = await _store.GetAsync(id, null, CancellationToken.None); + stored!.Document.Should().Be(rewrite, "the rewritten document must be persisted"); + + // Result still matches (shape is the same), but the underlying + // doc/operation entries must have been refreshed. + var documentCache = _sp.GetRequiredService(); + var prepared = _executor.Schema.Services.GetRequiredService(); + documentCache.Count.Should().Be(0); + prepared.Count.Should().Be(0); + } + + [Test] + public async Task Invalidator_ClearsBothCaches_Directly() + { + // Direct exercise of the invalidator from root services. Pins the + // contract that both cache layers are reachable from the invalidator + // (not just one of them). + const string id = "invalidator_v1"; + await _store.UpsertAsync(id, DocHello, null, CancellationToken.None); + await ExecuteByIdAsync(id); + + var documentCache = _sp.GetRequiredService(); + var prepared = _executor.Schema.Services.GetRequiredService(); + documentCache.Count.Should().BeGreaterThan(0); + prepared.Count.Should().BeGreaterThan(0); + + var invalidator = _sp.GetRequiredService(); + await invalidator.InvalidateAsync(CancellationToken.None); + + documentCache.Count.Should().Be(0); + prepared.Count.Should().Be(0); + } + + private async Task ExecuteByIdAsync(string id) + { + var request = OperationRequestBuilder + .New() + .SetDocumentId(new OperationDocumentId(id)) + .Build(); + var result = await _executor.ExecuteAsync(request); + var op = result as IOperationResult; + op.Should().NotBeNull(); + return op!.ToJson(); + } + + private async Task ExecuteUploadMutationAsync(string id, string document, bool bypassShapeDiff) + { + const string mutation = """ + mutation Upload($input: UploadPersistedOperationInput!) { + operations { + persistedOperations { + uploadPersistedOperation(input: $input) { + success + errors { code message } + } + } + } + } + """; + var input = new Dictionary + { + ["id"] = id, + ["document"] = document, + ["bypassShapeDiff"] = bypassShapeDiff, + }; + var request = OperationRequestBuilder + .New() + .SetDocument(mutation) + .SetVariableValues(new Dictionary { ["input"] = input }) + .Build(); + var result = await _executor.ExecuteAsync(request); + var op = (IOperationResult)result; + op.Errors.Should().BeNullOrEmpty(); + op.ToJson().Should().Contain("\"success\": true"); + } +} diff --git a/tests/Trax.Api.Tests/PersistedOperations/IntegrationTests/RabbitMqBroadcasterTests.cs b/tests/Trax.Api.Tests/PersistedOperations/IntegrationTests/RabbitMqBroadcasterTests.cs index 1285794..cf8969a 100644 --- a/tests/Trax.Api.Tests/PersistedOperations/IntegrationTests/RabbitMqBroadcasterTests.cs +++ b/tests/Trax.Api.Tests/PersistedOperations/IntegrationTests/RabbitMqBroadcasterTests.cs @@ -1,4 +1,5 @@ using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using Trax.Api.GraphQL.PersistedOperations.Broadcasting; using Trax.Api.GraphQL.PersistedOperations.Configuration; @@ -62,6 +63,7 @@ public async Task PublishAsync_DeliversMessage_ToReceiverOnSameExchange() var receiver = new PersistedOperationReceiverService( options, cache, + NoOpInvalidator(), NullLogger.Instance ); @@ -114,11 +116,13 @@ public async Task TwoReceivers_BothObserveSameMessage() var receiverA = new PersistedOperationReceiverService( options, cacheA, + NoOpInvalidator(), NullLogger.Instance ); var receiverB = new PersistedOperationReceiverService( options, cacheB, + NoOpInvalidator(), NullLogger.Instance ); @@ -171,6 +175,7 @@ public async Task ReceiverService_StoppedReceiver_DoesNotInvalidateCache() var svc = new PersistedOperationReceiverService( options, cache, + NoOpInvalidator(), NullLogger.Instance ); @@ -214,6 +219,7 @@ public async Task ReceiverService_DisposeWithoutStop_ReleasesResourcesIdempotent var svc = new PersistedOperationReceiverService( options, new RecordingCache(), + NoOpInvalidator(), NullLogger.Instance ); await svc.StartAsync(CancellationToken.None); @@ -241,6 +247,7 @@ public async Task ReceiverService_MalformedMessage_NacksAndContinues() var svc = new PersistedOperationReceiverService( options, cache, + NoOpInvalidator(), NullLogger.Instance ); await svc.StartAsync(CancellationToken.None); @@ -294,6 +301,17 @@ public async Task PublishAsync_EmptyConnectionString_ThrowsAtConstruction() act.Should().Throw(); } + /// + /// Builds an invalidator backed by an empty service provider. The + /// invalidator's HC-cache lookups all return null, so it is effectively + /// a no-op for tests that only care about the RabbitMQ receive path. + /// + private static HotChocolateOperationCacheInvalidator NoOpInvalidator() => + new( + new ServiceCollection().BuildServiceProvider(), + NullLogger.Instance + ); + private static async Task WaitUntilAsync(Func condition, TimeSpan timeout) { var deadline = DateTimeOffset.UtcNow + timeout; diff --git a/tests/Trax.Api.Tests/PersistedOperations/UnitTests/ExtensionMethodTests.cs b/tests/Trax.Api.Tests/PersistedOperations/UnitTests/ExtensionMethodTests.cs index 6e952da..28b8667 100644 --- a/tests/Trax.Api.Tests/PersistedOperations/UnitTests/ExtensionMethodTests.cs +++ b/tests/Trax.Api.Tests/PersistedOperations/UnitTests/ExtensionMethodTests.cs @@ -282,6 +282,7 @@ public async Task ReceiverService_StartAsync_NoConnectionString_DoesNotAttemptCo var svc = new PersistedOperationReceiverService( options, new NoOpPersistedOperationCache(), + NoOpInvalidator(), NullLogger.Instance ); @@ -310,6 +311,7 @@ public void ReceiverService_NullArgs_Throw() _ = new PersistedOperationReceiverService( null!, new NoOpPersistedOperationCache(), + NoOpInvalidator(), NullLogger.Instance ) ) @@ -322,6 +324,7 @@ public void ReceiverService_NullArgs_Throw() _ = new PersistedOperationReceiverService( options, null!, + NoOpInvalidator(), NullLogger.Instance ) ) @@ -334,6 +337,20 @@ public void ReceiverService_NullArgs_Throw() _ = new PersistedOperationReceiverService( options, new NoOpPersistedOperationCache(), + null!, + NullLogger.Instance + ) + ) + ) + .Should() + .Throw(); + ( + (Action)( + () => + _ = new PersistedOperationReceiverService( + options, + new NoOpPersistedOperationCache(), + NoOpInvalidator(), null! ) ) @@ -341,4 +358,10 @@ public void ReceiverService_NullArgs_Throw() .Should() .Throw(); } + + private static HotChocolateOperationCacheInvalidator NoOpInvalidator() => + new( + new ServiceCollection().BuildServiceProvider(), + NullLogger.Instance + ); }