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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<PersistedOperationReceiverService> _logger;

private IConnection? _connection;
Expand All @@ -31,14 +32,17 @@ internal sealed class PersistedOperationReceiverService : IHostedService, IAsync
public PersistedOperationReceiverService(
PersistedOperationsOptions options,
IPersistedOperationCache cache,
HotChocolateOperationCacheInvalidator hcInvalidator,
ILogger<PersistedOperationReceiverService> logger
)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(cache);
ArgumentNullException.ThrowIfNull(hcInvalidator);
ArgumentNullException.ThrowIfNull(logger);
_options = options;
_cache = cache;
_hcInvalidator = hcInvalidator;
_logger = logger;
}

Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,11 @@ Action<PersistedOperationsBuilder> 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<HotChocolateOperationCacheInvalidator>();

// Storage: implements both IPersistedOperationStore and the HC hot-path.
services.AddSingleton<DbPersistedOperationStorage>();
services.AddSingleton<IPersistedOperationStore>(sp =>
Expand All @@ -133,6 +138,12 @@ Action<PersistedOperationsBuilder> configure
sc.AddSingleton<IOperationDocumentStorage>(sp =>
{
var root = sp.GetRequiredService<HotChocolate.IApplicationServiceProvider>();
// 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<HotChocolateOperationCacheInvalidator>()
.SetSchemaName(schema.Name);
return root.GetRequiredService<DbPersistedOperationStorage>();
})
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<DbPersistedOperationStorage> _logger;

Expand All @@ -43,6 +44,7 @@ public DbPersistedOperationStorage(
IPersistedOperationCache cache,
IPersistedOperationBroadcaster broadcaster,
IPersistedOperationValidator validator,
HotChocolateOperationCacheInvalidator hcInvalidator,
TimeProvider clock,
ILogger<DbPersistedOperationStorage> logger
)
Expand All @@ -52,13 +54,15 @@ ILogger<DbPersistedOperationStorage> logger
ArgumentNullException.ThrowIfNull(cache);
ArgumentNullException.ThrowIfNull(broadcaster);
ArgumentNullException.ThrowIfNull(validator);
ArgumentNullException.ThrowIfNull(hcInvalidator);
ArgumentNullException.ThrowIfNull(clock);
ArgumentNullException.ThrowIfNull(logger);
_factory = factory;
_options = options;
_cache = cache;
_broadcaster = broadcaster;
_validator = validator;
_hcInvalidator = hcInvalidator;
_clock = clock;
_logger = logger;
}
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Invalidates HotChocolate's request-pipeline caches when a persisted
/// operation document changes. Without this, HC's <see cref="IDocumentCache"/>
/// (parsed <c>DocumentNode</c> keyed by persisted-op id) and
/// <see cref="IPreparedOperationCache"/> (compiled operation keyed by
/// <c>{schema}-{executorVersion}-{documentId}+{operationName}</c>) will keep
/// serving the previously cached version even after
/// <c>IOperationDocumentStorage.TryReadAsync</c> returns the new text.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
internal sealed class HotChocolateOperationCacheInvalidator
{
private readonly IServiceProvider _rootServices;
private readonly ILogger<HotChocolateOperationCacheInvalidator> _logger;
private volatile string? _schemaName;

public HotChocolateOperationCacheInvalidator(
IServiceProvider rootServices,
ILogger<HotChocolateOperationCacheInvalidator> logger
)
{
ArgumentNullException.ThrowIfNull(rootServices);
ArgumentNullException.ThrowIfNull(logger);
_rootServices = rootServices;
_logger = logger;
}

/// <summary>
/// Schema name captured at <c>ConfigureSchema</c> time so we know which
/// executor to ask for. <c>null</c> resolves to HC's default schema.
/// </summary>
public void SetSchemaName(string? schemaName) => _schemaName = schemaName;

/// <summary>
/// Clears both the parsed-document cache and the prepared-operation
/// cache. Safe to call from any thread. Never throws; logs and returns.
/// </summary>
public async Task InvalidateAsync(CancellationToken ct)
{
try
{
var documentCache = _rootServices.GetService<IDocumentCache>();
documentCache?.Clear();
}
catch (Exception ex)
{
_logger.LogWarning(
ex,
"Failed to clear HotChocolate IDocumentCache during persisted-operation invalidation."
);
}

try
{
var resolver = _rootServices.GetService<IRequestExecutorResolver>();
if (resolver is null)
return;

var executor = await resolver
.GetRequestExecutorAsync(_schemaName, ct)
.ConfigureAwait(false);
var prepared = executor.Schema.Services.GetService<IPreparedOperationCache>();
prepared?.Clear();
}
catch (Exception ex)
{
_logger.LogWarning(
ex,
"Failed to clear HotChocolate IPreparedOperationCache during persisted-operation invalidation."
);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ public async Task SetUp()
cache,
_broadcaster,
new NoOpPersistedOperationValidator(),
NoOpInvalidator(),
TimeProvider.System,
NullLogger<DbPersistedOperationStorage>.Instance
);
Expand Down Expand Up @@ -283,6 +284,7 @@ public async Task Upsert_BroadcasterThrows_OperationStillSucceeds()
new NoOpPersistedOperationCache(),
throwing,
new NoOpPersistedOperationValidator(),
NoOpInvalidator(),
TimeProvider.System,
NullLogger<DbPersistedOperationStorage>.Instance
);
Expand Down Expand Up @@ -605,6 +607,7 @@ public async Task TryReadAsync_UsesCache_WhenConfigured()
memCache,
new NoOpPersistedOperationBroadcaster(),
new NoOpPersistedOperationValidator(),
NoOpInvalidator(),
TimeProvider.System,
NullLogger<DbPersistedOperationStorage>.Instance
);
Expand Down Expand Up @@ -638,6 +641,17 @@ await storage.UpsertAsync(
second!.ToString().Should().Contain("hello");
}

/// <summary>
/// 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.
/// </summary>
private static HotChocolateOperationCacheInvalidator NoOpInvalidator() =>
new(
new ServiceCollection().BuildServiceProvider(),
NullLogger<HotChocolateOperationCacheInvalidator>.Instance
);

private sealed class RecordingBroadcaster : IPersistedOperationBroadcaster
{
public List<PersistedOperationChangedMessage> Messages { get; } = new();
Expand Down
Loading
Loading