diff --git a/src/HotChocolate/PersistedOperations/src/PersistedOperations.Redis/Extensions/HotChocolateRedisPersistedOperationsRequestExecutorBuilderExtensions.cs b/src/HotChocolate/PersistedOperations/src/PersistedOperations.Redis/Extensions/HotChocolateRedisPersistedOperationsRequestExecutorBuilderExtensions.cs index f95141d9ffa..d7115272a30 100644 --- a/src/HotChocolate/PersistedOperations/src/PersistedOperations.Redis/Extensions/HotChocolateRedisPersistedOperationsRequestExecutorBuilderExtensions.cs +++ b/src/HotChocolate/PersistedOperations/src/PersistedOperations.Redis/Extensions/HotChocolateRedisPersistedOperationsRequestExecutorBuilderExtensions.cs @@ -22,10 +22,14 @@ public static class HotChocolateRedisPersistedOperationsRequestExecutorBuilderEx /// /// A timeout after which an operation document is removed from the Redis cache. /// + /// + /// An optional prefix for the cache keys used to store operation documents. + /// public static IRequestExecutorBuilder AddRedisOperationDocumentStorage( this IRequestExecutorBuilder builder, Func databaseFactory, - TimeSpan? queryExpiration = null) + TimeSpan? queryExpiration = null, + string? cacheKeyPrefix = null) { ArgumentNullException.ThrowIfNull(builder); ArgumentNullException.ThrowIfNull(databaseFactory); @@ -33,7 +37,8 @@ public static IRequestExecutorBuilder AddRedisOperationDocumentStorage( return builder.ConfigureSchemaServices( s => s.AddRedisOperationDocumentStorage( sp => databaseFactory(sp.GetCombinedServices()), - queryExpiration)); + queryExpiration, + cacheKeyPrefix)); } /// @@ -48,10 +53,14 @@ public static IRequestExecutorBuilder AddRedisOperationDocumentStorage( /// /// A timeout after which an operation document is removed from the Redis cache. /// + /// + /// An optional prefix for the cache keys used to store operation documents. + /// public static IRequestExecutorBuilder AddRedisOperationDocumentStorage( this IRequestExecutorBuilder builder, Func multiplexerFactory, - TimeSpan? queryExpiration = null) + TimeSpan? queryExpiration = null, + string? cacheKeyPrefix = null) { ArgumentNullException.ThrowIfNull(builder); ArgumentNullException.ThrowIfNull(multiplexerFactory); @@ -59,7 +68,8 @@ public static IRequestExecutorBuilder AddRedisOperationDocumentStorage( return builder.ConfigureSchemaServices( s => s.AddRedisOperationDocumentStorage( sp => multiplexerFactory(sp.GetCombinedServices()).GetDatabase(), - queryExpiration)); + queryExpiration, + cacheKeyPrefix)); } /// @@ -73,14 +83,19 @@ public static IRequestExecutorBuilder AddRedisOperationDocumentStorage( /// /// A timeout after which an operation document is removed from the Redis cache. /// + /// + /// An optional prefix for the cache keys used to store operation documents. + /// public static IRequestExecutorBuilder AddRedisOperationDocumentStorage( this IRequestExecutorBuilder builder, - TimeSpan? queryExpiration = null) + TimeSpan? queryExpiration = null, + string? cacheKeyPrefix = null) { ArgumentNullException.ThrowIfNull(builder); return builder.AddRedisOperationDocumentStorage( sp => sp.GetRequiredService(), - queryExpiration); + queryExpiration, + cacheKeyPrefix); } } diff --git a/src/HotChocolate/PersistedOperations/src/PersistedOperations.Redis/Extensions/HotChocolateRedisPersistedOperationsServiceCollectionExtensions.cs b/src/HotChocolate/PersistedOperations/src/PersistedOperations.Redis/Extensions/HotChocolateRedisPersistedOperationsServiceCollectionExtensions.cs index c4a228281e9..d7dc15f7820 100644 --- a/src/HotChocolate/PersistedOperations/src/PersistedOperations.Redis/Extensions/HotChocolateRedisPersistedOperationsServiceCollectionExtensions.cs +++ b/src/HotChocolate/PersistedOperations/src/PersistedOperations.Redis/Extensions/HotChocolateRedisPersistedOperationsServiceCollectionExtensions.cs @@ -23,10 +23,15 @@ public static class HotChocolateRedisPersistedOperationsServiceCollectionExtensi /// /// A timeout after which an operation document is removed from the Redis cache. /// + /// + /// An optional prefix for the cache keys used to store operation documents. + /// This can be useful to avoid key collisions when multiple applications share the same Redis instance. + /// public static IServiceCollection AddRedisOperationDocumentStorage( this IServiceCollection services, Func databaseFactory, - TimeSpan? queryExpiration = null) + TimeSpan? queryExpiration = null, + string? cacheKeyPrefix = null) { ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(databaseFactory); @@ -34,7 +39,7 @@ public static IServiceCollection AddRedisOperationDocumentStorage( return services .RemoveService() .AddSingleton( - sp => new RedisOperationDocumentStorage(databaseFactory(sp), queryExpiration)); + sp => new RedisOperationDocumentStorage(databaseFactory(sp), queryExpiration, cacheKeyPrefix)); } private static IServiceCollection RemoveService( diff --git a/src/HotChocolate/PersistedOperations/src/PersistedOperations.Redis/RedisOperationDocumentStorage.cs b/src/HotChocolate/PersistedOperations/src/PersistedOperations.Redis/RedisOperationDocumentStorage.cs index c579d8c9f5c..5dab99d88dd 100644 --- a/src/HotChocolate/PersistedOperations/src/PersistedOperations.Redis/RedisOperationDocumentStorage.cs +++ b/src/HotChocolate/PersistedOperations/src/PersistedOperations.Redis/RedisOperationDocumentStorage.cs @@ -11,6 +11,7 @@ public class RedisOperationDocumentStorage : IOperationDocumentStorage { private readonly IDatabase _database; private readonly TimeSpan? _expiration; + private readonly string? _cacheKeyPrefix; /// /// Initializes a new instance of the class. @@ -19,10 +20,14 @@ public class RedisOperationDocumentStorage : IOperationDocumentStorage /// /// A time span after which an operation document will be removed from the cache. /// - public RedisOperationDocumentStorage(IDatabase database, TimeSpan? expiration = null) + /// + /// An optional prefix for the cache keys used to store operation documents. + /// + public RedisOperationDocumentStorage(IDatabase database, TimeSpan? expiration = null, string? cacheKeyPrefix = null) { _database = database ?? throw new ArgumentNullException(nameof(database)); _expiration = expiration; + _cacheKeyPrefix = cacheKeyPrefix; } /// @@ -40,7 +45,7 @@ public RedisOperationDocumentStorage(IDatabase database, TimeSpan? expiration = private async ValueTask TryReadInternalAsync(OperationDocumentId documentId) { - var buffer = (byte[]?)await _database.StringGetAsync(documentId.Value).ConfigureAwait(false); + var buffer = (byte[]?)await _database.StringGetAsync(GetCacheKey(documentId.Value)).ConfigureAwait(false); return buffer is null ? null : new OperationDocument(Utf8GraphQLParser.Parse(buffer)); } @@ -65,8 +70,23 @@ private async ValueTask SaveInternalAsync( IOperationDocument document) { var promise = _expiration.HasValue - ? _database.StringSetAsync(documentId.Value, document.ToArray(), _expiration.Value) - : _database.StringSetAsync(documentId.Value, document.ToArray()); + ? _database.StringSetAsync(GetCacheKey(documentId.Value), document.ToArray(), _expiration.Value) + : _database.StringSetAsync(GetCacheKey(documentId.Value), document.ToArray()); await promise.ConfigureAwait(false); } + + private string GetCacheKey(string documentId) + { + if (string.IsNullOrEmpty(documentId)) + { + throw new ArgumentNullException(nameof(documentId)); + } + + if (string.IsNullOrEmpty(_cacheKeyPrefix)) + { + return documentId; + } + + return $"{_cacheKeyPrefix}:{{{documentId}}}"; + } } diff --git a/src/HotChocolate/PersistedOperations/test/PersistedOperations.Redis.Tests/IntegrationTests.cs b/src/HotChocolate/PersistedOperations/test/PersistedOperations.Redis.Tests/IntegrationTests.cs index 18729b9ac00..9edfa2457a6 100644 --- a/src/HotChocolate/PersistedOperations/test/PersistedOperations.Redis.Tests/IntegrationTests.cs +++ b/src/HotChocolate/PersistedOperations/test/PersistedOperations.Redis.Tests/IntegrationTests.cs @@ -57,6 +57,46 @@ await storage.SaveAsync( result.MatchSnapshot(); } + [Fact] + public async Task ExecutePersistedOperation_With_CacheKeyPrefix() + { + // arrange + const string cacheKeyPrefix = "test-prefix"; + var documentId = new OperationDocumentId(Guid.NewGuid().ToString("N")); + var storage = new RedisOperationDocumentStorage(_database, cacheKeyPrefix: cacheKeyPrefix); + + await storage.SaveAsync( + documentId, + new OperationDocumentSourceText("{ __typename }")); + + var executor = + await new ServiceCollection() + .AddGraphQL() + .AddQueryType(c => c.Name("Query").Field("a").Resolve("b")) + .AddRedisOperationDocumentStorage(_ => _database, cacheKeyPrefix: cacheKeyPrefix) + .UseRequest((_, n) => async c => + { + await n(c); + + var documentInfo = c.OperationDocumentInfo; + if (documentInfo.Id == documentId && c.Result is IOperationResult r) + { + c.Result = OperationResultBuilder + .FromResult(r) + .SetExtension("persistedDocument", true) + .Build(); + } + }) + .UsePersistedOperationPipeline() + .BuildRequestExecutorAsync(); + + // act + var result = await executor.ExecuteAsync(OperationRequest.FromId(documentId)); + + // assert + result.MatchSnapshot(); + } + [Fact] public async Task ExecutePersistedOperation_After_Expiration() { diff --git a/src/HotChocolate/PersistedOperations/test/PersistedOperations.Redis.Tests/__snapshots__/IntegrationTests.ExecutePersistedOperation_With_CacheKeyPrefix.snap b/src/HotChocolate/PersistedOperations/test/PersistedOperations.Redis.Tests/__snapshots__/IntegrationTests.ExecutePersistedOperation_With_CacheKeyPrefix.snap new file mode 100644 index 00000000000..20b47cc26bc --- /dev/null +++ b/src/HotChocolate/PersistedOperations/test/PersistedOperations.Redis.Tests/__snapshots__/IntegrationTests.ExecutePersistedOperation_With_CacheKeyPrefix.snap @@ -0,0 +1,8 @@ +{ + "data": { + "__typename": "Query" + }, + "extensions": { + "persistedDocument": true + } +}