From 047da047b7f72e748898a87bfba513fab228dbb7 Mon Sep 17 00:00:00 2001 From: AboubakrNasef Date: Fri, 24 Jul 2026 10:56:42 +0300 Subject: [PATCH 01/13] wip 101 init the porject --- .gitignore | 3 +- ZiggyCreatures.FusionCache.slnx | 8 + .../AzureServiceBusBackplaneExtensions.cs | 104 +++++ .../AzureServiceBusBackplaneOptions.cs | 70 +++ .../Admin/AzureServiceBusAdminWrapper.cs | 52 +++ .../Admin/IAzureServiceBusAdminWrapper.cs | 20 + .../Admin/NoOpAzureServiceBusAdminWrapper.cs | 23 + .../Client/AzureServiceBusClientWrapper.cs | 212 +++++++++ .../Client/IAzureServiceBusClientWrapper.cs | 32 ++ .../Backplane/AzureServiceBusBackplane.cs | 48 ++ .../AzureServiceBusBackplane_Async.cs | 93 ++++ .../AzureServiceBusBackplane_Sync.cs | 25 ++ .../Helpers/AzureServiceBusHelpers.cs | 76 ++++ .../IMPLEMENTATION_PLAN.md | 169 +++++++ ...sionCache.Backplane.AzureServiceBus.csproj | 25 ++ ...che.Serialization.NeueccMessagePack.csproj | 2 +- ...AzureServiceBusBackplaneExtensionsTests.cs | 126 ++++++ .../AzureServiceBusBackplaneOptionsTests.cs | 90 ++++ .../AzureServiceBusBackplaneTests.cs | 416 ++++++++++++++++++ ...eServiceBusCommunicatorIntegrationTests.cs | 255 +++++++++++ .../AzureServiceBusCommunicatorTests.cs | 109 +++++ .../AzureServiceBusNamingTests.cs | 90 ++++ .../AzureServiceBusProvisionerTests.cs | 55 +++ .../L1L2BackplaneTests.cs | 30 +- .../Stuff/TestsUtils.cs | 15 + .../ZiggyCreatures.FusionCache.Tests.csproj | 1 + 26 files changed, 2145 insertions(+), 4 deletions(-) create mode 100644 src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/AzureServiceBusBackplaneExtensions.cs create mode 100644 src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/AzureServiceBusBackplaneOptions.cs create mode 100644 src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/AzureServiceBusWrapper/Admin/AzureServiceBusAdminWrapper.cs create mode 100644 src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/AzureServiceBusWrapper/Admin/IAzureServiceBusAdminWrapper.cs create mode 100644 src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/AzureServiceBusWrapper/Admin/NoOpAzureServiceBusAdminWrapper.cs create mode 100644 src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/AzureServiceBusWrapper/Client/AzureServiceBusClientWrapper.cs create mode 100644 src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/AzureServiceBusWrapper/Client/IAzureServiceBusClientWrapper.cs create mode 100644 src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/Backplane/AzureServiceBusBackplane.cs create mode 100644 src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/Backplane/AzureServiceBusBackplane_Async.cs create mode 100644 src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/Backplane/AzureServiceBusBackplane_Sync.cs create mode 100644 src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/Helpers/AzureServiceBusHelpers.cs create mode 100644 src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/IMPLEMENTATION_PLAN.md create mode 100644 src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus.csproj create mode 100644 tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBusBackplaneExtensionsTests.cs create mode 100644 tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBusBackplaneOptionsTests.cs create mode 100644 tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBusBackplaneTests.cs create mode 100644 tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBusCommunicatorIntegrationTests.cs create mode 100644 tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBusCommunicatorTests.cs create mode 100644 tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBusNamingTests.cs create mode 100644 tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBusProvisionerTests.cs diff --git a/.gitignore b/.gitignore index 4ce6fdde..1f20676f 100644 --- a/.gitignore +++ b/.gitignore @@ -337,4 +337,5 @@ ASALocalRun/ .localhistory/ # BeatPulse healthcheck temp database -healthchecksdb \ No newline at end of file +healthchecksdb +/eaxmple/Playground diff --git a/ZiggyCreatures.FusionCache.slnx b/ZiggyCreatures.FusionCache.slnx index 24b67352..a255986d 100644 --- a/ZiggyCreatures.FusionCache.slnx +++ b/ZiggyCreatures.FusionCache.slnx @@ -2,6 +2,13 @@ + + + + + + + @@ -9,6 +16,7 @@ + diff --git a/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/AzureServiceBusBackplaneExtensions.cs b/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/AzureServiceBusBackplaneExtensions.cs new file mode 100644 index 00000000..044a3c94 --- /dev/null +++ b/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/AzureServiceBusBackplaneExtensions.cs @@ -0,0 +1,104 @@ +using Azure.Messaging.ServiceBus; +using Azure.Messaging.ServiceBus.Administration; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using ZiggyCreatures.Caching.Fusion; +using ZiggyCreatures.Caching.Fusion.Backplane; +using ZiggyCreatures.Caching.Fusion.Backplane.AzureServiceBus; +using ZiggyCreatures.Caching.Fusion.Backplane.AzureServiceBus.AzureServiceBusWrapper; +using ZiggyCreatures.Caching.Fusion.Backplane.AzureServiceBus.Helpers; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Extension methods for setting up FusionCache related services in an . +/// +public static class AzureServiceBusBackplaneExtensions +{ + private static AzureServiceBusBackplane BuildBackplane(IServiceProvider sp, AzureServiceBusBackplaneOptions options, string topicNameFallback) + { + var backplaneLogger = sp.GetService>(); + + var client = new ServiceBusClient(options.ConnectionString); + var adminClient = new ServiceBusAdministrationClient(options.ConnectionString); + var topicName = AzureServiceBusHelpers.ResolveTopicName(options.TopicName, topicNameFallback); + + string subscriptionName; + IAzureServiceBusAdminWrapper provisioner; + + if (options.IsAdmin) + { + subscriptionName = options.SubscriptionName ?? AzureServiceBusHelpers.GenerateId(); + + var provisionerLogger = sp.GetService>() ?? NullLogger.Instance; + provisioner = new AzureServiceBusAdminWrapper(adminClient, topicName, subscriptionName, provisionerLogger); + } + else + { + if (string.IsNullOrWhiteSpace(options.SubscriptionName)) + throw new InvalidOperationException($"{nameof(AzureServiceBusBackplaneOptions)}.{nameof(AzureServiceBusBackplaneOptions.IsAdmin)} is false, but no {nameof(AzureServiceBusBackplaneOptions.SubscriptionName)} was provided: without administrative rights, an existing subscription name must be specified upfront."); + + subscriptionName = options.SubscriptionName!; + provisioner = NoOpAzureServiceBusAdminWrapper.Instance; + } + + var communicatorLogger = sp.GetService>() ?? NullLogger.Instance; + var communicator = new AzureServiceBusClientWrapper(client, topicName, subscriptionName, communicatorLogger,options); + + return new AzureServiceBusBackplane(communicator, provisioner, backplaneLogger); + } + + /// + /// Adds an Azure Service Bus based implementation of a backplane to the . + /// + /// The to add services to. + /// The to configure the provided . + /// The so that additional calls can be chained. + public static IServiceCollection AddFusionCacheAzureServiceBusBackplane(this IServiceCollection services, Action? setupOptionsAction = null) + { + if (services is null) + throw new ArgumentNullException(nameof(services)); + + services.AddOptions(); + + if (setupOptionsAction is not null) + services.Configure(setupOptionsAction); + + services.TryAddTransient(sp => + { + var options = sp.GetRequiredService>().Value; + + return BuildBackplane(sp, options, FusionCacheOptions.DefaultCacheName); + }); + + return services; + } + + /// + /// Adds an Azure Service Bus based implementation of a backplane to the . + /// + /// The to add the backplane to. + /// The to configure the provided . + /// The so that additional calls can be chained. + public static IFusionCacheBuilder WithAzureServiceBusBackplane(this IFusionCacheBuilder builder, Action? setupOptionsAction = null) + { + if (builder is null) + throw new ArgumentNullException(nameof(builder)); + + return builder + .WithBackplane(sp => + { + var options = sp.GetService>()?.Get(builder.CacheName); + + if (options is null) + throw new InvalidOperationException($"Unable to find a valid {nameof(AzureServiceBusBackplaneOptions)} instance for the current cache name '{builder.CacheName}'."); + + setupOptionsAction?.Invoke(options); + + return BuildBackplane(sp, options, builder.CacheName); + }) + ; + } +} diff --git a/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/AzureServiceBusBackplaneOptions.cs b/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/AzureServiceBusBackplaneOptions.cs new file mode 100644 index 00000000..55e28513 --- /dev/null +++ b/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/AzureServiceBusBackplaneOptions.cs @@ -0,0 +1,70 @@ +using Azure.Core; +using Azure.Messaging.ServiceBus; +using Azure.Messaging.ServiceBus.Administration; +using Microsoft.Extensions.Options; + +namespace ZiggyCreatures.Caching.Fusion.Backplane.AzureServiceBus; + +/// +/// Represents the options available for the Azure Service Bus backplane. +/// +public class AzureServiceBusBackplaneOptions : IOptions +{ + /// + /// The connection string used to connect to Azure Service Bus. + /// + public string? ConnectionString { get; set; } + + /// + /// The fully qualified namespace (e.g. "mynamespace.servicebus.windows.net") used, together with , to connect to Azure Service Bus via Azure Identity. + /// This is an alternative to . + /// + public string? FullyQualifiedNamespace { get; set; } + + /// + /// The to use together with for Azure Identity based authentication. + /// + public TokenCredential? Credential { get; set; } + + /// + /// The name of the Service Bus topic to use. + /// If (the default), the cache name is used instead (sanitized into a valid Service Bus entity name). + /// Set this explicitly to use a specific topic, e.g. to share a single topic across multiple differently-named caches. + /// + public string? TopicName { get; set; } + + /// + /// Whether this backplane instance is allowed to perform administrative operations against Azure Service Bus: + /// creating/deleting the topic, the per-instance subscription, and its self-message-filter rule. Defaults to . + ///
+ /// Set to for least-privilege deployments where the connection string/credential only has + /// Send/Listen claims, not Manage. In that case must be set to an already-existing + /// subscription (provisioned out of band, e.g. via IaC), since one cannot be created on the fly, and it will never + /// be deleted either. + ///
+ public bool IsAdmin { get; set; } = true; + + /// + /// The name of the Service Bus subscription to attach to. + /// Required when is (the subscription must already exist). + /// When is and this is left (the default), a unique + /// subscription name is generated automatically for this instance. + /// + public string? SubscriptionName { get; set; } + + /// + /// The after which an idle, auto-created per-instance subscription will be deleted by the Service Bus service. + /// + public TimeSpan SubscriptionAutoDeleteOnIdle { get; set; } = TimeSpan.FromMinutes(10); + + /// + /// The max amount of time to wait to acquire the internal lock used to coordinate connection/subscription setup. + /// + public TimeSpan LockTimeout { get; set; } = TimeSpan.FromSeconds(5); + + AzureServiceBusBackplaneOptions IOptions.Value + { + get { return this; } + } + +} diff --git a/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/AzureServiceBusWrapper/Admin/AzureServiceBusAdminWrapper.cs b/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/AzureServiceBusWrapper/Admin/AzureServiceBusAdminWrapper.cs new file mode 100644 index 00000000..a5c7dc24 --- /dev/null +++ b/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/AzureServiceBusWrapper/Admin/AzureServiceBusAdminWrapper.cs @@ -0,0 +1,52 @@ +using Azure.Messaging.ServiceBus.Administration; +using Microsoft.Extensions.Logging; +using ZiggyCreatures.Caching.Fusion.Backplane.AzureServiceBus.AzureServiceBusWrapper; + +namespace ZiggyCreatures.Caching.Fusion.Backplane.AzureServiceBus; + +/// +/// An implementation that creates/deletes the topic, subscription, and +/// self-message-filter rule via a . Requires Manage permissions. +/// +/// The administrative client used to create/delete the topic, subscription, and self-filter rule. +/// The name of the topic to create if missing. +/// The name of the subscription to create if missing. +/// The logger to use. +public class AzureServiceBusAdminWrapper( + ServiceBusAdministrationClient serviceBusAdministrationClient, + string topicName, + string subscriptionName, + ILogger logger) : IAzureServiceBusAdminWrapper +{ + /// + public async ValueTask EnsureTopicAsync() + { + if (!await serviceBusAdministrationClient.TopicExistsAsync(topicName)) + await serviceBusAdministrationClient.CreateTopicAsync(topicName); + } + + /// + public async ValueTask EnsureSubscriptionAsync() + { + if (await serviceBusAdministrationClient.SubscriptionExistsAsync(topicName, subscriptionName)) + return; + logger.LogInformation("Creating a new topic subscription: {subscriptionName}", subscriptionName); + + await EnsureTopicAsync(); + + await serviceBusAdministrationClient.CreateSubscriptionAsync(new CreateSubscriptionOptions(topicName, subscriptionName)); + } + + /// + public async ValueTask DisposeAsync() + { + try + { + await serviceBusAdministrationClient.DeleteSubscriptionAsync(topicName, subscriptionName); + } + catch (Exception exc) + { + logger.LogError(exc, "An error occurred while deleting subscription {subscriptionName}", subscriptionName); + } + } +} diff --git a/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/AzureServiceBusWrapper/Admin/IAzureServiceBusAdminWrapper.cs b/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/AzureServiceBusWrapper/Admin/IAzureServiceBusAdminWrapper.cs new file mode 100644 index 00000000..07d8b45e --- /dev/null +++ b/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/AzureServiceBusWrapper/Admin/IAzureServiceBusAdminWrapper.cs @@ -0,0 +1,20 @@ +namespace ZiggyCreatures.Caching.Fusion.Backplane.AzureServiceBus.AzureServiceBusWrapper; + +/// +/// Abstracts the administrative operations (creating/deleting a topic, subscription, and self-message-filter rule) that +/// orchestrates around an : it always +/// ensures the topic and subscription before asking the communicator to subscribe, and tears down whatever it provisioned +/// after asking the communicator to unsubscribe. +/// +public interface IAzureServiceBusAdminWrapper :IAsyncDisposable +{ + /// + /// Ensures the topic exists, creating it if missing. + /// + ValueTask EnsureTopicAsync(); + + /// + /// Ensures the subscription (and its self-message-filter rule) exists, creating it if missing. + /// + ValueTask EnsureSubscriptionAsync(); +} diff --git a/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/AzureServiceBusWrapper/Admin/NoOpAzureServiceBusAdminWrapper.cs b/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/AzureServiceBusWrapper/Admin/NoOpAzureServiceBusAdminWrapper.cs new file mode 100644 index 00000000..840d57eb --- /dev/null +++ b/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/AzureServiceBusWrapper/Admin/NoOpAzureServiceBusAdminWrapper.cs @@ -0,0 +1,23 @@ +namespace ZiggyCreatures.Caching.Fusion.Backplane.AzureServiceBus.AzureServiceBusWrapper; + +/// +/// A no-op , used when a backplane instance has no administrative capability: it +/// assumes the topic and subscription already exist (provisioned out of band, e.g. via IaC) and never attempts to create, +/// delete, or otherwise administer anything. Used as a Null Object instead of a nullable/optional provisioner dependency. +/// +public sealed class NoOpAzureServiceBusAdminWrapper : IAzureServiceBusAdminWrapper +{ + /// + /// A shared, stateless instance. + /// + public static readonly NoOpAzureServiceBusAdminWrapper Instance = new(); + + /// + public ValueTask EnsureTopicAsync() => default; + + /// + public ValueTask EnsureSubscriptionAsync() => default; + + /// + public ValueTask DisposeAsync() => default; +} diff --git a/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/AzureServiceBusWrapper/Client/AzureServiceBusClientWrapper.cs b/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/AzureServiceBusWrapper/Client/AzureServiceBusClientWrapper.cs new file mode 100644 index 00000000..814255d7 --- /dev/null +++ b/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/AzureServiceBusWrapper/Client/AzureServiceBusClientWrapper.cs @@ -0,0 +1,212 @@ +using Azure.Messaging.ServiceBus; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using ZiggyCreatures.Caching.Fusion.Backplane.AzureServiceBus.AzureServiceBusWrapper; +using ZiggyCreatures.Caching.Fusion.Backplane.AzureServiceBus.Helpers; + +// see https://github.com/ZiggyCreatures/FusionCache/issues/370 + +namespace ZiggyCreatures.Caching.Fusion.Backplane.AzureServiceBus; + +/// +/// An implementation based on a Service Bus topic/subscription pair. It only +/// ever sends and receives messages: it has no knowledge of, and never performs, any administrative operation (creating +/// or deleting a topic, subscription, or rule). The topic and subscription must already exist by the time +/// is called — provisioning that is the responsibility of an , which +/// orchestrates around calls to this class. +/// +/// The client used to send/receive messages. +/// The name of the topic to use. Must already exist by the time is called. +/// The name of the subscription to use. Must already exist by the time is called. +/// The logger to use. +public class AzureServiceBusClientWrapper( + ServiceBusClient serviceBusClient, + string topicName, + string subscriptionName, + ILogger logger, IOptions asbOptions) : IAzureServiceBusClientWrapper +{ + /// + /// The application property used to carry the publishing instance's subscription name, for self-message filtering. + /// Also used by when creating the corresponding self-filter rule. + /// + internal const string ConnectionIdApplicationPropertyName = "ConnectionId"; + + /// + public event Func? SubscriptionMissing; + + /// + /// The name of the Service Bus subscription this instance is attached to (also used as the self-message filter value). + /// + internal string SubscriptionName => subscriptionName; + + /// + /// The name of the Service Bus topic this instance talks on. + /// + internal string TopicName => topicName; + private readonly SemaphoreSlim _lock = new SemaphoreSlim(1, 1); + private readonly List> _handlers = new(); + + + private ServiceBusProcessor? _serviceBusProcessor; + private ServiceBusSender? _serviceBusSender; + + /// + public async Task Subscribe(Func handler) + { + if (!await _lock.WaitAsync(asbOptions.Value.LockTimeout)) + throw new TimeoutException("Can't acquire lock"); + + try + { + _handlers.Add(handler); + } + finally + { + _lock.Release(); + } + + await EnsureProcessor(); + } + + /// + public async Task Unsubscribe(Func handler) + { + if (!await _lock.WaitAsync(asbOptions.Value.LockTimeout)) + throw new TimeoutException("Can't acquire lock"); + + try + { + _handlers.Remove(handler); + } + finally + { + _lock.Release(); + } + } + + /// + public async Task SendMessage(ServiceBusMessage message, CancellationToken cancellationToken) + { + var sender = await EnsureSender(); + message.ApplicationProperties.Add(ConnectionIdApplicationPropertyName, subscriptionName); + await sender.SendMessageAsync(message, cancellationToken); + } + + /// + public async ValueTask DisposeAsync() + { + if (_serviceBusProcessor is null) + return; + + if (!await _lock.WaitAsync(asbOptions.Value.LockTimeout)) + throw new TimeoutException("Can't acquire lock"); + + try + { + if (_serviceBusProcessor is not null) + { + await _serviceBusProcessor.StopProcessingAsync(); + await _serviceBusProcessor.DisposeAsync(); + _serviceBusProcessor = null; + } + } + catch (Exception exc) + { + logger.LogError(exc, "An error occurred while stopping the processor for {subscriptionName}", subscriptionName); + } + finally + { + _lock.Release(); + } + } + + private async Task EnsureProcessor() + { + if (_serviceBusProcessor is not null) + return _serviceBusProcessor; + + if (!await _lock.WaitAsync(asbOptions.Value.LockTimeout)) + throw new TimeoutException("Can't acquire lock"); + try + { + if (_serviceBusProcessor is null) + { + _serviceBusProcessor = serviceBusClient.CreateProcessor(topicName, subscriptionName, new ServiceBusProcessorOptions + { + AutoCompleteMessages = true, + Identifier = subscriptionName + }); + + _serviceBusProcessor.ProcessErrorAsync += ProcessErrorAsync; + _serviceBusProcessor.ProcessMessageAsync += ProcessMessageAsync; + + await _serviceBusProcessor.StartProcessingAsync(); + } + } + finally + { + _lock.Release(); + } + + return _serviceBusProcessor!; + } + + private async Task EnsureSender() + { + if (_serviceBusSender is not null) + return _serviceBusSender; + + await EnsureProcessor(); + + if (!await _lock.WaitAsync(asbOptions.Value.LockTimeout)) + throw new TimeoutException("Can't acquire lock"); + + try + { + _serviceBusSender ??= serviceBusClient.CreateSender(topicName); + } + finally + { + _lock.Release(); + } + + return _serviceBusSender; + } + + private async Task ProcessMessageAsync(ProcessMessageEventArgs args) + { + if (!args.Message.ApplicationProperties.TryGetValue(ConnectionIdApplicationPropertyName, out var oOriginConnectionId) + || oOriginConnectionId is not string originConnectionId) + { + logger.LogError("Received a message without a {ConnectionId} application property", ConnectionIdApplicationPropertyName); + return; + } + + if (originConnectionId == subscriptionName) + { + logger.LogError("Received a message from itself ({ConnectionId}), it should not happen and may indicate that a subscription filter is missing", originConnectionId); + return; + } + + foreach (var handler in _handlers) + { + await handler(args.Message); + } + } + + private async Task ProcessErrorAsync(ProcessErrorEventArgs args) + { + if (logger.IsEnabled(LogLevel.Warning)) + logger.Log(LogLevel.Warning, args.Exception, "An error occurred while processing a ServiceBus message for connection {subscriptionName}", subscriptionName); + + if (args.Exception is ServiceBusException { Reason: ServiceBusFailureReason.MessagingEntityNotFound }) + { + if (logger.IsEnabled(LogLevel.Information)) + logger.Log(LogLevel.Information, "Subscription {subscriptionName} appears to be missing", subscriptionName); + + var handler = SubscriptionMissing; + if (handler is not null) + await handler(); + } + } +} diff --git a/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/AzureServiceBusWrapper/Client/IAzureServiceBusClientWrapper.cs b/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/AzureServiceBusWrapper/Client/IAzureServiceBusClientWrapper.cs new file mode 100644 index 00000000..46ded20a --- /dev/null +++ b/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/AzureServiceBusWrapper/Client/IAzureServiceBusClientWrapper.cs @@ -0,0 +1,32 @@ +using Azure.Messaging.ServiceBus; + +namespace ZiggyCreatures.Caching.Fusion.Backplane.AzureServiceBus.AzureServiceBusWrapper; + +/// +/// Abstracts the Azure Service Bus data-plane operations (subscribe/unsubscribe/send) used by . +/// This has no knowledge of topic/subscription provisioning: see for that — +/// is what orchestrates the two together. +/// +public interface IAzureServiceBusClientWrapper : IAsyncDisposable +{ + /// + /// Registers a handler to be invoked for every incoming message, ensuring the underlying processor is running. + /// + Task Subscribe(Func handler); + + /// + /// Removes a previously registered handler. + /// + Task Unsubscribe(Func handler); + + /// + /// Sends a message to the topic. + /// + Task SendMessage(ServiceBusMessage message, CancellationToken cancellationToken); + + /// + /// Raised when the underlying processor reports that the subscription appears to be missing (e.g. it was reaped by + /// idle auto-delete). reacts to this by re-running its . + /// + event Func? SubscriptionMissing; +} diff --git a/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/Backplane/AzureServiceBusBackplane.cs b/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/Backplane/AzureServiceBusBackplane.cs new file mode 100644 index 00000000..2b35702b --- /dev/null +++ b/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/Backplane/AzureServiceBusBackplane.cs @@ -0,0 +1,48 @@ +using Azure.Messaging.ServiceBus; +using Microsoft.Extensions.Logging; +using ZiggyCreatures.Caching.Fusion.Backplane.AzureServiceBus.AzureServiceBusWrapper; + +// see https://github.com/ZiggyCreatures/FusionCache/issues/370 + +namespace ZiggyCreatures.Caching.Fusion.Backplane.AzureServiceBus; + +/// +/// An Azure Service Bus based implementation of a FusionCache backplane. It owns the subscribe/unsubscribe orchestration: +/// provisioning (via ) always runs before the underlying +/// is asked to subscribe, and unprovisioning always runs after it is asked to +/// unsubscribe. If self-healing isn't needed (or isn't possible, e.g. no administrative rights), pass +/// — there is no nullable/optional provisioner dependency. +/// +public partial class AzureServiceBusBackplane + : IFusionCacheBackplane +{ + /// + /// Initializes a new instance of the class. + /// + /// The to use for sending/receiving messages. + /// + /// The to use for provisioning the topic/subscription before subscribing, and + /// tearing it down after unsubscribing. Use when this instance has + /// no administrative capability and the topic/subscription are provisioned out of band. + /// + /// The instance to use. If null, logging will be completely disabled. + public AzureServiceBusBackplane( + IAzureServiceBusClientWrapper serviceBusCommunicator, + IAzureServiceBusAdminWrapper serviceBusProvisioner, + ILogger? logger = null) + { + _serviceBusCommunicator = serviceBusCommunicator ?? throw new ArgumentNullException(nameof(serviceBusCommunicator)); + _serviceBusProvisioner = serviceBusProvisioner ?? throw new ArgumentNullException(nameof(serviceBusProvisioner)); + _logger = logger; + } + + private readonly SemaphoreSlim _lock = new SemaphoreSlim(1, 1); + private readonly IAzureServiceBusClientWrapper _serviceBusCommunicator; + private readonly IAzureServiceBusAdminWrapper _serviceBusProvisioner; + private readonly ILogger? _logger; + + private string? _cacheName; + private string? _cacheInstanceId; + private Func? _incomingMessageHandler; + private Func? _subscriptionMissingHandler; +} diff --git a/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/Backplane/AzureServiceBusBackplane_Async.cs b/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/Backplane/AzureServiceBusBackplane_Async.cs new file mode 100644 index 00000000..432c3d47 --- /dev/null +++ b/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/Backplane/AzureServiceBusBackplane_Async.cs @@ -0,0 +1,93 @@ +using Azure.Messaging.ServiceBus; +using Microsoft.Extensions.Logging; + +namespace ZiggyCreatures.Caching.Fusion.Backplane.AzureServiceBus; + +public partial class AzureServiceBusBackplane +{ + /// + public async ValueTask SubscribeAsync(BackplaneSubscriptionOptions options) + { + if (options is null) + throw new ArgumentNullException(nameof(options)); + if (options.ChannelName is null) + throw new NullReferenceException($"The {nameof(BackplaneSubscriptionOptions)}.{nameof(options.ChannelName)} cannot be null"); + + if (options.CacheName is null) + throw new NullReferenceException($"The {nameof(BackplaneSubscriptionOptions)}.{nameof(options.CacheName)} cannot be null"); + + if (options.CacheInstanceId is null) + throw new NullReferenceException($"The {nameof(BackplaneSubscriptionOptions)}.{nameof(options.CacheInstanceId)} cannot be null"); + + if (options.IncomingMessageHandler is null && options.IncomingMessageHandlerAsync is null) + throw new ArgumentException("At least one of the incoming message handlers must be provided."); + + if (!await _lock.WaitAsync(TimeSpan.FromSeconds(5))) + throw new TimeoutException("Can't acquire lock"); + + try + { + await _serviceBusProvisioner.EnsureTopicAsync(); + await _serviceBusProvisioner.EnsureSubscriptionAsync(); + + _cacheName = options.CacheName; + _cacheInstanceId = options.CacheInstanceId; + _incomingMessageHandler = async serviceBusMessage => + { + if (serviceBusMessage.Subject != _cacheName) + return; + + var data = serviceBusMessage.Body.ToArray(); + var msg = BackplaneMessage.FromByteArray(data); + + if (options.IncomingMessageHandlerAsync is not null) + await options.IncomingMessageHandlerAsync(msg); + else + options.IncomingMessageHandler?.Invoke(msg); + }; + + _subscriptionMissingHandler = () => _serviceBusProvisioner.EnsureSubscriptionAsync().AsTask(); + _serviceBusCommunicator.SubscriptionMissing += _subscriptionMissingHandler; + + await _serviceBusCommunicator.Subscribe(_incomingMessageHandler); + + if (options.ConnectHandlerAsync is not null) + await options.ConnectHandlerAsync(new BackplaneConnectionInfo(false)); + else + options.ConnectHandler?.Invoke(new BackplaneConnectionInfo(false)); + } + finally + { + _lock.Release(); + } + } + + /// + public async ValueTask PublishAsync(BackplaneMessage message, FusionCacheEntryOptions options, CancellationToken token = default) + { + if (_logger?.IsEnabled(LogLevel.Information) ?? false) + _logger.Log(LogLevel.Information, "FUSION [N={CacheName} I={CacheInstanceId}]: [BP] new message {Action} {CacheKey} - {Duration} - {DistributedDuration}", _cacheName, _cacheInstanceId, message.Action, message.CacheKey, options.Duration, options.DistributedCacheDuration); + + await _serviceBusCommunicator.SendMessage(new ServiceBusMessage + { + Body = new BinaryData(BackplaneMessage.ToByteArray(message)), + Subject = _cacheName, + TimeToLive = TimeSpan.FromSeconds(5) + options.Duration // ADD A BUFFER TO BE SURE THE MESSAGE IS PROPAGATED, EVEN WITH A ZERO DURATION + }, token); + } + + /// + public async ValueTask UnsubscribeAsync() + { + if (_incomingMessageHandler is null) + return; + + if (_subscriptionMissingHandler is not null) + { + _serviceBusCommunicator.SubscriptionMissing -= _subscriptionMissingHandler; + _subscriptionMissingHandler = null; + } + + await _serviceBusCommunicator.Unsubscribe(_incomingMessageHandler); + } +} diff --git a/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/Backplane/AzureServiceBusBackplane_Sync.cs b/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/Backplane/AzureServiceBusBackplane_Sync.cs new file mode 100644 index 00000000..46e03968 --- /dev/null +++ b/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/Backplane/AzureServiceBusBackplane_Sync.cs @@ -0,0 +1,25 @@ +namespace ZiggyCreatures.Caching.Fusion.Backplane.AzureServiceBus; + +public partial class AzureServiceBusBackplane +{ + // UNLIKE RedisBackplane, THE Azure.Messaging.ServiceBus SDK IS ASYNC-ONLY: THERE IS NO NATIVE SYNC API TO CALL INTO, + // SO THESE METHODS BLOCK ON THEIR ASYNC COUNTERPARTS RATHER THAN BEING INDEPENDENT IMPLEMENTATIONS. + + /// + public void Publish(BackplaneMessage message, FusionCacheEntryOptions options, CancellationToken token = default) + { + PublishAsync(message, options, token).GetAwaiter().GetResult(); + } + + /// + public void Subscribe(BackplaneSubscriptionOptions options) + { + SubscribeAsync(options).GetAwaiter().GetResult(); + } + + /// + public void Unsubscribe() + { + UnsubscribeAsync().GetAwaiter().GetResult(); + } +} diff --git a/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/Helpers/AzureServiceBusHelpers.cs b/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/Helpers/AzureServiceBusHelpers.cs new file mode 100644 index 00000000..fb23b876 --- /dev/null +++ b/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/Helpers/AzureServiceBusHelpers.cs @@ -0,0 +1,76 @@ +using System.Text; + +namespace ZiggyCreatures.Caching.Fusion.Backplane.AzureServiceBus.Helpers; + +/// +/// Pure helper logic for deriving valid Azure Service Bus entity names (topics/subscriptions) from arbitrary strings, e.g. FusionCache's computed backplane channel name. +/// This is a best-effort sanitizer, not an exhaustive validator against the full Service Bus naming spec. +/// +internal static class AzureServiceBusHelpers +{ + /// + /// The maximum length of a Service Bus topic name. + /// + public const int MaxTopicNameLength = 260; + + /// + /// The maximum length of a Service Bus subscription name. + /// + public const int MaxSubscriptionNameLength = 50; + + /// + /// Sanitizes into a valid Service Bus entity name: only letters, digits, '.', '-', '_', '/' are allowed, + /// the result cannot start/end with a separator, and it is truncated to characters. + /// If sanitization removes every character, is returned instead. + /// + public static string SanitizeEntityName(string name, int maxLength, string fallback = "entity") + { + if (name is null) + throw new ArgumentNullException(nameof(name)); + + if (maxLength <= 0) + throw new ArgumentOutOfRangeException(nameof(maxLength)); + + var sanitized = new StringBuilder(name.Length); + foreach (var c in name) + { + if (char.IsLetterOrDigit(c) || c == '.' || c == '-' || c == '_' || c == '/') + sanitized.Append(c); + else + sanitized.Append('-'); + } + + var result = sanitized.ToString().Trim('/', '-', '.'); + + if (result.Length > maxLength) + result = result.Substring(0, maxLength).Trim('/', '-', '.'); + + return result.Length == 0 ? fallback : result; + } + + /// + /// Resolves the Service Bus topic name to use: if set (sanitized), otherwise (typically the cache name, also sanitized). + /// + public static string ResolveTopicName(string? explicitTopicName, string fallback) + { + var source = string.IsNullOrWhiteSpace(explicitTopicName) ? fallback : explicitTopicName!; + + return SanitizeEntityName(source, MaxTopicNameLength, fallback: "fusioncache-backplane"); + } + + /// + /// Generates a unique id, suitable for use as a per-instance subscription name and as the self-message filter value. + /// Guaranteed to be a valid Service Bus subscription name (max characters, valid character set only). + /// + public static string GenerateId() + { + var randomPart = Guid.NewGuid().ToString("N").Substring(0, 6); + var machineNamePart = SanitizeEntityName(Environment.MachineName, 30, fallback: "machine"); + + var id = $"{DateTime.UtcNow:yyMMddHHmmss}-{machineNamePart}-{randomPart}"; + + return id.Length >MaxSubscriptionNameLength + ? id.Substring(0, MaxSubscriptionNameLength) + : id; + } +} diff --git a/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/IMPLEMENTATION_PLAN.md b/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/IMPLEMENTATION_PLAN.md new file mode 100644 index 00000000..086d24fe --- /dev/null +++ b/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/IMPLEMENTATION_PLAN.md @@ -0,0 +1,169 @@ +# Azure Service Bus Backplane Implementation Plan + +## Goal + +Bring `ZiggyCreatures.FusionCache.Backplane.AzureServiceBus` to release-ready quality: a correct broadcast topology, reliable lifecycle handling, supported authentication modes, an executable test suite, and documented deployment guidance. + +## Delivery order + +1. Stabilize the internal contract and tests. +2. Define the subscription topology and ownership rules. +3. Implement authentication, validation, and client construction. +4. Complete provisioning, filtering, and cleanup. +5. Harden runtime behavior and add broker-backed coverage. +6. Finish documentation and package verification. + +## 1. Stabilize the internal contract and test suite + +- Finalize the wrapper contract around: + - `AzureServiceBusClientWrapper` + - `AzureServiceBusAdminWrapper` + - `IAsyncDisposable.DisposeAsync` +- Update Azure Service Bus unit tests, integration tests, shared test helpers, and L1/L2 backplane tests to use that contract. +- Add `DisposeAsync` implementations to all fake wrappers. +- Remove stale references to the deleted API, including `AzureServiceBusNaming`, `AzureServiceBusAdminProvisioner`, `UnprovisionAsync`, and old wrapper constructors. +- Do not proceed until the Azure Service Bus test subset compiles and passes. + +**Acceptance criteria** + +- `dotnet test tests/ZiggyCreatures.FusionCache.Tests/ZiggyCreatures.FusionCache.Tests.csproj --filter "FullyQualifiedName~AzureServiceBus"` compiles and passes. +- Tests exercise the current production types rather than compatibility shims. + +## 2. Define subscription topology and ownership + +Azure Service Bus topics broadcast only when every cache node consumes through its own subscription. Multiple nodes sharing a subscription compete for messages and will miss invalidations. + +- In admin mode, generate one unique subscription per cache-process instance by default. +- In non-admin mode, require an externally provisioned, unique subscription per cache-process instance. +- Define ownership rules for manually supplied subscription names in admin mode: + - whether the library may create it; + - whether the library may delete it; + - how this differs from externally provisioned resources. +- Add an explicit instance/subscription identity option if the current `SubscriptionName` property is insufficient to describe the deployment model. +- Document required Azure permissions for each mode. + +**Acceptance criteria** + +- A multi-node integration test proves one published invalidation reaches every other node. +- Configuration and documentation make a shared subscription an explicitly unsupported multi-node topology. + +## 3. Implement options validation and client creation + +- Add one validation path, used before a backplane is constructed. +- Support exactly one authentication mode: + - connection string; or + - fully qualified namespace plus `TokenCredential`. +- Reject missing, partial, or conflicting configurations with actionable exceptions. +- Construct both `ServiceBusClient` and `ServiceBusAdministrationClient` from the selected authentication mode. +- Consider optional client factories for advanced hosts and deterministic tests; define ownership so caller-provided clients are not disposed by the backplane. +- Apply `LockTimeout` consistently to all locks; remove hard-coded lock timeouts. + +**Acceptance criteria** + +- Unit tests cover connection-string authentication, token-credential authentication, invalid configurations, and client-factory precedence if factories are added. +- Identity-only configuration works without requiring a connection string. + +## 4. Align default topic selection with FusionCache channels + +- Decide and document whether the default topic derives from `BackplaneSubscriptionOptions.ChannelName` or `CacheName`. +- Prefer `ChannelName` when protocol/version isolation is expected from FusionCache's normal channel naming. +- Keep `TopicName` as an explicit override. +- Retain deterministic sanitization, length limits, and valid fallbacks. +- Add tests for invalid characters, long names, fallback names, and topic isolation. + +**Acceptance criteria** + +- The value validated during subscription is also the value used to derive the default topic. +- Two incompatible channels cannot silently share the same default topic. + +## 5. Complete provisioning and self-message filtering + +- Create subscriptions using `SubscriptionAutoDeleteOnIdle`. +- Add a server-side rule that excludes messages whose `ConnectionId` matches the local subscription identity. +- Remove or replace the default match-all rule so the exclusion rule takes effect. +- Make topic, subscription, and rule creation idempotent and safe against concurrent starts. +- Define behavior for existing subscriptions and rules in admin mode: validate/repair them or fail with a clear diagnostic. +- Keep the in-process self-message guard as defense in depth, not as the primary filtering mechanism. + +**Acceptance criteria** + +- Real-broker tests show self-published messages are filtered server-side. +- The configured auto-delete interval is observable on the created subscription. +- Concurrent startup does not fail due to benign `AlreadyExists` races. + +## 6. Implement lifecycle ownership and cleanup + +- Make the backplane explicitly own shutdown, preferably with `IAsyncDisposable`. +- On unsubscribe/dispose: + - detach `SubscriptionMissing` handlers; + - stop and dispose the processor; + - dispose the sender and library-created Service Bus client; + - delete only subscriptions the instance created and owns; + - never delete externally provisioned non-admin resources. +- Make cleanup idempotent and preserve the primary operation exception when cleanup also fails. +- Clear local state after successful teardown so an intentional future subscribe can start cleanly, if re-subscription is supported. + +**Acceptance criteria** + +- Repeated unsubscribe/dispose calls are safe. +- Disposal stops the processor and releases owned SDK resources. +- Non-admin disposal never attempts an administrative operation. + +## 7. Harden subscribe, recovery, and message processing + +- Make duplicate `SubscribeAsync` calls either fail clearly or be fully idempotent; choose one behavior and test it. +- Roll back state and event registration if provisioning, processor startup, or connect callbacks fail. +- Serialize subscription recovery after `MessagingEntityNotFound` to prevent repeated provisioning attempts. +- Re-establish processing after recovery and invoke the FusionCache connection handler with `IsReconnection = true`. +- Snapshot message handlers before invocation to avoid concurrent mutation while dispatching. +- Define and test handling for malformed bodies, missing properties, handler failures, abandon/retry, and dead-letter behavior. +- Deliberately configure processor concurrency and prefetch behavior; expose options only where necessary. + +**Acceptance criteria** + +- A deleted auto-delete subscription is recreated and resumes processing. +- Failure during subscribe leaves no orphan event handler or partial state. +- Invalid messages have deliberate, tested settlement behavior. + +## 8. Documentation and packaging + +- Add a package README and include it in the project file. +- Document: + - connection-string and managed-identity setup; + - admin versus non-admin permissions; + - per-instance subscription/IaC requirements; + - topic and subscription naming; + - cleanup and auto-delete behavior; + - Azure Service Bus emulator integration tests. +- Include a minimal multi-node configuration example. +- Verify package icon, README, dependencies, and target frameworks during packing. + +**Acceptance criteria** + +- `dotnet pack` creates an installable package with its README included. +- A user can configure both supported authentication modes by following the package README alone. + +## 9. Final verification + +- Run all unit tests across supported target frameworks. +- Run emulator or real-broker integration tests in CI. +- Add coverage for: + - multi-node broadcast delivery; + - identity authentication; + - non-admin externally provisioned subscriptions; + - reconnect and subscription recreation; + - self-message filtering; + - duplicate subscribe and repeated disposal. +- Run `dotnet test`, `dotnet pack`, formatting/analyzers, and a package-consumption smoke test before merge. + +## Priority + +The required order is: + +1. Test-contract repair. +2. Subscription topology decision. +3. Authentication and validation. +4. Provisioning and lifecycle implementation. +5. Integration coverage and documentation. + +This order prevents the project from shipping a configuration that appears valid but either cannot authenticate, leaks resources, or fails to deliver cache invalidations to every node. diff --git a/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus.csproj b/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus.csproj new file mode 100644 index 00000000..1551af1d --- /dev/null +++ b/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus.csproj @@ -0,0 +1,25 @@ + + + + netstandard2.0;net8.0;net9.0 + ZiggyCreatures.Caching.Fusion.Backplane.AzureServiceBus + + + + + + + + + + + + + + + + + + + + diff --git a/src/ZiggyCreatures.FusionCache.Serialization.NeueccMessagePack/ZiggyCreatures.FusionCache.Serialization.NeueccMessagePack.csproj b/src/ZiggyCreatures.FusionCache.Serialization.NeueccMessagePack/ZiggyCreatures.FusionCache.Serialization.NeueccMessagePack.csproj index d008194d..03a87119 100644 --- a/src/ZiggyCreatures.FusionCache.Serialization.NeueccMessagePack/ZiggyCreatures.FusionCache.Serialization.NeueccMessagePack.csproj +++ b/src/ZiggyCreatures.FusionCache.Serialization.NeueccMessagePack/ZiggyCreatures.FusionCache.Serialization.NeueccMessagePack.csproj @@ -28,7 +28,7 @@ - +
diff --git a/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBusBackplaneExtensionsTests.cs b/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBusBackplaneExtensionsTests.cs new file mode 100644 index 00000000..a7221f33 --- /dev/null +++ b/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBusBackplaneExtensionsTests.cs @@ -0,0 +1,126 @@ +using FusionCacheTests.Stuff; +using Microsoft.Extensions.DependencyInjection; +using Xunit; +using ZiggyCreatures.Caching.Fusion; +using ZiggyCreatures.Caching.Fusion.Backplane; +using ZiggyCreatures.Caching.Fusion.Backplane.AzureServiceBus; + +namespace FusionCacheTests; + +public class AzureServiceBusBackplaneExtensionsTests + : AbstractTests +{ + public AzureServiceBusBackplaneExtensionsTests(ITestOutputHelper output) + : base(output, null) + { + } + + [Fact] + public void AddFusionCacheAzureServiceBusBackplaneRegistersAResolvableBackplane() + { + var services = new ServiceCollection(); + + services.AddFusionCacheAzureServiceBusBackplane(opt => + { + opt.ConnectionString = "Endpoint=sb://fake-namespace.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=ZmFrZS1rZXk="; + }); + + using var serviceProvider = services.BuildServiceProvider(); + + var backplane = serviceProvider.GetRequiredService(); + + Assert.NotNull(backplane); + Assert.IsType(backplane); + } + + [Fact] + public void WithAzureServiceBusBackplaneResolvesOptionsPerCacheName() + { + var services = new ServiceCollection(); + + services.Configure("Foo", opt => + { + opt.ConnectionString = "Endpoint=sb://foo-namespace.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=ZmFrZS1rZXk="; + }); + + services.AddFusionCache("Foo") + .WithAzureServiceBusBackplane() + ; + + services.AddFusionCache("Bar") + .WithAzureServiceBusBackplane(opt => + { + opt.ConnectionString = "Endpoint=sb://bar-namespace.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=ZmFrZS1rZXk="; + opt.TopicName = "custom-bar-topic"; + }) + ; + + using var serviceProvider = services.BuildServiceProvider(); + + var cacheProvider = serviceProvider.GetRequiredService(); + + var fooCache = cacheProvider.GetCache("Foo"); + var barCache = cacheProvider.GetCache("Bar"); + + var fooBackplane = TestsUtils.GetBackplane(fooCache); + var fooTopicName = TestsUtils.GetAzureServiceBusCommunicatorTopicName(fooCache); + var barBackplane = TestsUtils.GetBackplane(barCache); + var barTopicName = TestsUtils.GetAzureServiceBusCommunicatorTopicName(barCache); + + Assert.True(fooCache.HasBackplane); + Assert.NotNull(fooBackplane); + // NO EXPLICIT TopicName WAS SET FOR "Foo": IT SHOULD DEFAULT TO THE (SANITIZED) CACHE NAME + Assert.Equal("Foo", fooTopicName); + + Assert.True(barCache.HasBackplane); + Assert.NotNull(barBackplane); + // AN EXPLICIT TopicName WAS SET FOR "Bar": IT SHOULD BE USED AS-IS INSTEAD OF THE CACHE NAME + Assert.Equal("custom-bar-topic", barTopicName); + + Assert.NotEqual(fooTopicName, barTopicName); + } + + [Fact] + public void WithAzureServiceBusBackplaneThrowsWhenNonAdminWithoutSubscriptionName() + { + var services = new ServiceCollection(); + + services.AddFusionCache("Foo") + .WithAzureServiceBusBackplane(opt => + { + opt.ConnectionString = "Endpoint=sb://foo-namespace.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=ZmFrZS1rZXk="; + opt.IsAdmin = false; + // NOTE: SubscriptionName IS DELIBERATELY LEFT UNSET HERE + }) + ; + + using var serviceProvider = services.BuildServiceProvider(); + + var cacheProvider = serviceProvider.GetRequiredService(); + + Assert.Throws(() => cacheProvider.GetCache("Foo")); + } + + [Fact] + public void WithAzureServiceBusBackplaneAllowsNonAdminWithSubscriptionName() + { + var services = new ServiceCollection(); + + services.AddFusionCache("Foo") + .WithAzureServiceBusBackplane(opt => + { + opt.ConnectionString = "Endpoint=sb://foo-namespace.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=ZmFrZS1rZXk="; + opt.IsAdmin = false; + opt.SubscriptionName = "my-existing-subscription"; + }) + ; + + using var serviceProvider = services.BuildServiceProvider(); + + var cacheProvider = serviceProvider.GetRequiredService(); + var fooCache = cacheProvider.GetCache("Foo"); + + Assert.True(fooCache.HasBackplane); + Assert.NotNull(TestsUtils.GetBackplane(fooCache)); + } +} diff --git a/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBusBackplaneOptionsTests.cs b/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBusBackplaneOptionsTests.cs new file mode 100644 index 00000000..e77ca8fa --- /dev/null +++ b/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBusBackplaneOptionsTests.cs @@ -0,0 +1,90 @@ +using Azure.Core; +using Azure.Messaging.ServiceBus; +using Azure.Messaging.ServiceBus.Administration; +using FusionCacheTests.Stuff; +using Xunit; +using ZiggyCreatures.Caching.Fusion.Backplane.AzureServiceBus; + +namespace FusionCacheTests; + +public class AzureServiceBusBackplaneOptionsTests + : AbstractTests +{ + public AzureServiceBusBackplaneOptionsTests(ITestOutputHelper output) + : base(output, null) + { + } + + private sealed class FakeTokenCredential + : TokenCredential + { + public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken) + { + return new AccessToken("fake-token", DateTimeOffset.UtcNow.AddHours(1)); + } + + public override ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken) + { + return new ValueTask(GetToken(requestContext, cancellationToken)); + } + } + + [Fact] + public async Task GetOrCreateClientsAsyncThrowsWhenNothingIsConfiguredAsync() + { + var options = new AzureServiceBusBackplaneOptions(); + + await Assert.ThrowsAsync(() => options.GetOrCreateClientsAsync()); + } + + [Fact] + public async Task GetOrCreateClientsAsyncUsesConnectionStringAsync() + { + var options = new AzureServiceBusBackplaneOptions + { + ConnectionString = "Endpoint=sb://fake-namespace.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=ZmFrZS1rZXk=" + }; + + var (client, adminClient) = await options.GetOrCreateClientsAsync(); + + Assert.NotNull(client); + Assert.NotNull(adminClient); + Assert.Equal("fake-namespace.servicebus.windows.net", client.FullyQualifiedNamespace); + } + + [Fact] + public async Task GetOrCreateClientsAsyncUsesFullyQualifiedNamespaceAndCredentialAsync() + { + var options = new AzureServiceBusBackplaneOptions + { + FullyQualifiedNamespace = "fake-namespace.servicebus.windows.net", + Credential = new FakeTokenCredential() + }; + + var (client, adminClient) = await options.GetOrCreateClientsAsync(); + + Assert.NotNull(client); + Assert.NotNull(adminClient); + Assert.Equal("fake-namespace.servicebus.windows.net", client.FullyQualifiedNamespace); + } + + [Fact] + public async Task ServiceBusClientFactoryTakesPrecedenceOverConnectionStringAsync() + { + const string factoryConnectionString = "Endpoint=sb://factory-namespace.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=ZmFrZS1rZXk="; + var factoryClient = new ServiceBusClient(factoryConnectionString); + var factoryAdminClient = new ServiceBusAdministrationClient(factoryConnectionString); + + var options = new AzureServiceBusBackplaneOptions + { + // AN INVALID CONNECTION STRING: IF THE RESOLVER TRIED TO USE IT INSTEAD OF THE FACTORY, CONSTRUCTING A CLIENT FROM IT WOULD THROW + ConnectionString = "this is not a valid connection string", + ServiceBusClientFactory = () => Task.FromResult((factoryClient, factoryAdminClient)) + }; + + var (client, adminClient) = await options.GetOrCreateClientsAsync(); + + Assert.Same(factoryClient, client); + Assert.Same(factoryAdminClient, adminClient); + } +} diff --git a/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBusBackplaneTests.cs b/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBusBackplaneTests.cs new file mode 100644 index 00000000..898d7ca7 --- /dev/null +++ b/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBusBackplaneTests.cs @@ -0,0 +1,416 @@ +using Azure.Messaging.ServiceBus; +using FusionCacheTests.Stuff; +using Xunit; +using ZiggyCreatures.Caching.Fusion.Backplane; +using ZiggyCreatures.Caching.Fusion.Backplane.AzureServiceBus; +using ZiggyCreatures.Caching.Fusion.Backplane.AzureServiceBus.AzureServiceBusWrapper; + +namespace FusionCacheTests; + +public class AzureServiceBusBackplaneTests + : AbstractTests +{ + public AzureServiceBusBackplaneTests(ITestOutputHelper output) + : base(output, null) + { + } + + private sealed class FakeAzureServiceBusCommunicator + : IAzureServiceBusClientWrapper + { + public FakeAzureServiceBusCommunicator(List? callLog = null) + { + _callLog = callLog; + } + + private readonly List? _callLog; + + public int SubscribeCallCount { get; private set; } + public Func? SubscribedHandler { get; private set; } + public Func? UnsubscribedHandler { get; private set; } + public List SentMessages { get; } = new(); + + public TimeSpan? SubscribeDelay { get; set; } + public TimeSpan? SendMessageDelay { get; set; } + + public event Func? SubscriptionMissing; + + public async Task RaiseSubscriptionMissingAsync() + { + var handler = SubscriptionMissing; + if (handler is not null) + await handler(); + } + + public async Task Subscribe(Func handler) + { + if (SubscribeDelay.HasValue) + await Task.Delay(SubscribeDelay.Value); + + _callLog?.Add(nameof(Subscribe)); + SubscribeCallCount++; + SubscribedHandler = handler; + } + + public Task Unsubscribe(Func handler) + { + _callLog?.Add(nameof(Unsubscribe)); + UnsubscribedHandler = handler; + return Task.CompletedTask; + } + + public async Task SendMessage(ServiceBusMessage message, CancellationToken cancellationToken) + { + if (SendMessageDelay.HasValue) + await Task.Delay(SendMessageDelay.Value); + + SentMessages.Add(message); + } + } + + private sealed class FakeAzureServiceBusProvisioner + : IAzureServiceBusAdminWrapper + { + public FakeAzureServiceBusProvisioner(List? callLog = null) + { + _callLog = callLog; + } + + private readonly List? _callLog; + + public int EnsureTopicCallCount { get; private set; } + public int EnsureSubscriptionCallCount { get; private set; } + public int UnprovisionCallCount { get; private set; } + + public ValueTask EnsureTopicAsync() + { + _callLog?.Add(nameof(EnsureTopicAsync)); + EnsureTopicCallCount++; + return default; + } + + public ValueTask EnsureSubscriptionAsync() + { + _callLog?.Add(nameof(EnsureSubscriptionAsync)); + EnsureSubscriptionCallCount++; + return default; + } + + public ValueTask UnprovisionAsync() + { + _callLog?.Add(nameof(UnprovisionAsync)); + UnprovisionCallCount++; + return default; + } + } + + private static (BackplaneSubscriptionOptions Options, List ReceivedMessages, List ConnectReconnectionFlags) CreateSubscriptionOptions( + string cacheName = "TestCache", + string cacheInstanceId = "TestInstance", + string? channelName = "TestCache.Backplane:v1") + { + var receivedMessages = new List(); + var connectReconnectionFlags = new List(); + + void IncomingMessageHandler(BackplaneMessage msg) => receivedMessages.Add(msg); + ValueTask IncomingMessageHandlerAsync(BackplaneMessage msg) { receivedMessages.Add(msg); return default; } + void ConnectHandler(BackplaneConnectionInfo info) => connectReconnectionFlags.Add(info.IsReconnection); + ValueTask ConnectHandlerAsync(BackplaneConnectionInfo info) { connectReconnectionFlags.Add(info.IsReconnection); return default; } + + var options = new BackplaneSubscriptionOptions( + cacheName, + cacheInstanceId, + channelName, + ConnectHandler, + IncomingMessageHandler, + ConnectHandlerAsync, + IncomingMessageHandlerAsync + ); + + return (options, receivedMessages, connectReconnectionFlags); + } + + private static ServiceBusReceivedMessage CreateReceivedMessage(BackplaneMessage message, string? subject) + { + return ServiceBusModelFactory.ServiceBusReceivedMessage( + body: new BinaryData(BackplaneMessage.ToByteArray(message)), + subject: subject + ); + } + + private AzureServiceBusBackplane CreateBackplane(FakeAzureServiceBusCommunicator communicator, IAzureServiceBusAdminWrapper? provisioner = null) + { + return new AzureServiceBusBackplane(communicator, provisioner ?? NoOpAzureServiceBusAdminWrapper.Instance, CreateXUnitLogger()); + } + + [Fact] + public async Task SubscribeAsyncThrowsWhenOptionsIsNullAsync() + { + var backplane = CreateBackplane(new FakeAzureServiceBusCommunicator()); + + await Assert.ThrowsAsync(() => backplane.SubscribeAsync(null!).AsTask()); + } + + [Fact] + public async Task SubscribeAsyncThrowsWhenChannelNameIsNullAsync() + { + var backplane = CreateBackplane(new FakeAzureServiceBusCommunicator()); + var (options, _, _) = CreateSubscriptionOptions(channelName: null); + + await Assert.ThrowsAsync(() => backplane.SubscribeAsync(options).AsTask()); + } + + [Fact] + public async Task SubscribeAsyncThrowsWhenCacheNameIsNullAsync() + { + var backplane = CreateBackplane(new FakeAzureServiceBusCommunicator()); + var (options, _, _) = CreateSubscriptionOptions(cacheName: null!); + + await Assert.ThrowsAsync(() => backplane.SubscribeAsync(options).AsTask()); + } + + [Fact] + public async Task SubscribeAsyncThrowsWhenCacheInstanceIdIsNullAsync() + { + var backplane = CreateBackplane(new FakeAzureServiceBusCommunicator()); + var (options, _, _) = CreateSubscriptionOptions(cacheInstanceId: null!); + + await Assert.ThrowsAsync(() => backplane.SubscribeAsync(options).AsTask()); + } + + [Fact] + public async Task SubscribeAsyncThrowsWhenBothIncomingMessageHandlersAreNullAsync() + { + var backplane = CreateBackplane(new FakeAzureServiceBusCommunicator()); + var options = new BackplaneSubscriptionOptions( + "TestCache", + "TestInstance", + "TestCache.Backplane:v1", + connectHandler: null, + incomingMessageHandler: null, + connectHandlerAsync: null, + incomingMessageHandlerAsync: null + ); + + await Assert.ThrowsAsync(() => backplane.SubscribeAsync(options).AsTask()); + } + + [Fact] + public async Task SubscribeAsyncThrowsWhenAlreadyInitializedAsync() + { + var backplane = CreateBackplane(new FakeAzureServiceBusCommunicator()); + var (options1, _, _) = CreateSubscriptionOptions(); + var (options2, _, _) = CreateSubscriptionOptions(cacheName: "OtherCache", cacheInstanceId: "OtherInstance"); + + await backplane.SubscribeAsync(options1); + + await Assert.ThrowsAsync(() => backplane.SubscribeAsync(options2).AsTask()); + } + + [Fact] + public async Task SubscribeAsyncCallsCommunicatorSubscribeExactlyOnceAsync() + { + var fake = new FakeAzureServiceBusCommunicator(); + var backplane = CreateBackplane(fake); + var (options, _, _) = CreateSubscriptionOptions(); + + await backplane.SubscribeAsync(options); + + Assert.Equal(1, fake.SubscribeCallCount); + Assert.NotNull(fake.SubscribedHandler); + } + + [Fact] + public async Task SubscribeAsyncRunsProvisionerBeforeCommunicatorSubscribeAsync() + { + var callLog = new List(); + var provisioner = new FakeAzureServiceBusProvisioner(callLog); + var communicator = new FakeAzureServiceBusCommunicator(callLog); + var backplane = CreateBackplane(communicator, provisioner); + var (options, _, _) = CreateSubscriptionOptions(); + + await backplane.SubscribeAsync(options); + + Assert.Equal(new[] { nameof(IAzureServiceBusAdminWrapper.EnsureTopicAsync), nameof(IAzureServiceBusAdminWrapper.EnsureSubscriptionAsync), nameof(IAzureServiceBusClientWrapper.Subscribe) }, callLog); + } + + [Fact] + public async Task UnsubscribeAsyncRunsCommunicatorUnsubscribeBeforeProvisionerUnprovisionAsync() + { + var callLog = new List(); + var provisioner = new FakeAzureServiceBusProvisioner(callLog); + var communicator = new FakeAzureServiceBusCommunicator(callLog); + var backplane = CreateBackplane(communicator, provisioner); + var (options, _, _) = CreateSubscriptionOptions(); + + await backplane.SubscribeAsync(options); + callLog.Clear(); + + await backplane.UnsubscribeAsync(); + + Assert.Equal(new[] { nameof(IAzureServiceBusClientWrapper.Unsubscribe), nameof(IAzureServiceBusAdminWrapper.UnprovisionAsync) }, callLog); + } + + [Fact] + public async Task SubscriptionMissingTriggersProvisionerEnsureSubscriptionAsync() + { + var provisioner = new FakeAzureServiceBusProvisioner(); + var communicator = new FakeAzureServiceBusCommunicator(); + var backplane = CreateBackplane(communicator, provisioner); + var (options, _, _) = CreateSubscriptionOptions(); + + await backplane.SubscribeAsync(options); + + Assert.Equal(1, provisioner.EnsureSubscriptionCallCount); + + await communicator.RaiseSubscriptionMissingAsync(); + + Assert.Equal(2, provisioner.EnsureSubscriptionCallCount); + } + + [Fact] + public async Task UnsubscribeAsyncStopsReactingToSubscriptionMissingAsync() + { + var provisioner = new FakeAzureServiceBusProvisioner(); + var communicator = new FakeAzureServiceBusCommunicator(); + var backplane = CreateBackplane(communicator, provisioner); + var (options, _, _) = CreateSubscriptionOptions(); + + await backplane.SubscribeAsync(options); + await backplane.UnsubscribeAsync(); + + var countAfterUnsubscribe = provisioner.EnsureSubscriptionCallCount; + + await communicator.RaiseSubscriptionMissingAsync(); + + Assert.Equal(countAfterUnsubscribe, provisioner.EnsureSubscriptionCallCount); + } + + [Fact] + public async Task IncomingMessageWithMatchingSubjectIsDispatchedToAsyncHandlerOnlyAsync() + { + var fake = new FakeAzureServiceBusCommunicator(); + var backplane = CreateBackplane(fake); + var (options, receivedMessages, _) = CreateSubscriptionOptions(cacheName: "TestCache"); + + await backplane.SubscribeAsync(options); + + var originalMessage = BackplaneMessage.CreateForEntrySet("source-instance", "my-key", 12345L); + var receivedMessage = CreateReceivedMessage(originalMessage, subject: "TestCache"); + + await fake.SubscribedHandler!(receivedMessage); + + // ONLY THE ASYNC HANDLER SHOULD HAVE FIRED (NOT BOTH), OTHERWISE THE SAME MESSAGE WOULD BE PROCESSED TWICE + var received = Assert.Single(receivedMessages); + Assert.Equal(originalMessage.SourceId, received.SourceId); + Assert.Equal(originalMessage.CacheKey, received.CacheKey); + Assert.Equal(originalMessage.Action, received.Action); + Assert.Equal(originalMessage.Timestamp, received.Timestamp); + } + + [Fact] + public async Task IncomingMessageWithMismatchedSubjectIsIgnoredAsync() + { + var fake = new FakeAzureServiceBusCommunicator(); + var backplane = CreateBackplane(fake); + var (options, receivedMessages, _) = CreateSubscriptionOptions(cacheName: "TestCache"); + + await backplane.SubscribeAsync(options); + + var originalMessage = BackplaneMessage.CreateForEntrySet("source-instance", "my-key", 12345L); + var receivedMessage = CreateReceivedMessage(originalMessage, subject: "SomeOtherCache"); + + await fake.SubscribedHandler!(receivedMessage); + + Assert.Empty(receivedMessages); + } + + [Fact] + public async Task SubscribeAsyncInvokesConnectHandlerOnceWithIsReconnectionFalseAsync() + { + var fake = new FakeAzureServiceBusCommunicator(); + var backplane = CreateBackplane(fake); + var (options, _, connectReconnectionFlags) = CreateSubscriptionOptions(); + + await backplane.SubscribeAsync(options); + + // ONLY THE ASYNC HANDLER SHOULD HAVE FIRED (NOT BOTH) + var flag = Assert.Single(connectReconnectionFlags); + Assert.False(flag); + } + + [Fact] + public async Task PublishAsyncSendsMessageWithExpectedSubjectBodyAndTtlAsync() + { + var fake = new FakeAzureServiceBusCommunicator(); + var backplane = CreateBackplane(fake); + var (options, _, _) = CreateSubscriptionOptions(cacheName: "TestCache"); + + await backplane.SubscribeAsync(options); + + var message = BackplaneMessage.CreateForEntrySet("source-instance", "my-key", 987654321L); + var entryOptions = new FusionCacheEntryOptions(TimeSpan.FromMinutes(10)); + + await backplane.PublishAsync(message, entryOptions); + + var sent = Assert.Single(fake.SentMessages); + Assert.Equal("TestCache", sent.Subject); + Assert.Equal(TimeSpan.FromSeconds(5) + entryOptions.Duration, sent.TimeToLive); + + var roundTripped = BackplaneMessage.FromByteArray(sent.Body.ToArray()); + Assert.Equal(message.SourceId, roundTripped.SourceId); + Assert.Equal(message.CacheKey, roundTripped.CacheKey); + Assert.Equal(message.Action, roundTripped.Action); + Assert.Equal(message.Timestamp, roundTripped.Timestamp); + } + + [Fact] + public async Task UnsubscribeAsyncCallsCommunicatorWithTheSameHandlerAsync() + { + var fake = new FakeAzureServiceBusCommunicator(); + var backplane = CreateBackplane(fake); + var (options, _, _) = CreateSubscriptionOptions(); + + await backplane.SubscribeAsync(options); + var subscribedHandler = fake.SubscribedHandler; + + await backplane.UnsubscribeAsync(); + + Assert.Same(subscribedHandler, fake.UnsubscribedHandler); + } + + [Fact] + public void SubscribeBlocksUntilCommunicatorSubscribeCompletes() + { + var fake = new FakeAzureServiceBusCommunicator { SubscribeDelay = TimeSpan.FromMilliseconds(200) }; + var backplane = CreateBackplane(fake); + var (options, _, _) = CreateSubscriptionOptions(); + + var sw = System.Diagnostics.Stopwatch.StartNew(); + backplane.Subscribe(options); + sw.Stop(); + + Assert.True(sw.ElapsedMilliseconds >= 150, $"Expected Subscribe() to block for the artificial delay, but it only took {sw.ElapsedMilliseconds}ms"); + Assert.Equal(1, fake.SubscribeCallCount); + } + + [Fact] + public void PublishBlocksUntilCommunicatorSendMessageCompletes() + { + var fake = new FakeAzureServiceBusCommunicator { SendMessageDelay = TimeSpan.FromMilliseconds(200) }; + var backplane = CreateBackplane(fake); + var (options, _, _) = CreateSubscriptionOptions(cacheName: "TestCache"); + + backplane.Subscribe(options); + + var message = BackplaneMessage.CreateForEntrySet("source-instance", "my-key", 1L); + var entryOptions = new FusionCacheEntryOptions(TimeSpan.FromMinutes(10)); + + var sw = System.Diagnostics.Stopwatch.StartNew(); + backplane.Publish(message, entryOptions); + sw.Stop(); + + Assert.True(sw.ElapsedMilliseconds >= 150, $"Expected Publish() to block for the artificial delay, but it only took {sw.ElapsedMilliseconds}ms"); + Assert.Single(fake.SentMessages); + } +} diff --git a/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBusCommunicatorIntegrationTests.cs b/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBusCommunicatorIntegrationTests.cs new file mode 100644 index 00000000..e4299d6e --- /dev/null +++ b/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBusCommunicatorIntegrationTests.cs @@ -0,0 +1,255 @@ +using Azure.Messaging.ServiceBus; +using Azure.Messaging.ServiceBus.Administration; +using FusionCacheTests.Stuff; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; +using ZiggyCreatures.Caching.Fusion.Backplane.AzureServiceBus; +using ZiggyCreatures.Caching.Fusion.Backplane.AzureServiceBus.AzureServiceBusWrapper; +using ZiggyCreatures.Caching.Fusion.Backplane.AzureServiceBus.Helpers; + +namespace FusionCacheTests; + +/// +/// Integration tests for and that +/// require a real or emulated Azure Service Bus broker. Skipped automatically unless the +/// environment variable is set, e.g. to: +/// - a real Azure Service Bus namespace connection string, or +/// - the local connection string exposed by the Azure Service Bus emulator (mcr.microsoft.com/azure-messaging/servicebus-emulator). +/// Each test provisions its own uniquely-named topic and deletes it afterward, so tests can run concurrently/repeatedly without colliding. +/// These tests manually do what does internally: run the provisioner before subscribing +/// the communicator, and (where relevant) after unsubscribing it. +/// +public class AzureServiceBusCommunicatorIntegrationTests + : AbstractTests +{ + private const string ConnectionStringEnvVarName = "FUSIONCACHE_TESTS_AZURESERVICEBUS_CONNECTIONSTRING"; + + public AzureServiceBusCommunicatorIntegrationTests(ITestOutputHelper output) + : base(output, null) + { + _connectionString = Environment.GetEnvironmentVariable(ConnectionStringEnvVarName); + } + + private readonly string? _connectionString; + + private void SkipIfNoBrokerConfigured() + { + if (string.IsNullOrWhiteSpace(_connectionString)) + Assert.Skip($"Set the {ConnectionStringEnvVarName} environment variable (pointing at a real Azure Service Bus namespace or the local emulator) to run these integration tests."); + } + + private (ServiceBusClient Client, ServiceBusAdministrationClient AdminClient) CreateClients() + { + return (new ServiceBusClient(_connectionString), new ServiceBusAdministrationClient(_connectionString)); + } + + private static string CreateUniqueTopicName(string testName) + { + var topicName = $"fusioncache-tests-{testName}-{Guid.NewGuid():N}".ToLowerInvariant(); + + return topicName.Length > AzureServiceBusNaming.MaxTopicNameLength + ? topicName.Substring(0, AzureServiceBusNaming.MaxTopicNameLength) + : topicName; + } + + private static AzureServiceBusAdminProvisioner CreateProvisioner(ServiceBusAdministrationClient adminClient, string topicName, string subscriptionName) + { + return new AzureServiceBusAdminProvisioner(adminClient, topicName, subscriptionName, NullLogger.Instance); + } + + private static AzureServiceBusClientWrapper CreateCommunicator(ServiceBusClient client, string topicName, string subscriptionName) + { + return new AzureServiceBusClientWrapper(client, topicName, subscriptionName, NullLogger.Instance); + } + + [Fact] + public async Task ProvisionerEnsureMethodsAreIdempotentWhenCalledTwiceAsync() + { + SkipIfNoBrokerConfigured(); + + var topicName = CreateUniqueTopicName(nameof(ProvisionerEnsureMethodsAreIdempotentWhenCalledTwiceAsync)); + const string subscriptionName = "idempotent-test-subscription"; + var (_, adminClient) = CreateClients(); + + try + { + var provisioner = CreateProvisioner(adminClient, topicName, subscriptionName); + + await provisioner.EnsureTopicAsync(); + await provisioner.EnsureTopicAsync(); + await provisioner.EnsureSubscriptionAsync(); + await provisioner.EnsureSubscriptionAsync(); + + Assert.True(await adminClient.TopicExistsAsync(topicName, TestContext.Current.CancellationToken)); + Assert.True(await adminClient.SubscriptionExistsAsync(topicName, subscriptionName, TestContext.Current.CancellationToken)); + } + finally + { + await TryDeleteTopicAsync(adminClient, topicName); + } + } + + [Fact] + public async Task SelfPublishedMessagesAreFilteredOutBySubscriptionRuleAsync() + { + SkipIfNoBrokerConfigured(); + + var topicName = CreateUniqueTopicName(nameof(SelfPublishedMessagesAreFilteredOutBySubscriptionRuleAsync)); + var (clientA, adminClientA) = CreateClients(); + + try + { + var provisionerA = CreateProvisioner(adminClientA, topicName, "subscription-a"); + await provisionerA.EnsureTopicAsync(); + await provisionerA.EnsureSubscriptionAsync(); + + var (clientB, adminClientB) = CreateClients(); + var provisionerB = CreateProvisioner(adminClientB, topicName, "subscription-b"); + await provisionerB.EnsureSubscriptionAsync(); + + await using var communicatorA = CreateCommunicator(clientA, topicName, "subscription-a"); + await using var communicatorB = CreateCommunicator(clientB, topicName, "subscription-b"); + + var aReceivedOwnMessage = false; + var bReceivedTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + await communicatorA.Subscribe(_ => { aReceivedOwnMessage = true; return Task.CompletedTask; }); + await communicatorB.Subscribe(_ => { bReceivedTcs.TrySetResult(true); return Task.CompletedTask; }); + + await communicatorA.SendMessage(new ServiceBusMessage(new BinaryData(new byte[] { 1, 2, 3 })), TestContext.Current.CancellationToken); + + var completed = await Task.WhenAny(bReceivedTcs.Task, Task.Delay(TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken)); + + Assert.Same(bReceivedTcs.Task, completed); + Assert.True(await bReceivedTcs.Task); + Assert.False(aReceivedOwnMessage, "Communicator A should never receive its own published message: the per-subscription 'FilterOutOwnMessages' rule should have filtered it out server-side."); + } + finally + { + await TryDeleteTopicAsync(adminClientA, topicName); + } + } + + [Fact] + public async Task SubscriptionMissingEventFiresAndProvisionerRecreatesTheSubscriptionAsync() + { + SkipIfNoBrokerConfigured(); + + var topicName = CreateUniqueTopicName(nameof(SubscriptionMissingEventFiresAndProvisionerRecreatesTheSubscriptionAsync)); + const string subscriptionName = "self-healing-test-subscription"; + var (client, adminClient) = CreateClients(); + + try + { + var provisioner = CreateProvisioner(adminClient, topicName, subscriptionName); + await provisioner.EnsureTopicAsync(); + await provisioner.EnsureSubscriptionAsync(); + + await using var communicator = CreateCommunicator(client, topicName, subscriptionName); + + var missingSignaled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + communicator.SubscriptionMissing += async () => + { + missingSignaled.TrySetResult(true); + await provisioner.EnsureSubscriptionAsync(); + }; + + await communicator.Subscribe(_ => Task.CompletedTask); + + // SIMULATE THE SUBSCRIPTION BEING REAPED BY IDLE AUTO-DELETE (E.G. A DEV MACHINE GOING TO SLEEP) + await adminClient.DeleteSubscriptionAsync(topicName, subscriptionName, TestContext.Current.CancellationToken); + + // THE PROCESSOR'S OWN BACKGROUND RECEIVE LOOP SHOULD EVENTUALLY HIT MessagingEntityNotFound ON ITS OWN, + // RAISING SubscriptionMissing, WITHOUT NEEDING ANY MESSAGE TO BE PUBLISHED + var completed = await Task.WhenAny(missingSignaled.Task, Task.Delay(TimeSpan.FromSeconds(60), TestContext.Current.CancellationToken)); + + Assert.Same(missingSignaled.Task, completed); + Assert.True(await adminClient.SubscriptionExistsAsync(topicName, subscriptionName, TestContext.Current.CancellationToken)); + } + finally + { + await TryDeleteTopicAsync(adminClient, topicName); + } + } + + [Fact] + public async Task UnprovisionAsyncDeletesTheSubscriptionButNotTheTopicAsync() + { + SkipIfNoBrokerConfigured(); + + var topicName = CreateUniqueTopicName(nameof(UnprovisionAsyncDeletesTheSubscriptionButNotTheTopicAsync)); + const string subscriptionName = "unprovision-test-subscription"; + var (_, adminClient) = CreateClients(); + + try + { + var provisioner = CreateProvisioner(adminClient, topicName, subscriptionName); + await provisioner.EnsureTopicAsync(); + await provisioner.EnsureSubscriptionAsync(); + + Assert.True(await adminClient.SubscriptionExistsAsync(topicName, subscriptionName, TestContext.Current.CancellationToken)); + + await provisioner.UnprovisionAsync(); + + Assert.False(await adminClient.SubscriptionExistsAsync(topicName, subscriptionName, TestContext.Current.CancellationToken)); + Assert.True(await adminClient.TopicExistsAsync(topicName, TestContext.Current.CancellationToken)); + } + finally + { + await TryDeleteTopicAsync(adminClient, topicName); + } + } + + [Fact] + public async Task CommunicatorWorksAgainstAnExternallyProvisionedSubscriptionWithoutAProvisionerAsync() + { + SkipIfNoBrokerConfigured(); + + var topicName = CreateUniqueTopicName(nameof(CommunicatorWorksAgainstAnExternallyProvisionedSubscriptionWithoutAProvisionerAsync)); + const string subscriptionName = "no-provisioner-test-subscription"; + var (_, adminClient) = CreateClients(); + + try + { + // PROVISION OUT OF BAND (E.G. VIA IAC), WITHOUT EVER USING AzureServiceBusAdminProvisioner. NOTE THIS + // SUBSCRIPTION KEEPS ITS DEFAULT MATCH-ALL RULE: NO ONE HERE CREATES THE "FilterOutOwnMessages" SQL RULE. + await adminClient.CreateTopicAsync(topicName, TestContext.Current.CancellationToken); + await adminClient.CreateSubscriptionAsync(new CreateSubscriptionOptions(topicName, subscriptionName), TestContext.Current.CancellationToken); + + var (client, _) = CreateClients(); + await using var communicator = CreateCommunicator(client, topicName, subscriptionName); + + var receivedTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + await communicator.Subscribe(_ => { receivedTcs.TrySetResult(true); return Task.CompletedTask; }); + + await communicator.SendMessage(new ServiceBusMessage(new BinaryData(new byte[] { 7 })), TestContext.Current.CancellationToken); + + // WITHOUT A SERVER-SIDE SELF-FILTER RULE, THE SUBSCRIPTION'S DEFAULT RULE DELIVERS THE MESSAGE BACK TO + // THIS SAME COMMUNICATOR. THE APP-LEVEL SELF-CHECK IN ProcessMessageAsync IS WHAT PREVENTS IT FROM EVER + // REACHING A REGISTERED HANDLER, SO IF THIS NEVER COMPLETES, THAT GUARD DID ITS JOB. + var completed = await Task.WhenAny(receivedTcs.Task, Task.Delay(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken)); + Assert.NotSame(receivedTcs.Task, completed); + + await communicator.DisposeAsync(); + + // THE COMMUNICATOR HAS NO ADMINISTRATIVE CAPABILITY AT ALL: DISPOSING IT MUST NEVER DELETE THE SUBSCRIPTION + Assert.True(await adminClient.SubscriptionExistsAsync(topicName, subscriptionName, TestContext.Current.CancellationToken)); + } + finally + { + await TryDeleteTopicAsync(adminClient, topicName); + } + } + + private static async Task TryDeleteTopicAsync(ServiceBusAdministrationClient adminClient, string topicName) + { + try + { + await adminClient.DeleteTopicAsync(topicName); + } + catch + { + // BEST-EFFORT CLEANUP: DON'T FAIL THE TEST RUN IF THE TOPIC WAS NEVER CREATED OR IS ALREADY GONE + } + } +} diff --git a/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBusCommunicatorTests.cs b/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBusCommunicatorTests.cs new file mode 100644 index 00000000..850cd2d8 --- /dev/null +++ b/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBusCommunicatorTests.cs @@ -0,0 +1,109 @@ +using Azure.Messaging.ServiceBus; +using FusionCacheTests.Stuff; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; +using ZiggyCreatures.Caching.Fusion.Backplane.AzureServiceBus; +using ZiggyCreatures.Caching.Fusion.Backplane.AzureServiceBus.AzureServiceBusWrapper; +using ZiggyCreatures.Caching.Fusion.Backplane.AzureServiceBus.Helpers; + +namespace FusionCacheTests; + +public class AzureServiceBusCommunicatorTests + : AbstractTests +{ + public AzureServiceBusCommunicatorTests(ITestOutputHelper output) + : base(output, null) + { + } + + private const string FakeConnectionString = "Endpoint=sb://fake-namespace.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=ZmFrZS1rZXk="; + + [Fact] + public void ConstructorThrowsWhenSubscriptionNameIsMissing() + { + var client = new ServiceBusClient(FakeConnectionString); + + Assert.Throws(() => new AzureServiceBusClientWrapper( + serviceBusClient: client, + topicName: "my-topic", + subscriptionName: null!, + logger: NullLogger.Instance + )); + } + + [Fact] + public void ConstructorThrowsWhenSubscriptionNameIsWhitespace() + { + var client = new ServiceBusClient(FakeConnectionString); + + Assert.Throws(() => new AzureServiceBusClientWrapper( + serviceBusClient: client, + topicName: "my-topic", + subscriptionName: " ", + logger: NullLogger.Instance + )); + } + + [Fact] + public void ConstructorUsesTheGivenTopicAndSubscriptionNames() + { + var client = new ServiceBusClient(FakeConnectionString); + + var communicator = new AzureServiceBusClientWrapper( + serviceBusClient: client, + topicName: "my-topic", + subscriptionName: "my-existing-subscription", + logger: NullLogger.Instance + ); + + Assert.Equal("my-topic", communicator.TopicName); + Assert.Equal("my-existing-subscription", communicator.SubscriptionName); + } + + [Fact] + public void SubscriptionMissingEventCanBeAddedAndRemovedWithoutThrowing() + { + var client = new ServiceBusClient(FakeConnectionString); + + var communicator = new AzureServiceBusClientWrapper( + serviceBusClient: client, + topicName: "my-topic", + subscriptionName: "my-existing-subscription", + logger: NullLogger.Instance + ); + + Task Handler() => Task.CompletedTask; + + communicator.SubscriptionMissing += Handler; + communicator.SubscriptionMissing -= Handler; + } + + [Fact] + public void GenerateIdReturnsAValidSubscriptionNameLength() + { + var id = AzureServiceBusClientWrapper.GenerateId(); + + Assert.True(id.Length <= AzureServiceBusNaming.MaxSubscriptionNameLength, $"Expected length <= {AzureServiceBusNaming.MaxSubscriptionNameLength}, but was {id.Length} ('{id}')"); + Assert.NotEmpty(id); + } + + [Fact] + public void GenerateIdOnlyContainsValidServiceBusEntityNameCharacters() + { + var id = AzureServiceBusClientWrapper.GenerateId(); + + foreach (var c in id) + { + Assert.True(char.IsLetterOrDigit(c) || c is '.' or '-' or '_' or '/', $"Unexpected character '{c}' in generated id '{id}'"); + } + } + + [Fact] + public void GenerateIdReturnsDifferentValuesOnSuccessiveCalls() + { + var id1 = AzureServiceBusClientWrapper.GenerateId(); + var id2 = AzureServiceBusClientWrapper.GenerateId(); + + Assert.NotEqual(id1, id2); + } +} diff --git a/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBusNamingTests.cs b/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBusNamingTests.cs new file mode 100644 index 00000000..ab2bcd80 --- /dev/null +++ b/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBusNamingTests.cs @@ -0,0 +1,90 @@ +using FusionCacheTests.Stuff; +using Xunit; +using ZiggyCreatures.Caching.Fusion.Backplane.AzureServiceBus.Helpers; + +namespace FusionCacheTests; + +public class AzureServiceBusNamingTests + : AbstractTests +{ + public AzureServiceBusNamingTests(ITestOutputHelper output) + : base(output, null) + { + } + + [Fact] + public void SanitizeEntityNameThrowsWhenNameIsNull() + { + Assert.Throws(() => AzureServiceBusNaming.SanitizeEntityName(null!, 50)); + } + + [Fact] + public void SanitizeEntityNameLeavesValidCharactersUntouched() + { + var result = AzureServiceBusNaming.SanitizeEntityName("My.Cache-Name_v1/sub", 260); + + Assert.Equal("My.Cache-Name_v1/sub", result); + } + + [Fact] + public void SanitizeEntityNameReplacesInvalidCharactersWithDashes() + { + // ':' AND ' ' ARE NOT VALID SERVICE BUS ENTITY NAME CHARACTERS + var result = AzureServiceBusNaming.SanitizeEntityName("MyCache.Backplane:v1", 260); + + Assert.Equal("MyCache.Backplane-v1", result); + Assert.DoesNotContain(':', result); + } + + [Fact] + public void SanitizeEntityNameTrimsLeadingAndTrailingSeparators() + { + var result = AzureServiceBusNaming.SanitizeEntityName("///cache-name---", 260); + + Assert.Equal("cache-name", result); + } + + [Fact] + public void SanitizeEntityNameTruncatesToMaxLength() + { + var longName = new string('a', 300); + + var result = AzureServiceBusNaming.SanitizeEntityName(longName, 50); + + Assert.Equal(50, result.Length); + } + + [Fact] + public void SanitizeEntityNameReturnsFallbackWhenResultWouldBeEmpty() + { + var result = AzureServiceBusNaming.SanitizeEntityName("::: ***", 50, fallback: "my-fallback"); + + Assert.Equal("my-fallback", result); + } + + [Fact] + public void ResolveTopicNameUsesExplicitTopicNameWhenProvided() + { + var result = AzureServiceBusNaming.ResolveTopicName("my-explicit-topic", "MyCache.Backplane:v1"); + + Assert.Equal("my-explicit-topic", result); + } + + [Fact] + public void ResolveTopicNameFallsBackToChannelNameWhenNotProvided() + { + // THIS IS FUSIONCACHE'S DEFAULT COMPUTED CHANNEL NAME SHAPE (SEE FusionCacheInternalUtils.GetBackplaneChannelName): + // THE ':' SEPARATOR IS NOT A VALID SERVICE BUS CHARACTER, SO IT MUST BE SANITIZED AWAY + var result = AzureServiceBusNaming.ResolveTopicName(null, "MyCache.Backplane:v1"); + + Assert.Equal("MyCache.Backplane-v1", result); + } + + [Fact] + public void ResolveTopicNameFallsBackToChannelNameWhenExplicitTopicNameIsWhitespace() + { + var result = AzureServiceBusNaming.ResolveTopicName(" ", "MyCache.Backplane:v1"); + + Assert.Equal("MyCache.Backplane-v1", result); + } +} diff --git a/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBusProvisionerTests.cs b/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBusProvisionerTests.cs new file mode 100644 index 00000000..fafd2d29 --- /dev/null +++ b/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBusProvisionerTests.cs @@ -0,0 +1,55 @@ +using Azure.Messaging.ServiceBus.Administration; +using FusionCacheTests.Stuff; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; +using ZiggyCreatures.Caching.Fusion.Backplane.AzureServiceBus.AzureServiceBusWrapper; + +namespace FusionCacheTests; + +public class AzureServiceBusProvisionerTests + : AbstractTests +{ + public AzureServiceBusProvisionerTests(ITestOutputHelper output) + : base(output, null) + { + } + + private const string FakeConnectionString = "Endpoint=sb://fake-namespace.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=ZmFrZS1rZXk="; + + [Fact] + public void AdminProvisionerConstructorThrowsWhenAdministrationClientIsNull() + { + Assert.Throws(() => new AzureServiceBusAdminProvisioner( + serviceBusAdministrationClient: null!, + topicName: "my-topic", + subscriptionName: "my-subscription", + logger: NullLogger.Instance + )); + } + + [Fact] + public void AdminProvisionerImplementsTheProvisionerInterface() + { + var adminClient = new ServiceBusAdministrationClient(FakeConnectionString); + + var provisioner = new AzureServiceBusAdminProvisioner(adminClient, "my-topic", "my-subscription", NullLogger.Instance); + + Assert.IsAssignableFrom(provisioner); + } + + [Fact] + public async Task NoOpProvisionerMethodsCompleteWithoutThrowingAsync() + { + var provisioner = NoOpAzureServiceBusAdminWrapper.Instance; + + await provisioner.EnsureTopicAsync(); + await provisioner.EnsureSubscriptionAsync(); + await provisioner.UnprovisionAsync(); + } + + [Fact] + public void NoOpProvisionerInstanceIsASingleton() + { + Assert.Same(NoOpAzureServiceBusAdminWrapper.Instance, NoOpAzureServiceBusAdminWrapper.Instance); + } +} diff --git a/tests/ZiggyCreatures.FusionCache.Tests/L1L2BackplaneTests.cs b/tests/ZiggyCreatures.FusionCache.Tests/L1L2BackplaneTests.cs index 265e6f40..9ac1bba1 100644 --- a/tests/ZiggyCreatures.FusionCache.Tests/L1L2BackplaneTests.cs +++ b/tests/ZiggyCreatures.FusionCache.Tests/L1L2BackplaneTests.cs @@ -1,4 +1,6 @@ -using FusionCacheTests.Stuff; +using Azure.Messaging.ServiceBus; +using Azure.Messaging.ServiceBus.Administration; +using FusionCacheTests.Stuff; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.StackExchangeRedis; @@ -6,6 +8,9 @@ using Xunit; using ZiggyCreatures.Caching.Fusion; using ZiggyCreatures.Caching.Fusion.Backplane; +using ZiggyCreatures.Caching.Fusion.Backplane.AzureServiceBus; +using ZiggyCreatures.Caching.Fusion.Backplane.AzureServiceBus.AzureServiceBusWrapper; +using ZiggyCreatures.Caching.Fusion.Backplane.AzureServiceBus.Helpers; using ZiggyCreatures.Caching.Fusion.Backplane.Memory; using ZiggyCreatures.Caching.Fusion.Backplane.StackExchangeRedis; using ZiggyCreatures.Caching.Fusion.DangerZone; @@ -35,8 +40,15 @@ private FusionCacheOptions CreateFusionCacheOptions() } private static readonly bool UseRedis = false; + private static readonly bool UseAzureServiceBus = true; private static readonly string RedisConnection = "127.0.0.1:6379,ssl=False,abortConnect=false,connectTimeout=1000,syncTimeout=1000"; + // DEFAULTS TO THE AZURE SERVICE BUS EMULATOR'S WELL-KNOWN LOCAL CONNECTION STRING (SEE MICROSOFT'S EMULATOR DOCS); + // SET THE FUSIONCACHE_TESTS_AZURESERVICEBUS_CONNECTIONSTRING ENV VAR TO POINT AT A REAL NAMESPACE INSTEAD + private static readonly string AzureServiceBusConnectionString = + Environment.GetEnvironmentVariable("FUSIONCACHE_TESTS_AZURESERVICEBUS_CONNECTIONSTRING") + ?? "Endpoint=sb://localhost;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true;"; + private readonly TimeSpan InitialBackplaneDelay = TimeSpan.FromMilliseconds(300); private readonly TimeSpan MultiNodeOperationsDelay = TimeSpan.FromMilliseconds(300); @@ -44,7 +56,21 @@ private IFusionCacheBackplane CreateBackplane(string connectionId) { if (UseRedis) return new RedisBackplane(new RedisBackplaneOptions { Configuration = RedisConnection }, logger: CreateXUnitLogger()); - + if (UseAzureServiceBus) + { + // USE THE SHARED connectionId AS THE TOPIC NAME, SO ALL THE BACKPLANE INSTANCES CREATED FOR THE SAME + // LOGICAL TEST "BUS" (E.G. cache1/cache2/cache3 IN A GIVEN TEST) END UP TALKING ON THE SAME SERVICE BUS + // TOPIC. EACH INSTANCE STILL NEEDS ITS OWN, UNIQUE SUBSCRIPTION (OTHERWISE THEY'D BE COMPETING CONSUMERS + // ON A SHARED SUBSCRIPTION, INSTEAD OF EACH RECEIVING EVERY MESSAGE AS A BACKPLANE REQUIRES). + var topicName = AzureServiceBusNaming.SanitizeEntityName($"fusioncache-tests-{connectionId}", AzureServiceBusNaming.MaxTopicNameLength); + var subscriptionName = AzureServiceBusClientWrapper.GenerateId(); + var adminClient = new ServiceBusAdministrationClient(AzureServiceBusConnectionString); + var client = new ServiceBusClient(AzureServiceBusConnectionString); + var communicator = new AzureServiceBusClientWrapper(client, topicName, subscriptionName, CreateXUnitLogger()); + var provisioner = new AzureServiceBusAdminProvisioner(adminClient, topicName, subscriptionName, CreateXUnitLogger()); + + return new AzureServiceBusBackplane(communicator, provisioner, CreateXUnitLogger()); + } return new MemoryBackplane(new MemoryBackplaneOptions() { ConnectionId = connectionId }, logger: CreateXUnitLogger()); } diff --git a/tests/ZiggyCreatures.FusionCache.Tests/Stuff/TestsUtils.cs b/tests/ZiggyCreatures.FusionCache.Tests/Stuff/TestsUtils.cs index a5962b45..e590d8cc 100644 --- a/tests/ZiggyCreatures.FusionCache.Tests/Stuff/TestsUtils.cs +++ b/tests/ZiggyCreatures.FusionCache.Tests/Stuff/TestsUtils.cs @@ -6,6 +6,8 @@ using Microsoft.Extensions.Logging; using ZiggyCreatures.Caching.Fusion; using ZiggyCreatures.Caching.Fusion.Backplane; +using ZiggyCreatures.Caching.Fusion.Backplane.AzureServiceBus; +using ZiggyCreatures.Caching.Fusion.Backplane.AzureServiceBus.AzureServiceBusWrapper; using ZiggyCreatures.Caching.Fusion.Backplane.StackExchangeRedis; using ZiggyCreatures.Caching.Fusion.Internals.Backplane; using ZiggyCreatures.Caching.Fusion.Internals.Distributed; @@ -152,6 +154,19 @@ public static FusionCacheOptions GetOptions(this IFusionCache cache) return typeof(RedisBackplane).GetField("_options", BindingFlags.NonPublic | BindingFlags.Instance)!.GetValue(backplane) as RedisBackplaneOptions; ; } + public static string? GetAzureServiceBusCommunicatorTopicName(IFusionCache cache) + { + var backplane = GetBackplane(cache); + if (backplane is null) + return null; + + var communicator = typeof(AzureServiceBusBackplane).GetField("_serviceBusCommunicator", BindingFlags.NonPublic | BindingFlags.Instance)!.GetValue(backplane) as AzureServiceBusClientWrapper; + if (communicator is null) + return null; + + return typeof(AzureServiceBusClientWrapper).GetField("_topicName", BindingFlags.NonPublic | BindingFlags.Instance)!.GetValue(communicator) as string; + } + public static IFusionCachePlugin[]? GetPlugins(IFusionCache cache) { return (typeof(FusionCache).GetField("_plugins", BindingFlags.NonPublic | BindingFlags.Instance)!.GetValue(cache) as List)?.ToArray(); diff --git a/tests/ZiggyCreatures.FusionCache.Tests/ZiggyCreatures.FusionCache.Tests.csproj b/tests/ZiggyCreatures.FusionCache.Tests/ZiggyCreatures.FusionCache.Tests.csproj index 93eb9bd2..394f7be5 100644 --- a/tests/ZiggyCreatures.FusionCache.Tests/ZiggyCreatures.FusionCache.Tests.csproj +++ b/tests/ZiggyCreatures.FusionCache.Tests/ZiggyCreatures.FusionCache.Tests.csproj @@ -31,6 +31,7 @@ + From 0c4935798854fba548a46ba30300a78f0a1ac13a Mon Sep 17 00:00:00 2001 From: AboubakrNasef Date: Fri, 24 Jul 2026 13:08:50 +0300 Subject: [PATCH 02/13] refactor implementations of service bus --- .../AzureServiceBusBackplaneExtensions.cs | 59 +++++++++++--- .../AzureServiceBusBackplaneOptions.cs | 13 +-- .../Admin/AzureServiceBusAdminWrapper.cs | 41 +++++++--- .../Client/AzureServiceBusClientWrapper.cs | 79 ++++++++++++++----- .../Backplane/AzureServiceBusBackplane.cs | 7 +- .../AzureServiceBusBackplane_Async.cs | 18 ++++- 6 files changed, 164 insertions(+), 53 deletions(-) diff --git a/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/AzureServiceBusBackplaneExtensions.cs b/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/AzureServiceBusBackplaneExtensions.cs index 044a3c94..1b37e238 100644 --- a/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/AzureServiceBusBackplaneExtensions.cs +++ b/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/AzureServiceBusBackplaneExtensions.cs @@ -1,4 +1,5 @@ -using Azure.Messaging.ServiceBus; +using Azure.Core; +using Azure.Messaging.ServiceBus; using Azure.Messaging.ServiceBus.Administration; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; @@ -21,8 +22,8 @@ private static AzureServiceBusBackplane BuildBackplane(IServiceProvider sp, Azur { var backplaneLogger = sp.GetService>(); - var client = new ServiceBusClient(options.ConnectionString); - var adminClient = new ServiceBusAdministrationClient(options.ConnectionString); + ValidateOptions(options); + var (client, adminClient) = CreateClients(options); var topicName = AzureServiceBusHelpers.ResolveTopicName(options.TopicName, topicNameFallback); string subscriptionName; @@ -33,21 +34,61 @@ private static AzureServiceBusBackplane BuildBackplane(IServiceProvider sp, Azur subscriptionName = options.SubscriptionName ?? AzureServiceBusHelpers.GenerateId(); var provisionerLogger = sp.GetService>() ?? NullLogger.Instance; - provisioner = new AzureServiceBusAdminWrapper(adminClient, topicName, subscriptionName, provisionerLogger); + provisioner = new AzureServiceBusAdminWrapper(adminClient, topicName, subscriptionName, options.SubscriptionAutoDeleteOnIdle, provisionerLogger); } else { - if (string.IsNullOrWhiteSpace(options.SubscriptionName)) - throw new InvalidOperationException($"{nameof(AzureServiceBusBackplaneOptions)}.{nameof(AzureServiceBusBackplaneOptions.IsAdmin)} is false, but no {nameof(AzureServiceBusBackplaneOptions.SubscriptionName)} was provided: without administrative rights, an existing subscription name must be specified upfront."); - subscriptionName = options.SubscriptionName!; provisioner = NoOpAzureServiceBusAdminWrapper.Instance; } var communicatorLogger = sp.GetService>() ?? NullLogger.Instance; - var communicator = new AzureServiceBusClientWrapper(client, topicName, subscriptionName, communicatorLogger,options); + var communicator = new AzureServiceBusClientWrapper(client, topicName, subscriptionName, communicatorLogger, options); + + return new AzureServiceBusBackplane(communicator, provisioner, backplaneLogger, options.LockTimeout); + } + + private static void ValidateOptions(AzureServiceBusBackplaneOptions options) + { + if (options.LockTimeout <= TimeSpan.Zero) + throw new InvalidOperationException($"{nameof(options.LockTimeout)} must be greater than zero."); + + if (options.SubscriptionAutoDeleteOnIdle <= TimeSpan.Zero) + throw new InvalidOperationException($"{nameof(options.SubscriptionAutoDeleteOnIdle)} must be greater than zero."); + + if (!options.IsAdmin && string.IsNullOrWhiteSpace(options.SubscriptionName)) + throw new InvalidOperationException($"{nameof(options.SubscriptionName)} is required when {nameof(options.IsAdmin)} is false. It must identify a unique, externally provisioned subscription for this cache-process instance."); + + ValidateAuthentication(options); + } + + private static void ValidateAuthentication(AzureServiceBusBackplaneOptions options) + { + var hasConnectionString = !string.IsNullOrWhiteSpace(options.ConnectionString); + var hasNamespace = !string.IsNullOrWhiteSpace(options.FullyQualifiedNamespace); + var hasCredential = options.Credential is not null; + + if (hasConnectionString && (hasNamespace || hasCredential)) + throw new InvalidOperationException("Configure either ConnectionString or FullyQualifiedNamespace with Credential, not both."); + + if (hasConnectionString) + return; + + if (!hasNamespace || !hasCredential) + throw new InvalidOperationException("Configure either ConnectionString or both FullyQualifiedNamespace and Credential."); + } + + private static (ServiceBusClient Client, ServiceBusAdministrationClient AdminClient) CreateClients(AzureServiceBusBackplaneOptions options) + { + ValidateAuthentication(options); + + if (!string.IsNullOrWhiteSpace(options.ConnectionString)) + return (new ServiceBusClient(options.ConnectionString), new ServiceBusAdministrationClient(options.ConnectionString)); - return new AzureServiceBusBackplane(communicator, provisioner, backplaneLogger); + return ( + new ServiceBusClient(options.FullyQualifiedNamespace!, options.Credential!), + new ServiceBusAdministrationClient(options.FullyQualifiedNamespace!, options.Credential!) + ); } /// diff --git a/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/AzureServiceBusBackplaneOptions.cs b/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/AzureServiceBusBackplaneOptions.cs index 55e28513..2c9b3d0c 100644 --- a/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/AzureServiceBusBackplaneOptions.cs +++ b/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/AzureServiceBusBackplaneOptions.cs @@ -1,14 +1,11 @@ -using Azure.Core; -using Azure.Messaging.ServiceBus; -using Azure.Messaging.ServiceBus.Administration; -using Microsoft.Extensions.Options; +using Azure.Core; namespace ZiggyCreatures.Caching.Fusion.Backplane.AzureServiceBus; /// /// Represents the options available for the Azure Service Bus backplane. /// -public class AzureServiceBusBackplaneOptions : IOptions +public class AzureServiceBusBackplaneOptions { /// /// The connection string used to connect to Azure Service Bus. @@ -61,10 +58,4 @@ public class AzureServiceBusBackplaneOptions : IOptions public TimeSpan LockTimeout { get; set; } = TimeSpan.FromSeconds(5); - - AzureServiceBusBackplaneOptions IOptions.Value - { - get { return this; } - } - } diff --git a/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/AzureServiceBusWrapper/Admin/AzureServiceBusAdminWrapper.cs b/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/AzureServiceBusWrapper/Admin/AzureServiceBusAdminWrapper.cs index a5c7dc24..1670d326 100644 --- a/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/AzureServiceBusWrapper/Admin/AzureServiceBusAdminWrapper.cs +++ b/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/AzureServiceBusWrapper/Admin/AzureServiceBusAdminWrapper.cs @@ -1,40 +1,59 @@ -using Azure.Messaging.ServiceBus.Administration; +using Azure.Messaging.ServiceBus; +using Azure.Messaging.ServiceBus.Administration; using Microsoft.Extensions.Logging; using ZiggyCreatures.Caching.Fusion.Backplane.AzureServiceBus.AzureServiceBusWrapper; namespace ZiggyCreatures.Caching.Fusion.Backplane.AzureServiceBus; /// -/// An implementation that creates/deletes the topic, subscription, and -/// self-message-filter rule via a . Requires Manage permissions. +/// Creates and owns an instance subscription and its server-side self-message filter. Requires Manage permissions. /// /// The administrative client used to create/delete the topic, subscription, and self-filter rule. /// The name of the topic to create if missing. /// The name of the subscription to create if missing. +/// The auto-delete timeout to apply to the created subscription. /// The logger to use. public class AzureServiceBusAdminWrapper( ServiceBusAdministrationClient serviceBusAdministrationClient, string topicName, string subscriptionName, + TimeSpan subscriptionAutoDeleteOnIdle, ILogger logger) : IAzureServiceBusAdminWrapper { + internal const string SelfMessageFilterRuleName = "FilterOutOwnMessages"; + /// public async ValueTask EnsureTopicAsync() { - if (!await serviceBusAdministrationClient.TopicExistsAsync(topicName)) - await serviceBusAdministrationClient.CreateTopicAsync(topicName); + if (await serviceBusAdministrationClient.TopicExistsAsync(topicName)) + return; + + await serviceBusAdministrationClient.CreateTopicAsync(topicName); } /// public async ValueTask EnsureSubscriptionAsync() { - if (await serviceBusAdministrationClient.SubscriptionExistsAsync(topicName, subscriptionName)) - return; - logger.LogInformation("Creating a new topic subscription: {subscriptionName}", subscriptionName); - await EnsureTopicAsync(); - await serviceBusAdministrationClient.CreateSubscriptionAsync(new CreateSubscriptionOptions(topicName, subscriptionName)); + if (!await serviceBusAdministrationClient.SubscriptionExistsAsync(topicName, subscriptionName)) + { + logger.LogInformation("Creating a new topic subscription: {SubscriptionName}", subscriptionName); + await serviceBusAdministrationClient.CreateSubscriptionAsync(new CreateSubscriptionOptions(topicName, subscriptionName) + { + AutoDeleteOnIdle = subscriptionAutoDeleteOnIdle + }); + + } + + if (await serviceBusAdministrationClient.RuleExistsAsync(topicName, subscriptionName, SelfMessageFilterRuleName)) + return; + + var escapedSubscriptionName = subscriptionName.Replace("'", "''"); + await serviceBusAdministrationClient.CreateRuleAsync(topicName, subscriptionName, new CreateRuleOptions( + SelfMessageFilterRuleName, + new SqlRuleFilter($"{AzureServiceBusClientWrapper.ConnectionIdApplicationPropertyName} <> '{escapedSubscriptionName}'") + )); } /// @@ -46,7 +65,7 @@ public async ValueTask DisposeAsync() } catch (Exception exc) { - logger.LogError(exc, "An error occurred while deleting subscription {subscriptionName}", subscriptionName); + logger.LogError(exc, "An error occurred while deleting subscription {SubscriptionName}", subscriptionName); } } } diff --git a/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/AzureServiceBusWrapper/Client/AzureServiceBusClientWrapper.cs b/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/AzureServiceBusWrapper/Client/AzureServiceBusClientWrapper.cs index 814255d7..65097adf 100644 --- a/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/AzureServiceBusWrapper/Client/AzureServiceBusClientWrapper.cs +++ b/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/AzureServiceBusWrapper/Client/AzureServiceBusClientWrapper.cs @@ -1,6 +1,6 @@ -using Azure.Messaging.ServiceBus; +using Azure.Messaging.ServiceBus; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; +using System.Collections.Immutable; using ZiggyCreatures.Caching.Fusion.Backplane.AzureServiceBus.AzureServiceBusWrapper; using ZiggyCreatures.Caching.Fusion.Backplane.AzureServiceBus.Helpers; @@ -19,11 +19,13 @@ namespace ZiggyCreatures.Caching.Fusion.Backplane.AzureServiceBus; /// The name of the topic to use. Must already exist by the time is called. /// The name of the subscription to use. Must already exist by the time is called. /// The logger to use. +/// The backplane options used for synchronization timeouts. public class AzureServiceBusClientWrapper( ServiceBusClient serviceBusClient, string topicName, string subscriptionName, - ILogger logger, IOptions asbOptions) : IAzureServiceBusClientWrapper + ILogger logger, + AzureServiceBusBackplaneOptions asbOptions) : IAzureServiceBusClientWrapper { /// /// The application property used to carry the publishing instance's subscription name, for self-message filtering. @@ -43,9 +45,9 @@ public class AzureServiceBusClientWrapper( /// The name of the Service Bus topic this instance talks on. /// internal string TopicName => topicName; - private readonly SemaphoreSlim _lock = new SemaphoreSlim(1, 1); - private readonly List> _handlers = new(); + private readonly SemaphoreSlim _lock = new SemaphoreSlim(1, 1); + private ImmutableArray> _handlers = ImmutableArray>.Empty; private ServiceBusProcessor? _serviceBusProcessor; private ServiceBusSender? _serviceBusSender; @@ -53,12 +55,16 @@ public class AzureServiceBusClientWrapper( /// public async Task Subscribe(Func handler) { - if (!await _lock.WaitAsync(asbOptions.Value.LockTimeout)) + if (handler is null) + throw new ArgumentNullException(nameof(handler)); + + if (!await _lock.WaitAsync(asbOptions.LockTimeout)) throw new TimeoutException("Can't acquire lock"); try { - _handlers.Add(handler); + if (!_handlers.Contains(handler)) + _handlers = _handlers.Add(handler); } finally { @@ -71,17 +77,23 @@ public async Task Subscribe(Func handler) /// public async Task Unsubscribe(Func handler) { - if (!await _lock.WaitAsync(asbOptions.Value.LockTimeout)) + if (!await _lock.WaitAsync(asbOptions.LockTimeout)) throw new TimeoutException("Can't acquire lock"); + var shouldStopProcessor = false; + try { - _handlers.Remove(handler); + _handlers = _handlers.Remove(handler); + shouldStopProcessor = _handlers.IsEmpty; } finally { _lock.Release(); } + + if (shouldStopProcessor) + await StopProcessorAsync(); } /// @@ -94,25 +106,38 @@ public async Task SendMessage(ServiceBusMessage message, CancellationToken cance /// public async ValueTask DisposeAsync() + { + await StopProcessorAsync(); + + if (_serviceBusSender is not null) + { + await _serviceBusSender.DisposeAsync(); + _serviceBusSender = null; + } + + await serviceBusClient.DisposeAsync(); + } + + private async Task StopProcessorAsync() { if (_serviceBusProcessor is null) return; - if (!await _lock.WaitAsync(asbOptions.Value.LockTimeout)) + if (!await _lock.WaitAsync(asbOptions.LockTimeout)) throw new TimeoutException("Can't acquire lock"); try { - if (_serviceBusProcessor is not null) - { - await _serviceBusProcessor.StopProcessingAsync(); - await _serviceBusProcessor.DisposeAsync(); - _serviceBusProcessor = null; - } + if (_serviceBusProcessor is null) + return; + + await _serviceBusProcessor.StopProcessingAsync(); + await _serviceBusProcessor.DisposeAsync(); + _serviceBusProcessor = null; } catch (Exception exc) { - logger.LogError(exc, "An error occurred while stopping the processor for {subscriptionName}", subscriptionName); + logger.LogError(exc, "An error occurred while stopping the processor for {SubscriptionName}", subscriptionName); } finally { @@ -125,8 +150,9 @@ private async Task EnsureProcessor() if (_serviceBusProcessor is not null) return _serviceBusProcessor; - if (!await _lock.WaitAsync(asbOptions.Value.LockTimeout)) + if (!await _lock.WaitAsync(asbOptions.LockTimeout)) throw new TimeoutException("Can't acquire lock"); + try { if (_serviceBusProcessor is null) @@ -158,7 +184,7 @@ private async Task EnsureSender() await EnsureProcessor(); - if (!await _lock.WaitAsync(asbOptions.Value.LockTimeout)) + if (!await _lock.WaitAsync(asbOptions.LockTimeout)) throw new TimeoutException("Can't acquire lock"); try @@ -188,7 +214,20 @@ private async Task ProcessMessageAsync(ProcessMessageEventArgs args) return; } - foreach (var handler in _handlers) + ImmutableArray> handlers; + if (!await _lock.WaitAsync(asbOptions.LockTimeout)) + throw new TimeoutException("Can't acquire lock"); + + try + { + handlers = _handlers; + } + finally + { + _lock.Release(); + } + + foreach (var handler in handlers) { await handler(args.Message); } diff --git a/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/Backplane/AzureServiceBusBackplane.cs b/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/Backplane/AzureServiceBusBackplane.cs index 2b35702b..2b3c70e6 100644 --- a/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/Backplane/AzureServiceBusBackplane.cs +++ b/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/Backplane/AzureServiceBusBackplane.cs @@ -29,17 +29,22 @@ public partial class AzureServiceBusBackplane public AzureServiceBusBackplane( IAzureServiceBusClientWrapper serviceBusCommunicator, IAzureServiceBusAdminWrapper serviceBusProvisioner, - ILogger? logger = null) + ILogger? logger = null, + TimeSpan? lockTimeout = null) { _serviceBusCommunicator = serviceBusCommunicator ?? throw new ArgumentNullException(nameof(serviceBusCommunicator)); _serviceBusProvisioner = serviceBusProvisioner ?? throw new ArgumentNullException(nameof(serviceBusProvisioner)); _logger = logger; + _lockTimeout = lockTimeout ?? TimeSpan.FromSeconds(5); + if (_lockTimeout <= TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(lockTimeout)); } private readonly SemaphoreSlim _lock = new SemaphoreSlim(1, 1); private readonly IAzureServiceBusClientWrapper _serviceBusCommunicator; private readonly IAzureServiceBusAdminWrapper _serviceBusProvisioner; private readonly ILogger? _logger; + private readonly TimeSpan _lockTimeout; private string? _cacheName; private string? _cacheInstanceId; diff --git a/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/Backplane/AzureServiceBusBackplane_Async.cs b/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/Backplane/AzureServiceBusBackplane_Async.cs index 432c3d47..5d9be9bd 100644 --- a/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/Backplane/AzureServiceBusBackplane_Async.cs +++ b/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/Backplane/AzureServiceBusBackplane_Async.cs @@ -22,7 +22,7 @@ public async ValueTask SubscribeAsync(BackplaneSubscriptionOptions options) if (options.IncomingMessageHandler is null && options.IncomingMessageHandlerAsync is null) throw new ArgumentException("At least one of the incoming message handlers must be provided."); - if (!await _lock.WaitAsync(TimeSpan.FromSeconds(5))) + if (!await _lock.WaitAsync(_lockTimeout)) throw new TimeoutException("Can't acquire lock"); try @@ -79,6 +79,11 @@ await _serviceBusCommunicator.SendMessage(new ServiceBusMessage /// public async ValueTask UnsubscribeAsync() { + if (!await _lock.WaitAsync(_lockTimeout)) + throw new TimeoutException("Can't acquire lock"); + + try + { if (_incomingMessageHandler is null) return; @@ -89,5 +94,16 @@ public async ValueTask UnsubscribeAsync() } await _serviceBusCommunicator.Unsubscribe(_incomingMessageHandler); + _incomingMessageHandler = null; + _cacheName = null; + _cacheInstanceId = null; + + await _serviceBusCommunicator.DisposeAsync(); + await _serviceBusProvisioner.DisposeAsync(); + } + finally + { + _lock.Release(); + } } } From 002159279e3fa8dac3d2793f253a8ba4a3908965 Mon Sep 17 00:00:00 2001 From: AboubakrNasef Date: Sun, 26 Jul 2026 15:05:50 +0300 Subject: [PATCH 03/13] wip --- .../AzureServiceBusBackplane_Async.cs | 3 +- .../AzureServiceBusAdminWrapperTests.cs} | 28 ++---- ...AzureServiceBusBackplaneExtensionsTests.cs | 4 +- .../AzureServiceBusBackplaneOptionsTests.cs | 59 ++++++++++++ .../AzureServiceBusBackplaneTests.cs | 62 +++++++------ ...erviceBusClientWrapperIntegrationTests.cs} | 74 +++++++-------- .../AzureServiceBusClientWrapperTests.cs} | 49 +++------- .../AzureServiceBusHelpersTests.cs} | 24 ++--- .../AzureServiceBusBackplaneOptionsTests.cs | 90 ------------------- .../L1L2BackplaneTests.cs | 20 ++--- .../Stuff/TestsUtils.cs | 2 +- 11 files changed, 174 insertions(+), 241 deletions(-) rename tests/ZiggyCreatures.FusionCache.Tests/{AzureServiceBusProvisionerTests.cs => AzureServiceBus/AzureServiceBusAdminWrapperTests.cs} (50%) rename tests/ZiggyCreatures.FusionCache.Tests/{ => AzureServiceBus}/AzureServiceBusBackplaneExtensionsTests.cs (98%) create mode 100644 tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBus/AzureServiceBusBackplaneOptionsTests.cs rename tests/ZiggyCreatures.FusionCache.Tests/{ => AzureServiceBus}/AzureServiceBusBackplaneTests.cs (86%) rename tests/ZiggyCreatures.FusionCache.Tests/{AzureServiceBusCommunicatorIntegrationTests.cs => AzureServiceBus/AzureServiceBusClientWrapperIntegrationTests.cs} (76%) rename tests/ZiggyCreatures.FusionCache.Tests/{AzureServiceBusCommunicatorTests.cs => AzureServiceBus/AzureServiceBusClientWrapperTests.cs} (58%) rename tests/ZiggyCreatures.FusionCache.Tests/{AzureServiceBusNamingTests.cs => AzureServiceBus/AzureServiceBusHelpersTests.cs} (63%) delete mode 100644 tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBusBackplaneOptionsTests.cs diff --git a/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/Backplane/AzureServiceBusBackplane_Async.cs b/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/Backplane/AzureServiceBusBackplane_Async.cs index 5d9be9bd..5847fe18 100644 --- a/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/Backplane/AzureServiceBusBackplane_Async.cs +++ b/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/Backplane/AzureServiceBusBackplane_Async.cs @@ -71,8 +71,7 @@ public async ValueTask PublishAsync(BackplaneMessage message, FusionCacheEntryOp await _serviceBusCommunicator.SendMessage(new ServiceBusMessage { Body = new BinaryData(BackplaneMessage.ToByteArray(message)), - Subject = _cacheName, - TimeToLive = TimeSpan.FromSeconds(5) + options.Duration // ADD A BUFFER TO BE SURE THE MESSAGE IS PROPAGATED, EVEN WITH A ZERO DURATION + Subject = _cacheName }, token); } diff --git a/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBusProvisionerTests.cs b/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBus/AzureServiceBusAdminWrapperTests.cs similarity index 50% rename from tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBusProvisionerTests.cs rename to tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBus/AzureServiceBusAdminWrapperTests.cs index fafd2d29..c45abcfc 100644 --- a/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBusProvisionerTests.cs +++ b/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBus/AzureServiceBusAdminWrapperTests.cs @@ -3,13 +3,14 @@ using Microsoft.Extensions.Logging.Abstractions; using Xunit; using ZiggyCreatures.Caching.Fusion.Backplane.AzureServiceBus.AzureServiceBusWrapper; +using ZiggyCreatures.Caching.Fusion.Backplane.AzureServiceBus; -namespace FusionCacheTests; +namespace FusionCacheTests.AzureServiceBus; -public class AzureServiceBusProvisionerTests +public class AzureServiceBusAdminWrapperTests : AbstractTests { - public AzureServiceBusProvisionerTests(ITestOutputHelper output) + public AzureServiceBusAdminWrapperTests(ITestOutputHelper output) : base(output, null) { } @@ -17,38 +18,27 @@ public AzureServiceBusProvisionerTests(ITestOutputHelper output) private const string FakeConnectionString = "Endpoint=sb://fake-namespace.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=ZmFrZS1rZXk="; [Fact] - public void AdminProvisionerConstructorThrowsWhenAdministrationClientIsNull() - { - Assert.Throws(() => new AzureServiceBusAdminProvisioner( - serviceBusAdministrationClient: null!, - topicName: "my-topic", - subscriptionName: "my-subscription", - logger: NullLogger.Instance - )); - } - - [Fact] - public void AdminProvisionerImplementsTheProvisionerInterface() + public void AdminWrapperImplementsTheAdminInterface() { var adminClient = new ServiceBusAdministrationClient(FakeConnectionString); - var provisioner = new AzureServiceBusAdminProvisioner(adminClient, "my-topic", "my-subscription", NullLogger.Instance); + var provisioner = new AzureServiceBusAdminWrapper(adminClient, "my-topic", "my-subscription", TimeSpan.FromMinutes(10), NullLogger.Instance); Assert.IsAssignableFrom(provisioner); } [Fact] - public async Task NoOpProvisionerMethodsCompleteWithoutThrowingAsync() + public async Task NoOpAdminWrapperMethodsCompleteWithoutThrowingAsync() { var provisioner = NoOpAzureServiceBusAdminWrapper.Instance; await provisioner.EnsureTopicAsync(); await provisioner.EnsureSubscriptionAsync(); - await provisioner.UnprovisionAsync(); + await provisioner.DisposeAsync(); } [Fact] - public void NoOpProvisionerInstanceIsASingleton() + public void NoOpAdminWrapperInstanceIsASingleton() { Assert.Same(NoOpAzureServiceBusAdminWrapper.Instance, NoOpAzureServiceBusAdminWrapper.Instance); } diff --git a/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBusBackplaneExtensionsTests.cs b/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBus/AzureServiceBusBackplaneExtensionsTests.cs similarity index 98% rename from tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBusBackplaneExtensionsTests.cs rename to tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBus/AzureServiceBusBackplaneExtensionsTests.cs index a7221f33..91518925 100644 --- a/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBusBackplaneExtensionsTests.cs +++ b/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBus/AzureServiceBusBackplaneExtensionsTests.cs @@ -1,11 +1,11 @@ -using FusionCacheTests.Stuff; +using FusionCacheTests.Stuff; using Microsoft.Extensions.DependencyInjection; using Xunit; using ZiggyCreatures.Caching.Fusion; using ZiggyCreatures.Caching.Fusion.Backplane; using ZiggyCreatures.Caching.Fusion.Backplane.AzureServiceBus; -namespace FusionCacheTests; +namespace FusionCacheTests.AzureServiceBus; public class AzureServiceBusBackplaneExtensionsTests : AbstractTests diff --git a/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBus/AzureServiceBusBackplaneOptionsTests.cs b/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBus/AzureServiceBusBackplaneOptionsTests.cs new file mode 100644 index 00000000..f4a59368 --- /dev/null +++ b/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBus/AzureServiceBusBackplaneOptionsTests.cs @@ -0,0 +1,59 @@ +using Azure.Core; +using FusionCacheTests.Stuff; +using Xunit; +using ZiggyCreatures.Caching.Fusion.Backplane.AzureServiceBus; + +namespace FusionCacheTests.AzureServiceBus; + +public class AzureServiceBusBackplaneOptionsTests + : AbstractTests +{ + public AzureServiceBusBackplaneOptionsTests(ITestOutputHelper output) + : base(output, null) + { + } + + private sealed class FakeTokenCredential : TokenCredential + { + public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken) + => new("fake-token", DateTimeOffset.UtcNow.AddHours(1)); + + public override ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken) + => new(GetToken(requestContext, cancellationToken)); + } + + [Fact] + public void DefaultsAreSuitableForAdminBackplane() + { + var options = new AzureServiceBusBackplaneOptions(); + + Assert.True(options.IsAdmin); + Assert.Equal(TimeSpan.FromMinutes(10), options.SubscriptionAutoDeleteOnIdle); + Assert.Equal(TimeSpan.FromSeconds(5), options.LockTimeout); + } + + [Fact] + public void ConnectionStringCanBeConfigured() + { + var options = new AzureServiceBusBackplaneOptions + { + ConnectionString = "Endpoint=sb://fake-namespace.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=ZmFrZS1rZXk=" + }; + + Assert.Equal("Endpoint=sb://fake-namespace.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=ZmFrZS1rZXk=", options.ConnectionString); + } + + [Fact] + public void ManagedIdentityCanBeConfigured() + { + var options = new AzureServiceBusBackplaneOptions + { + FullyQualifiedNamespace = "fake-namespace.servicebus.windows.net", + Credential = new FakeTokenCredential() + }; + + Assert.Equal("fake-namespace.servicebus.windows.net", options.FullyQualifiedNamespace); + Assert.NotNull(options.Credential); + } + +} diff --git a/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBusBackplaneTests.cs b/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBus/AzureServiceBusBackplaneTests.cs similarity index 86% rename from tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBusBackplaneTests.cs rename to tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBus/AzureServiceBusBackplaneTests.cs index 898d7ca7..b8076555 100644 --- a/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBusBackplaneTests.cs +++ b/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBus/AzureServiceBusBackplaneTests.cs @@ -1,11 +1,12 @@ using Azure.Messaging.ServiceBus; using FusionCacheTests.Stuff; using Xunit; +using ZiggyCreatures.Caching.Fusion; using ZiggyCreatures.Caching.Fusion.Backplane; using ZiggyCreatures.Caching.Fusion.Backplane.AzureServiceBus; using ZiggyCreatures.Caching.Fusion.Backplane.AzureServiceBus.AzureServiceBusWrapper; -namespace FusionCacheTests; +namespace FusionCacheTests.AzureServiceBus; public class AzureServiceBusBackplaneTests : AbstractTests @@ -66,12 +67,14 @@ public async Task SendMessage(ServiceBusMessage message, CancellationToken cance SentMessages.Add(message); } + + public ValueTask DisposeAsync() => default; } - private sealed class FakeAzureServiceBusProvisioner + private sealed class FakeAzureServiceBusAdminWrapper : IAzureServiceBusAdminWrapper { - public FakeAzureServiceBusProvisioner(List? callLog = null) + public FakeAzureServiceBusAdminWrapper(List? callLog = null) { _callLog = callLog; } @@ -80,7 +83,7 @@ public FakeAzureServiceBusProvisioner(List? callLog = null) public int EnsureTopicCallCount { get; private set; } public int EnsureSubscriptionCallCount { get; private set; } - public int UnprovisionCallCount { get; private set; } + public int DisposeCallCount { get; private set; } public ValueTask EnsureTopicAsync() { @@ -96,10 +99,10 @@ public ValueTask EnsureSubscriptionAsync() return default; } - public ValueTask UnprovisionAsync() + public ValueTask DisposeAsync() { - _callLog?.Add(nameof(UnprovisionAsync)); - UnprovisionCallCount++; + _callLog?.Add(nameof(DisposeAsync)); + DisposeCallCount++; return default; } } @@ -138,9 +141,9 @@ private static ServiceBusReceivedMessage CreateReceivedMessage(BackplaneMessage ); } - private AzureServiceBusBackplane CreateBackplane(FakeAzureServiceBusCommunicator communicator, IAzureServiceBusAdminWrapper? provisioner = null) + private AzureServiceBusBackplane CreateBackplane(FakeAzureServiceBusCommunicator communicator, IAzureServiceBusAdminWrapper? adminWrapper = null) { - return new AzureServiceBusBackplane(communicator, provisioner ?? NoOpAzureServiceBusAdminWrapper.Instance, CreateXUnitLogger()); + return new AzureServiceBusBackplane(communicator, adminWrapper ?? NoOpAzureServiceBusAdminWrapper.Instance, CreateXUnitLogger()); } [Fact] @@ -196,15 +199,18 @@ public async Task SubscribeAsyncThrowsWhenBothIncomingMessageHandlersAreNullAsyn } [Fact] - public async Task SubscribeAsyncThrowsWhenAlreadyInitializedAsync() + public async Task SubscribeAsyncCanBeCalledAgainWithCurrentImplementationAsync() { - var backplane = CreateBackplane(new FakeAzureServiceBusCommunicator()); + var fake = new FakeAzureServiceBusCommunicator(); + var backplane = CreateBackplane(fake); var (options1, _, _) = CreateSubscriptionOptions(); var (options2, _, _) = CreateSubscriptionOptions(cacheName: "OtherCache", cacheInstanceId: "OtherInstance"); await backplane.SubscribeAsync(options1); - await Assert.ThrowsAsync(() => backplane.SubscribeAsync(options2).AsTask()); + await backplane.SubscribeAsync(options2); + + Assert.Equal(2, fake.SubscribeCallCount); } [Fact] @@ -221,12 +227,12 @@ public async Task SubscribeAsyncCallsCommunicatorSubscribeExactlyOnceAsync() } [Fact] - public async Task SubscribeAsyncRunsProvisionerBeforeCommunicatorSubscribeAsync() + public async Task SubscribeAsyncRunsAdminWrapperBeforeClientWrapperSubscribeAsync() { var callLog = new List(); - var provisioner = new FakeAzureServiceBusProvisioner(callLog); + var adminWrapper = new FakeAzureServiceBusAdminWrapper(callLog); var communicator = new FakeAzureServiceBusCommunicator(callLog); - var backplane = CreateBackplane(communicator, provisioner); + var backplane = CreateBackplane(communicator, adminWrapper); var (options, _, _) = CreateSubscriptionOptions(); await backplane.SubscribeAsync(options); @@ -235,12 +241,12 @@ public async Task SubscribeAsyncRunsProvisionerBeforeCommunicatorSubscribeAsync( } [Fact] - public async Task UnsubscribeAsyncRunsCommunicatorUnsubscribeBeforeProvisionerUnprovisionAsync() + public async Task UnsubscribeAsyncRunsClientWrapperUnsubscribeBeforeAdminWrapperDisposeAsync() { var callLog = new List(); - var provisioner = new FakeAzureServiceBusProvisioner(callLog); + var adminWrapper = new FakeAzureServiceBusAdminWrapper(callLog); var communicator = new FakeAzureServiceBusCommunicator(callLog); - var backplane = CreateBackplane(communicator, provisioner); + var backplane = CreateBackplane(communicator, adminWrapper); var (options, _, _) = CreateSubscriptionOptions(); await backplane.SubscribeAsync(options); @@ -248,42 +254,42 @@ public async Task UnsubscribeAsyncRunsCommunicatorUnsubscribeBeforeProvisionerUn await backplane.UnsubscribeAsync(); - Assert.Equal(new[] { nameof(IAzureServiceBusClientWrapper.Unsubscribe), nameof(IAzureServiceBusAdminWrapper.UnprovisionAsync) }, callLog); + Assert.Equal(new[] { nameof(IAzureServiceBusClientWrapper.Unsubscribe), nameof(IAsyncDisposable.DisposeAsync) }, callLog); } [Fact] - public async Task SubscriptionMissingTriggersProvisionerEnsureSubscriptionAsync() + public async Task SubscriptionMissingTriggersAdminWrapperEnsureSubscriptionAsync() { - var provisioner = new FakeAzureServiceBusProvisioner(); + var adminWrapper = new FakeAzureServiceBusAdminWrapper(); var communicator = new FakeAzureServiceBusCommunicator(); - var backplane = CreateBackplane(communicator, provisioner); + var backplane = CreateBackplane(communicator, adminWrapper); var (options, _, _) = CreateSubscriptionOptions(); await backplane.SubscribeAsync(options); - Assert.Equal(1, provisioner.EnsureSubscriptionCallCount); + Assert.Equal(1, adminWrapper.EnsureSubscriptionCallCount); await communicator.RaiseSubscriptionMissingAsync(); - Assert.Equal(2, provisioner.EnsureSubscriptionCallCount); + Assert.Equal(2, adminWrapper.EnsureSubscriptionCallCount); } [Fact] public async Task UnsubscribeAsyncStopsReactingToSubscriptionMissingAsync() { - var provisioner = new FakeAzureServiceBusProvisioner(); + var adminWrapper = new FakeAzureServiceBusAdminWrapper(); var communicator = new FakeAzureServiceBusCommunicator(); - var backplane = CreateBackplane(communicator, provisioner); + var backplane = CreateBackplane(communicator, adminWrapper); var (options, _, _) = CreateSubscriptionOptions(); await backplane.SubscribeAsync(options); await backplane.UnsubscribeAsync(); - var countAfterUnsubscribe = provisioner.EnsureSubscriptionCallCount; + var countAfterUnsubscribe = adminWrapper.EnsureSubscriptionCallCount; await communicator.RaiseSubscriptionMissingAsync(); - Assert.Equal(countAfterUnsubscribe, provisioner.EnsureSubscriptionCallCount); + Assert.Equal(countAfterUnsubscribe, adminWrapper.EnsureSubscriptionCallCount); } [Fact] diff --git a/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBusCommunicatorIntegrationTests.cs b/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBus/AzureServiceBusClientWrapperIntegrationTests.cs similarity index 76% rename from tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBusCommunicatorIntegrationTests.cs rename to tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBus/AzureServiceBusClientWrapperIntegrationTests.cs index e4299d6e..ac28ecea 100644 --- a/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBusCommunicatorIntegrationTests.cs +++ b/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBus/AzureServiceBusClientWrapperIntegrationTests.cs @@ -7,24 +7,24 @@ using ZiggyCreatures.Caching.Fusion.Backplane.AzureServiceBus.AzureServiceBusWrapper; using ZiggyCreatures.Caching.Fusion.Backplane.AzureServiceBus.Helpers; -namespace FusionCacheTests; +namespace FusionCacheTests.AzureServiceBus; /// -/// Integration tests for and that +/// Integration tests for and that /// require a real or emulated Azure Service Bus broker. Skipped automatically unless the /// environment variable is set, e.g. to: /// - a real Azure Service Bus namespace connection string, or /// - the local connection string exposed by the Azure Service Bus emulator (mcr.microsoft.com/azure-messaging/servicebus-emulator). /// Each test provisions its own uniquely-named topic and deletes it afterward, so tests can run concurrently/repeatedly without colliding. -/// These tests manually do what does internally: run the provisioner before subscribing +/// These tests manually do what does internally: run the admin wrapper before subscribing /// the communicator, and (where relevant) after unsubscribing it. /// -public class AzureServiceBusCommunicatorIntegrationTests +public class AzureServiceBusClientWrapperIntegrationTests : AbstractTests { private const string ConnectionStringEnvVarName = "FUSIONCACHE_TESTS_AZURESERVICEBUS_CONNECTIONSTRING"; - public AzureServiceBusCommunicatorIntegrationTests(ITestOutputHelper output) + public AzureServiceBusClientWrapperIntegrationTests(ITestOutputHelper output) : base(output, null) { _connectionString = Environment.GetEnvironmentVariable(ConnectionStringEnvVarName); @@ -47,38 +47,38 @@ private static string CreateUniqueTopicName(string testName) { var topicName = $"fusioncache-tests-{testName}-{Guid.NewGuid():N}".ToLowerInvariant(); - return topicName.Length > AzureServiceBusNaming.MaxTopicNameLength - ? topicName.Substring(0, AzureServiceBusNaming.MaxTopicNameLength) + return topicName.Length > AzureServiceBusHelpers.MaxTopicNameLength + ? topicName.Substring(0, AzureServiceBusHelpers.MaxTopicNameLength) : topicName; } - private static AzureServiceBusAdminProvisioner CreateProvisioner(ServiceBusAdministrationClient adminClient, string topicName, string subscriptionName) + private static AzureServiceBusAdminWrapper CreateAdminWrapper(ServiceBusAdministrationClient adminClient, string topicName, string subscriptionName) { - return new AzureServiceBusAdminProvisioner(adminClient, topicName, subscriptionName, NullLogger.Instance); + return new AzureServiceBusAdminWrapper(adminClient, topicName, subscriptionName, TimeSpan.FromMinutes(10), NullLogger.Instance); } private static AzureServiceBusClientWrapper CreateCommunicator(ServiceBusClient client, string topicName, string subscriptionName) { - return new AzureServiceBusClientWrapper(client, topicName, subscriptionName, NullLogger.Instance); + return new AzureServiceBusClientWrapper(client, topicName, subscriptionName, NullLogger.Instance, new AzureServiceBusBackplaneOptions()); } [Fact] - public async Task ProvisionerEnsureMethodsAreIdempotentWhenCalledTwiceAsync() + public async Task AdminWrapperEnsureMethodsAreIdempotentWhenCalledTwiceAsync() { SkipIfNoBrokerConfigured(); - var topicName = CreateUniqueTopicName(nameof(ProvisionerEnsureMethodsAreIdempotentWhenCalledTwiceAsync)); + var topicName = CreateUniqueTopicName(nameof(AdminWrapperEnsureMethodsAreIdempotentWhenCalledTwiceAsync)); const string subscriptionName = "idempotent-test-subscription"; var (_, adminClient) = CreateClients(); try { - var provisioner = CreateProvisioner(adminClient, topicName, subscriptionName); + var adminWrapper = CreateAdminWrapper(adminClient, topicName, subscriptionName); - await provisioner.EnsureTopicAsync(); - await provisioner.EnsureTopicAsync(); - await provisioner.EnsureSubscriptionAsync(); - await provisioner.EnsureSubscriptionAsync(); + await adminWrapper.EnsureTopicAsync(); + await adminWrapper.EnsureTopicAsync(); + await adminWrapper.EnsureSubscriptionAsync(); + await adminWrapper.EnsureSubscriptionAsync(); Assert.True(await adminClient.TopicExistsAsync(topicName, TestContext.Current.CancellationToken)); Assert.True(await adminClient.SubscriptionExistsAsync(topicName, subscriptionName, TestContext.Current.CancellationToken)); @@ -99,13 +99,13 @@ public async Task SelfPublishedMessagesAreFilteredOutBySubscriptionRuleAsync() try { - var provisionerA = CreateProvisioner(adminClientA, topicName, "subscription-a"); - await provisionerA.EnsureTopicAsync(); - await provisionerA.EnsureSubscriptionAsync(); + var adminWrapperA = CreateAdminWrapper(adminClientA, topicName, "subscription-a"); + await adminWrapperA.EnsureTopicAsync(); + await adminWrapperA.EnsureSubscriptionAsync(); var (clientB, adminClientB) = CreateClients(); - var provisionerB = CreateProvisioner(adminClientB, topicName, "subscription-b"); - await provisionerB.EnsureSubscriptionAsync(); + var adminWrapperB = CreateAdminWrapper(adminClientB, topicName, "subscription-b"); + await adminWrapperB.EnsureSubscriptionAsync(); await using var communicatorA = CreateCommunicator(clientA, topicName, "subscription-a"); await using var communicatorB = CreateCommunicator(clientB, topicName, "subscription-b"); @@ -131,19 +131,19 @@ public async Task SelfPublishedMessagesAreFilteredOutBySubscriptionRuleAsync() } [Fact] - public async Task SubscriptionMissingEventFiresAndProvisionerRecreatesTheSubscriptionAsync() + public async Task SubscriptionMissingEventFiresAndAdminWrapperRecreatesTheSubscriptionAsync() { SkipIfNoBrokerConfigured(); - var topicName = CreateUniqueTopicName(nameof(SubscriptionMissingEventFiresAndProvisionerRecreatesTheSubscriptionAsync)); + var topicName = CreateUniqueTopicName(nameof(SubscriptionMissingEventFiresAndAdminWrapperRecreatesTheSubscriptionAsync)); const string subscriptionName = "self-healing-test-subscription"; var (client, adminClient) = CreateClients(); try { - var provisioner = CreateProvisioner(adminClient, topicName, subscriptionName); - await provisioner.EnsureTopicAsync(); - await provisioner.EnsureSubscriptionAsync(); + var adminWrapper = CreateAdminWrapper(adminClient, topicName, subscriptionName); + await adminWrapper.EnsureTopicAsync(); + await adminWrapper.EnsureSubscriptionAsync(); await using var communicator = CreateCommunicator(client, topicName, subscriptionName); @@ -151,7 +151,7 @@ public async Task SubscriptionMissingEventFiresAndProvisionerRecreatesTheSubscri communicator.SubscriptionMissing += async () => { missingSignaled.TrySetResult(true); - await provisioner.EnsureSubscriptionAsync(); + await adminWrapper.EnsureSubscriptionAsync(); }; await communicator.Subscribe(_ => Task.CompletedTask); @@ -173,23 +173,23 @@ public async Task SubscriptionMissingEventFiresAndProvisionerRecreatesTheSubscri } [Fact] - public async Task UnprovisionAsyncDeletesTheSubscriptionButNotTheTopicAsync() + public async Task DisposeAsyncDeletesTheSubscriptionButNotTheTopicAsync() { SkipIfNoBrokerConfigured(); - var topicName = CreateUniqueTopicName(nameof(UnprovisionAsyncDeletesTheSubscriptionButNotTheTopicAsync)); + var topicName = CreateUniqueTopicName(nameof(DisposeAsyncDeletesTheSubscriptionButNotTheTopicAsync)); const string subscriptionName = "unprovision-test-subscription"; var (_, adminClient) = CreateClients(); try { - var provisioner = CreateProvisioner(adminClient, topicName, subscriptionName); - await provisioner.EnsureTopicAsync(); - await provisioner.EnsureSubscriptionAsync(); + var adminWrapper = CreateAdminWrapper(adminClient, topicName, subscriptionName); + await adminWrapper.EnsureTopicAsync(); + await adminWrapper.EnsureSubscriptionAsync(); Assert.True(await adminClient.SubscriptionExistsAsync(topicName, subscriptionName, TestContext.Current.CancellationToken)); - await provisioner.UnprovisionAsync(); + await adminWrapper.DisposeAsync(); Assert.False(await adminClient.SubscriptionExistsAsync(topicName, subscriptionName, TestContext.Current.CancellationToken)); Assert.True(await adminClient.TopicExistsAsync(topicName, TestContext.Current.CancellationToken)); @@ -201,17 +201,17 @@ public async Task UnprovisionAsyncDeletesTheSubscriptionButNotTheTopicAsync() } [Fact] - public async Task CommunicatorWorksAgainstAnExternallyProvisionedSubscriptionWithoutAProvisionerAsync() + public async Task ClientWrapperWorksAgainstAnExternallyProvisionedSubscriptionWithoutAnAdminWrapperAsync() { SkipIfNoBrokerConfigured(); - var topicName = CreateUniqueTopicName(nameof(CommunicatorWorksAgainstAnExternallyProvisionedSubscriptionWithoutAProvisionerAsync)); + var topicName = CreateUniqueTopicName(nameof(ClientWrapperWorksAgainstAnExternallyProvisionedSubscriptionWithoutAnAdminWrapperAsync)); const string subscriptionName = "no-provisioner-test-subscription"; var (_, adminClient) = CreateClients(); try { - // PROVISION OUT OF BAND (E.G. VIA IAC), WITHOUT EVER USING AzureServiceBusAdminProvisioner. NOTE THIS + // PROVISION OUT OF BAND (E.G. VIA IAC), WITHOUT EVER USING AzureServiceBusAdminWrapper. NOTE THIS // SUBSCRIPTION KEEPS ITS DEFAULT MATCH-ALL RULE: NO ONE HERE CREATES THE "FilterOutOwnMessages" SQL RULE. await adminClient.CreateTopicAsync(topicName, TestContext.Current.CancellationToken); await adminClient.CreateSubscriptionAsync(new CreateSubscriptionOptions(topicName, subscriptionName), TestContext.Current.CancellationToken); diff --git a/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBusCommunicatorTests.cs b/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBus/AzureServiceBusClientWrapperTests.cs similarity index 58% rename from tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBusCommunicatorTests.cs rename to tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBus/AzureServiceBusClientWrapperTests.cs index 850cd2d8..e0d8e476 100644 --- a/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBusCommunicatorTests.cs +++ b/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBus/AzureServiceBusClientWrapperTests.cs @@ -6,43 +6,18 @@ using ZiggyCreatures.Caching.Fusion.Backplane.AzureServiceBus.AzureServiceBusWrapper; using ZiggyCreatures.Caching.Fusion.Backplane.AzureServiceBus.Helpers; -namespace FusionCacheTests; +namespace FusionCacheTests.AzureServiceBus; -public class AzureServiceBusCommunicatorTests +public class AzureServiceBusClientWrapperTests : AbstractTests { - public AzureServiceBusCommunicatorTests(ITestOutputHelper output) + public AzureServiceBusClientWrapperTests(ITestOutputHelper output) : base(output, null) { } private const string FakeConnectionString = "Endpoint=sb://fake-namespace.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=ZmFrZS1rZXk="; - - [Fact] - public void ConstructorThrowsWhenSubscriptionNameIsMissing() - { - var client = new ServiceBusClient(FakeConnectionString); - - Assert.Throws(() => new AzureServiceBusClientWrapper( - serviceBusClient: client, - topicName: "my-topic", - subscriptionName: null!, - logger: NullLogger.Instance - )); - } - - [Fact] - public void ConstructorThrowsWhenSubscriptionNameIsWhitespace() - { - var client = new ServiceBusClient(FakeConnectionString); - - Assert.Throws(() => new AzureServiceBusClientWrapper( - serviceBusClient: client, - topicName: "my-topic", - subscriptionName: " ", - logger: NullLogger.Instance - )); - } + private static AzureServiceBusBackplaneOptions Options => new() { LockTimeout = TimeSpan.FromSeconds(1) }; [Fact] public void ConstructorUsesTheGivenTopicAndSubscriptionNames() @@ -53,7 +28,8 @@ public void ConstructorUsesTheGivenTopicAndSubscriptionNames() serviceBusClient: client, topicName: "my-topic", subscriptionName: "my-existing-subscription", - logger: NullLogger.Instance + logger: NullLogger.Instance, + asbOptions: Options ); Assert.Equal("my-topic", communicator.TopicName); @@ -69,7 +45,8 @@ public void SubscriptionMissingEventCanBeAddedAndRemovedWithoutThrowing() serviceBusClient: client, topicName: "my-topic", subscriptionName: "my-existing-subscription", - logger: NullLogger.Instance + logger: NullLogger.Instance, + asbOptions: Options ); Task Handler() => Task.CompletedTask; @@ -81,16 +58,16 @@ public void SubscriptionMissingEventCanBeAddedAndRemovedWithoutThrowing() [Fact] public void GenerateIdReturnsAValidSubscriptionNameLength() { - var id = AzureServiceBusClientWrapper.GenerateId(); + var id = AzureServiceBusHelpers.GenerateId(); - Assert.True(id.Length <= AzureServiceBusNaming.MaxSubscriptionNameLength, $"Expected length <= {AzureServiceBusNaming.MaxSubscriptionNameLength}, but was {id.Length} ('{id}')"); + Assert.True(id.Length <= AzureServiceBusHelpers.MaxSubscriptionNameLength, $"Expected length <= {AzureServiceBusHelpers.MaxSubscriptionNameLength}, but was {id.Length} ('{id}')"); Assert.NotEmpty(id); } [Fact] public void GenerateIdOnlyContainsValidServiceBusEntityNameCharacters() { - var id = AzureServiceBusClientWrapper.GenerateId(); + var id = AzureServiceBusHelpers.GenerateId(); foreach (var c in id) { @@ -101,8 +78,8 @@ public void GenerateIdOnlyContainsValidServiceBusEntityNameCharacters() [Fact] public void GenerateIdReturnsDifferentValuesOnSuccessiveCalls() { - var id1 = AzureServiceBusClientWrapper.GenerateId(); - var id2 = AzureServiceBusClientWrapper.GenerateId(); + var id1 = AzureServiceBusHelpers.GenerateId(); + var id2 = AzureServiceBusHelpers.GenerateId(); Assert.NotEqual(id1, id2); } diff --git a/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBusNamingTests.cs b/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBus/AzureServiceBusHelpersTests.cs similarity index 63% rename from tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBusNamingTests.cs rename to tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBus/AzureServiceBusHelpersTests.cs index ab2bcd80..38dd8d1d 100644 --- a/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBusNamingTests.cs +++ b/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBus/AzureServiceBusHelpersTests.cs @@ -2,12 +2,12 @@ using Xunit; using ZiggyCreatures.Caching.Fusion.Backplane.AzureServiceBus.Helpers; -namespace FusionCacheTests; +namespace FusionCacheTests.AzureServiceBus; -public class AzureServiceBusNamingTests +public class AzureServiceBusHelpersTests : AbstractTests { - public AzureServiceBusNamingTests(ITestOutputHelper output) + public AzureServiceBusHelpersTests(ITestOutputHelper output) : base(output, null) { } @@ -15,13 +15,13 @@ public AzureServiceBusNamingTests(ITestOutputHelper output) [Fact] public void SanitizeEntityNameThrowsWhenNameIsNull() { - Assert.Throws(() => AzureServiceBusNaming.SanitizeEntityName(null!, 50)); + Assert.Throws(() => AzureServiceBusHelpers.SanitizeEntityName(null!, 50)); } [Fact] public void SanitizeEntityNameLeavesValidCharactersUntouched() { - var result = AzureServiceBusNaming.SanitizeEntityName("My.Cache-Name_v1/sub", 260); + var result = AzureServiceBusHelpers.SanitizeEntityName("My.Cache-Name_v1/sub", 260); Assert.Equal("My.Cache-Name_v1/sub", result); } @@ -30,7 +30,7 @@ public void SanitizeEntityNameLeavesValidCharactersUntouched() public void SanitizeEntityNameReplacesInvalidCharactersWithDashes() { // ':' AND ' ' ARE NOT VALID SERVICE BUS ENTITY NAME CHARACTERS - var result = AzureServiceBusNaming.SanitizeEntityName("MyCache.Backplane:v1", 260); + var result = AzureServiceBusHelpers.SanitizeEntityName("MyCache.Backplane:v1", 260); Assert.Equal("MyCache.Backplane-v1", result); Assert.DoesNotContain(':', result); @@ -39,7 +39,7 @@ public void SanitizeEntityNameReplacesInvalidCharactersWithDashes() [Fact] public void SanitizeEntityNameTrimsLeadingAndTrailingSeparators() { - var result = AzureServiceBusNaming.SanitizeEntityName("///cache-name---", 260); + var result = AzureServiceBusHelpers.SanitizeEntityName("///cache-name---", 260); Assert.Equal("cache-name", result); } @@ -49,7 +49,7 @@ public void SanitizeEntityNameTruncatesToMaxLength() { var longName = new string('a', 300); - var result = AzureServiceBusNaming.SanitizeEntityName(longName, 50); + var result = AzureServiceBusHelpers.SanitizeEntityName(longName, 50); Assert.Equal(50, result.Length); } @@ -57,7 +57,7 @@ public void SanitizeEntityNameTruncatesToMaxLength() [Fact] public void SanitizeEntityNameReturnsFallbackWhenResultWouldBeEmpty() { - var result = AzureServiceBusNaming.SanitizeEntityName("::: ***", 50, fallback: "my-fallback"); + var result = AzureServiceBusHelpers.SanitizeEntityName("::: ***", 50, fallback: "my-fallback"); Assert.Equal("my-fallback", result); } @@ -65,7 +65,7 @@ public void SanitizeEntityNameReturnsFallbackWhenResultWouldBeEmpty() [Fact] public void ResolveTopicNameUsesExplicitTopicNameWhenProvided() { - var result = AzureServiceBusNaming.ResolveTopicName("my-explicit-topic", "MyCache.Backplane:v1"); + var result = AzureServiceBusHelpers.ResolveTopicName("my-explicit-topic", "MyCache.Backplane:v1"); Assert.Equal("my-explicit-topic", result); } @@ -75,7 +75,7 @@ public void ResolveTopicNameFallsBackToChannelNameWhenNotProvided() { // THIS IS FUSIONCACHE'S DEFAULT COMPUTED CHANNEL NAME SHAPE (SEE FusionCacheInternalUtils.GetBackplaneChannelName): // THE ':' SEPARATOR IS NOT A VALID SERVICE BUS CHARACTER, SO IT MUST BE SANITIZED AWAY - var result = AzureServiceBusNaming.ResolveTopicName(null, "MyCache.Backplane:v1"); + var result = AzureServiceBusHelpers.ResolveTopicName(null, "MyCache.Backplane:v1"); Assert.Equal("MyCache.Backplane-v1", result); } @@ -83,7 +83,7 @@ public void ResolveTopicNameFallsBackToChannelNameWhenNotProvided() [Fact] public void ResolveTopicNameFallsBackToChannelNameWhenExplicitTopicNameIsWhitespace() { - var result = AzureServiceBusNaming.ResolveTopicName(" ", "MyCache.Backplane:v1"); + var result = AzureServiceBusHelpers.ResolveTopicName(" ", "MyCache.Backplane:v1"); Assert.Equal("MyCache.Backplane-v1", result); } diff --git a/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBusBackplaneOptionsTests.cs b/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBusBackplaneOptionsTests.cs deleted file mode 100644 index e77ca8fa..00000000 --- a/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBusBackplaneOptionsTests.cs +++ /dev/null @@ -1,90 +0,0 @@ -using Azure.Core; -using Azure.Messaging.ServiceBus; -using Azure.Messaging.ServiceBus.Administration; -using FusionCacheTests.Stuff; -using Xunit; -using ZiggyCreatures.Caching.Fusion.Backplane.AzureServiceBus; - -namespace FusionCacheTests; - -public class AzureServiceBusBackplaneOptionsTests - : AbstractTests -{ - public AzureServiceBusBackplaneOptionsTests(ITestOutputHelper output) - : base(output, null) - { - } - - private sealed class FakeTokenCredential - : TokenCredential - { - public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken) - { - return new AccessToken("fake-token", DateTimeOffset.UtcNow.AddHours(1)); - } - - public override ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken) - { - return new ValueTask(GetToken(requestContext, cancellationToken)); - } - } - - [Fact] - public async Task GetOrCreateClientsAsyncThrowsWhenNothingIsConfiguredAsync() - { - var options = new AzureServiceBusBackplaneOptions(); - - await Assert.ThrowsAsync(() => options.GetOrCreateClientsAsync()); - } - - [Fact] - public async Task GetOrCreateClientsAsyncUsesConnectionStringAsync() - { - var options = new AzureServiceBusBackplaneOptions - { - ConnectionString = "Endpoint=sb://fake-namespace.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=ZmFrZS1rZXk=" - }; - - var (client, adminClient) = await options.GetOrCreateClientsAsync(); - - Assert.NotNull(client); - Assert.NotNull(adminClient); - Assert.Equal("fake-namespace.servicebus.windows.net", client.FullyQualifiedNamespace); - } - - [Fact] - public async Task GetOrCreateClientsAsyncUsesFullyQualifiedNamespaceAndCredentialAsync() - { - var options = new AzureServiceBusBackplaneOptions - { - FullyQualifiedNamespace = "fake-namespace.servicebus.windows.net", - Credential = new FakeTokenCredential() - }; - - var (client, adminClient) = await options.GetOrCreateClientsAsync(); - - Assert.NotNull(client); - Assert.NotNull(adminClient); - Assert.Equal("fake-namespace.servicebus.windows.net", client.FullyQualifiedNamespace); - } - - [Fact] - public async Task ServiceBusClientFactoryTakesPrecedenceOverConnectionStringAsync() - { - const string factoryConnectionString = "Endpoint=sb://factory-namespace.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=ZmFrZS1rZXk="; - var factoryClient = new ServiceBusClient(factoryConnectionString); - var factoryAdminClient = new ServiceBusAdministrationClient(factoryConnectionString); - - var options = new AzureServiceBusBackplaneOptions - { - // AN INVALID CONNECTION STRING: IF THE RESOLVER TRIED TO USE IT INSTEAD OF THE FACTORY, CONSTRUCTING A CLIENT FROM IT WOULD THROW - ConnectionString = "this is not a valid connection string", - ServiceBusClientFactory = () => Task.FromResult((factoryClient, factoryAdminClient)) - }; - - var (client, adminClient) = await options.GetOrCreateClientsAsync(); - - Assert.Same(factoryClient, client); - Assert.Same(factoryAdminClient, adminClient); - } -} diff --git a/tests/ZiggyCreatures.FusionCache.Tests/L1L2BackplaneTests.cs b/tests/ZiggyCreatures.FusionCache.Tests/L1L2BackplaneTests.cs index 9ac1bba1..37b722b0 100644 --- a/tests/ZiggyCreatures.FusionCache.Tests/L1L2BackplaneTests.cs +++ b/tests/ZiggyCreatures.FusionCache.Tests/L1L2BackplaneTests.cs @@ -9,7 +9,6 @@ using ZiggyCreatures.Caching.Fusion; using ZiggyCreatures.Caching.Fusion.Backplane; using ZiggyCreatures.Caching.Fusion.Backplane.AzureServiceBus; -using ZiggyCreatures.Caching.Fusion.Backplane.AzureServiceBus.AzureServiceBusWrapper; using ZiggyCreatures.Caching.Fusion.Backplane.AzureServiceBus.Helpers; using ZiggyCreatures.Caching.Fusion.Backplane.Memory; using ZiggyCreatures.Caching.Fusion.Backplane.StackExchangeRedis; @@ -43,10 +42,7 @@ private FusionCacheOptions CreateFusionCacheOptions() private static readonly bool UseAzureServiceBus = true; private static readonly string RedisConnection = "127.0.0.1:6379,ssl=False,abortConnect=false,connectTimeout=1000,syncTimeout=1000"; - // DEFAULTS TO THE AZURE SERVICE BUS EMULATOR'S WELL-KNOWN LOCAL CONNECTION STRING (SEE MICROSOFT'S EMULATOR DOCS); - // SET THE FUSIONCACHE_TESTS_AZURESERVICEBUS_CONNECTIONSTRING ENV VAR TO POINT AT A REAL NAMESPACE INSTEAD - private static readonly string AzureServiceBusConnectionString = - Environment.GetEnvironmentVariable("FUSIONCACHE_TESTS_AZURESERVICEBUS_CONNECTIONSTRING") + private static readonly string AzureServiceBusConnectionString = Environment.GetEnvironmentVariable("FUSIONCACHE_TESTS_AZURESERVICEBUS_CONNECTIONSTRING") ?? "Endpoint=sb://localhost;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true;"; private readonly TimeSpan InitialBackplaneDelay = TimeSpan.FromMilliseconds(300); @@ -58,18 +54,14 @@ private IFusionCacheBackplane CreateBackplane(string connectionId) return new RedisBackplane(new RedisBackplaneOptions { Configuration = RedisConnection }, logger: CreateXUnitLogger()); if (UseAzureServiceBus) { - // USE THE SHARED connectionId AS THE TOPIC NAME, SO ALL THE BACKPLANE INSTANCES CREATED FOR THE SAME - // LOGICAL TEST "BUS" (E.G. cache1/cache2/cache3 IN A GIVEN TEST) END UP TALKING ON THE SAME SERVICE BUS - // TOPIC. EACH INSTANCE STILL NEEDS ITS OWN, UNIQUE SUBSCRIPTION (OTHERWISE THEY'D BE COMPETING CONSUMERS - // ON A SHARED SUBSCRIPTION, INSTEAD OF EACH RECEIVING EVERY MESSAGE AS A BACKPLANE REQUIRES). - var topicName = AzureServiceBusNaming.SanitizeEntityName($"fusioncache-tests-{connectionId}", AzureServiceBusNaming.MaxTopicNameLength); - var subscriptionName = AzureServiceBusClientWrapper.GenerateId(); + var topicName = AzureServiceBusHelpers.SanitizeEntityName($"fusioncache-tests", AzureServiceBusHelpers.MaxTopicNameLength); + var subscriptionName = AzureServiceBusHelpers.GenerateId(); var adminClient = new ServiceBusAdministrationClient(AzureServiceBusConnectionString); var client = new ServiceBusClient(AzureServiceBusConnectionString); - var communicator = new AzureServiceBusClientWrapper(client, topicName, subscriptionName, CreateXUnitLogger()); - var provisioner = new AzureServiceBusAdminProvisioner(adminClient, topicName, subscriptionName, CreateXUnitLogger()); + var clientWrapper = new AzureServiceBusClientWrapper(client, topicName, subscriptionName, CreateXUnitLogger(), new AzureServiceBusBackplaneOptions()); + var adminWrapper = new AzureServiceBusAdminWrapper(adminClient, topicName, subscriptionName, TimeSpan.FromMinutes(10), CreateXUnitLogger()); - return new AzureServiceBusBackplane(communicator, provisioner, CreateXUnitLogger()); + return new AzureServiceBusBackplane(clientWrapper, adminWrapper, CreateXUnitLogger()); } return new MemoryBackplane(new MemoryBackplaneOptions() { ConnectionId = connectionId }, logger: CreateXUnitLogger()); } diff --git a/tests/ZiggyCreatures.FusionCache.Tests/Stuff/TestsUtils.cs b/tests/ZiggyCreatures.FusionCache.Tests/Stuff/TestsUtils.cs index e590d8cc..a2ea9144 100644 --- a/tests/ZiggyCreatures.FusionCache.Tests/Stuff/TestsUtils.cs +++ b/tests/ZiggyCreatures.FusionCache.Tests/Stuff/TestsUtils.cs @@ -164,7 +164,7 @@ public static FusionCacheOptions GetOptions(this IFusionCache cache) if (communicator is null) return null; - return typeof(AzureServiceBusClientWrapper).GetField("_topicName", BindingFlags.NonPublic | BindingFlags.Instance)!.GetValue(communicator) as string; + return (string?)typeof(AzureServiceBusClientWrapper).GetProperty("TopicName", BindingFlags.NonPublic | BindingFlags.Instance)!.GetValue(communicator); } public static IFusionCachePlugin[]? GetPlugins(IFusionCache cache) From 0978eaf691d25a503c304f12fef5d260de1fd89e Mon Sep 17 00:00:00 2001 From: AboubakrNasef Date: Sun, 26 Jul 2026 15:06:01 +0300 Subject: [PATCH 04/13] add aspire playground --- .gitignore | 2 +- eaxmple/Playground/BACKPLANE_FLOWS.md | 397 +++++++++++++++ eaxmple/Playground/CHANGES_SUMMARY.md | 378 ++++++++++++++ .../Playground/Playground.AppHost/AppHost.cs | 26 + .../Playground.AppHost.csproj | 21 + .../Properties/launchSettings.json | 31 ++ .../appsettings.Development.json | 8 + .../Playground.AppHost/appsettings.json | 9 + .../Playground.ServiceDefaults/Extensions.cs | 127 +++++ .../Playground.ServiceDefaults.csproj | 22 + eaxmple/Playground/QUICK_START.md | 255 ++++++++++ eaxmple/Playground/README.md | 264 ++++++++++ eaxmple/Playground/SETUP_GUIDE.md | 478 ++++++++++++++++++ eaxmple/Playground/WebApplication1/Program.cs | 110 ++++ .../Properties/launchSettings.json | 23 + .../WebApplication1/WebApplication1.csproj | 21 + .../WebApplication1/WebApplication1.http | 6 + .../Playground/WebApplication1/api-demo.http | 45 ++ .../appsettings.Development.json | 8 + .../WebApplication1/appsettings.json | 9 + eaxmple/Playground/WebApplication2/Program.cs | 110 ++++ .../Properties/launchSettings.json | 23 + .../WebApplication2/WebApplication2.csproj | 21 + .../WebApplication2/WebApplication2.http | 6 + .../Playground/WebApplication2/api-demo.http | 45 ++ .../appsettings.Development.json | 8 + .../WebApplication2/appsettings.json | 9 + 27 files changed, 2461 insertions(+), 1 deletion(-) create mode 100644 eaxmple/Playground/BACKPLANE_FLOWS.md create mode 100644 eaxmple/Playground/CHANGES_SUMMARY.md create mode 100644 eaxmple/Playground/Playground.AppHost/AppHost.cs create mode 100644 eaxmple/Playground/Playground.AppHost/Playground.AppHost.csproj create mode 100644 eaxmple/Playground/Playground.AppHost/Properties/launchSettings.json create mode 100644 eaxmple/Playground/Playground.AppHost/appsettings.Development.json create mode 100644 eaxmple/Playground/Playground.AppHost/appsettings.json create mode 100644 eaxmple/Playground/Playground.ServiceDefaults/Extensions.cs create mode 100644 eaxmple/Playground/Playground.ServiceDefaults/Playground.ServiceDefaults.csproj create mode 100644 eaxmple/Playground/QUICK_START.md create mode 100644 eaxmple/Playground/README.md create mode 100644 eaxmple/Playground/SETUP_GUIDE.md create mode 100644 eaxmple/Playground/WebApplication1/Program.cs create mode 100644 eaxmple/Playground/WebApplication1/Properties/launchSettings.json create mode 100644 eaxmple/Playground/WebApplication1/WebApplication1.csproj create mode 100644 eaxmple/Playground/WebApplication1/WebApplication1.http create mode 100644 eaxmple/Playground/WebApplication1/api-demo.http create mode 100644 eaxmple/Playground/WebApplication1/appsettings.Development.json create mode 100644 eaxmple/Playground/WebApplication1/appsettings.json create mode 100644 eaxmple/Playground/WebApplication2/Program.cs create mode 100644 eaxmple/Playground/WebApplication2/Properties/launchSettings.json create mode 100644 eaxmple/Playground/WebApplication2/WebApplication2.csproj create mode 100644 eaxmple/Playground/WebApplication2/WebApplication2.http create mode 100644 eaxmple/Playground/WebApplication2/api-demo.http create mode 100644 eaxmple/Playground/WebApplication2/appsettings.Development.json create mode 100644 eaxmple/Playground/WebApplication2/appsettings.json diff --git a/.gitignore b/.gitignore index 1f20676f..f4da5705 100644 --- a/.gitignore +++ b/.gitignore @@ -338,4 +338,4 @@ ASALocalRun/ # BeatPulse healthcheck temp database healthchecksdb -/eaxmple/Playground + diff --git a/eaxmple/Playground/BACKPLANE_FLOWS.md b/eaxmple/Playground/BACKPLANE_FLOWS.md new file mode 100644 index 00000000..48744afc --- /dev/null +++ b/eaxmple/Playground/BACKPLANE_FLOWS.md @@ -0,0 +1,397 @@ +# FusionCache Redis Backplane - Flow Diagrams + +## Scenario 1: First Request (Cache Miss) + +``` +WebApplication1: GET /data +├─ FusionCache checks L1 (Memory Cache) +│ └─ NOT FOUND ❌ +├─ FusionCache checks L2 (Redis) +│ └─ NOT FOUND ❌ +├─ FusionCache executes factory function +│ ├─ Generates: "Data from WebApplication1 at 12:00:00Z" +│ └─ Stores in L1 (Memory) + L2 (Redis) +└─ Returns data to client ✅ + +📊 Performance: Slowest (factory execution) +🔔 Backplane: No message sent +💾 Cache State: + App1: L1=cached, L2=cached + App2: L1=empty, L2=empty +``` + +--- + +## Scenario 2: Subsequent Request in Same App (Cache Hit) + +``` +WebApplication1: GET /data (second call) +├─ FusionCache checks L1 (Memory Cache) +│ └─ FOUND ✅ (still valid, <30s old) +└─ Returns data immediately ⚡ + +📊 Performance: Fastest (memory access) +🔔 Backplane: No message sent +💾 Cache State: + App1: L1=cached ✅, L2=cached ✅ + App2: L1=empty, L2=empty +``` + +--- + +## Scenario 3: Request in Different App (Cache Sharing) + +``` +WebApplication2: GET /data (App1 already warmed the cache) +├─ FusionCache checks L1 (Memory Cache) +│ └─ NOT FOUND ❌ (not accessed yet) +├─ FusionCache checks L2 (Redis) +│ └─ FOUND ✅ (data stored by App1) +│ Returns: "Data from WebApplication1 at 12:00:00Z" +└─ Also caches in L1 for future hits ⚡ + +📊 Performance: Fast (Redis access) +🔔 Backplane: No message sent (no update) +💾 Cache State: + App1: L1=cached ✅, L2=cached ✅ + App2: L1=cached ✅, L2=cached ✅ +``` + +--- + +## Scenario 4: Update from Different App (Invalidation) + +``` +WebApplication2: POST /data with "Updated value" +├─ Call: await cache.SetAsync("shared:data", "Updated value") +│ +├─ FusionCache stores in L1 (Memory) + L2 (Redis) +│ ├─ L1 Update: "Updated value" ✅ +│ └─ L2 Update: "Updated value" ✅ +│ +├─ FusionCache publishes to Redis Pub/Sub: +│ └─ Message: "Invalidate shared:data" +│ Channel: "FusionCache:shared:data" +│ +├─ WebApplication1 subscribes to that channel +│ ├─ Receives: "Invalidate shared:data" +│ ├─ Removes from L1 (Memory Cache) 🗑️ +│ └─ L2 (Redis) still has the value +│ +└─ Returns to client ✅ + +📊 Performance: Moderate (Redis write + Pub/Sub broadcast) +🔔 Backplane: Message sent ✅ +💾 Cache State DURING MESSAGE: + App1: L1=empty ❌, L2=updated ✅ + App2: L1=updated ✅, L2=updated ✅ + +💾 Cache State AFTER MESSAGE (100ms): + App1: L1=empty ❌, L2=updated ✅ + App2: L1=updated ✅, L2=updated ✅ +``` + +--- + +## Scenario 5: Next Request After Invalidation + +``` +WebApplication1: GET /data (after invalidation message received) +├─ FusionCache checks L1 (Memory Cache) +│ └─ NOT FOUND ❌ (invalidated by backplane) +├─ FusionCache checks L2 (Redis) +│ └─ FOUND ✅ "Updated value" +│ Returns immediately without factory execution ⚡ +└─ Also caches in L1 for next hit + +📊 Performance: Fast (Redis hit, no factory) +🔔 Backplane: No message sent +💾 Cache State: + App1: L1=updated ✅, L2=updated ✅ + App2: L1=updated ✅, L2=updated ✅ +``` + +--- + +## Scenario 6: Simultaneous Requests (Race Condition Prevention) + +``` +App1 & App2: Both request GET /data at SAME TIME +Both detect cache miss + +┌─ App1: GetOrSetAsync("shared:data") +│ ├─ L1 miss, L2 miss +│ ├─ Acquires lock 🔒 +│ ├─ Executes factory +│ └─ Stores in Redis +│ +└─ App2: GetOrSetAsync("shared:data") + ├─ L1 miss, L2 miss + ├─ Tries to acquire lock 🔒 + ├─ Waits for lock... + ├─ App1 releases lock after factory + ├─ L2 hit now! Uses App1's value ✅ + └─ Returns same value + +📊 Result: Both return same data ✅ +🔔 Factory executed once (only in App1) +``` + +--- + +## Scenario 7: Cache Expiration (30 seconds) + +``` +Time: 00:00 - WebApplication1: GET /data +├─ Factory executes +├─ Stores with TTL=30s +└─ Cache valid until 00:30 + +Time: 00:15 - WebApplication1: GET /data +├─ Cache still valid (15s remaining) +├─ L1 hit ⚡ +└─ No factory execution + +Time: 00:31 - WebApplication1: GET /data +├─ L1: EXPIRED ❌ (>30s old) +├─ L2 (Redis): EXPIRED ❌ (TTL reached) +├─ Factory executes (generates new data) +└─ New cache valid until 01:01 + +Time: 00:35 - WebApplication2: GET /data +├─ L1: MISS (never cached in this app) +├─ L2: MISS (Redis key expired) +├─ App1's factory already executed at 00:31 +├─ App2 executes its own factory +│ └─ Different timestamp! +└─ Returns: "Data from WebApplication2 at 00:35" + +📊 Result: Different data in each app after expiration +🎯 Reason: Cache expired in L2, each app generated new value +🔔 Backplane: Only broadcasts if manually invalidated +``` + +--- + +## Scenario 8: Manual Cache Clear (RemoveAsync) + +``` +WebApplication2: await cache.RemoveAsync("shared:data") +├─ Removes from L1 (Memory) 🗑️ +├─ Removes from L2 (Redis) 🗑️ +└─ Publishes: "Remove shared:data" + +WebApplication1 receives broadcast: +├─ Removes from L1 (Memory) 🗑️ +├─ Removes from L2 (Redis) 🗑️ +└─ Both apps now have cache miss + +Next request in either app: +├─ L1: MISS, L2: MISS +├─ Factory executes +└─ Cache regenerated + +📊 Result: Forced cache refresh across all apps +🔔 Backplane: Message sent for removal +``` + +--- + +## Scenario 9: Network Latency (Delayed Invalidation) + +``` +WebApplication2: POST /data with new value (12:00:00.000) +├─ Stores immediately in memory ✅ +├─ Stores immediately in Redis ✅ +└─ Publishes invalidation message + +WebApplication1: GET /data (12:00:00.050) +├─ L1 still has old value +│ └─ Invalidation message not yet received +└─ Returns OLD data (50ms race condition) + +WebApplication1: GET /data (12:00:00.100) +├─ Invalidation message received ✅ +├─ L1 cleared 🗑️ +├─ L2 hit with new value ✅ +└─ Returns NEW data + +📊 Edge Case: Small window where different data returned +⚠️ Note: Redis Pub/Sub is fast (~10ms typical) +💡 Mitigation: Critical data can use short TTL + refresh on startup +``` + +--- + +## Scenario 10: Redis Connection Lost + +``` +WebApplication1: GET /data (Redis unavailable) +├─ Try L1 (Memory Cache) +│ └─ FOUND ✅ or MISS ❌ +├─ Try L2 (Redis) +│ └─ CONNECTION ERROR 🔴 +├─ Fallback options: +│ ├─ Use stale L1 value if available ✅ +│ ├─ Execute factory (slow, no cache benefit) ⚡❌ +│ └─ Return error (configured behavior) ❌ +└─ Result depends on configuration + +Configuration: WithFailSafeMaxDuration(minutes: 5) +├─ If error occurred <5 min ago: use last known good value +└─ If error occurred >5 min ago: execute factory + +📊 Resilience: System continues working even without Redis +🔔 Backplane: Messages buffered/queued until Redis recovers +``` + +--- + +## Scenario 11: Multiple Cache Keys + +``` +WebApplication1: Cache three different items +├─ Key 1: "config:app" → "Config from WebApplication1" +├─ Key 2: "users:list" → [User1, User2, User3] +└─ Key 3: "shared:data" → "Common data" + +WebApplication2: Shares Key 3 +├─ Key 1: "config:app" → L2 miss (different data) +├─ Key 2: "users:list" → L2 miss (different data) +└─ Key 3: "shared:data" → L2 hit ✅ + +Update in WebApplication2: +├─ SetAsync("shared:data", "new value") +├─ Publishes: "Invalidate shared:data" +└─ WebApplication1 receives: Clears Key 3 from L1 + +Key 1 & 2 unaffected: +└─ Each app maintains its own cache ✅ + +📊 Result: Selective cache sharing based on key names +``` + +--- + +## Scenario 12: Application Restart + +``` +WebApplication1 stops and restarts +├─ L1 (Memory Cache): Lost 🗑️ +│ └─ New empty in-memory cache +├─ L2 (Redis): Still present ✅ +│ └─ All data preserved in Redis +├─ First request: L1 miss → L2 hit ⚡ +└─ No factory execution, Redis serves stale data + +WebApplication2 (running): Unaffected +├─ Continues serving from L1 cache +├─ After 30s expiration: Fetches from Redis +└─ Gets same data as restarted App1 + +Graceful degradation: +└─ System continues working ✅ + Restart window: ~5 seconds + Cache warm-up: First few requests + +📊 Redis acts as persistent cache layer +🎯 No data loss during app restart +``` + +--- + +## Cache State Transition Diagram + +``` + ┌─────────────────────────┐ + │ EMPTY (No Cache) │ + │ L1: empty L2: empty │ + └────────────┬────────────┘ + │ + GetOrSetAsync() + (cache miss) + │ + ┌────────────▼────────────┐ + │ POPULATED (From App1) │ + │ L1: cached L2: cached │ + └────────────┬────────────┘ + │ + ┌────────┴────────┐ + │ │ + SetAsync() GetOrSetAsync() + (from App2) (same app) + │ │ + │ │ ⚡ L1 hit + │ │ (no change) + │ │ + ┌───▼─────────────────▼──┐ + │ INVALIDATED (In App1) │ + │ L1: empty L2: cached │ ◄── Backplane + │ (awaiting broadcast) │ publishes + └───┬────────────────────┘ + │ + App1 receives backplane message + │ + ┌───▼────────────────────┐ + │ SYNCHRONIZED (App1) │ + │ L1: empty L2: cached │ + └───┬────────────────────┘ + │ + GetOrSetAsync() + │ + ┌───▼────────────────────┐ + │ WARM (Both Apps) │ + │ L1: cached L2: cached │ + └────────────────────────┘ + + (repeats for each update) +``` + +--- + +## Performance Timeline + +``` +Operation Time Network Calls Cache Level +──────────────────────────────────────────────────────────────── +Initial L1 Miss 1-2ms 0 Factory +Initial L2 Hit 5-10ms 1 (Redis read) L2 Redis +L1 Hit (Cached) <1ms 0 L1 Memory +SetAsync (Update) 5-15ms 1 (Redis write) L1+L2 +Backplane Broadcast ~10ms 1 (Pub/Sub) All apps +L2 Hit After Invalid 5-10ms 1 (Redis read) L2 Redis +Lock Contention 50-100ms N/A Lock wait +Expiration Refresh 20-30ms 1 (Factory) Factory result + +Legend: + Fastest: L1 Hit <1ms + Good: L2 Hit 5-10ms + Moderate: Redis write/broadcast 10-15ms + Slow: Factory execution 20-100ms +``` + +--- + +## Summary Table + +| Scenario | L1 Hit | L2 Hit | Factory | Backplane | Other App | +|----------|--------|---------|---------|-----------|-----------| +| First request | ❌ | ❌ | ✅ | ❌ | ❌ | +| Same app, again | ✅ | ❌ | ❌ | ❌ | - | +| Different app | ❌ | ✅ | ❌ | ❌ | - | +| Update from other | ❌* | ✅ | ❌ | ✅ | Notified | +| After expiration | ❌ | ❌ | ✅ | ❌ | - | +| Manual clear | ❌ | ❌ | ✅ | ✅ | Notified | +| Network down | ✅ | ❌ | ✅ | ❌ | Buffered | + +*L1 cleared by invalidation message from backplane + +--- + +**Note:** Timing values are approximate and depend on: +- Network latency +- Redis configuration +- Factory function complexity +- System load + +For production, profile your specific workload! diff --git a/eaxmple/Playground/CHANGES_SUMMARY.md b/eaxmple/Playground/CHANGES_SUMMARY.md new file mode 100644 index 00000000..8b0a0204 --- /dev/null +++ b/eaxmple/Playground/CHANGES_SUMMARY.md @@ -0,0 +1,378 @@ +# FusionCache Redis Backplane Example - Changes Summary + +## 📋 Overview +This document summarizes all changes made to create a complete, working example of two applications synchronizing cache data through a Redis backplane using .NET Aspire. + +## 🔄 Files Modified + +### 1. **Playground.AppHost/AppHost.cs** +**Purpose:** Aspire orchestration configuration + +**Changes:** +- Added Redis service: `builder.AddRedis("cache-redis")` +- Both web applications now reference the Redis instance via `.WithReference(redis)` +- This ensures both apps connect to the same Redis server + +```csharp +var redis = builder.AddRedis("cache-redis"); + +builder + .AddProject("webapplication1") + .WithReference(redis); + +builder + .AddProject("webapplication2") + .WithReference(redis); +``` + +--- + +### 2. **WebApplication1/WebApplication1.csproj** +**Purpose:** Project dependencies for App1 + +**Changes:** +- Added project reference to FusionCache core +- Added project reference to FusionCache Redis Backplane +- Added NuGet package: `StackExchange.Redis` v2.8.7 + +```xml + + + + + + + + +``` + +--- + +### 3. **WebApplication1/Program.cs** +**Purpose:** Application startup and API endpoints + +**Key Additions:** +- Redis connection initialization +- FusionCache registration with lazy memory factory (two-level cache) +- Redis backplane configuration +- `SharedDataService` class for cache operations +- Three API endpoints: + - `GET /data` - Retrieve cached data + - `POST /data` - Update cached data (triggers backplane invalidation) + - `GET /cache/info` - Show cache configuration + +**Features:** +- Automatic cache invalidation across apps +- Detailed logging for debugging +- REST API for easy testing + +--- + +### 4. **WebApplication2/WebApplication2.csproj** +**Purpose:** Project dependencies for App2 + +**Changes:** Identical to WebApplication1.csproj + +--- + +### 5. **WebApplication2/Program.cs** +**Purpose:** Application startup and API endpoints + +**Changes:** Identical structure to WebApplication1 but with: +- Unique cache key prefix: `app2:` (instead of `app1:`) +- Same endpoints and `SharedDataService` +- Both apps use the same cache key (`shared:data`) to demonstrate synchronization + +--- + +## 📁 Files Created + +### 1. **README.md** +**Purpose:** High-level overview and quick reference guide + +**Contents:** +- Architecture diagram +- How FusionCache backplane works +- Project structure +- Running instructions +- Example API scenarios +- Troubleshooting tips + +### 2. **SETUP_GUIDE.md** +**Purpose:** Comprehensive setup and testing guide + +**Contents:** +- Prerequisites and installation +- Quick start (5 steps) +- Detailed architecture explanation +- Four complete test scenarios +- File structure breakdown +- Advanced concepts +- Troubleshooting guide +- Common patterns +- Resources and next steps + +### 3. **WebApplication1/api-demo.http** +**Purpose:** REST client file for testing (works in VS Code REST Client or Bruno) + +**Features:** +- Test cache info endpoint +- Test cache hit/miss scenarios +- Test cross-app synchronization +- Ready-to-use requests with examples + +### 4. **WebApplication2/api-demo.http** +**Purpose:** Same as above but for WebApplication2 + +### 5. **CHANGES_SUMMARY.md** +**Purpose:** This file - documents all modifications + +--- + +## 🎯 What Was Implemented + +### Two-Level Cache Architecture +``` +Request → Memory Cache (L1) → Redis Cache (L2) → Factory Function + ↑ Invalidated by ↑ Shared between ↑ Generates data + │ Backplane │ apps │ on L1/L2 miss +``` + +### Cache Synchronization Flow +``` +WebApplication1: SetAsync("shared:data", "value1") + ↓ +FusionCache stores in memory + Redis + ↓ +Publishes to Redis Pub/Sub: "shared:data was modified" + ↓ +WebApplication2 receives message + ↓ +Removes "shared:data" from its memory cache (L1 invalidation) + ↓ +Next request: Cache miss → Fetch from Redis (L2) ✓ +``` + +### Three API Endpoints Per Application + +| Endpoint | Method | Purpose | +|----------|--------|---------| +| `/data` | GET | Retrieve cached data (hit or miss) | +| `/data` | POST | Update cache value (invalidates in all apps) | +| `/cache/info` | GET | Display cache configuration | + +--- + +## 🚀 How to Use + +### Start the Example +```bash +cd eaxmple/Playground/Playground.AppHost +dotnet run +``` + +### Access the Applications +- **WebApplication1:** https://localhost:7001 +- **WebApplication2:** https://localhost:7002 +- **Aspire Dashboard:** http://localhost:15000 + +### Test Cache Synchronization +1. Call `GET https://localhost:7001/data` → Cache miss, generates data +2. Call `GET https://localhost:7002/data` → Same data! (shared via Redis) +3. Call `POST https://localhost:7002/data` with new value → Updates cache +4. Call `GET https://localhost:7001/data` → New value! (backplane invalidated memory cache) + +--- + +## 🔑 Key Configuration Points + +### AppHost Configuration +- Redis service name: `cache-redis` +- Apps referenced Redis: Enables service discovery +- Connection string auto-managed by Aspire + +### FusionCache Configuration +- **Cache Duration:** 30 seconds (both L1 and L2) +- **Memory Factory:** Lazy (created on first use) +- **Redis Backplane:** Enabled for invalidation broadcasts +- **Cache Key Prefix:** `app1:` and `app2:` (unique per app) + +### SharedDataService +- **Cache Key:** `shared:data` (same in both apps) +- **Factory Function:** Returns app name + timestamp when cache misses +- **Logging:** Detailed logs for debugging + +--- + +## 🧪 Testing Scenarios Included + +### Test 1: Basic Cache Hit +✓ Verify same request returns same data quickly + +### Test 2: Cache Sharing +✓ Verify App2 sees data generated by App1 + +### Test 3: Backplane Invalidation +✓ Verify updating in App2 invalidates App1's cache + +### Test 4: Cache Duration +✓ Verify cache expires after 30 seconds + +Each scenario is documented in `SETUP_GUIDE.md` with curl commands. + +--- + +## 📊 Project Dependencies + +### Direct Package References +``` +StackExchange.Redis v2.8.7 + ↓ +ZiggyCreatures.FusionCache (local) +ZiggyCreatures.FusionCache.Backplane.StackExchangeRedis (local) + ↓ +Microsoft.AspNetCore.* (implicit via Web SDK) +``` + +### Service Dependencies (Runtime) +``` +WebApplication1 ┐ + ├→ Redis (cache-redis) +WebApplication2 ┘ +``` + +--- + +## 🎓 Learning Outcomes + +After running this example, you'll understand: + +✅ **FusionCache Basics** +- Two-level caching (memory + distributed) +- GetOrSetAsync pattern +- Cache expiration + +✅ **Redis Backplane** +- How invalidations are broadcast +- Pub/Sub messaging pattern +- Cross-application cache synchronization + +✅ **.NET Aspire** +- Service orchestration +- Service discovery +- Container management + +✅ **Distributed Caching Patterns** +- Cache-aside pattern +- Write-through updates +- Handling cache staleness + +--- + +## 🔧 Next Steps + +### To Extend This Example + +1. **Add Database Integration** + ```csharp + // Fetch data from database in factory + public async Task GetUserAsync(int id) + { + return await _cache.GetOrSetAsync( + $"user:{id}", + async ct => await _db.GetUserAsync(id) + ); + } + ``` + +2. **Add Error Handling** + ```csharp + options.WithOptions(opt => + opt.SetFailSafeMaxDuration(TimeSpan.FromMinutes(5)) + ); + ``` + +3. **Monitor Cache Performance** + ```csharp + options.WithOptions(opt => + opt.SetEagerRefreshThreshold(0.8) // Refresh at 80% of TTL + ); + ``` + +4. **Add More Cache Keys** + ```csharp + private const string CacheKey1 = "shared:data"; + private const string CacheKey2 = "shared:config"; + private const string CacheKey3 = "shared:users"; + ``` + +5. **Implement Distributed Locks** + - Use Redis locks to prevent thundering herd + - See FusionCache documentation for patterns + +--- + +## 📝 Files at a Glance + +| File | Status | Purpose | +|------|--------|---------| +| `Playground.AppHost/AppHost.cs` | ✏️ Modified | Aspire orchestration | +| `WebApplication1/WebApplication1.csproj` | ✏️ Modified | Dependencies | +| `WebApplication1/Program.cs` | ✏️ Modified | App setup & endpoints | +| `WebApplication2/WebApplication2.csproj` | ✏️ Modified | Dependencies | +| `WebApplication2/Program.cs` | ✏️ Modified | App setup & endpoints | +| `README.md` | ✨ New | Quick reference | +| `SETUP_GUIDE.md` | ✨ New | Comprehensive guide | +| `CHANGES_SUMMARY.md` | ✨ New | This document | +| `WebApplication1/api-demo.http` | ✨ New | API test requests | +| `WebApplication2/api-demo.http` | ✨ New | API test requests | + +--- + +## ✅ Verification Checklist + +Before running, ensure: +- [ ] .NET 10.0 SDK installed (`dotnet --version`) +- [ ] Docker Desktop running (`docker ps`) +- [ ] Redis is not already running on port 6379 +- [ ] Port 7001, 7002, 15000 are available +- [ ] Git repository is up to date + +After running: +- [ ] Aspire dashboard loads at http://localhost:15000 +- [ ] All three services show green (redis, webapplication1, webapplication2) +- [ ] Can access https://localhost:7001/cache/info +- [ ] Can access https://localhost:7002/cache/info +- [ ] Cache synchronization test works (see SETUP_GUIDE.md) + +--- + +## 🆘 Common Issues + +**Problem:** Redis connection fails +**Solution:** Ensure Docker is running: `docker ps` + +**Problem:** Port already in use +**Solution:** Change ports in launchSettings.json or kill existing process + +**Problem:** Apps not syncing +**Solution:** Check logs for backplane errors; verify Redis is healthy + +**Problem:** Cache stays stale +**Solution:** Check TTL setting; adjust `WithDefaultDuration()` if needed + +--- + +## 📚 Additional Resources + +- **FusionCache Wiki:** https://github.com/ZiggyCreatures/FusionCache/wiki +- **Redis Pub/Sub:** https://redis.io/docs/interact/pubsub/ +- **.NET Aspire Docs:** https://learn.microsoft.com/aspire +- **StackExchange.Redis:** https://stackexchange.github.io/StackExchange.Redis/ + +--- + +**Version:** 1.0 +**Date:** 2024-06-20 +**Author:** Claude Code +**License:** See repository license diff --git a/eaxmple/Playground/Playground.AppHost/AppHost.cs b/eaxmple/Playground/Playground.AppHost/AppHost.cs new file mode 100644 index 00000000..7707b970 --- /dev/null +++ b/eaxmple/Playground/Playground.AppHost/AppHost.cs @@ -0,0 +1,26 @@ +var builder = DistributedApplication.CreateBuilder(args); + +var redis = builder.AddRedis("cache-redis").WithRedisInsight(); +var serviceBus = builder + .AddAzureServiceBus("cache-servicebus") + .RunAsEmulator(c => + { + c.WithLifetime(ContainerLifetime.Persistent); + c.WithContainerName("cache_serviceBus"); + }); + +var topic = serviceBus.AddServiceBusTopic("fusioncache-playground"); +topic.AddServiceBusSubscription("webApp1-sub"); +topic.AddServiceBusSubscription("webApp2-sub"); + +builder + .AddProject("webapplication1") + .WithReference(redis) + .WithReference(serviceBus).WaitFor(serviceBus); + +builder + .AddProject("webapplication2") + .WithReference(redis) + .WithReference(serviceBus).WaitFor(serviceBus); + +builder.Build().Run(); diff --git a/eaxmple/Playground/Playground.AppHost/Playground.AppHost.csproj b/eaxmple/Playground/Playground.AppHost/Playground.AppHost.csproj new file mode 100644 index 00000000..6a4b607c --- /dev/null +++ b/eaxmple/Playground/Playground.AppHost/Playground.AppHost.csproj @@ -0,0 +1,21 @@ + + + + Exe + net10.0 + enable + enable + 49525522-f3ee-4ff4-8ba7-d948c8b09a39 + + + + + + + + + + + + + diff --git a/eaxmple/Playground/Playground.AppHost/Properties/launchSettings.json b/eaxmple/Playground/Playground.AppHost/Properties/launchSettings.json new file mode 100644 index 00000000..fe775e00 --- /dev/null +++ b/eaxmple/Playground/Playground.AppHost/Properties/launchSettings.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17158;http://localhost:15235", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21272", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:23085", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22031" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15235", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19244", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://localhost:18008", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20253" + } + } + } +} diff --git a/eaxmple/Playground/Playground.AppHost/appsettings.Development.json b/eaxmple/Playground/Playground.AppHost/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/eaxmple/Playground/Playground.AppHost/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/eaxmple/Playground/Playground.AppHost/appsettings.json b/eaxmple/Playground/Playground.AppHost/appsettings.json new file mode 100644 index 00000000..31c092aa --- /dev/null +++ b/eaxmple/Playground/Playground.AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/eaxmple/Playground/Playground.ServiceDefaults/Extensions.cs b/eaxmple/Playground/Playground.ServiceDefaults/Extensions.cs new file mode 100644 index 00000000..b72c8753 --- /dev/null +++ b/eaxmple/Playground/Playground.ServiceDefaults/Extensions.cs @@ -0,0 +1,127 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.ServiceDiscovery; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +namespace Microsoft.Extensions.Hosting; + +// Adds common Aspire services: service discovery, resilience, health checks, and OpenTelemetry. +// This project should be referenced by each service project in your solution. +// To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults +public static class Extensions +{ + private const string HealthEndpointPath = "/health"; + private const string AlivenessEndpointPath = "/alive"; + + public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.ConfigureOpenTelemetry(); + + builder.AddDefaultHealthChecks(); + + builder.Services.AddServiceDiscovery(); + + builder.Services.ConfigureHttpClientDefaults(http => + { + // Turn on resilience by default + http.AddStandardResilienceHandler(); + + // Turn on service discovery by default + http.AddServiceDiscovery(); + }); + + // Uncomment the following to restrict the allowed schemes for service discovery. + // builder.Services.Configure(options => + // { + // options.AllowedSchemes = ["https"]; + // }); + + return builder; + } + + public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation(); + }) + .WithTracing(tracing => + { + tracing.AddSource(builder.Environment.ApplicationName) + .AddAspNetCoreInstrumentation(tracing => + // Exclude health check requests from tracing + tracing.Filter = context => + !context.Request.Path.StartsWithSegments(HealthEndpointPath) + && !context.Request.Path.StartsWithSegments(AlivenessEndpointPath) + ) + // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) + //.AddGrpcClientInstrumentation() + .AddHttpClientInstrumentation(); + }); + + builder.AddOpenTelemetryExporters(); + + return builder; + } + + private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + + if (useOtlpExporter) + { + builder.Services.AddOpenTelemetry().UseOtlpExporter(); + } + + // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package) + //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"])) + //{ + // builder.Services.AddOpenTelemetry() + // .UseAzureMonitor(); + //} + + return builder; + } + + public static TBuilder AddDefaultHealthChecks(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Services.AddHealthChecks() + // Add a default liveness check to ensure app is responsive + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + return builder; + } + + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + // Adding health checks endpoints to applications in non-development environments has security implications. + // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments. + if (app.Environment.IsDevelopment()) + { + // All health checks must pass for app to be considered ready to accept traffic after starting + app.MapHealthChecks(HealthEndpointPath); + + // Only health checks tagged with the "live" tag must pass for app to be considered alive + app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("live") + }); + } + + return app; + } +} diff --git a/eaxmple/Playground/Playground.ServiceDefaults/Playground.ServiceDefaults.csproj b/eaxmple/Playground/Playground.ServiceDefaults/Playground.ServiceDefaults.csproj new file mode 100644 index 00000000..73c3375e --- /dev/null +++ b/eaxmple/Playground/Playground.ServiceDefaults/Playground.ServiceDefaults.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + enable + enable + true + + + + + + + + + + + + + + + diff --git a/eaxmple/Playground/QUICK_START.md b/eaxmple/Playground/QUICK_START.md new file mode 100644 index 00000000..28e593ae --- /dev/null +++ b/eaxmple/Playground/QUICK_START.md @@ -0,0 +1,255 @@ +# Quick Start - FusionCache Redis Backplane Example + +## ⚡ 5-Minute Setup + +### Step 1: Prerequisites Check (30 seconds) +```bash +# Check .NET SDK +dotnet --version +# Expected: 10.0.x or higher + +# Check Docker +docker --version +# Expected: Docker version 20.x or higher + +# Ensure Docker is running +docker ps +# Should show running containers (may be empty) +``` + +### Step 2: Navigate to Project (10 seconds) +```bash +cd D:\Programming\0.Practice\Contributions\FusionCache-AboubakrFork\eaxmple\Playground\Playground.AppHost +``` + +### Step 3: Run Aspire (2 minutes) +```bash +dotnet run +``` + +**Wait for output:** +``` +Building... +Starting services... +Aspire dashboard available at http://localhost:15000 +``` + +### Step 4: Open Aspire Dashboard (10 seconds) +- Open browser: http://localhost:15000 +- You should see: + - ✅ cache-redis (green) + - ✅ webapplication1 (green) + - ✅ webapplication2 (green) + +### Step 5: Test Cache Synchronization (1 minute) + +**Option A: Using PowerShell** +```powershell +# Get data from App1 +Invoke-RestMethod -Uri "https://localhost:7001/data" -SkipCertificateCheck + +# Get data from App2 (should be same!) +Invoke-RestMethod -Uri "https://localhost:7002/data" -SkipCertificateCheck + +# Update from App2 +Invoke-RestMethod -Uri "https://localhost:7002/data" ` + -Method Post ` + -Body '"Updated data"' ` + -ContentType "application/json" ` + -SkipCertificateCheck + +# Check App1 (should have new data!) +Invoke-RestMethod -Uri "https://localhost:7001/data" -SkipCertificateCheck +``` + +**Option B: Using curl** +```bash +# Get data from App1 +curl -k https://localhost:7001/data + +# Get data from App2 +curl -k https://localhost:7002/data + +# Update from App2 +curl -X POST https://localhost:7002/data \ + -H "Content-Type: application/json" \ + -d '"Updated data"' \ + -k + +# Check App1 +curl -k https://localhost:7001/data +``` + +**Option C: Using REST Client in VS Code** +1. Open `WebApplication1/api-demo.http` +2. Run the requests in order +3. Observe cache behavior + +--- + +## 📝 What You Should See + +### Console Output +``` +[Information] WebApplication1: Cache miss for shared:data, generating data +[Information] WebApplication2: Cache miss for shared:data, generating data +[Information] WebApplication2: Setting cache value: Updated data +``` + +### API Responses + +**Get from App1:** +```json +{ + "appName": "WebApplication1", + "timestamp": "2024-06-20T12:00:00Z", + "data": "Data from WebApplication1 at 2024-06-20T12:00:00Z" +} +``` + +**Get from App2 (same data!):** +```json +{ + "appName": "WebApplication2", + "timestamp": "2024-06-20T12:00:05Z", + "data": "Data from WebApplication1 at 2024-06-20T12:00:00Z" +} +``` + +**After Update from App2:** +```json +{ + "appName": "WebApplication1", + "timestamp": "2024-06-20T12:00:10Z", + "data": "Updated data" +} +``` + +--- + +## ✅ Success Criteria + +Your setup is working correctly if: + +- [ ] Aspire dashboard shows all three services green +- [ ] Both apps return the same data timestamp on first access +- [ ] Updating in App2 changes the data in App1 +- [ ] No errors in the console logs +- [ ] You see "Cache miss" logged only once until cache expires + +--- + +## 🔍 Debugging + +### Check Redis is Running +```bash +# In another terminal +docker ps | findstr redis +# Should show: fusioncache_cache-redis +``` + +### Check Logs for Errors +Look at the Aspire dashboard console output for: +``` +[Error] Redis connection failed +[Error] Backplane initialization failed +``` + +### Verify Connections +```powershell +# Check App1 is accessible +Invoke-RestMethod -Uri "https://localhost:7001/cache/info" -SkipCertificateCheck + +# Check App2 is accessible +Invoke-RestMethod -Uri "https://localhost:7002/cache/info" -SkipCertificateCheck +``` + +--- + +## 📚 What Happened + +1. ✅ **Aspire** orchestrated Redis, App1, and App2 +2. ✅ **FusionCache** set up two-level caching in each app +3. ✅ **Redis Backplane** connected both apps to the same Redis instance +4. ✅ **Cache Key** `shared:data` is shared between both apps +5. ✅ **Invalidation** broadcast when App2 updated the cache +6. ✅ **Synchronization** App1's memory cache was cleared automatically + +--- + +## 🎯 Next Steps + +### Learn More +- Read `README.md` for architecture overview +- Read `SETUP_GUIDE.md` for detailed explanation +- Check `CHANGES_SUMMARY.md` for what was implemented + +### Advanced Testing +```bash +# Wait 31 seconds for cache to expire +# Then get data - should generate new timestamp +curl -k https://localhost:7001/data + +# Watch logs - should see "Cache miss" +``` + +### Extend the Example +1. Add database integration +2. Add error handling +3. Add monitoring +4. Add more cache keys +5. Add distributed locks + +--- + +## ⚠️ Troubleshooting + +### "Timeout connecting to cache-redis" +**Fix:** Ensure Docker is running +```bash +docker ps +docker start # if stopped +``` + +### "Port 7001 already in use" +**Fix:** Kill existing process or change port in launchSettings.json + +### "Apps not synchronizing" +**Fix:** +1. Check both apps use same `AddRedis()` reference +2. Wait a moment - Redis Pub/Sub has slight latency +3. Check logs for connection errors + +### "Cache stays stale" +**Fix:** Check TTL - adjust `WithDefaultDuration()` or manually clear + +--- + +## 🆘 Need Help? + +1. **Check logs** - Aspire dashboard shows detailed output +2. **Review `SETUP_GUIDE.md`** - Has troubleshooting section +3. **Run tests manually** - Use `api-demo.http` files +4. **Inspect Redis** - Use `docker exec cache-redis redis-cli keys '*'` + +--- + +## 🎉 You're Done! + +You now have a working example of: +- ✅ FusionCache with two-level caching +- ✅ Redis backplane for cache synchronization +- ✅ Aspire orchestration of multiple services +- ✅ Distributed caching pattern + +**Total time: ~5 minutes** + +Next, explore the code and understand how cache invalidation works! + +--- + +**Tips:** +- Keep Aspire dashboard open to see logs in real-time +- Use the REST Client extension in VS Code for easy testing +- Check Docker stats: `docker stats cache-redis` +- Monitor Redis keys: `docker exec cache-redis redis-cli monitor` diff --git a/eaxmple/Playground/README.md b/eaxmple/Playground/README.md new file mode 100644 index 00000000..53e1a05c --- /dev/null +++ b/eaxmple/Playground/README.md @@ -0,0 +1,264 @@ +# FusionCache Redis Backplane Example + +This example demonstrates how to use **FusionCache** with a **Redis backplane** to synchronize cached data across multiple applications running on **.NET Aspire**. + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ │ +│ .NET Aspire Orchestrator │ +│ │ +│ ┌────────────────────┐ ┌────────────────────┐ │ +│ │ WebApplication1 │ │ WebApplication2 │ │ +│ │ (FusionCache) │ │ (FusionCache) │ │ +│ │ Cache Key Prefix: │◄───────►│ Cache Key Prefix: │ │ +│ │ app1: │ Backplane│ app2: │ │ +│ └────────────────────┘ └────────────────────┘ │ +│ ▲ ▲ │ +│ │ │ │ +│ └────────────┬────────────────────┘ │ +│ │ Redis Protocol │ +│ ┌─────▼──────┐ │ +│ │ Redis │ │ +│ │ Backplane │ │ +│ │ (cache- │ │ +│ │ redis) │ │ +│ └────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## How It Works + +### 1. **FusionCache Setup** +- Each application configures FusionCache with a **lazy memory factory** (two-level cache) +- The **Redis backplane** intercepts cache invalidations and broadcasts them across all connected instances +- Each app has its own cache key prefix (`app1:` and `app2:`) but shares the `shared:data` key + +### 2. **Data Synchronization** +- When **WebApplication1** updates the cache, Redis broadcasts an invalidation message +- **WebApplication2** receives the message and removes the value from its local memory cache +- Next access on **WebApplication2** fetches the fresh value from Redis or regenerates it + +### 3. **Aspire Integration** +- `Playground.AppHost` orchestrates the entire solution +- Redis runs as a containerized service managed by Aspire +- Both web applications receive the Redis connection string via service discovery + +## Project Structure + +``` +eaxmple/Playground/ +├── Playground.AppHost/ +│ ├── AppHost.cs # Aspire configuration +│ └── Playground.AppHost.csproj # References both web apps +│ +├── Playground.ServiceDefaults/ +│ └── Extensions.cs # Shared service configuration +│ +├── WebApplication1/ +│ ├── Program.cs # FusionCache + Redis setup +│ └── WebApplication1.csproj # References FusionCache & Redis packages +│ +├── WebApplication2/ +│ ├── Program.cs # FusionCache + Redis setup +│ └── WebApplication2.csproj # References FusionCache & Redis packages +│ +└── README.md # This file +``` + +## Running the Example + +### Prerequisites +- .NET 10.0 SDK +- Docker (required for Redis in Aspire) + +### Start the Solution + +```bash +cd eaxmple/Playground/Playground.AppHost +dotnet run +``` + +This will: +1. Start the Aspire orchestrator dashboard (usually at `http://localhost:15000`) +2. Spin up Redis container +3. Launch WebApplication1 (usually at `https://localhost:7001`) +4. Launch WebApplication2 (usually at `https://localhost:7002`) + +## API Endpoints + +### WebApplication1 + +| Endpoint | Method | Purpose | +|----------|--------|---------| +| `/data` | GET | Retrieve cached data | +| `/data` | POST | Update cached data (triggers backplane invalidation) | +| `/cache/info` | GET | Show cache configuration | + +### WebApplication2 + +Same endpoints as WebApplication1, running on a different port. + +## Example Scenario + +### Step 1: Check Cache Info +```bash +# Terminal 1: Check App1 cache configuration +curl https://localhost:7001/cache/info -k + +# Output: +# { +# "appName": "WebApplication1", +# "message": "FusionCache with Redis Backplane enabled", +# "cacheKeyPrefix": "app1:", +# "defaultDuration": "30 seconds" +# } +``` + +### Step 2: Get Data from App1 +```bash +curl https://localhost:7001/data -k + +# Output: +# { +# "appName": "WebApplication1", +# "timestamp": "2024-06-20T12:00:00Z", +# "data": "Data from WebApplication1 at 2024-06-20T12:00:00Z" +# } +``` + +### Step 3: Get Data from App2 (Same Data!) +```bash +curl https://localhost:7002/data -k + +# Output: +# { +# "appName": "WebApplication2", +# "timestamp": "2024-06-20T12:00:00Z", +# "data": "Data from WebApplication1 at 2024-06-20T12:00:00Z" +# } +``` + +Notice how **WebApplication2 returns the same data** that was generated by **WebApplication1**. The backplane has synchronized the cache across both instances. + +### Step 4: Update Data from App2 +```bash +curl -X POST "https://localhost:7002/data" \ + -H "Content-Type: application/json" \ + -d '"Updated value from App2"' \ + -k + +# Output: +# { +# "appName": "WebApplication2", +# "timestamp": "2024-06-20T12:01:00Z", +# "message": "Data updated to: Updated value from App2" +# } +``` + +### Step 5: Verify Synchronization in App1 +```bash +curl https://localhost:7001/data -k + +# Output: +# { +# "appName": "WebApplication1", +# "timestamp": "2024-06-20T12:01:30Z", +# "data": "Updated value from App2" +# } +``` + +The **backplane invalidated the cache in App1**, causing it to fetch the fresh value from Redis! + +## Key Configuration Details + +### AppHost.cs +```csharp +var redis = builder.AddRedis("cache-redis"); +builder.AddProject("webapplication1").WithReference(redis); +builder.AddProject("webapplication2").WithReference(redis); +``` +- Adds Redis as a managed service +- Both apps reference the same Redis instance + +### Program.cs (Both Apps) +```csharp +builder.Services.AddFusionCache(options => +{ + options + .WithDefaultDuration(TimeSpan.FromSeconds(30)) + .WithLazyMemoryFactory(); +}) +.WithBackplane( + new RedisBackplane( + new RedisBackplaneOptions + { + Connection = redis, + CacheKeyPrefix = "app1:" // or "app2:" for App2 + } + ) +); +``` +- **Lazy Memory Factory**: Two-level cache (memory + Redis) +- **Redis Backplane**: Handles cross-app invalidation + +### SharedDataService Class +```csharp +class SharedDataService +{ + public async Task GetDataAsync() + { + return await _cache.GetOrSetAsync("shared:data", ...); + } + + public async Task SetDataAsync(string value) + { + await _cache.SetAsync("shared:data", ...); + } +} +``` +- Both applications use the **same cache key** (`shared:data`) +- `GetOrSetAsync` + backplane = distributed caching pattern +- Updates trigger automatic invalidation broadcasts + +## Benefits of This Setup + +1. **Reduced Database Load**: Cache hits are served from memory or Redis +2. **Data Consistency**: Backplane invalidations ensure all instances see fresh data +3. **Horizontal Scaling**: Add more application instances - they all stay synchronized +4. **Simple Configuration**: Aspire handles Redis lifecycle; apps just reference it +5. **No Cache Staleness**: When one app updates data, others are notified immediately + +## Monitoring + +You can watch cache activity in the application logs: + +``` +[WebApplication1] Cache miss for shared:data, generating data +[WebApplication2] Setting cache value: Updated value from App2 +[WebApplication2] Cache miss for shared:data, generating data +``` + +The logs show exactly when cache hits/misses occur and when backplane synchronization happens. + +## Troubleshooting + +### Redis Connection Issues +- Ensure Docker is running +- Check that the connection string matches: `:` (Aspire resolves this) + +### Cache Not Synchronizing +- Verify both apps are using the same `RedisBackplane` instance +- Check Redis is accessible: `redis-cli PING` or use Aspire dashboard + +### Stale Data +- Adjust `WithDefaultDuration()` if cached data is too stale +- Set it to `0` to disable caching and always fetch fresh data (for debugging) + +## Further Reading + +- [FusionCache Documentation](https://github.com/ZiggyCreatures/FusionCache) +- [Redis Backplane Details](https://github.com/ZiggyCreatures/FusionCache/tree/main/src/ZiggyCreatures.FusionCache.Backplane.StackExchangeRedis) +- [.NET Aspire Docs](https://learn.microsoft.com/en-us/dotnet/aspire/get-started/aspire-overview) diff --git a/eaxmple/Playground/SETUP_GUIDE.md b/eaxmple/Playground/SETUP_GUIDE.md new file mode 100644 index 00000000..8bd473a0 --- /dev/null +++ b/eaxmple/Playground/SETUP_GUIDE.md @@ -0,0 +1,478 @@ +# FusionCache Redis Backplane - Complete Setup Guide + +## Overview + +This example demonstrates **FusionCache** with a **Redis backplane** for distributed cache synchronization across multiple applications. Perfect for understanding how FusionCache handles cache invalidation in microservices architectures. + +## What You'll Learn + +✅ How to configure FusionCache with Redis backplane +✅ How cache invalidations are broadcast across applications +✅ How Aspire orchestrates multi-app solutions with Redis +✅ Real-world patterns for distributed caching + +## Prerequisites + +### Required +- **.NET 10.0 SDK** or later ([Download](https://dotnet.microsoft.com/download/dotnet)) +- **Docker Desktop** (for Redis container) + +### Recommended +- Visual Studio 2022 or VS Code with C# extensions +- REST client (Bruno, Postman, or use REST Client extension in VS Code) + +## Quick Start + +### 1. Clone/Navigate to Repository +```bash +cd D:\Programming\0.Practice\Contributions\FusionCache-AboubakrFork +cd eaxmple\Playground +``` + +### 2. Restore and Build +```bash +# Navigate to AppHost directory +cd Playground.AppHost + +# Restore NuGet packages +dotnet restore + +# Build the solution +dotnet build +``` + +### 3. Run with Aspire +```bash +# From Playground.AppHost directory +dotnet run +``` + +**What happens next:** +1. Aspire starts and opens a dashboard (usually `http://localhost:15000`) +2. Redis container is pulled and started +3. WebApplication1 launches (typically `https://localhost:7001`) +4. WebApplication2 launches (typically `https://localhost:7002`) + +### 4. Verify It's Running +Open the Aspire dashboard and you should see: +- ✅ `cache-redis` - Redis container (green) +- ✅ `webapplication1` - First web app (green) +- ✅ `webapplication2` - Second web app (green) + +## Understanding the Setup + +### Architecture Components + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ .NET Aspire Orchestrator │ +│ (Playground.AppHost) │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ Application Instance 1 Application Instance 2 │ +│ (WebApplication1) (WebApplication2) │ +│ ┌──────────────────────┐ ┌──────────────────────┐ │ +│ │ FusionCache │ │ FusionCache │ │ +│ │ ┌────────────────┐ │ │ ┌────────────────┐ │ │ +│ │ │ Memory Cache │ │ │ │ Memory Cache │ │ │ +│ │ │ (L1) │ │ │ │ (L1) │ │ │ +│ │ └────────┬───────┘ │ │ └────────┬───────┘ │ │ +│ │ │ │ │ │ │ │ +│ │ ┌────────▼───────┐ │ │ ┌────────▼───────┐ │ │ +│ │ │ Redis Cache │ │ │ │ Redis Cache │ │ │ +│ │ │ (L2) │ │ │ │ (L2) │ │ │ +│ │ └────────┬───────┘ │ │ └────────┬───────┘ │ │ +│ │ │ │ │ │ │ │ +│ │ ┌────────▼──────────────────────────────▼──────┐ │ │ +│ │ │ Redis Backplane (Broadcast Channel) │ │ │ +│ │ └──────────────────────────────────────────────┘ │ │ +│ │ ▲ │ │ +│ │ │ │ │ +│ └──────────────────┼──────────────────────────────────┘ │ +│ │ │ +│ ┌────────▼────────┐ │ +│ │ Redis Server │ │ +│ │ (cache-redis) │ │ +│ │ │ │ +│ │ - Data Store │ │ +│ │ - Pub/Sub │ │ +│ │ - Backplane │ │ +│ │ Messages │ │ +│ └─────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Two-Level Cache (L1/L2) + +**WebApplication1:** +1. Request comes in for "shared:data" +2. L1 (Memory): Check local memory cache first ⚡ (fastest) +3. L1 Miss: Check L2 (Redis) 🚀 (fast) +4. L2 Miss: Execute factory function (slowest, but cached for next request) + +**WebApplication2:** +1. Request comes in for "shared:data" +2. L1 (Memory): Check local memory cache first ⚡ +3. **Key Point:** L1 entry was removed by backplane invalidation +4. L1 Miss: Check L2 (Redis) 🚀 (has the value from App1!) +5. Use value from Redis ✅ + +### Redis Backplane (Pub/Sub) + +When **WebApplication1 updates** a cache key: + +``` +1. SetAsync("shared:data", "new value") + ↓ +2. FusionCache stores in local memory + ↓ +3. FusionCache publishes invalidation message to Redis Pub/Sub + ↓ +4. Redis broadcasts: "shared:data was invalidated" + ↓ +5. WebApplication2 receives message + ↓ +6. WebApplication2 removes "shared:data" from its memory cache + ↓ +7. Next request gets FRESH data from Redis (or factory) +``` + +## Testing the Backplane + +### Test 1: Basic Cache Hit + +**Terminal 1:** +```bash +curl -k https://localhost:7001/data +# First request - cache miss, generates data +``` + +**Terminal 2:** +```bash +curl -k https://localhost:7001/data +# Second request - cache hit from memory (same timestamp) +``` + +**Expected:** Both requests return the same timestamp. + +--- + +### Test 2: Cache Sharing Between Apps + +**Terminal 1:** +```bash +curl -k https://localhost:7001/data +# Generates data at 12:00:00Z +``` + +**Terminal 2:** +```bash +curl -k https://localhost:7002/data +# Should return the SAME data generated by App1 +# Timestamp: 12:00:00Z (not a new timestamp!) +``` + +**Why?** Both apps share the same Redis instance through the backplane. + +--- + +### Test 3: Backplane Invalidation + +**Terminal 1:** +```bash +# App1 has cached data +curl -k https://localhost:7001/data +# Returns: "Data from WebApplication1 at 2024-06-20T12:00:00Z" + +# Cache is warm, second request hits memory +curl -k https://localhost:7001/data +``` + +**Terminal 2:** +```bash +# Update from App2 +curl -X POST https://localhost:7002/data \ + -H "Content-Type: application/json" \ + -d '"Updated by App2"' \ + -k +# Returns: "Data updated to: Updated by App2" +``` + +**Terminal 1 (again):** +```bash +# App1's cache was INVALIDATED by the backplane! +curl -k https://localhost:7001/data +# Returns: "Updated by App2" (fresh value from Redis) +``` + +**What happened:** +1. ✅ App2 called `SetAsync()` with new value +2. ✅ FusionCache stored it in Redis +3. ✅ FusionCache published invalidation to all subscribers +4. ✅ App1 received the invalidation message +5. ✅ App1 removed the key from memory cache +6. ✅ Next request fetches from Redis ✨ + +--- + +### Test 4: Cache Duration + +**Setup:** +```bash +# Get data from App1 +curl -k https://localhost:7001/data +# Returns: "Data from WebApplication1 at 12:00:00Z" +``` + +**Wait 31 seconds** (cache duration is 30s) + +**Check again:** +```bash +# Cache expired in both L1 and L2! +curl -k https://localhost:7001/data +# Returns: NEW timestamp "Data from WebApplication1 at 12:00:31Z" +``` + +**Note:** The factory function is executed again because both cache levels expired. + +## File Structure Explained + +### Playground.AppHost/AppHost.cs +```csharp +var redis = builder.AddRedis("cache-redis"); + +builder + .AddProject("webapplication1") + .WithReference(redis); // App1 gets Redis connection + +builder + .AddProject("webapplication2") + .WithReference(redis); // App2 gets Redis connection +``` + +**Key Points:** +- `AddRedis()` creates a managed Redis container +- `WithReference()` injects the connection string +- Aspire handles service discovery automatically + +### WebApplication1/Program.cs & WebApplication2/Program.cs + +```csharp +// 1. Connect to Redis +var redis = ConnectionMultiplexer.Connect(redisConnectionString); + +// 2. Add FusionCache with lazy memory factory (two-level cache) +builder.Services.AddFusionCache(options => +{ + options + .WithDefaultDuration(TimeSpan.FromSeconds(30)) + .WithLazyMemoryFactory(); +}) +// 3. Add Redis backplane for invalidation broadcasts +.WithBackplane( + new RedisBackplane( + new RedisBackplaneOptions + { + Connection = redis, + CacheKeyPrefix = "app1:" // Unique per app + } + ) +); +``` + +**Configuration Explained:** +- **`WithLazyMemoryFactory()`:** Creates memory cache on first use (two-level) +- **`DefaultDuration`:** 30 seconds - then cache expires +- **`RedisBackplane`:** Enables Pub/Sub for invalidation broadcasts +- **`CacheKeyPrefix`:** Prevents key conflicts between apps + +### SharedDataService Class + +```csharp +class SharedDataService +{ + // Both apps use THE SAME cache key: "shared:data" + private const string CacheKey = "shared:data"; + + public async Task GetDataAsync() + { + // GetOrSetAsync: + // - Returns cached value if present + // - Executes factory and caches result if missing + // - Other apps are notified of L1 invalidation + return await _cache.GetOrSetAsync( + CacheKey, + async ct => $"Data from {AppName} at {DateTime.UtcNow:O}", + options => options.SetDuration(TimeSpan.FromSeconds(30)) + ); + } + + public async Task SetDataAsync(string value) + { + // SetAsync: + // - Sets value in memory and Redis + // - Broadcasts invalidation through backplane + // - Other apps remove the key from memory + await _cache.SetAsync(CacheKey, value, + options => options.SetDuration(TimeSpan.FromSeconds(30)) + ); + } +} +``` + +## Advanced Concepts + +### Cache Key Prefix Strategy + +``` +WebApplication1: app1:shared:data +WebApplication2: app2:shared:data +``` + +Both apps can have their own cache entries, but they both also subscribe to `shared:data` changes through the backplane. + +### When Backplane Invalidation Happens + +✅ **Happens:** +- `SetAsync()` - Set a value +- `RemoveAsync()` - Remove a key +- `ExpireAsync()` - Expire a key +- `ClearAsync()` - Clear all + +❌ **Doesn't Happen:** +- `GetOrSetAsync()` - Only on factory execution failure +- Direct memory cache hits +- Cache expiration (local to each app) + +### Performance Implications + +``` +Cache Scenario | Speed | Network Calls +─────────────────────────┼─────────┼────────────── +L1 Hit (Memory) | ⚡⚡⚡ | None +L2 Hit (Redis) | ⚡⚡ | 1 +Factory Execution | ⚡ | 1 (to store) +Cross-App Invalidation | ⚡ | 1 (backplane message) +``` + +## Troubleshooting + +### Problem: Redis Connection Failed +**Error:** `Timeout connecting to cache-redis:6379` + +**Solution:** +1. Ensure Docker Desktop is running +2. Check Aspire dashboard - is Redis green? +3. Manually test: `docker ps | grep redis` + +### Problem: Apps Not Synchronizing +**Error:** App2 doesn't see the data from App1 + +**Solution:** +1. Check both apps have the same `CacheKey` value +2. Verify Redis backplane is initialized in both +3. Check logs: should see "Cache miss" on first access +4. Wait - Redis Pub/Sub is fast but not instant (~10ms) + +### Problem: Cache Not Expiring +**Symptom:** Data doesn't change even after 30 seconds + +**Solution:** +1. Check the duration setting: `WithDefaultDuration(TimeSpan.FromSeconds(30))` +2. Manually clear: Call the endpoint twice rapidly +3. Check logs for expiration messages + +### Problem: Service Discovery Issues +**Error:** `System.Net.Http.HttpRequestException: No such host is known` + +**Solution:** +1. Ensure you're using the service name from Aspire: `cache-redis` +2. Check `appsettings.json` for correct connection string format +3. Aspire's service discovery converts `cache-redis` → `localhost:6379` + +## Running Without Aspire (Advanced) + +If you need to run without Aspire, update the connection string: + +```csharp +// Replace this: +var redisConnectionString = builder.Configuration.GetConnectionString("cache-redis"); + +// With this: +var redisConnectionString = "localhost:6379"; + +// Make sure Redis is running on localhost:6379 +``` + +## Common Patterns + +### Pattern 1: Write-Through Cache +```csharp +public async Task UpdateUserAsync(int userId, UserData userData) +{ + // Update database + await _db.Users.Update(userData); + + // Update cache (triggers backplane invalidation) + await _cache.SetAsync($"user:{userId}", userData); +} +``` + +### Pattern 2: Cache-Aside +```csharp +public async Task GetUserAsync(int userId) +{ + return await _cache.GetOrSetAsync( + $"user:{userId}", + async ct => await _db.Users.GetAsync(userId) + ); +} +``` + +### Pattern 3: Distributed Cache Warming +```csharp +public async Task PrecacheAsync() +{ + foreach (var key in _importantKeys) + { + await _cache.SetAsync(key, await _generateValue(key)); + } + // All apps now have warmed caches thanks to backplane +} +``` + +## Next Steps + +1. ✅ Run the example +2. ✅ Test cache hit/miss patterns +3. ✅ Observe backplane invalidations +4. ✅ Modify the factory function to see how it works +5. ✅ Add more cache keys +6. ✅ Add error handling and logging + +## Resources + +- **FusionCache GitHub:** https://github.com/ZiggyCreatures/FusionCache +- **Redis Backplane Docs:** https://github.com/ZiggyCreatures/FusionCache/wiki/Backplane +- **.NET Aspire:** https://learn.microsoft.com/en-us/dotnet/aspire/ +- **StackExchange.Redis:** https://github.com/StackExchange/StackExchange.Redis + +## Questions? + +Check the logs! Enable Debug logging to see exactly what's happening: + +```json +{ + "Logging": { + "LogLevel": { + "ZiggyCreatures.Caching.Fusion": "Debug", + "StackExchange.Redis": "Information" + } + } +} +``` + +--- + +Happy caching! 🚀 diff --git a/eaxmple/Playground/WebApplication1/Program.cs b/eaxmple/Playground/WebApplication1/Program.cs new file mode 100644 index 00000000..0f2e06df --- /dev/null +++ b/eaxmple/Playground/WebApplication1/Program.cs @@ -0,0 +1,110 @@ +using StackExchange.Redis; +using ZiggyCreatures.Caching.Fusion; +using ZiggyCreatures.Caching.Fusion.Backplane.AzureServiceBus; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); + +var redisConnectionString = builder.Configuration.GetConnectionString("cache-redis"); +var redis = ConnectionMultiplexer.Connect(redisConnectionString ?? "localhost:6379"); + +builder.Services.AddSingleton(redis); + +var serviceBusConnectionString = builder.Configuration.GetConnectionString("cache-servicebus"); + +builder.Services.AddFusionCache().WithAzureServiceBusBackplane(opt => +{ + opt.ConnectionString = serviceBusConnectionString; + opt.TopicName = "fusioncache-playground"; + opt.SubscriptionName = "webApp1-sub"; + opt.IsAdmin = false; +}); + +builder.Services.AddSingleton(); + +builder.Services.AddSwaggerGen(options => +{ + options.SwaggerDoc("v1", new() { Title = "WebApplication1 API", Version = "v1" }); +}); + +var app = builder.Build(); + +app.MapDefaultEndpoints(); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); + +var sharedDataService = app.Services.GetRequiredService(); + +app.MapGet("/data", async () => +{ + var data = await sharedDataService.GetDataAsync(); + return Results.Ok(new + { + AppName = "WebApplication1", + Timestamp = DateTime.UtcNow, + Data = data + }); +}); + +app.MapPost("/data", async (string value) => +{ + await sharedDataService.SetDataAsync(value); + return Results.Ok(new + { + AppName = "WebApplication1", + Timestamp = DateTime.UtcNow, + Message = $"Data updated to: {value}" + }); +}); + +app.MapGet("/cache/info", () => +{ + return Results.Ok(new + { + AppName = "WebApplication1", + Message = "FusionCache with Azure Service Bus Backplane enabled", + CacheKeyPrefix = "app1:", + DefaultDuration = "30 seconds" + }); +}); + +app.Run(); + +class SharedDataService +{ + private const string CacheKey = "shared:data"; + private readonly IFusionCache _cache; + private readonly ILogger _logger; + + public SharedDataService(IFusionCache cache, ILogger logger) + { + _cache = cache; + _logger = logger; + } + + public async Task GetDataAsync() + { + return await _cache.GetOrSetAsync( + CacheKey, + async ct => + { + _logger.LogInformation("[WebApplication1] Cache miss for {CacheKey}, generating data", CacheKey); + return $"Data from WebApplication1 at {DateTime.UtcNow:O}"; + }, + options => options.SetDuration(TimeSpan.FromSeconds(30)) + ); + } + + public async Task SetDataAsync(string value) + { + _logger.LogInformation("[WebApplication1] Setting cache value: {Value}", value); + await _cache.SetAsync(CacheKey, value, options => options.SetDuration(TimeSpan.FromSeconds(30))); + } +} diff --git a/eaxmple/Playground/WebApplication1/Properties/launchSettings.json b/eaxmple/Playground/WebApplication1/Properties/launchSettings.json new file mode 100644 index 00000000..5a9fa8e5 --- /dev/null +++ b/eaxmple/Playground/WebApplication1/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5260", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7064;http://localhost:5260", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/eaxmple/Playground/WebApplication1/WebApplication1.csproj b/eaxmple/Playground/WebApplication1/WebApplication1.csproj new file mode 100644 index 00000000..c26fc8e0 --- /dev/null +++ b/eaxmple/Playground/WebApplication1/WebApplication1.csproj @@ -0,0 +1,21 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + + diff --git a/eaxmple/Playground/WebApplication1/WebApplication1.http b/eaxmple/Playground/WebApplication1/WebApplication1.http new file mode 100644 index 00000000..b15c6b4f --- /dev/null +++ b/eaxmple/Playground/WebApplication1/WebApplication1.http @@ -0,0 +1,6 @@ +@WebApplication1_HostAddress = http://localhost:5260 + +GET {{WebApplication1_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/eaxmple/Playground/WebApplication1/api-demo.http b/eaxmple/Playground/WebApplication1/api-demo.http new file mode 100644 index 00000000..c7d8ff5a --- /dev/null +++ b/eaxmple/Playground/WebApplication1/api-demo.http @@ -0,0 +1,45 @@ +### FusionCache Redis Backplane Example - WebApplication1 +### This file demonstrates cache synchronization across applications + +@host = https://localhost:7001 +@contentType = application/json + +### 1. Check cache configuration +GET {{host}}/cache/info + +### + +### 2. Get cached data (first call - cache miss) +GET {{host}}/data + +### + +### 3. Get cached data (second call - cache hit from memory) +GET {{host}}/data + +### + +### 4. Update cache data from App1 +POST {{host}}/data +Content-Type: application/json + +"Data updated by WebApplication1" + +### + +### 5. Verify the update +GET {{host}}/data + +### + +### CROSS-APP SYNCHRONIZATION TEST +### Now test with WebApplication2 to see the backplane in action! +### +### Steps: +### 1. Make a request to App1: GET {{host}}/data +### 2. Make a request to App2: GET https://localhost:7002/data +### (You should see the SAME data in both!) +### 3. Update from App2: POST https://localhost:7002/data with new value +### 4. Check App1 again: GET {{host}}/data +### (It should have the new value from App2 - backplane invalidated the cache!) + diff --git a/eaxmple/Playground/WebApplication1/appsettings.Development.json b/eaxmple/Playground/WebApplication1/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/eaxmple/Playground/WebApplication1/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/eaxmple/Playground/WebApplication1/appsettings.json b/eaxmple/Playground/WebApplication1/appsettings.json new file mode 100644 index 00000000..10f68b8c --- /dev/null +++ b/eaxmple/Playground/WebApplication1/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/eaxmple/Playground/WebApplication2/Program.cs b/eaxmple/Playground/WebApplication2/Program.cs new file mode 100644 index 00000000..9a913fea --- /dev/null +++ b/eaxmple/Playground/WebApplication2/Program.cs @@ -0,0 +1,110 @@ +using StackExchange.Redis; +using ZiggyCreatures.Caching.Fusion; +using ZiggyCreatures.Caching.Fusion.Backplane.AzureServiceBus; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); + +var redisConnectionString = builder.Configuration.GetConnectionString("cache-redis"); +var redis = ConnectionMultiplexer.Connect(redisConnectionString ?? "localhost:6379"); + +builder.Services.AddSingleton(redis); + +var serviceBusConnectionString = builder.Configuration.GetConnectionString("cache-servicebus"); + +builder.Services.AddFusionCache().WithAzureServiceBusBackplane(opt => +{ + opt.ConnectionString = serviceBusConnectionString; + opt.TopicName = "fusioncache-playground"; + opt.SubscriptionName = "webApp2-sub"; + opt.IsAdmin = false; +}); + +builder.Services.AddSingleton(); + +builder.Services.AddSwaggerGen(options => +{ + options.SwaggerDoc("v1", new() { Title = "WebApplication2 API", Version = "v1" }); +}); + +var app = builder.Build(); + +app.MapDefaultEndpoints(); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); + +var sharedDataService = app.Services.GetRequiredService(); + +app.MapGet("/data", async () => +{ + var data = await sharedDataService.GetDataAsync(); + return Results.Ok(new + { + AppName = "WebApplication2", + Timestamp = DateTime.UtcNow, + Data = data + }); +}); + +app.MapPost("/data", async (string value) => +{ + await sharedDataService.SetDataAsync(value); + return Results.Ok(new + { + AppName = "WebApplication2", + Timestamp = DateTime.UtcNow, + Message = $"Data updated to: {value}" + }); +}); + +app.MapGet("/cache/info", () => +{ + return Results.Ok(new + { + AppName = "WebApplication2", + Message = "FusionCache with Azure Service Bus Backplane enabled", + CacheKeyPrefix = "app2:", + DefaultDuration = "30 seconds" + }); +}); + +app.Run(); + +class SharedDataService +{ + private const string CacheKey = "shared:data"; + private readonly IFusionCache _cache; + private readonly ILogger _logger; + + public SharedDataService(IFusionCache cache, ILogger logger) + { + _cache = cache; + _logger = logger; + } + + public async Task GetDataAsync() + { + return await _cache.GetOrSetAsync( + CacheKey, + async ct => + { + _logger.LogInformation("[WebApplication2] Cache miss for {CacheKey}, generating data", CacheKey); + return $"Data from WebApplication2 at {DateTime.UtcNow:O}"; + }, + options => options.SetDuration(TimeSpan.FromSeconds(30)) + ); + } + + public async Task SetDataAsync(string value) + { + _logger.LogInformation("[WebApplication2] Setting cache value: {Value}", value); + await _cache.SetAsync(CacheKey, value, options => options.SetDuration(TimeSpan.FromSeconds(30))); + } +} diff --git a/eaxmple/Playground/WebApplication2/Properties/launchSettings.json b/eaxmple/Playground/WebApplication2/Properties/launchSettings.json new file mode 100644 index 00000000..4b3a72a5 --- /dev/null +++ b/eaxmple/Playground/WebApplication2/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5228", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7216;http://localhost:5228", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/eaxmple/Playground/WebApplication2/WebApplication2.csproj b/eaxmple/Playground/WebApplication2/WebApplication2.csproj new file mode 100644 index 00000000..98775c80 --- /dev/null +++ b/eaxmple/Playground/WebApplication2/WebApplication2.csproj @@ -0,0 +1,21 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + + diff --git a/eaxmple/Playground/WebApplication2/WebApplication2.http b/eaxmple/Playground/WebApplication2/WebApplication2.http new file mode 100644 index 00000000..af4e1f6a --- /dev/null +++ b/eaxmple/Playground/WebApplication2/WebApplication2.http @@ -0,0 +1,6 @@ +@WebApplication2_HostAddress = http://localhost:5228 + +GET {{WebApplication2_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/eaxmple/Playground/WebApplication2/api-demo.http b/eaxmple/Playground/WebApplication2/api-demo.http new file mode 100644 index 00000000..2a37620b --- /dev/null +++ b/eaxmple/Playground/WebApplication2/api-demo.http @@ -0,0 +1,45 @@ +### FusionCache Redis Backplane Example - WebApplication2 +### This file demonstrates cache synchronization across applications + +@host = https://localhost:7002 +@contentType = application/json + +### 1. Check cache configuration +GET {{host}}/cache/info + +### + +### 2. Get cached data (should match WebApplication1's cache if App1 was accessed first) +GET {{host}}/data + +### + +### 3. Get cached data (second call - cache hit from memory) +GET {{host}}/data + +### + +### 4. Update cache data from App2 +POST {{host}}/data +Content-Type: application/json + +"Data updated by WebApplication2" + +### + +### 5. Verify the update +GET {{host}}/data + +### + +### CROSS-APP SYNCHRONIZATION TEST +### Now test with WebApplication1 to see the backplane in action! +### +### Steps: +### 1. Make a request to App2: GET {{host}}/data +### 2. Make a request to App1: GET https://localhost:7001/data +### (You should see the SAME data in both!) +### 3. Update from App1: POST https://localhost:7001/data with new value +### 4. Check App2 again: GET {{host}}/data +### (It should have the new value from App1 - backplane invalidated the cache!) + diff --git a/eaxmple/Playground/WebApplication2/appsettings.Development.json b/eaxmple/Playground/WebApplication2/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/eaxmple/Playground/WebApplication2/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/eaxmple/Playground/WebApplication2/appsettings.json b/eaxmple/Playground/WebApplication2/appsettings.json new file mode 100644 index 00000000..10f68b8c --- /dev/null +++ b/eaxmple/Playground/WebApplication2/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} From 8212454063f26a95133ae6332b210e5d0928dc09 Mon Sep 17 00:00:00 2001 From: AboubakrNasef Date: Thu, 30 Jul 2026 14:13:54 +0300 Subject: [PATCH 05/13] refactor --- ZiggyCreatures.FusionCache.slnx | 14 +-- eaxmple/Playground/WebApplication1/Program.cs | 110 ----------------- .../WebApplication1/WebApplication1.csproj | 21 ---- eaxmple/Playground/WebApplication2/Program.cs | 110 ----------------- .../Backplane/AzureServiceBusBackplane.cs | 8 +- .../AzureServiceBusBackplane_Async.cs | 20 +-- .../Aspire.Playground}/BACKPLANE_FLOWS.md | 0 .../Aspire.Playground}/CHANGES_SUMMARY.md | 0 .../Playground.AppHost/AppHost.cs | 10 +- .../Playground.AppHost.csproj | 0 .../Properties/launchSettings.json | 0 .../appsettings.Development.json | 0 .../Playground.AppHost/appsettings.json | 0 .../Playground.ServiceDefaults/Extensions.cs | 16 ++- .../Playground.ServiceDefaults.csproj | 2 + .../Playground.Shared.csproj | 11 +- .../PlaygroundCacheExtensions.cs | 115 ++++++++++++++++++ .../Aspire.Playground}/QUICK_START.md | 0 .../Aspire.Playground}/README.md | 0 .../Aspire.Playground}/SETUP_GUIDE.md | 0 .../WebApplication1/Program.cs | 34 ++++++ .../Properties/launchSettings.json | 0 .../WebApplication1/WebApplication1.csproj | 18 +++ .../WebApplication1/WebApplication1.http | 0 .../WebApplication1/api-demo.http | 0 .../appsettings.Development.json | 0 .../WebApplication1/appsettings.json | 0 .../WebApplication2/Program.cs | 35 ++++++ .../Properties/launchSettings.json | 0 .../WebApplication2/WebApplication2.csproj | 18 +++ .../WebApplication2/WebApplication2.http | 0 .../WebApplication2/api-demo.http | 0 .../appsettings.Development.json | 0 .../WebApplication2/appsettings.json | 0 34 files changed, 269 insertions(+), 273 deletions(-) delete mode 100644 eaxmple/Playground/WebApplication1/Program.cs delete mode 100644 eaxmple/Playground/WebApplication1/WebApplication1.csproj delete mode 100644 eaxmple/Playground/WebApplication2/Program.cs rename {eaxmple/Playground => tests/Aspire.Playground}/BACKPLANE_FLOWS.md (100%) rename {eaxmple/Playground => tests/Aspire.Playground}/CHANGES_SUMMARY.md (100%) rename {eaxmple/Playground => tests/Aspire.Playground}/Playground.AppHost/AppHost.cs (70%) rename {eaxmple/Playground => tests/Aspire.Playground}/Playground.AppHost/Playground.AppHost.csproj (100%) rename {eaxmple/Playground => tests/Aspire.Playground}/Playground.AppHost/Properties/launchSettings.json (100%) rename {eaxmple/Playground => tests/Aspire.Playground}/Playground.AppHost/appsettings.Development.json (100%) rename {eaxmple/Playground => tests/Aspire.Playground}/Playground.AppHost/appsettings.json (100%) rename {eaxmple/Playground => tests/Aspire.Playground}/Playground.ServiceDefaults/Extensions.cs (87%) rename {eaxmple/Playground => tests/Aspire.Playground}/Playground.ServiceDefaults/Playground.ServiceDefaults.csproj (87%) rename eaxmple/Playground/WebApplication2/WebApplication2.csproj => tests/Aspire.Playground/Playground.Shared/Playground.Shared.csproj (58%) create mode 100644 tests/Aspire.Playground/Playground.Shared/PlaygroundCacheExtensions.cs rename {eaxmple/Playground => tests/Aspire.Playground}/QUICK_START.md (100%) rename {eaxmple/Playground => tests/Aspire.Playground}/README.md (100%) rename {eaxmple/Playground => tests/Aspire.Playground}/SETUP_GUIDE.md (100%) create mode 100644 tests/Aspire.Playground/WebApplication1/Program.cs rename {eaxmple/Playground => tests/Aspire.Playground}/WebApplication1/Properties/launchSettings.json (100%) create mode 100644 tests/Aspire.Playground/WebApplication1/WebApplication1.csproj rename {eaxmple/Playground => tests/Aspire.Playground}/WebApplication1/WebApplication1.http (100%) rename {eaxmple/Playground => tests/Aspire.Playground}/WebApplication1/api-demo.http (100%) rename {eaxmple/Playground => tests/Aspire.Playground}/WebApplication1/appsettings.Development.json (100%) rename {eaxmple/Playground => tests/Aspire.Playground}/WebApplication1/appsettings.json (100%) create mode 100644 tests/Aspire.Playground/WebApplication2/Program.cs rename {eaxmple/Playground => tests/Aspire.Playground}/WebApplication2/Properties/launchSettings.json (100%) create mode 100644 tests/Aspire.Playground/WebApplication2/WebApplication2.csproj rename {eaxmple/Playground => tests/Aspire.Playground}/WebApplication2/WebApplication2.http (100%) rename {eaxmple/Playground => tests/Aspire.Playground}/WebApplication2/api-demo.http (100%) rename {eaxmple/Playground => tests/Aspire.Playground}/WebApplication2/appsettings.Development.json (100%) rename {eaxmple/Playground => tests/Aspire.Playground}/WebApplication2/appsettings.json (100%) diff --git a/ZiggyCreatures.FusionCache.slnx b/ZiggyCreatures.FusionCache.slnx index a255986d..43ea829d 100644 --- a/ZiggyCreatures.FusionCache.slnx +++ b/ZiggyCreatures.FusionCache.slnx @@ -2,13 +2,6 @@ - - - - - - - @@ -40,4 +33,11 @@ + + + + + + + diff --git a/eaxmple/Playground/WebApplication1/Program.cs b/eaxmple/Playground/WebApplication1/Program.cs deleted file mode 100644 index 0f2e06df..00000000 --- a/eaxmple/Playground/WebApplication1/Program.cs +++ /dev/null @@ -1,110 +0,0 @@ -using StackExchange.Redis; -using ZiggyCreatures.Caching.Fusion; -using ZiggyCreatures.Caching.Fusion.Backplane.AzureServiceBus; - -var builder = WebApplication.CreateBuilder(args); - -builder.AddServiceDefaults(); - -var redisConnectionString = builder.Configuration.GetConnectionString("cache-redis"); -var redis = ConnectionMultiplexer.Connect(redisConnectionString ?? "localhost:6379"); - -builder.Services.AddSingleton(redis); - -var serviceBusConnectionString = builder.Configuration.GetConnectionString("cache-servicebus"); - -builder.Services.AddFusionCache().WithAzureServiceBusBackplane(opt => -{ - opt.ConnectionString = serviceBusConnectionString; - opt.TopicName = "fusioncache-playground"; - opt.SubscriptionName = "webApp1-sub"; - opt.IsAdmin = false; -}); - -builder.Services.AddSingleton(); - -builder.Services.AddSwaggerGen(options => -{ - options.SwaggerDoc("v1", new() { Title = "WebApplication1 API", Version = "v1" }); -}); - -var app = builder.Build(); - -app.MapDefaultEndpoints(); - -if (app.Environment.IsDevelopment()) -{ - app.UseSwagger(); - app.UseSwaggerUI(); -} - -app.UseHttpsRedirection(); - -var sharedDataService = app.Services.GetRequiredService(); - -app.MapGet("/data", async () => -{ - var data = await sharedDataService.GetDataAsync(); - return Results.Ok(new - { - AppName = "WebApplication1", - Timestamp = DateTime.UtcNow, - Data = data - }); -}); - -app.MapPost("/data", async (string value) => -{ - await sharedDataService.SetDataAsync(value); - return Results.Ok(new - { - AppName = "WebApplication1", - Timestamp = DateTime.UtcNow, - Message = $"Data updated to: {value}" - }); -}); - -app.MapGet("/cache/info", () => -{ - return Results.Ok(new - { - AppName = "WebApplication1", - Message = "FusionCache with Azure Service Bus Backplane enabled", - CacheKeyPrefix = "app1:", - DefaultDuration = "30 seconds" - }); -}); - -app.Run(); - -class SharedDataService -{ - private const string CacheKey = "shared:data"; - private readonly IFusionCache _cache; - private readonly ILogger _logger; - - public SharedDataService(IFusionCache cache, ILogger logger) - { - _cache = cache; - _logger = logger; - } - - public async Task GetDataAsync() - { - return await _cache.GetOrSetAsync( - CacheKey, - async ct => - { - _logger.LogInformation("[WebApplication1] Cache miss for {CacheKey}, generating data", CacheKey); - return $"Data from WebApplication1 at {DateTime.UtcNow:O}"; - }, - options => options.SetDuration(TimeSpan.FromSeconds(30)) - ); - } - - public async Task SetDataAsync(string value) - { - _logger.LogInformation("[WebApplication1] Setting cache value: {Value}", value); - await _cache.SetAsync(CacheKey, value, options => options.SetDuration(TimeSpan.FromSeconds(30))); - } -} diff --git a/eaxmple/Playground/WebApplication1/WebApplication1.csproj b/eaxmple/Playground/WebApplication1/WebApplication1.csproj deleted file mode 100644 index c26fc8e0..00000000 --- a/eaxmple/Playground/WebApplication1/WebApplication1.csproj +++ /dev/null @@ -1,21 +0,0 @@ - - - - net10.0 - enable - enable - - - - - - - - - - - - - - - diff --git a/eaxmple/Playground/WebApplication2/Program.cs b/eaxmple/Playground/WebApplication2/Program.cs deleted file mode 100644 index 9a913fea..00000000 --- a/eaxmple/Playground/WebApplication2/Program.cs +++ /dev/null @@ -1,110 +0,0 @@ -using StackExchange.Redis; -using ZiggyCreatures.Caching.Fusion; -using ZiggyCreatures.Caching.Fusion.Backplane.AzureServiceBus; - -var builder = WebApplication.CreateBuilder(args); - -builder.AddServiceDefaults(); - -var redisConnectionString = builder.Configuration.GetConnectionString("cache-redis"); -var redis = ConnectionMultiplexer.Connect(redisConnectionString ?? "localhost:6379"); - -builder.Services.AddSingleton(redis); - -var serviceBusConnectionString = builder.Configuration.GetConnectionString("cache-servicebus"); - -builder.Services.AddFusionCache().WithAzureServiceBusBackplane(opt => -{ - opt.ConnectionString = serviceBusConnectionString; - opt.TopicName = "fusioncache-playground"; - opt.SubscriptionName = "webApp2-sub"; - opt.IsAdmin = false; -}); - -builder.Services.AddSingleton(); - -builder.Services.AddSwaggerGen(options => -{ - options.SwaggerDoc("v1", new() { Title = "WebApplication2 API", Version = "v1" }); -}); - -var app = builder.Build(); - -app.MapDefaultEndpoints(); - -if (app.Environment.IsDevelopment()) -{ - app.UseSwagger(); - app.UseSwaggerUI(); -} - -app.UseHttpsRedirection(); - -var sharedDataService = app.Services.GetRequiredService(); - -app.MapGet("/data", async () => -{ - var data = await sharedDataService.GetDataAsync(); - return Results.Ok(new - { - AppName = "WebApplication2", - Timestamp = DateTime.UtcNow, - Data = data - }); -}); - -app.MapPost("/data", async (string value) => -{ - await sharedDataService.SetDataAsync(value); - return Results.Ok(new - { - AppName = "WebApplication2", - Timestamp = DateTime.UtcNow, - Message = $"Data updated to: {value}" - }); -}); - -app.MapGet("/cache/info", () => -{ - return Results.Ok(new - { - AppName = "WebApplication2", - Message = "FusionCache with Azure Service Bus Backplane enabled", - CacheKeyPrefix = "app2:", - DefaultDuration = "30 seconds" - }); -}); - -app.Run(); - -class SharedDataService -{ - private const string CacheKey = "shared:data"; - private readonly IFusionCache _cache; - private readonly ILogger _logger; - - public SharedDataService(IFusionCache cache, ILogger logger) - { - _cache = cache; - _logger = logger; - } - - public async Task GetDataAsync() - { - return await _cache.GetOrSetAsync( - CacheKey, - async ct => - { - _logger.LogInformation("[WebApplication2] Cache miss for {CacheKey}, generating data", CacheKey); - return $"Data from WebApplication2 at {DateTime.UtcNow:O}"; - }, - options => options.SetDuration(TimeSpan.FromSeconds(30)) - ); - } - - public async Task SetDataAsync(string value) - { - _logger.LogInformation("[WebApplication2] Setting cache value: {Value}", value); - await _cache.SetAsync(CacheKey, value, options => options.SetDuration(TimeSpan.FromSeconds(30))); - } -} diff --git a/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/Backplane/AzureServiceBusBackplane.cs b/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/Backplane/AzureServiceBusBackplane.cs index 2b3c70e6..1af5d348 100644 --- a/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/Backplane/AzureServiceBusBackplane.cs +++ b/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/Backplane/AzureServiceBusBackplane.cs @@ -32,8 +32,8 @@ public AzureServiceBusBackplane( ILogger? logger = null, TimeSpan? lockTimeout = null) { - _serviceBusCommunicator = serviceBusCommunicator ?? throw new ArgumentNullException(nameof(serviceBusCommunicator)); - _serviceBusProvisioner = serviceBusProvisioner ?? throw new ArgumentNullException(nameof(serviceBusProvisioner)); + _serviceBusClientWrapper = serviceBusCommunicator ?? throw new ArgumentNullException(nameof(serviceBusCommunicator)); + _serviceBusAdminWrapper = serviceBusProvisioner ?? throw new ArgumentNullException(nameof(serviceBusProvisioner)); _logger = logger; _lockTimeout = lockTimeout ?? TimeSpan.FromSeconds(5); if (_lockTimeout <= TimeSpan.Zero) @@ -41,8 +41,8 @@ public AzureServiceBusBackplane( } private readonly SemaphoreSlim _lock = new SemaphoreSlim(1, 1); - private readonly IAzureServiceBusClientWrapper _serviceBusCommunicator; - private readonly IAzureServiceBusAdminWrapper _serviceBusProvisioner; + private readonly IAzureServiceBusClientWrapper _serviceBusClientWrapper; + private readonly IAzureServiceBusAdminWrapper _serviceBusAdminWrapper; private readonly ILogger? _logger; private readonly TimeSpan _lockTimeout; diff --git a/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/Backplane/AzureServiceBusBackplane_Async.cs b/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/Backplane/AzureServiceBusBackplane_Async.cs index 5847fe18..9fdfd6af 100644 --- a/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/Backplane/AzureServiceBusBackplane_Async.cs +++ b/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/Backplane/AzureServiceBusBackplane_Async.cs @@ -27,8 +27,8 @@ public async ValueTask SubscribeAsync(BackplaneSubscriptionOptions options) try { - await _serviceBusProvisioner.EnsureTopicAsync(); - await _serviceBusProvisioner.EnsureSubscriptionAsync(); + await _serviceBusAdminWrapper.EnsureTopicAsync(); + await _serviceBusAdminWrapper.EnsureSubscriptionAsync(); _cacheName = options.CacheName; _cacheInstanceId = options.CacheInstanceId; @@ -46,10 +46,10 @@ public async ValueTask SubscribeAsync(BackplaneSubscriptionOptions options) options.IncomingMessageHandler?.Invoke(msg); }; - _subscriptionMissingHandler = () => _serviceBusProvisioner.EnsureSubscriptionAsync().AsTask(); - _serviceBusCommunicator.SubscriptionMissing += _subscriptionMissingHandler; + _subscriptionMissingHandler = () => _serviceBusAdminWrapper.EnsureSubscriptionAsync().AsTask(); + _serviceBusClientWrapper.SubscriptionMissing += _subscriptionMissingHandler; - await _serviceBusCommunicator.Subscribe(_incomingMessageHandler); + await _serviceBusClientWrapper.Subscribe(_incomingMessageHandler); if (options.ConnectHandlerAsync is not null) await options.ConnectHandlerAsync(new BackplaneConnectionInfo(false)); @@ -68,7 +68,7 @@ public async ValueTask PublishAsync(BackplaneMessage message, FusionCacheEntryOp if (_logger?.IsEnabled(LogLevel.Information) ?? false) _logger.Log(LogLevel.Information, "FUSION [N={CacheName} I={CacheInstanceId}]: [BP] new message {Action} {CacheKey} - {Duration} - {DistributedDuration}", _cacheName, _cacheInstanceId, message.Action, message.CacheKey, options.Duration, options.DistributedCacheDuration); - await _serviceBusCommunicator.SendMessage(new ServiceBusMessage + await _serviceBusClientWrapper.SendMessage(new ServiceBusMessage { Body = new BinaryData(BackplaneMessage.ToByteArray(message)), Subject = _cacheName @@ -88,17 +88,17 @@ public async ValueTask UnsubscribeAsync() if (_subscriptionMissingHandler is not null) { - _serviceBusCommunicator.SubscriptionMissing -= _subscriptionMissingHandler; + _serviceBusClientWrapper.SubscriptionMissing -= _subscriptionMissingHandler; _subscriptionMissingHandler = null; } - await _serviceBusCommunicator.Unsubscribe(_incomingMessageHandler); + await _serviceBusClientWrapper.Unsubscribe(_incomingMessageHandler); _incomingMessageHandler = null; _cacheName = null; _cacheInstanceId = null; - await _serviceBusCommunicator.DisposeAsync(); - await _serviceBusProvisioner.DisposeAsync(); + await _serviceBusClientWrapper.DisposeAsync(); + await _serviceBusAdminWrapper.DisposeAsync(); } finally { diff --git a/eaxmple/Playground/BACKPLANE_FLOWS.md b/tests/Aspire.Playground/BACKPLANE_FLOWS.md similarity index 100% rename from eaxmple/Playground/BACKPLANE_FLOWS.md rename to tests/Aspire.Playground/BACKPLANE_FLOWS.md diff --git a/eaxmple/Playground/CHANGES_SUMMARY.md b/tests/Aspire.Playground/CHANGES_SUMMARY.md similarity index 100% rename from eaxmple/Playground/CHANGES_SUMMARY.md rename to tests/Aspire.Playground/CHANGES_SUMMARY.md diff --git a/eaxmple/Playground/Playground.AppHost/AppHost.cs b/tests/Aspire.Playground/Playground.AppHost/AppHost.cs similarity index 70% rename from eaxmple/Playground/Playground.AppHost/AppHost.cs rename to tests/Aspire.Playground/Playground.AppHost/AppHost.cs index 7707b970..90e41d6c 100644 --- a/eaxmple/Playground/Playground.AppHost/AppHost.cs +++ b/tests/Aspire.Playground/Playground.AppHost/AppHost.cs @@ -1,4 +1,7 @@ -var builder = DistributedApplication.CreateBuilder(args); +using Aspire.Hosting.Azure; +using Microsoft.AspNetCore.SignalR; + +var builder = DistributedApplication.CreateBuilder(args); var redis = builder.AddRedis("cache-redis").WithRedisInsight(); var serviceBus = builder @@ -10,9 +13,10 @@ }); var topic = serviceBus.AddServiceBusTopic("fusioncache-playground"); -topic.AddServiceBusSubscription("webApp1-sub"); -topic.AddServiceBusSubscription("webApp2-sub"); +var subscription1 = topic.AddServiceBusSubscription("webApp1-sub"); +var subscription2 = topic.AddServiceBusSubscription("webApp2-sub"); +; builder .AddProject("webapplication1") .WithReference(redis) diff --git a/eaxmple/Playground/Playground.AppHost/Playground.AppHost.csproj b/tests/Aspire.Playground/Playground.AppHost/Playground.AppHost.csproj similarity index 100% rename from eaxmple/Playground/Playground.AppHost/Playground.AppHost.csproj rename to tests/Aspire.Playground/Playground.AppHost/Playground.AppHost.csproj diff --git a/eaxmple/Playground/Playground.AppHost/Properties/launchSettings.json b/tests/Aspire.Playground/Playground.AppHost/Properties/launchSettings.json similarity index 100% rename from eaxmple/Playground/Playground.AppHost/Properties/launchSettings.json rename to tests/Aspire.Playground/Playground.AppHost/Properties/launchSettings.json diff --git a/eaxmple/Playground/Playground.AppHost/appsettings.Development.json b/tests/Aspire.Playground/Playground.AppHost/appsettings.Development.json similarity index 100% rename from eaxmple/Playground/Playground.AppHost/appsettings.Development.json rename to tests/Aspire.Playground/Playground.AppHost/appsettings.Development.json diff --git a/eaxmple/Playground/Playground.AppHost/appsettings.json b/tests/Aspire.Playground/Playground.AppHost/appsettings.json similarity index 100% rename from eaxmple/Playground/Playground.AppHost/appsettings.json rename to tests/Aspire.Playground/Playground.AppHost/appsettings.json diff --git a/eaxmple/Playground/Playground.ServiceDefaults/Extensions.cs b/tests/Aspire.Playground/Playground.ServiceDefaults/Extensions.cs similarity index 87% rename from eaxmple/Playground/Playground.ServiceDefaults/Extensions.cs rename to tests/Aspire.Playground/Playground.ServiceDefaults/Extensions.cs index b72c8753..2bac9cb4 100644 --- a/eaxmple/Playground/Playground.ServiceDefaults/Extensions.cs +++ b/tests/Aspire.Playground/Playground.ServiceDefaults/Extensions.cs @@ -57,7 +57,13 @@ public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) w { metrics.AddAspNetCoreInstrumentation() .AddHttpClientInstrumentation() - .AddRuntimeInstrumentation(); + .AddRuntimeInstrumentation() + .AddFusionCacheInstrumentation(options => + { + options.IncludeMemoryLevel = true; + options.IncludeDistributedLevel = true; + options.IncludeBackplane = true; + }); }) .WithTracing(tracing => { @@ -70,7 +76,13 @@ public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) w ) // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) //.AddGrpcClientInstrumentation() - .AddHttpClientInstrumentation(); + .AddHttpClientInstrumentation() + .AddFusionCacheInstrumentation(options => + { + options.IncludeMemoryLevel = true; + options.IncludeDistributedLevel = true; + options.IncludeBackplane = true; + }); }); builder.AddOpenTelemetryExporters(); diff --git a/eaxmple/Playground/Playground.ServiceDefaults/Playground.ServiceDefaults.csproj b/tests/Aspire.Playground/Playground.ServiceDefaults/Playground.ServiceDefaults.csproj similarity index 87% rename from eaxmple/Playground/Playground.ServiceDefaults/Playground.ServiceDefaults.csproj rename to tests/Aspire.Playground/Playground.ServiceDefaults/Playground.ServiceDefaults.csproj index 73c3375e..8a7335d6 100644 --- a/eaxmple/Playground/Playground.ServiceDefaults/Playground.ServiceDefaults.csproj +++ b/tests/Aspire.Playground/Playground.ServiceDefaults/Playground.ServiceDefaults.csproj @@ -10,6 +10,8 @@ + + diff --git a/eaxmple/Playground/WebApplication2/WebApplication2.csproj b/tests/Aspire.Playground/Playground.Shared/Playground.Shared.csproj similarity index 58% rename from eaxmple/Playground/WebApplication2/WebApplication2.csproj rename to tests/Aspire.Playground/Playground.Shared/Playground.Shared.csproj index 98775c80..aa5548db 100644 --- a/eaxmple/Playground/WebApplication2/WebApplication2.csproj +++ b/tests/Aspire.Playground/Playground.Shared/Playground.Shared.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -7,15 +7,14 @@ - + - + - - + - + \ No newline at end of file diff --git a/tests/Aspire.Playground/Playground.Shared/PlaygroundCacheExtensions.cs b/tests/Aspire.Playground/Playground.Shared/PlaygroundCacheExtensions.cs new file mode 100644 index 00000000..c4254fc6 --- /dev/null +++ b/tests/Aspire.Playground/Playground.Shared/PlaygroundCacheExtensions.cs @@ -0,0 +1,115 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Caching.StackExchangeRedis; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using ZiggyCreatures.Caching.Fusion; +using ZiggyCreatures.Caching.Fusion.Backplane.AzureServiceBus; +using ZiggyCreatures.Caching.Fusion.Serialization.SystemTextJson; + +namespace Playground.Shared; + +public sealed record PlaygroundCacheOptions( + string AppName, + string ServiceBusSubscriptionName, + string CacheKeyPrefix, + TimeSpan Duration +); + +public static class PlaygroundCacheExtensions +{ + private const string TopicName = "fusioncache-playground"; + + public static IServiceCollection AddPlaygroundCache( + this IServiceCollection services, + PlaygroundCacheOptions options, + string? redisConnectionString, + string? serviceBusConnectionString) + { + services + .AddFusionCache() + .WithDistributedCache(new RedisCache(new RedisCacheOptions + { + Configuration = redisConnectionString ?? "localhost:6379" + })) + .WithSerializer(new FusionCacheSystemTextJsonSerializer()) + .WithAzureServiceBusBackplane(backplaneOptions => + { + backplaneOptions.ConnectionString = serviceBusConnectionString; + backplaneOptions.TopicName = TopicName; + backplaneOptions.SubscriptionName = options.ServiceBusSubscriptionName; + backplaneOptions.IsAdmin = false; + }); + + services.AddSingleton(options); + services.AddSingleton(); + + return services; + } + + public static WebApplication MapPlaygroundCacheEndpoints(this WebApplication app) + { + app.MapGet("/data", async (SharedDataService sharedDataService, PlaygroundCacheOptions options) => + { + var data = await sharedDataService.GetDataAsync(); + return Results.Ok(new + { + options.AppName, + Timestamp = DateTime.UtcNow, + Data = !data.HasValue ? "nothing ": data.Value + }); + }); + + app.MapPost("/data", async (string value, SharedDataService sharedDataService, PlaygroundCacheOptions options) => + { + await sharedDataService.SetDataAsync(value); + return Results.Ok(new + { + options.AppName, + Timestamp = DateTime.UtcNow, + Message = $"Data updated to: {value}" + }); + }); + + app.MapGet("/cache/info", (PlaygroundCacheOptions options) => Results.Ok(new + { + options.AppName, + Message = "FusionCache with Redis L2 and Azure Service Bus Backplane enabled", + options.CacheKeyPrefix, + DefaultDuration = options.Duration.ToString() + })); + + return app; + } +} + +public sealed class SharedDataService +{ + private const string CacheKey = "shared:data"; + private readonly IFusionCache _cache; + private readonly ILogger _logger; + private readonly PlaygroundCacheOptions _options; + + public SharedDataService( + IFusionCache cache, + ILogger logger, + PlaygroundCacheOptions options) + { + _cache = cache; + _logger = logger; + _options = options; + } + + public ValueTask> GetDataAsync() + { + return _cache.TryGetAsync( + CacheKey + ); + } + + public ValueTask SetDataAsync(string value) + { + _logger.LogInformation("[{AppName}] Setting cache value: {Value}", _options.AppName, value); + return _cache.SetAsync(CacheKey, value, entryOptions => entryOptions.SetDuration(_options.Duration)); + } +} diff --git a/eaxmple/Playground/QUICK_START.md b/tests/Aspire.Playground/QUICK_START.md similarity index 100% rename from eaxmple/Playground/QUICK_START.md rename to tests/Aspire.Playground/QUICK_START.md diff --git a/eaxmple/Playground/README.md b/tests/Aspire.Playground/README.md similarity index 100% rename from eaxmple/Playground/README.md rename to tests/Aspire.Playground/README.md diff --git a/eaxmple/Playground/SETUP_GUIDE.md b/tests/Aspire.Playground/SETUP_GUIDE.md similarity index 100% rename from eaxmple/Playground/SETUP_GUIDE.md rename to tests/Aspire.Playground/SETUP_GUIDE.md diff --git a/tests/Aspire.Playground/WebApplication1/Program.cs b/tests/Aspire.Playground/WebApplication1/Program.cs new file mode 100644 index 00000000..2ecac288 --- /dev/null +++ b/tests/Aspire.Playground/WebApplication1/Program.cs @@ -0,0 +1,34 @@ +using Playground.Shared; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); + +builder.Services.AddPlaygroundCache( + new PlaygroundCacheOptions("WebApplication1", "webApp1-sub", "app1:", TimeSpan.FromSeconds(300)), + builder.Configuration.GetConnectionString("cache-redis"), + builder.Configuration.GetConnectionString("cache-servicebus") +); + +builder.Services.AddSwaggerGen(options => +{ + options.SwaggerDoc("v1", new() { Title = "WebApplication1 API", Version = "v1" }); +}); + +var app = builder.Build(); + +app.MapDefaultEndpoints(); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} +app.MapGet("/", () => +{ + return Results.Redirect("/swagger"); +}); +app.UseHttpsRedirection(); +app.MapPlaygroundCacheEndpoints(); + +app.Run(); diff --git a/eaxmple/Playground/WebApplication1/Properties/launchSettings.json b/tests/Aspire.Playground/WebApplication1/Properties/launchSettings.json similarity index 100% rename from eaxmple/Playground/WebApplication1/Properties/launchSettings.json rename to tests/Aspire.Playground/WebApplication1/Properties/launchSettings.json diff --git a/tests/Aspire.Playground/WebApplication1/WebApplication1.csproj b/tests/Aspire.Playground/WebApplication1/WebApplication1.csproj new file mode 100644 index 00000000..787f3c21 --- /dev/null +++ b/tests/Aspire.Playground/WebApplication1/WebApplication1.csproj @@ -0,0 +1,18 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + \ No newline at end of file diff --git a/eaxmple/Playground/WebApplication1/WebApplication1.http b/tests/Aspire.Playground/WebApplication1/WebApplication1.http similarity index 100% rename from eaxmple/Playground/WebApplication1/WebApplication1.http rename to tests/Aspire.Playground/WebApplication1/WebApplication1.http diff --git a/eaxmple/Playground/WebApplication1/api-demo.http b/tests/Aspire.Playground/WebApplication1/api-demo.http similarity index 100% rename from eaxmple/Playground/WebApplication1/api-demo.http rename to tests/Aspire.Playground/WebApplication1/api-demo.http diff --git a/eaxmple/Playground/WebApplication1/appsettings.Development.json b/tests/Aspire.Playground/WebApplication1/appsettings.Development.json similarity index 100% rename from eaxmple/Playground/WebApplication1/appsettings.Development.json rename to tests/Aspire.Playground/WebApplication1/appsettings.Development.json diff --git a/eaxmple/Playground/WebApplication1/appsettings.json b/tests/Aspire.Playground/WebApplication1/appsettings.json similarity index 100% rename from eaxmple/Playground/WebApplication1/appsettings.json rename to tests/Aspire.Playground/WebApplication1/appsettings.json diff --git a/tests/Aspire.Playground/WebApplication2/Program.cs b/tests/Aspire.Playground/WebApplication2/Program.cs new file mode 100644 index 00000000..1db2abe9 --- /dev/null +++ b/tests/Aspire.Playground/WebApplication2/Program.cs @@ -0,0 +1,35 @@ +using Playground.Shared; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); + +builder.Services.AddPlaygroundCache( + new PlaygroundCacheOptions("WebApplication2", "webApp2-sub", "app2:", TimeSpan.FromSeconds(300)), + builder.Configuration.GetConnectionString("cache-redis"), + builder.Configuration.GetConnectionString("cache-servicebus") +); + +builder.Services.AddSwaggerGen(options => +{ + options.SwaggerDoc("v1", new() { Title = "WebApplication2 API", Version = "v1" }); +}); + +var app = builder.Build(); + +app.MapDefaultEndpoints(); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.MapGet("/", () => +{ + return Results.Redirect("/swagger"); +}); +app.UseHttpsRedirection(); +app.MapPlaygroundCacheEndpoints(); + +app.Run(); diff --git a/eaxmple/Playground/WebApplication2/Properties/launchSettings.json b/tests/Aspire.Playground/WebApplication2/Properties/launchSettings.json similarity index 100% rename from eaxmple/Playground/WebApplication2/Properties/launchSettings.json rename to tests/Aspire.Playground/WebApplication2/Properties/launchSettings.json diff --git a/tests/Aspire.Playground/WebApplication2/WebApplication2.csproj b/tests/Aspire.Playground/WebApplication2/WebApplication2.csproj new file mode 100644 index 00000000..787f3c21 --- /dev/null +++ b/tests/Aspire.Playground/WebApplication2/WebApplication2.csproj @@ -0,0 +1,18 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + \ No newline at end of file diff --git a/eaxmple/Playground/WebApplication2/WebApplication2.http b/tests/Aspire.Playground/WebApplication2/WebApplication2.http similarity index 100% rename from eaxmple/Playground/WebApplication2/WebApplication2.http rename to tests/Aspire.Playground/WebApplication2/WebApplication2.http diff --git a/eaxmple/Playground/WebApplication2/api-demo.http b/tests/Aspire.Playground/WebApplication2/api-demo.http similarity index 100% rename from eaxmple/Playground/WebApplication2/api-demo.http rename to tests/Aspire.Playground/WebApplication2/api-demo.http diff --git a/eaxmple/Playground/WebApplication2/appsettings.Development.json b/tests/Aspire.Playground/WebApplication2/appsettings.Development.json similarity index 100% rename from eaxmple/Playground/WebApplication2/appsettings.Development.json rename to tests/Aspire.Playground/WebApplication2/appsettings.Development.json diff --git a/eaxmple/Playground/WebApplication2/appsettings.json b/tests/Aspire.Playground/WebApplication2/appsettings.json similarity index 100% rename from eaxmple/Playground/WebApplication2/appsettings.json rename to tests/Aspire.Playground/WebApplication2/appsettings.json From 7b2e9c27604fc5193a7a1bc08bb58fc3a11c52e4 Mon Sep 17 00:00:00 2001 From: AboubakrNasef Date: Thu, 30 Jul 2026 14:23:31 +0300 Subject: [PATCH 06/13] fix the solution file --- ZiggyCreatures.FusionCache.slnx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ZiggyCreatures.FusionCache.slnx b/ZiggyCreatures.FusionCache.slnx index 43ea829d..81b2defe 100644 --- a/ZiggyCreatures.FusionCache.slnx +++ b/ZiggyCreatures.FusionCache.slnx @@ -34,10 +34,10 @@ - - - - - + + + + + From 7cccfea550cf78ce8f1c76dede54d5c316bb64cd Mon Sep 17 00:00:00 2001 From: AboubakrNasef Date: Tue, 4 Aug 2026 20:39:53 +0300 Subject: [PATCH 07/13] wip finalizing test --- .../AzureServiceBusBackplaneExtensions.cs | 7 +- .../AzureServiceBusBackplaneOptions.cs | 7 +- .../Admin/AzureServiceBusAdminWrapper.cs | 7 +- .../Backplane/AzureServiceBusBackplane.cs | 12 +-- .../AzureServiceBusBackplane_Async.cs | 5 +- .../AzureServiceBusAdminWrapperTests.cs | 2 +- .../AzureServiceBusBackplaneOptionsTests.cs | 1 - .../AzureServiceBusBackplaneTests.cs | 95 +------------------ ...ServiceBusClientWrapperIntegrationTests.cs | 17 +--- .../AzureServiceBusClientWrapperTests.cs | 30 ------ .../AzureServiceBusHelpersTests.cs | 36 +++++-- ...1L2BackplaneTests.servicebus-emulator.json | 93 ++++++++++++++++++ .../FakeAzureServiceBusAdminWrapper.cs | 40 ++++++++ .../FakeAzureServiceBusCommunicator.cs | 65 +++++++++++++ .../L1L2AzureServiceBusEmulator.cs | 44 +++++++++ .../L1L2BackplaneTests.cs | 32 ++++--- .../Stuff/TestsUtils.cs | 2 +- .../ZiggyCreatures.FusionCache.Tests.csproj | 4 + 18 files changed, 310 insertions(+), 189 deletions(-) create mode 100644 tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBus/L1L2BackplaneTests.servicebus-emulator.json create mode 100644 tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBus/TestDoubles/FakeAzureServiceBusAdminWrapper.cs create mode 100644 tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBus/TestDoubles/FakeAzureServiceBusCommunicator.cs create mode 100644 tests/ZiggyCreatures.FusionCache.Tests/L1L2AzureServiceBusEmulator.cs diff --git a/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/AzureServiceBusBackplaneExtensions.cs b/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/AzureServiceBusBackplaneExtensions.cs index 1b37e238..06b94d70 100644 --- a/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/AzureServiceBusBackplaneExtensions.cs +++ b/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/AzureServiceBusBackplaneExtensions.cs @@ -1,4 +1,4 @@ -using Azure.Core; +using Azure.Core; using Azure.Messaging.ServiceBus; using Azure.Messaging.ServiceBus.Administration; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -34,7 +34,7 @@ private static AzureServiceBusBackplane BuildBackplane(IServiceProvider sp, Azur subscriptionName = options.SubscriptionName ?? AzureServiceBusHelpers.GenerateId(); var provisionerLogger = sp.GetService>() ?? NullLogger.Instance; - provisioner = new AzureServiceBusAdminWrapper(adminClient, topicName, subscriptionName, options.SubscriptionAutoDeleteOnIdle, provisionerLogger); + provisioner = new AzureServiceBusAdminWrapper(adminClient, topicName, subscriptionName, provisionerLogger); } else { @@ -53,9 +53,6 @@ private static void ValidateOptions(AzureServiceBusBackplaneOptions options) if (options.LockTimeout <= TimeSpan.Zero) throw new InvalidOperationException($"{nameof(options.LockTimeout)} must be greater than zero."); - if (options.SubscriptionAutoDeleteOnIdle <= TimeSpan.Zero) - throw new InvalidOperationException($"{nameof(options.SubscriptionAutoDeleteOnIdle)} must be greater than zero."); - if (!options.IsAdmin && string.IsNullOrWhiteSpace(options.SubscriptionName)) throw new InvalidOperationException($"{nameof(options.SubscriptionName)} is required when {nameof(options.IsAdmin)} is false. It must identify a unique, externally provisioned subscription for this cache-process instance."); diff --git a/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/AzureServiceBusBackplaneOptions.cs b/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/AzureServiceBusBackplaneOptions.cs index 2c9b3d0c..7d022b3d 100644 --- a/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/AzureServiceBusBackplaneOptions.cs +++ b/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/AzureServiceBusBackplaneOptions.cs @@ -1,4 +1,4 @@ -using Azure.Core; +using Azure.Core; namespace ZiggyCreatures.Caching.Fusion.Backplane.AzureServiceBus; @@ -49,11 +49,6 @@ public class AzureServiceBusBackplaneOptions /// public string? SubscriptionName { get; set; } - /// - /// The after which an idle, auto-created per-instance subscription will be deleted by the Service Bus service. - /// - public TimeSpan SubscriptionAutoDeleteOnIdle { get; set; } = TimeSpan.FromMinutes(10); - /// /// The max amount of time to wait to acquire the internal lock used to coordinate connection/subscription setup. /// diff --git a/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/AzureServiceBusWrapper/Admin/AzureServiceBusAdminWrapper.cs b/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/AzureServiceBusWrapper/Admin/AzureServiceBusAdminWrapper.cs index 1670d326..d4eacaf8 100644 --- a/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/AzureServiceBusWrapper/Admin/AzureServiceBusAdminWrapper.cs +++ b/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/AzureServiceBusWrapper/Admin/AzureServiceBusAdminWrapper.cs @@ -17,7 +17,6 @@ public class AzureServiceBusAdminWrapper( ServiceBusAdministrationClient serviceBusAdministrationClient, string topicName, string subscriptionName, - TimeSpan subscriptionAutoDeleteOnIdle, ILogger logger) : IAzureServiceBusAdminWrapper { internal const string SelfMessageFilterRuleName = "FilterOutOwnMessages"; @@ -39,11 +38,7 @@ public async ValueTask EnsureSubscriptionAsync() if (!await serviceBusAdministrationClient.SubscriptionExistsAsync(topicName, subscriptionName)) { logger.LogInformation("Creating a new topic subscription: {SubscriptionName}", subscriptionName); - await serviceBusAdministrationClient.CreateSubscriptionAsync(new CreateSubscriptionOptions(topicName, subscriptionName) - { - AutoDeleteOnIdle = subscriptionAutoDeleteOnIdle - }); - + await serviceBusAdministrationClient.CreateSubscriptionAsync(new CreateSubscriptionOptions(topicName, subscriptionName)); } if (await serviceBusAdministrationClient.RuleExistsAsync(topicName, subscriptionName, SelfMessageFilterRuleName)) diff --git a/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/Backplane/AzureServiceBusBackplane.cs b/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/Backplane/AzureServiceBusBackplane.cs index 1af5d348..76e04b67 100644 --- a/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/Backplane/AzureServiceBusBackplane.cs +++ b/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/Backplane/AzureServiceBusBackplane.cs @@ -19,21 +19,21 @@ public partial class AzureServiceBusBackplane /// /// Initializes a new instance of the class. /// - /// The to use for sending/receiving messages. - /// + /// The to use for sending/receiving messages. + /// /// The to use for provisioning the topic/subscription before subscribing, and /// tearing it down after unsubscribing. Use when this instance has /// no administrative capability and the topic/subscription are provisioned out of band. /// /// The instance to use. If null, logging will be completely disabled. public AzureServiceBusBackplane( - IAzureServiceBusClientWrapper serviceBusCommunicator, - IAzureServiceBusAdminWrapper serviceBusProvisioner, + IAzureServiceBusClientWrapper serviceBusClientWrapper, + IAzureServiceBusAdminWrapper serviceBusAdminWrapper, ILogger? logger = null, TimeSpan? lockTimeout = null) { - _serviceBusClientWrapper = serviceBusCommunicator ?? throw new ArgumentNullException(nameof(serviceBusCommunicator)); - _serviceBusAdminWrapper = serviceBusProvisioner ?? throw new ArgumentNullException(nameof(serviceBusProvisioner)); + _serviceBusClientWrapper = serviceBusClientWrapper ?? throw new ArgumentNullException(nameof(serviceBusClientWrapper)); + _serviceBusAdminWrapper = serviceBusAdminWrapper ?? throw new ArgumentNullException(nameof(serviceBusAdminWrapper)); _logger = logger; _lockTimeout = lockTimeout ?? TimeSpan.FromSeconds(5); if (_lockTimeout <= TimeSpan.Zero) diff --git a/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/Backplane/AzureServiceBusBackplane_Async.cs b/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/Backplane/AzureServiceBusBackplane_Async.cs index 9fdfd6af..7f322cde 100644 --- a/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/Backplane/AzureServiceBusBackplane_Async.cs +++ b/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/Backplane/AzureServiceBusBackplane_Async.cs @@ -68,11 +68,12 @@ public async ValueTask PublishAsync(BackplaneMessage message, FusionCacheEntryOp if (_logger?.IsEnabled(LogLevel.Information) ?? false) _logger.Log(LogLevel.Information, "FUSION [N={CacheName} I={CacheInstanceId}]: [BP] new message {Action} {CacheKey} - {Duration} - {DistributedDuration}", _cacheName, _cacheInstanceId, message.Action, message.CacheKey, options.Duration, options.DistributedCacheDuration); - await _serviceBusClientWrapper.SendMessage(new ServiceBusMessage + var asb_message = new ServiceBusMessage { Body = new BinaryData(BackplaneMessage.ToByteArray(message)), Subject = _cacheName - }, token); + }; + await _serviceBusClientWrapper.SendMessage(asb_message, token); } /// diff --git a/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBus/AzureServiceBusAdminWrapperTests.cs b/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBus/AzureServiceBusAdminWrapperTests.cs index c45abcfc..d2d50477 100644 --- a/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBus/AzureServiceBusAdminWrapperTests.cs +++ b/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBus/AzureServiceBusAdminWrapperTests.cs @@ -22,7 +22,7 @@ public void AdminWrapperImplementsTheAdminInterface() { var adminClient = new ServiceBusAdministrationClient(FakeConnectionString); - var provisioner = new AzureServiceBusAdminWrapper(adminClient, "my-topic", "my-subscription", TimeSpan.FromMinutes(10), NullLogger.Instance); + var provisioner = new AzureServiceBusAdminWrapper(adminClient, "my-topic", "my-subscription", NullLogger.Instance); Assert.IsAssignableFrom(provisioner); } diff --git a/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBus/AzureServiceBusBackplaneOptionsTests.cs b/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBus/AzureServiceBusBackplaneOptionsTests.cs index f4a59368..0dbdb052 100644 --- a/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBus/AzureServiceBusBackplaneOptionsTests.cs +++ b/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBus/AzureServiceBusBackplaneOptionsTests.cs @@ -28,7 +28,6 @@ public void DefaultsAreSuitableForAdminBackplane() var options = new AzureServiceBusBackplaneOptions(); Assert.True(options.IsAdmin); - Assert.Equal(TimeSpan.FromMinutes(10), options.SubscriptionAutoDeleteOnIdle); Assert.Equal(TimeSpan.FromSeconds(5), options.LockTimeout); } diff --git a/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBus/AzureServiceBusBackplaneTests.cs b/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBus/AzureServiceBusBackplaneTests.cs index b8076555..e34eae31 100644 --- a/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBus/AzureServiceBusBackplaneTests.cs +++ b/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBus/AzureServiceBusBackplaneTests.cs @@ -1,4 +1,5 @@ using Azure.Messaging.ServiceBus; +using FusionCacheTests.AzureServiceBus.TestDoubles; using FusionCacheTests.Stuff; using Xunit; using ZiggyCreatures.Caching.Fusion; @@ -16,97 +17,6 @@ public AzureServiceBusBackplaneTests(ITestOutputHelper output) { } - private sealed class FakeAzureServiceBusCommunicator - : IAzureServiceBusClientWrapper - { - public FakeAzureServiceBusCommunicator(List? callLog = null) - { - _callLog = callLog; - } - - private readonly List? _callLog; - - public int SubscribeCallCount { get; private set; } - public Func? SubscribedHandler { get; private set; } - public Func? UnsubscribedHandler { get; private set; } - public List SentMessages { get; } = new(); - - public TimeSpan? SubscribeDelay { get; set; } - public TimeSpan? SendMessageDelay { get; set; } - - public event Func? SubscriptionMissing; - - public async Task RaiseSubscriptionMissingAsync() - { - var handler = SubscriptionMissing; - if (handler is not null) - await handler(); - } - - public async Task Subscribe(Func handler) - { - if (SubscribeDelay.HasValue) - await Task.Delay(SubscribeDelay.Value); - - _callLog?.Add(nameof(Subscribe)); - SubscribeCallCount++; - SubscribedHandler = handler; - } - - public Task Unsubscribe(Func handler) - { - _callLog?.Add(nameof(Unsubscribe)); - UnsubscribedHandler = handler; - return Task.CompletedTask; - } - - public async Task SendMessage(ServiceBusMessage message, CancellationToken cancellationToken) - { - if (SendMessageDelay.HasValue) - await Task.Delay(SendMessageDelay.Value); - - SentMessages.Add(message); - } - - public ValueTask DisposeAsync() => default; - } - - private sealed class FakeAzureServiceBusAdminWrapper - : IAzureServiceBusAdminWrapper - { - public FakeAzureServiceBusAdminWrapper(List? callLog = null) - { - _callLog = callLog; - } - - private readonly List? _callLog; - - public int EnsureTopicCallCount { get; private set; } - public int EnsureSubscriptionCallCount { get; private set; } - public int DisposeCallCount { get; private set; } - - public ValueTask EnsureTopicAsync() - { - _callLog?.Add(nameof(EnsureTopicAsync)); - EnsureTopicCallCount++; - return default; - } - - public ValueTask EnsureSubscriptionAsync() - { - _callLog?.Add(nameof(EnsureSubscriptionAsync)); - EnsureSubscriptionCallCount++; - return default; - } - - public ValueTask DisposeAsync() - { - _callLog?.Add(nameof(DisposeAsync)); - DisposeCallCount++; - return default; - } - } - private static (BackplaneSubscriptionOptions Options, List ReceivedMessages, List ConnectReconnectionFlags) CreateSubscriptionOptions( string cacheName = "TestCache", string cacheInstanceId = "TestInstance", @@ -357,11 +267,10 @@ public async Task PublishAsyncSendsMessageWithExpectedSubjectBodyAndTtlAsync() var message = BackplaneMessage.CreateForEntrySet("source-instance", "my-key", 987654321L); var entryOptions = new FusionCacheEntryOptions(TimeSpan.FromMinutes(10)); - await backplane.PublishAsync(message, entryOptions); + await backplane.PublishAsync(message, entryOptions,TestContext.Current.CancellationToken); var sent = Assert.Single(fake.SentMessages); Assert.Equal("TestCache", sent.Subject); - Assert.Equal(TimeSpan.FromSeconds(5) + entryOptions.Duration, sent.TimeToLive); var roundTripped = BackplaneMessage.FromByteArray(sent.Body.ToArray()); Assert.Equal(message.SourceId, roundTripped.SourceId); diff --git a/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBus/AzureServiceBusClientWrapperIntegrationTests.cs b/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBus/AzureServiceBusClientWrapperIntegrationTests.cs index ac28ecea..12d38ff2 100644 --- a/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBus/AzureServiceBusClientWrapperIntegrationTests.cs +++ b/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBus/AzureServiceBusClientWrapperIntegrationTests.cs @@ -54,7 +54,7 @@ private static string CreateUniqueTopicName(string testName) private static AzureServiceBusAdminWrapper CreateAdminWrapper(ServiceBusAdministrationClient adminClient, string topicName, string subscriptionName) { - return new AzureServiceBusAdminWrapper(adminClient, topicName, subscriptionName, TimeSpan.FromMinutes(10), NullLogger.Instance); + return new AzureServiceBusAdminWrapper(adminClient, topicName, subscriptionName, NullLogger.Instance); } private static AzureServiceBusClientWrapper CreateCommunicator(ServiceBusClient client, string topicName, string subscriptionName) @@ -211,8 +211,6 @@ public async Task ClientWrapperWorksAgainstAnExternallyProvisionedSubscriptionWi try { - // PROVISION OUT OF BAND (E.G. VIA IAC), WITHOUT EVER USING AzureServiceBusAdminWrapper. NOTE THIS - // SUBSCRIPTION KEEPS ITS DEFAULT MATCH-ALL RULE: NO ONE HERE CREATES THE "FilterOutOwnMessages" SQL RULE. await adminClient.CreateTopicAsync(topicName, TestContext.Current.CancellationToken); await adminClient.CreateSubscriptionAsync(new CreateSubscriptionOptions(topicName, subscriptionName), TestContext.Current.CancellationToken); @@ -224,15 +222,11 @@ public async Task ClientWrapperWorksAgainstAnExternallyProvisionedSubscriptionWi await communicator.SendMessage(new ServiceBusMessage(new BinaryData(new byte[] { 7 })), TestContext.Current.CancellationToken); - // WITHOUT A SERVER-SIDE SELF-FILTER RULE, THE SUBSCRIPTION'S DEFAULT RULE DELIVERS THE MESSAGE BACK TO - // THIS SAME COMMUNICATOR. THE APP-LEVEL SELF-CHECK IN ProcessMessageAsync IS WHAT PREVENTS IT FROM EVER - // REACHING A REGISTERED HANDLER, SO IF THIS NEVER COMPLETES, THAT GUARD DID ITS JOB. var completed = await Task.WhenAny(receivedTcs.Task, Task.Delay(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken)); Assert.NotSame(receivedTcs.Task, completed); await communicator.DisposeAsync(); - // THE COMMUNICATOR HAS NO ADMINISTRATIVE CAPABILITY AT ALL: DISPOSING IT MUST NEVER DELETE THE SUBSCRIPTION Assert.True(await adminClient.SubscriptionExistsAsync(topicName, subscriptionName, TestContext.Current.CancellationToken)); } finally @@ -243,13 +237,6 @@ public async Task ClientWrapperWorksAgainstAnExternallyProvisionedSubscriptionWi private static async Task TryDeleteTopicAsync(ServiceBusAdministrationClient adminClient, string topicName) { - try - { - await adminClient.DeleteTopicAsync(topicName); - } - catch - { - // BEST-EFFORT CLEANUP: DON'T FAIL THE TEST RUN IF THE TOPIC WAS NEVER CREATED OR IS ALREADY GONE - } + await adminClient.DeleteTopicAsync(topicName); } } diff --git a/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBus/AzureServiceBusClientWrapperTests.cs b/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBus/AzureServiceBusClientWrapperTests.cs index e0d8e476..e09aa644 100644 --- a/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBus/AzureServiceBusClientWrapperTests.cs +++ b/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBus/AzureServiceBusClientWrapperTests.cs @@ -3,8 +3,6 @@ using Microsoft.Extensions.Logging.Abstractions; using Xunit; using ZiggyCreatures.Caching.Fusion.Backplane.AzureServiceBus; -using ZiggyCreatures.Caching.Fusion.Backplane.AzureServiceBus.AzureServiceBusWrapper; -using ZiggyCreatures.Caching.Fusion.Backplane.AzureServiceBus.Helpers; namespace FusionCacheTests.AzureServiceBus; @@ -55,32 +53,4 @@ public void SubscriptionMissingEventCanBeAddedAndRemovedWithoutThrowing() communicator.SubscriptionMissing -= Handler; } - [Fact] - public void GenerateIdReturnsAValidSubscriptionNameLength() - { - var id = AzureServiceBusHelpers.GenerateId(); - - Assert.True(id.Length <= AzureServiceBusHelpers.MaxSubscriptionNameLength, $"Expected length <= {AzureServiceBusHelpers.MaxSubscriptionNameLength}, but was {id.Length} ('{id}')"); - Assert.NotEmpty(id); - } - - [Fact] - public void GenerateIdOnlyContainsValidServiceBusEntityNameCharacters() - { - var id = AzureServiceBusHelpers.GenerateId(); - - foreach (var c in id) - { - Assert.True(char.IsLetterOrDigit(c) || c is '.' or '-' or '_' or '/', $"Unexpected character '{c}' in generated id '{id}'"); - } - } - - [Fact] - public void GenerateIdReturnsDifferentValuesOnSuccessiveCalls() - { - var id1 = AzureServiceBusHelpers.GenerateId(); - var id2 = AzureServiceBusHelpers.GenerateId(); - - Assert.NotEqual(id1, id2); - } } diff --git a/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBus/AzureServiceBusHelpersTests.cs b/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBus/AzureServiceBusHelpersTests.cs index 38dd8d1d..1a34bb4b 100644 --- a/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBus/AzureServiceBusHelpersTests.cs +++ b/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBus/AzureServiceBusHelpersTests.cs @@ -12,12 +12,6 @@ public AzureServiceBusHelpersTests(ITestOutputHelper output) { } - [Fact] - public void SanitizeEntityNameThrowsWhenNameIsNull() - { - Assert.Throws(() => AzureServiceBusHelpers.SanitizeEntityName(null!, 50)); - } - [Fact] public void SanitizeEntityNameLeavesValidCharactersUntouched() { @@ -73,8 +67,6 @@ public void ResolveTopicNameUsesExplicitTopicNameWhenProvided() [Fact] public void ResolveTopicNameFallsBackToChannelNameWhenNotProvided() { - // THIS IS FUSIONCACHE'S DEFAULT COMPUTED CHANNEL NAME SHAPE (SEE FusionCacheInternalUtils.GetBackplaneChannelName): - // THE ':' SEPARATOR IS NOT A VALID SERVICE BUS CHARACTER, SO IT MUST BE SANITIZED AWAY var result = AzureServiceBusHelpers.ResolveTopicName(null, "MyCache.Backplane:v1"); Assert.Equal("MyCache.Backplane-v1", result); @@ -87,4 +79,32 @@ public void ResolveTopicNameFallsBackToChannelNameWhenExplicitTopicNameIsWhitesp Assert.Equal("MyCache.Backplane-v1", result); } + [Fact] + public void GenerateIdReturnsAValidSubscriptionNameLength() + { + var id = AzureServiceBusHelpers.GenerateId(); + + Assert.True(id.Length <= AzureServiceBusHelpers.MaxSubscriptionNameLength, $"Expected length <= {AzureServiceBusHelpers.MaxSubscriptionNameLength}, but was {id.Length} ('{id}')"); + Assert.NotEmpty(id); + } + + [Fact] + public void GenerateIdOnlyContainsValidServiceBusEntityNameCharacters() + { + var id = AzureServiceBusHelpers.GenerateId(); + + foreach (var c in id) + { + Assert.True(char.IsLetterOrDigit(c) || c is '.' or '-' or '_' or '/', $"Unexpected character '{c}' in generated id '{id}'"); + } + } + + [Fact] + public void GenerateIdReturnsDifferentValuesOnSuccessiveCalls() + { + var id1 = AzureServiceBusHelpers.GenerateId(); + var id2 = AzureServiceBusHelpers.GenerateId(); + + Assert.NotEqual(id1, id2); + } } diff --git a/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBus/L1L2BackplaneTests.servicebus-emulator.json b/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBus/L1L2BackplaneTests.servicebus-emulator.json new file mode 100644 index 00000000..cecc36ee --- /dev/null +++ b/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBus/L1L2BackplaneTests.servicebus-emulator.json @@ -0,0 +1,93 @@ +{ + "UserConfig": { + "Namespaces": [ + { + "Name": "sbemulatorns", + "Topics": [ + { + "Name": "fusioncache-tests", + "Properties": { + "DefaultMessageTimeToLive": "PT1H", + "DuplicateDetectionHistoryTimeWindow": "PT20S", + "RequiresDuplicateDetection": false + }, + "Subscriptions": [ + { + "Name": "fusioncache-tests-1", + "Properties": { + "DeadLetteringOnMessageExpiration": false, + "DefaultMessageTimeToLive": "PT1H", + "LockDuration": "PT1M", + "MaxDeliveryCount": 3, + "ForwardDeadLetteredMessagesTo": "", + "ForwardTo": "", + "RequiresSession": false + }, + "Rules": [ + { + "Name": "filter-out-own-messages", + "Properties": { + "FilterType": "Sql", + "SqlFilter": { + "SqlExpression": "ConnectionId <> 'fusioncache-tests-1'" + } + } + } + ] + }, + { + "Name": "fusioncache-tests-2", + "Properties": { + "DeadLetteringOnMessageExpiration": false, + "DefaultMessageTimeToLive": "PT1H", + "LockDuration": "PT1M", + "MaxDeliveryCount": 3, + "ForwardDeadLetteredMessagesTo": "", + "ForwardTo": "", + "RequiresSession": false + }, + "Rules": [ + { + "Name": "filter-out-own-messages", + "Properties": { + "FilterType": "Sql", + "SqlFilter": { + "SqlExpression": "ConnectionId <> 'fusioncache-tests-2'" + } + } + } + ] + }, + { + "Name": "fusioncache-tests-3", + "Properties": { + "DeadLetteringOnMessageExpiration": false, + "DefaultMessageTimeToLive": "PT1H", + "LockDuration": "PT1M", + "MaxDeliveryCount": 3, + "ForwardDeadLetteredMessagesTo": "", + "ForwardTo": "", + "RequiresSession": false + }, + "Rules": [ + { + "Name": "filter-out-own-messages", + "Properties": { + "FilterType": "Sql", + "SqlFilter": { + "SqlExpression": "ConnectionId <> 'fusioncache-tests-3'" + } + } + } + ] + } + ] + } + ] + } + ], + "Logging": { + "Type": "Console" + } + } +} \ No newline at end of file diff --git a/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBus/TestDoubles/FakeAzureServiceBusAdminWrapper.cs b/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBus/TestDoubles/FakeAzureServiceBusAdminWrapper.cs new file mode 100644 index 00000000..f6501d53 --- /dev/null +++ b/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBus/TestDoubles/FakeAzureServiceBusAdminWrapper.cs @@ -0,0 +1,40 @@ +using ZiggyCreatures.Caching.Fusion.Backplane.AzureServiceBus.AzureServiceBusWrapper; + +namespace FusionCacheTests.AzureServiceBus.TestDoubles +{ + internal sealed class FakeAzureServiceBusAdminWrapper : IAzureServiceBusAdminWrapper + { + public FakeAzureServiceBusAdminWrapper(List? callLog = null) + { + _callLog = callLog; + } + + private readonly List? _callLog; + + public int EnsureTopicCallCount { get; private set; } + public int EnsureSubscriptionCallCount { get; private set; } + public int DisposeCallCount { get; private set; } + + public ValueTask EnsureTopicAsync() + { + _callLog?.Add(nameof(EnsureTopicAsync)); + EnsureTopicCallCount++; + return default; + } + + public ValueTask EnsureSubscriptionAsync() + { + _callLog?.Add(nameof(EnsureSubscriptionAsync)); + EnsureSubscriptionCallCount++; + return default; + } + + public ValueTask DisposeAsync() + { + _callLog?.Add(nameof(DisposeAsync)); + DisposeCallCount++; + return default; + } + } + +} diff --git a/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBus/TestDoubles/FakeAzureServiceBusCommunicator.cs b/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBus/TestDoubles/FakeAzureServiceBusCommunicator.cs new file mode 100644 index 00000000..3baf6321 --- /dev/null +++ b/tests/ZiggyCreatures.FusionCache.Tests/AzureServiceBus/TestDoubles/FakeAzureServiceBusCommunicator.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Azure.Messaging.ServiceBus; +using ZiggyCreatures.Caching.Fusion.Backplane.AzureServiceBus.AzureServiceBusWrapper; + +namespace FusionCacheTests.AzureServiceBus.TestDoubles +{ + internal sealed class FakeAzureServiceBusCommunicator : IAzureServiceBusClientWrapper + { + public FakeAzureServiceBusCommunicator(List? callLog = null) + { + _callLog = callLog; + } + + private readonly List? _callLog; + + public int SubscribeCallCount { get; private set; } + public Func? SubscribedHandler { get; private set; } + public Func? UnsubscribedHandler { get; private set; } + public List SentMessages { get; } = new(); + + public TimeSpan? SubscribeDelay { get; set; } + public TimeSpan? SendMessageDelay { get; set; } + + public event Func? SubscriptionMissing; + + public async Task RaiseSubscriptionMissingAsync() + { + var handler = SubscriptionMissing; + if (handler is not null) + await handler(); + } + + public async Task Subscribe(Func handler) + { + if (SubscribeDelay.HasValue) + await Task.Delay(SubscribeDelay.Value); + + _callLog?.Add(nameof(Subscribe)); + SubscribeCallCount++; + SubscribedHandler = handler; + } + + public Task Unsubscribe(Func handler) + { + _callLog?.Add(nameof(Unsubscribe)); + UnsubscribedHandler = handler; + return Task.CompletedTask; + } + + public async Task SendMessage(ServiceBusMessage message, CancellationToken cancellationToken) + { + if (SendMessageDelay.HasValue) + await Task.Delay(SendMessageDelay.Value); + + SentMessages.Add(message); + } + + public ValueTask DisposeAsync() => default; + } + +} diff --git a/tests/ZiggyCreatures.FusionCache.Tests/L1L2AzureServiceBusEmulator.cs b/tests/ZiggyCreatures.FusionCache.Tests/L1L2AzureServiceBusEmulator.cs new file mode 100644 index 00000000..978da3d3 --- /dev/null +++ b/tests/ZiggyCreatures.FusionCache.Tests/L1L2AzureServiceBusEmulator.cs @@ -0,0 +1,44 @@ +using Testcontainers.ServiceBus; +using Xunit; + +namespace FusionCacheTests; + +public sealed class L1L2AzureServiceBusFixture : IAsyncLifetime +{ + private const string ConfigurationFileName = "AzureServiceBus/L1L2BackplaneTests.servicebus-emulator.json"; + private const string ConfiguredTopicName = "fusioncache-tests"; + private static readonly string[] SubscriptionNames = ["fusioncache-tests-1", "fusioncache-tests-2", "fusioncache-tests-3"]; + + private readonly Lazy _container = new(CreateAndStartContainer, LazyThreadSafetyMode.ExecutionAndPublication); + private int _nextSubscriptionIndex = -1; + + public ValueTask InitializeAsync() => default; + + public async ValueTask DisposeAsync() + { + if (_container.IsValueCreated) + await _container.Value.DisposeAsync(); + } + + public string ConnectionString => _container.Value.GetConnectionString(); + + public string TopicName => ConfiguredTopicName; + + public string GetNextSubscriptionName() + { + var subscriptionIndex = Interlocked.Increment(ref _nextSubscriptionIndex) % SubscriptionNames.Length; + return SubscriptionNames[subscriptionIndex]; + } + + private static ServiceBusContainer CreateAndStartContainer() + { + var configurationFilePath = Path.Combine(AppContext.BaseDirectory, ConfigurationFileName); + var container = new ServiceBusBuilder("mcr.microsoft.com/azure-messaging/servicebus-emulator:latest") + .WithAcceptLicenseAgreement(true) + .WithConfig(configurationFilePath) + .Build(); + + container.StartAsync().GetAwaiter().GetResult(); + return container; + } +} \ No newline at end of file diff --git a/tests/ZiggyCreatures.FusionCache.Tests/L1L2BackplaneTests.cs b/tests/ZiggyCreatures.FusionCache.Tests/L1L2BackplaneTests.cs index 37b722b0..c65b0f69 100644 --- a/tests/ZiggyCreatures.FusionCache.Tests/L1L2BackplaneTests.cs +++ b/tests/ZiggyCreatures.FusionCache.Tests/L1L2BackplaneTests.cs @@ -1,5 +1,4 @@ using Azure.Messaging.ServiceBus; -using Azure.Messaging.ServiceBus.Administration; using FusionCacheTests.Stuff; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Memory; @@ -9,7 +8,7 @@ using ZiggyCreatures.Caching.Fusion; using ZiggyCreatures.Caching.Fusion.Backplane; using ZiggyCreatures.Caching.Fusion.Backplane.AzureServiceBus; -using ZiggyCreatures.Caching.Fusion.Backplane.AzureServiceBus.Helpers; +using ZiggyCreatures.Caching.Fusion.Backplane.AzureServiceBus.AzureServiceBusWrapper; using ZiggyCreatures.Caching.Fusion.Backplane.Memory; using ZiggyCreatures.Caching.Fusion.Backplane.StackExchangeRedis; using ZiggyCreatures.Caching.Fusion.DangerZone; @@ -17,12 +16,13 @@ namespace FusionCacheTests; public partial class L1L2BackplaneTests - : AbstractTests + : AbstractTests, IClassFixture { - public L1L2BackplaneTests(ITestOutputHelper output) + public L1L2BackplaneTests(ITestOutputHelper output, L1L2AzureServiceBusFixture azureServiceBusFixture) : base(output, "MyCache:") { - if (UseRedis) + _azureServiceBusFixture = azureServiceBusFixture; + if (UseRedis || UseAzureServiceBus) InitialBackplaneDelay = TimeSpan.FromSeconds(5).PlusALittleBit(); } @@ -42,8 +42,7 @@ private FusionCacheOptions CreateFusionCacheOptions() private static readonly bool UseAzureServiceBus = true; private static readonly string RedisConnection = "127.0.0.1:6379,ssl=False,abortConnect=false,connectTimeout=1000,syncTimeout=1000"; - private static readonly string AzureServiceBusConnectionString = Environment.GetEnvironmentVariable("FUSIONCACHE_TESTS_AZURESERVICEBUS_CONNECTIONSTRING") - ?? "Endpoint=sb://localhost;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true;"; + private readonly L1L2AzureServiceBusFixture _azureServiceBusFixture; private readonly TimeSpan InitialBackplaneDelay = TimeSpan.FromMilliseconds(300); private readonly TimeSpan MultiNodeOperationsDelay = TimeSpan.FromMilliseconds(300); @@ -54,14 +53,17 @@ private IFusionCacheBackplane CreateBackplane(string connectionId) return new RedisBackplane(new RedisBackplaneOptions { Configuration = RedisConnection }, logger: CreateXUnitLogger()); if (UseAzureServiceBus) { - var topicName = AzureServiceBusHelpers.SanitizeEntityName($"fusioncache-tests", AzureServiceBusHelpers.MaxTopicNameLength); - var subscriptionName = AzureServiceBusHelpers.GenerateId(); - var adminClient = new ServiceBusAdministrationClient(AzureServiceBusConnectionString); - var client = new ServiceBusClient(AzureServiceBusConnectionString); - var clientWrapper = new AzureServiceBusClientWrapper(client, topicName, subscriptionName, CreateXUnitLogger(), new AzureServiceBusBackplaneOptions()); - var adminWrapper = new AzureServiceBusAdminWrapper(adminClient, topicName, subscriptionName, TimeSpan.FromMinutes(10), CreateXUnitLogger()); - - return new AzureServiceBusBackplane(clientWrapper, adminWrapper, CreateXUnitLogger()); + var emulator = _azureServiceBusFixture; + var subscriptionName = emulator.GetNextSubscriptionName(); + var client = new ServiceBusClient(emulator.ConnectionString); + var options = new AzureServiceBusBackplaneOptions + { + IsAdmin = false, + SubscriptionName = subscriptionName, + }; + var clientWrapper = new AzureServiceBusClientWrapper(client, emulator.TopicName, subscriptionName, CreateXUnitLogger(), options); + + return new AzureServiceBusBackplane(clientWrapper, NoOpAzureServiceBusAdminWrapper.Instance, CreateXUnitLogger()); } return new MemoryBackplane(new MemoryBackplaneOptions() { ConnectionId = connectionId }, logger: CreateXUnitLogger()); } diff --git a/tests/ZiggyCreatures.FusionCache.Tests/Stuff/TestsUtils.cs b/tests/ZiggyCreatures.FusionCache.Tests/Stuff/TestsUtils.cs index a2ea9144..72996c02 100644 --- a/tests/ZiggyCreatures.FusionCache.Tests/Stuff/TestsUtils.cs +++ b/tests/ZiggyCreatures.FusionCache.Tests/Stuff/TestsUtils.cs @@ -160,7 +160,7 @@ public static FusionCacheOptions GetOptions(this IFusionCache cache) if (backplane is null) return null; - var communicator = typeof(AzureServiceBusBackplane).GetField("_serviceBusCommunicator", BindingFlags.NonPublic | BindingFlags.Instance)!.GetValue(backplane) as AzureServiceBusClientWrapper; + var communicator = typeof(AzureServiceBusBackplane).GetField("_serviceBusClientWrapper", BindingFlags.NonPublic | BindingFlags.Instance)!.GetValue(backplane) as AzureServiceBusClientWrapper; if (communicator is null) return null; diff --git a/tests/ZiggyCreatures.FusionCache.Tests/ZiggyCreatures.FusionCache.Tests.csproj b/tests/ZiggyCreatures.FusionCache.Tests/ZiggyCreatures.FusionCache.Tests.csproj index 394f7be5..7ae5c5e5 100644 --- a/tests/ZiggyCreatures.FusionCache.Tests/ZiggyCreatures.FusionCache.Tests.csproj +++ b/tests/ZiggyCreatures.FusionCache.Tests/ZiggyCreatures.FusionCache.Tests.csproj @@ -12,6 +12,9 @@ Always + + PreserveNewest + @@ -22,6 +25,7 @@ + From 70d91ff46a8f23f40eed58877deaf79e752d083f Mon Sep 17 00:00:00 2001 From: AboubakrNasef Date: Thu, 6 Aug 2026 17:21:10 +0300 Subject: [PATCH 08/13] add small sleep --- .../L1L2BackplaneTests_Async.cs | 1 + .../L1L2BackplaneTests_Sync.cs | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/ZiggyCreatures.FusionCache.Tests/L1L2BackplaneTests_Async.cs b/tests/ZiggyCreatures.FusionCache.Tests/L1L2BackplaneTests_Async.cs index 16e9d25f..6f5b12b2 100644 --- a/tests/ZiggyCreatures.FusionCache.Tests/L1L2BackplaneTests_Async.cs +++ b/tests/ZiggyCreatures.FusionCache.Tests/L1L2BackplaneTests_Async.cs @@ -820,6 +820,7 @@ public async Task CanClearAsync(SerializerType serializerType) Assert.Equal(0, cache2_foo_4); Assert.Equal(0, cache2_bar_4); + Thread.Sleep(10); logger.LogInformation("STEP 10"); diff --git a/tests/ZiggyCreatures.FusionCache.Tests/L1L2BackplaneTests_Sync.cs b/tests/ZiggyCreatures.FusionCache.Tests/L1L2BackplaneTests_Sync.cs index 7e9bee11..1ea445c7 100644 --- a/tests/ZiggyCreatures.FusionCache.Tests/L1L2BackplaneTests_Sync.cs +++ b/tests/ZiggyCreatures.FusionCache.Tests/L1L2BackplaneTests_Sync.cs @@ -721,7 +721,7 @@ public void RemoveByTagDoesNotRemoveTaggingData(SerializerType serializerType) [Theory] [ClassData(typeof(SerializerTypesClassData))] - public void CanClear(SerializerType serializerType) + public async Task CanClear(SerializerType serializerType) { var logger = CreateXUnitLogger(); @@ -821,8 +821,9 @@ public void CanClear(SerializerType serializerType) Assert.Equal(0, cache2_foo_4); Assert.Equal(0, cache2_bar_4); - logger.LogInformation("STEP 10"); + Thread.Sleep(10); + logger.LogInformation("STEP 10"); var cache1_foo_5 = cache1.GetOrDefault("foo", opt => opt.SetAllowStaleOnReadOnly(), token: TestContext.Current.CancellationToken); var cache1_bar_5 = cache1.GetOrDefault("bar", opt => opt.SetAllowStaleOnReadOnly(), token: TestContext.Current.CancellationToken); From fa32451615d831850fa706f3604eca2b08acf89c Mon Sep 17 00:00:00 2001 From: AboubakrNasef Date: Thu, 6 Aug 2026 17:23:14 +0300 Subject: [PATCH 09/13] clean unused files --- .../IMPLEMENTATION_PLAN.md | 169 ------------------ 1 file changed, 169 deletions(-) delete mode 100644 src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/IMPLEMENTATION_PLAN.md diff --git a/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/IMPLEMENTATION_PLAN.md b/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/IMPLEMENTATION_PLAN.md deleted file mode 100644 index 086d24fe..00000000 --- a/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/IMPLEMENTATION_PLAN.md +++ /dev/null @@ -1,169 +0,0 @@ -# Azure Service Bus Backplane Implementation Plan - -## Goal - -Bring `ZiggyCreatures.FusionCache.Backplane.AzureServiceBus` to release-ready quality: a correct broadcast topology, reliable lifecycle handling, supported authentication modes, an executable test suite, and documented deployment guidance. - -## Delivery order - -1. Stabilize the internal contract and tests. -2. Define the subscription topology and ownership rules. -3. Implement authentication, validation, and client construction. -4. Complete provisioning, filtering, and cleanup. -5. Harden runtime behavior and add broker-backed coverage. -6. Finish documentation and package verification. - -## 1. Stabilize the internal contract and test suite - -- Finalize the wrapper contract around: - - `AzureServiceBusClientWrapper` - - `AzureServiceBusAdminWrapper` - - `IAsyncDisposable.DisposeAsync` -- Update Azure Service Bus unit tests, integration tests, shared test helpers, and L1/L2 backplane tests to use that contract. -- Add `DisposeAsync` implementations to all fake wrappers. -- Remove stale references to the deleted API, including `AzureServiceBusNaming`, `AzureServiceBusAdminProvisioner`, `UnprovisionAsync`, and old wrapper constructors. -- Do not proceed until the Azure Service Bus test subset compiles and passes. - -**Acceptance criteria** - -- `dotnet test tests/ZiggyCreatures.FusionCache.Tests/ZiggyCreatures.FusionCache.Tests.csproj --filter "FullyQualifiedName~AzureServiceBus"` compiles and passes. -- Tests exercise the current production types rather than compatibility shims. - -## 2. Define subscription topology and ownership - -Azure Service Bus topics broadcast only when every cache node consumes through its own subscription. Multiple nodes sharing a subscription compete for messages and will miss invalidations. - -- In admin mode, generate one unique subscription per cache-process instance by default. -- In non-admin mode, require an externally provisioned, unique subscription per cache-process instance. -- Define ownership rules for manually supplied subscription names in admin mode: - - whether the library may create it; - - whether the library may delete it; - - how this differs from externally provisioned resources. -- Add an explicit instance/subscription identity option if the current `SubscriptionName` property is insufficient to describe the deployment model. -- Document required Azure permissions for each mode. - -**Acceptance criteria** - -- A multi-node integration test proves one published invalidation reaches every other node. -- Configuration and documentation make a shared subscription an explicitly unsupported multi-node topology. - -## 3. Implement options validation and client creation - -- Add one validation path, used before a backplane is constructed. -- Support exactly one authentication mode: - - connection string; or - - fully qualified namespace plus `TokenCredential`. -- Reject missing, partial, or conflicting configurations with actionable exceptions. -- Construct both `ServiceBusClient` and `ServiceBusAdministrationClient` from the selected authentication mode. -- Consider optional client factories for advanced hosts and deterministic tests; define ownership so caller-provided clients are not disposed by the backplane. -- Apply `LockTimeout` consistently to all locks; remove hard-coded lock timeouts. - -**Acceptance criteria** - -- Unit tests cover connection-string authentication, token-credential authentication, invalid configurations, and client-factory precedence if factories are added. -- Identity-only configuration works without requiring a connection string. - -## 4. Align default topic selection with FusionCache channels - -- Decide and document whether the default topic derives from `BackplaneSubscriptionOptions.ChannelName` or `CacheName`. -- Prefer `ChannelName` when protocol/version isolation is expected from FusionCache's normal channel naming. -- Keep `TopicName` as an explicit override. -- Retain deterministic sanitization, length limits, and valid fallbacks. -- Add tests for invalid characters, long names, fallback names, and topic isolation. - -**Acceptance criteria** - -- The value validated during subscription is also the value used to derive the default topic. -- Two incompatible channels cannot silently share the same default topic. - -## 5. Complete provisioning and self-message filtering - -- Create subscriptions using `SubscriptionAutoDeleteOnIdle`. -- Add a server-side rule that excludes messages whose `ConnectionId` matches the local subscription identity. -- Remove or replace the default match-all rule so the exclusion rule takes effect. -- Make topic, subscription, and rule creation idempotent and safe against concurrent starts. -- Define behavior for existing subscriptions and rules in admin mode: validate/repair them or fail with a clear diagnostic. -- Keep the in-process self-message guard as defense in depth, not as the primary filtering mechanism. - -**Acceptance criteria** - -- Real-broker tests show self-published messages are filtered server-side. -- The configured auto-delete interval is observable on the created subscription. -- Concurrent startup does not fail due to benign `AlreadyExists` races. - -## 6. Implement lifecycle ownership and cleanup - -- Make the backplane explicitly own shutdown, preferably with `IAsyncDisposable`. -- On unsubscribe/dispose: - - detach `SubscriptionMissing` handlers; - - stop and dispose the processor; - - dispose the sender and library-created Service Bus client; - - delete only subscriptions the instance created and owns; - - never delete externally provisioned non-admin resources. -- Make cleanup idempotent and preserve the primary operation exception when cleanup also fails. -- Clear local state after successful teardown so an intentional future subscribe can start cleanly, if re-subscription is supported. - -**Acceptance criteria** - -- Repeated unsubscribe/dispose calls are safe. -- Disposal stops the processor and releases owned SDK resources. -- Non-admin disposal never attempts an administrative operation. - -## 7. Harden subscribe, recovery, and message processing - -- Make duplicate `SubscribeAsync` calls either fail clearly or be fully idempotent; choose one behavior and test it. -- Roll back state and event registration if provisioning, processor startup, or connect callbacks fail. -- Serialize subscription recovery after `MessagingEntityNotFound` to prevent repeated provisioning attempts. -- Re-establish processing after recovery and invoke the FusionCache connection handler with `IsReconnection = true`. -- Snapshot message handlers before invocation to avoid concurrent mutation while dispatching. -- Define and test handling for malformed bodies, missing properties, handler failures, abandon/retry, and dead-letter behavior. -- Deliberately configure processor concurrency and prefetch behavior; expose options only where necessary. - -**Acceptance criteria** - -- A deleted auto-delete subscription is recreated and resumes processing. -- Failure during subscribe leaves no orphan event handler or partial state. -- Invalid messages have deliberate, tested settlement behavior. - -## 8. Documentation and packaging - -- Add a package README and include it in the project file. -- Document: - - connection-string and managed-identity setup; - - admin versus non-admin permissions; - - per-instance subscription/IaC requirements; - - topic and subscription naming; - - cleanup and auto-delete behavior; - - Azure Service Bus emulator integration tests. -- Include a minimal multi-node configuration example. -- Verify package icon, README, dependencies, and target frameworks during packing. - -**Acceptance criteria** - -- `dotnet pack` creates an installable package with its README included. -- A user can configure both supported authentication modes by following the package README alone. - -## 9. Final verification - -- Run all unit tests across supported target frameworks. -- Run emulator or real-broker integration tests in CI. -- Add coverage for: - - multi-node broadcast delivery; - - identity authentication; - - non-admin externally provisioned subscriptions; - - reconnect and subscription recreation; - - self-message filtering; - - duplicate subscribe and repeated disposal. -- Run `dotnet test`, `dotnet pack`, formatting/analyzers, and a package-consumption smoke test before merge. - -## Priority - -The required order is: - -1. Test-contract repair. -2. Subscription topology decision. -3. Authentication and validation. -4. Provisioning and lifecycle implementation. -5. Integration coverage and documentation. - -This order prevents the project from shipping a configuration that appears valid but either cannot authenticate, leaks resources, or fails to deliver cache invalidations to every node. From 65a67df4afb55b4126550d2059becb0c5f2d8088 Mon Sep 17 00:00:00 2001 From: AboubakrNasef Date: Sat, 8 Aug 2026 10:00:09 +0300 Subject: [PATCH 10/13] update L1 Tests --- .../L1BackplaneTests.cs | 27 ++++++++++++++++--- .../L1BackplaneTests_Async.cs | 4 ++- .../L1L2BackplaneTests.cs | 2 +- 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/tests/ZiggyCreatures.FusionCache.Tests/L1BackplaneTests.cs b/tests/ZiggyCreatures.FusionCache.Tests/L1BackplaneTests.cs index b30e0ad8..9de74f58 100644 --- a/tests/ZiggyCreatures.FusionCache.Tests/L1BackplaneTests.cs +++ b/tests/ZiggyCreatures.FusionCache.Tests/L1BackplaneTests.cs @@ -1,9 +1,12 @@ using FusionCacheTests.Stuff; +using Azure.Messaging.ServiceBus; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using Xunit; using ZiggyCreatures.Caching.Fusion; using ZiggyCreatures.Caching.Fusion.Backplane; +using ZiggyCreatures.Caching.Fusion.Backplane.AzureServiceBus; +using ZiggyCreatures.Caching.Fusion.Backplane.AzureServiceBus.AzureServiceBusWrapper; using ZiggyCreatures.Caching.Fusion.Backplane.Memory; using ZiggyCreatures.Caching.Fusion.Backplane.StackExchangeRedis; using ZiggyCreatures.Caching.Fusion.DangerZone; @@ -11,12 +14,13 @@ namespace FusionCacheTests; public partial class L1BackplaneTests - : AbstractTests + : AbstractTests, IClassFixture { - public L1BackplaneTests(ITestOutputHelper output) + public L1BackplaneTests(ITestOutputHelper output, L1L2AzureServiceBusFixture azureServiceBusFixture) : base(output, "MyCache:") { - if (UseRedis) + _azureServiceBusFixture = azureServiceBusFixture; + if (UseRedis || UseAzureServiceBus) InitialBackplaneDelay = TimeSpan.FromSeconds(5).PlusALittleBit(); } @@ -33,8 +37,11 @@ private FusionCacheOptions CreateFusionCacheOptions() } private static readonly bool UseRedis = false; + private static readonly bool UseAzureServiceBus = false; private static readonly string RedisConnection = "127.0.0.1:6379,ssl=False,abortConnect=false,connectTimeout=1000,syncTimeout=1000"; + private readonly L1L2AzureServiceBusFixture _azureServiceBusFixture; + private readonly TimeSpan InitialBackplaneDelay = TimeSpan.FromMilliseconds(300); private readonly TimeSpan MultiNodeOperationsDelay = TimeSpan.FromMilliseconds(300); @@ -42,6 +49,20 @@ private IFusionCacheBackplane CreateBackplane(string connectionId, ILogger? logg { if (UseRedis) return new RedisBackplane(new RedisBackplaneOptions { Configuration = RedisConnection }, logger: (logger as ILogger) ?? CreateXUnitLogger()); + if (UseAzureServiceBus) + { + var emulator = _azureServiceBusFixture; + var subscriptionName = emulator.GetNextSubscriptionName(); + var client = new ServiceBusClient(emulator.ConnectionString); + var options = new AzureServiceBusBackplaneOptions + { + IsAdmin = false, + SubscriptionName = subscriptionName, + }; + var clientWrapper = new AzureServiceBusClientWrapper(client, emulator.TopicName, subscriptionName, CreateXUnitLogger(), options); + + return new AzureServiceBusBackplane(clientWrapper, NoOpAzureServiceBusAdminWrapper.Instance, CreateXUnitLogger()); + } return new MemoryBackplane(new MemoryBackplaneOptions() { ConnectionId = connectionId }, logger: (logger as ILogger) ?? CreateXUnitLogger()); } diff --git a/tests/ZiggyCreatures.FusionCache.Tests/L1BackplaneTests_Async.cs b/tests/ZiggyCreatures.FusionCache.Tests/L1BackplaneTests_Async.cs index 2b9130ef..574c7ce9 100644 --- a/tests/ZiggyCreatures.FusionCache.Tests/L1BackplaneTests_Async.cs +++ b/tests/ZiggyCreatures.FusionCache.Tests/L1BackplaneTests_Async.cs @@ -1,4 +1,6 @@ -using Microsoft.Extensions.Logging; + + +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Xunit; using ZiggyCreatures.Caching.Fusion; diff --git a/tests/ZiggyCreatures.FusionCache.Tests/L1L2BackplaneTests.cs b/tests/ZiggyCreatures.FusionCache.Tests/L1L2BackplaneTests.cs index c65b0f69..f20d501a 100644 --- a/tests/ZiggyCreatures.FusionCache.Tests/L1L2BackplaneTests.cs +++ b/tests/ZiggyCreatures.FusionCache.Tests/L1L2BackplaneTests.cs @@ -39,7 +39,7 @@ private FusionCacheOptions CreateFusionCacheOptions() } private static readonly bool UseRedis = false; - private static readonly bool UseAzureServiceBus = true; + private static readonly bool UseAzureServiceBus = false; private static readonly string RedisConnection = "127.0.0.1:6379,ssl=False,abortConnect=false,connectTimeout=1000,syncTimeout=1000"; private readonly L1L2AzureServiceBusFixture _azureServiceBusFixture; From 84d6a52c1ab7157f823640e64bbd1063009825da Mon Sep 17 00:00:00 2001 From: AboubakrNasef Date: Sat, 8 Aug 2026 10:11:18 +0300 Subject: [PATCH 11/13] add artwork --- .../.editorconfig | 6 ++++++ ....FusionCache.Backplane.AzureServiceBus.csproj | 4 ++++ .../artwork/logo-128x128.png | Bin 0 -> 5286 bytes .../docs/README.md | 13 +++++++++++++ 4 files changed, 23 insertions(+) create mode 100644 src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/.editorconfig create mode 100644 src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/artwork/logo-128x128.png create mode 100644 src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/docs/README.md diff --git a/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/.editorconfig b/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/.editorconfig new file mode 100644 index 00000000..2e187963 --- /dev/null +++ b/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/.editorconfig @@ -0,0 +1,6 @@ +# This .editorconfig applies only to the ZiggyCreatures.FusionCache project +root = true + +[*.cs] +# CA2007: Consider calling ConfigureAwait on the awaited task +dotnet_diagnostic.CA2007.severity = warning diff --git a/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus.csproj b/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus.csproj index 1551af1d..99d071f8 100644 --- a/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus.csproj +++ b/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus.csproj @@ -5,6 +5,10 @@ ZiggyCreatures.Caching.Fusion.Backplane.AzureServiceBus + + + + diff --git a/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/artwork/logo-128x128.png b/src/ZiggyCreatures.FusionCache.Backplane.AzureServiceBus/artwork/logo-128x128.png new file mode 100644 index 0000000000000000000000000000000000000000..ce400a79d7afa412829bf756fd31598f30250c2a GIT binary patch literal 5286 zcmV;X6j|$uP)C0006#P)t-s00000 z001^IEC2ui0|Np8000RH2LJ#7000010RT8JC;$Ke0RaI3001Q*8x9Q%00000000jT z3;+NC3JC`o6%r^UArcP^BOV$@Ks*Ko1Q`|*TT@HW!L_23hIwaK(7?5#l7;OYC3j_2 zp^}AqX;@xSN&o-=pOAy1l7^d$esW+?nTmYU!nUcJjjNoGosNK`l!w^Ky``0j&%m^& zn2XiMy5P~pka}m%zq4>&O?6{ZhjU?mYg&YHU(>|6fNove%)!^lyo`5c!Lp{Vo{`$h zzvI)$v!s?sJT$kam}F8xqLYQZt)g2`M`~F~xv87p&%?c|oQ{2OmWO(6TufSoX zapl3XS4>5UUP|XCE9~0MnwyUA-qHX6|NR08m#3BFH#_;mv1?;o9~u^IZ(OXBfn#7* zyp4JWfzi#&z~oa^0I1kDyT<{)*L`PI17W+?r=FWpMaeTYvAwm|94`Qs;i-y!tVTiS zZ)=8_krcSdfhlHtzrn>^S=@bjZd_OK@$uM@it$ZOah{^CAV@Stm5#o>Ub(se*5)Xt zv$J||-t6O`p{vcFj9Np50I%yFO`-hGx%K4NlWAS;nTjAootHIKOn7~Y5lcxQa+h0l zkN^Mx9duGoQvf>Y37ciCEFgmZOpRCL{&GaQ6V1lh;PL*z^z)8F&n5r>5&KC*K~#9! z?2?Cz@<+*W&gx3Bo<*m{ z&CXzzCE5JA^d!U4s8ON;TU3;da!xpA(fy7H!|?DhLfNJpC@eCJd9a#r6719&+VBW~ zLlR&da*RlDe;Y(pRW?lZJ`)%df>1c6A=8wN(|E!Hi~!6VdLJipBE74M!3p zZU=)!{ZuKR3A*Jh!{%* zCKwa+3_?N?L@XlHfu^>;MG$+nqxv6n&`x-m>*A1LS`0gL=UH1#Xz&``C(R4~TF z+^$by@7;ANA{aLG3WT$W4vtF7*P>F>ZfV4V5IqA20i}qhzYB`Jc!n{06^sJLZ?xUw zSBbt{pTg(BCH8%vQs2jjE`Pk{o;Uj?^=Letc`uZ6{w?N1+my+#K#HUqWGjAQ(r;_k6Lu>vn7hF#72d z)2{VnyzF(7&X3)7E;!^#9H;5*dQBP!A-n@Ah=uz3b2v~JBoh$07sg$u*K-}m)@|Ey zdp##k`i|S}7>3&uzoOu-3kPdl2~^{HDX8c3Y<|*EK9APvJmVPLi`2GUXRNpN=Qx($ zHEqYx9cPK-jtlp7z=W-B!Uj+DEN7Nr53^uVQ$7cEkVF_^kEE_8zn(YHZtLwa6x%k( zaKY}Ry5;H;6MiqA)7ubQe+m6(lk)@RQ>h+KW%_jKSYT8)+kmuaf=6(&`hn+X{z>IiSKmd**7n1$djLCLrAvoIu5O$MJ|frMm{J@u>^nMeGh zpnM9A`y|1{_m*yN{aN$54kVG+R~i`Sk-wb3{_NDSEhm}t=zi~0mfwnTOsJni`Y%4! zHbK#N)`Yy8Pr`%?@OyFfCLHT(AQaqQ+uNwU~T|bu| z%Q9};R^R`dJ$&pGDi2aWv@Ju=d1}ynI)!{MRSf5s_KT2YcY3};$0pS0qq1}qMi*7( zWB7+Lt8h#l`_?~Och?N*01FEVnkEevf+WbJlnPb>>hA8|uDiRtyZ&wO22nI=N>NHwR{y;=y6I#Avt0;Z(R$Bp3jSYsibRW^)^Z z!TbJM$Ey!Z{E*A!z^dvSxDO8x=bGMl;|=GWg|L`mHEQmR2xfCZatwpnVIc?$z~O*z zgu{#!)};8Nt=Z`F{F4#xhH_t(Uvd8?DF+@N?!NKO`$tFH2;aYIHv?ITB_d>jP7!7x zblRe^wp1#Dkc!z11Yxd)F494`c>C2IV5ZNg^8^vU;#W;`hlfBuzj&~?xHdRAc<{+G z&*j@~z0OQ2LT2#J&P&l$i6!ZfOGd~ggt$ti;3O;tqy3o!!h{_057+Ii{F1W zH@K$Q2Q8Kp{15n%n?DZp+XsvDmldq7RhC)c!ATdL$4PRe;+ilZq^o2H|B$um zv`_hF?z*A}CR_BLUb{hdUO4`fZ$I_fK|MVfVEJhrLjs^rnNH?Yen%_Hg3A>#6LMl| zYIcXa?!00OXfb-G?A5Ajn0xZcFMs@fe6XH>e&D4E7VWG#v=(TYB6-T*X?9Ax8tg>) z#w+;Y?X4{k0B!>mI|*+XzW(a`^UuHdno5AdwU;LFnu%3PuhTq=$Pi6d|3Gnk1m#O;Qk;DktKg1!A2hlK zRNI|qqW$U7(PuxGEigX_n7=>)YEU%8xUhUFUm*o}A}L7~BtHTYfEO6-j*GqETKx;b zW;as5(?O(u{$gkpSzvH%Y;kaY9)64sd^~YZ*|=H~mKM2$qXVWlquqOLpn&W<_s0tFz=qfub|)y#vxoNJ@IlHpj-?~{W7R#-xvni3pF@{J&GaK(Ir~UF3PIZ<}>g|~f8=}k( zNd+J zYz2T6J@F_*xk_GrE^eB+&1AamMo){bCW0q9RS_|vn}8x`h&meD;Px_MH%JMau)8>l zrFTAk;^i0Q;|F|k)6$YjLOEb$X-stV3_qem|8OhoVxrmblIgqsk7m)U(AX$2>ax~^ z=%Q^FC**=e!;8&ptxn_t4l@;^Sb;Hnwv6%){=li}?fw0yZ!-bXFSKP`mDb`T=g?Y= zF^t1%HJSDSNZ2l4{jEh;(!hu#s6lA|u+i9lUxyu403sq#Y)0rvdKG7GiOaigngC0| z0+UIVH_!2J4#!hWhVXWoOeY_Gv};1Iw$%8MCroBUn*{_YbY9zF6rOP-0f?wTvJ4kU z)-GOJW=y6BzWwMsq=A`WEW=0OX0BLofMWT>b&O^l>D(#UA6`VSwis0s=#x2-l|r#O zdTXORoRE^B*-;6x3}wvL{aergpt@^05D_@4*&h9{PJ-fUM96R~W6%2Z=skqt7M&W# zq|X(zz`W4?fHoOkv)TqtgR?Us0KZ=0SA3wh=?gi5q)1WRV;?D>*?&0k3r*sUV^6&74g2*vjRa}b?fx3S5DuU;ph1_<=KjAZp-n}1+H98|HCQ4{W-5vJ)qwo! z3;4T|L?#3Z0FarLr6Nj0#rW_fjcOFm^4wH1tylb4576ucEtk{g)|TXZ3+_uz4mUSV zBz2AP*b_;Aj0v&T+DE4*Pg;wR3|Bi+A}zORAORosd9v9wAe)Ag*p*Ft)#s6#PIvTP zjR60NxxoU3y(sAL95IwZz+={5W6R%8Of0iFj-$#qO-EwULW-PbFiNS)EGLFi&6eKY zUZSaxf(gml;mvxC#`=6$YBg`OP<9d1c_OZ(sWOgJ<62 zl$HRec}5g7GzJwKk%wHY)Td{lKJ!wYhVP6hjJSiz`b~{iV{`j|8VX=`S90|Z_`;U) zg*RW>9vnkhJUSX&8yI+N0=VQ(P~C#FJe{JetvZR3%HK#{r0xD>ogtv}?rtoAq0pPI z99 zCsyWmq!3XbUj|O$jL?}q+uiTKN1KQ)+3g)3<5~F*R+fiGpZ#eJ=ECuV`Pb&p<`SG^ zR#5!+OIUs4ywwmH7M*fc!{t)7_q`BLuo`))u3ck(|e< z@b@x2i`w$P4nFzqov(g``9!gRbTCo@b{TA;v>>L5#DVqPyx5H4S%Z3$c3y^04W zdqo$EAHN3z{P5ev&U0Xj!7`xmS-N z$sd1#CM3xP)?QjhhtUGBS{psold4BqcDiJ>8hzjbTKqfSr$=eZlV@0ohQc;BC`=j< zke7wp|0gE0WZl4_A+Gl&jKdwO;Vw{r919MxTvNQ&lx1AH!irLsK$x0gYX8 zwaL}TuX&5I8j89*b^f~>Ur?1>^k9G~hQn19FrrkQ0O|l|f-y0eNBhM4{Ci*D(`3$nd1E@~Hu?j*prdD2^`yf4cz; za3StkN)!kuCY(z#6iSMJfBZd5SYih#WkZEz`)AbX_DlK6I079?-)etog?Tjt>q z_HYXUr4g(Q;_2Jt{Y6dZn+XQ1&VlT%J!4!bN!D{Y5e9oC-YQZ{;@dU-9Ixi~udh4X zB@e~-I$W}DqG?It4Hf-mkFR$bWxLuVsZ@CsiGe*6t;;Lag3wRsVThT{&7Zs5z8EVe zL)m%AThzU}1vSI^E)^Czum@3PtFadBi)3S7q_zRe%I?kfmz~YhCEsO1(`42CdSgYe zTy%xLa)1gU@}tJ&L1sr5rVvF2%#8U47H)1ru>w!tYekEhDfv|1jQ@r}Q62AEg_0ky@yfI$LzSQtV_PMOi`_8bj7AvcU$Yi_2A$HQcWLR(Wb;Pb(ZWuw{oS@ zE6q>JHr-GhLa{lO7b4*pI4sd{NcfOjQf!uqRK*bW;<$wOhWC^_GM_F{9d7DE5*7A=&vwK$MX z)zpO3$v`v~1Sd?E(2ncK4S-XkzLhl)endjxv=D5P1@W~MI4$Zd+yp>d8k`n2{ZtDc Date: Sat, 8 Aug 2026 10:24:03 +0300 Subject: [PATCH 12/13] remove unused md --- tests/Aspire.Playground/BACKPLANE_FLOWS.md | 397 ----------------- tests/Aspire.Playground/CHANGES_SUMMARY.md | 378 ---------------- tests/Aspire.Playground/QUICK_START.md | 255 ----------- tests/Aspire.Playground/SETUP_GUIDE.md | 478 --------------------- 4 files changed, 1508 deletions(-) delete mode 100644 tests/Aspire.Playground/BACKPLANE_FLOWS.md delete mode 100644 tests/Aspire.Playground/CHANGES_SUMMARY.md delete mode 100644 tests/Aspire.Playground/QUICK_START.md delete mode 100644 tests/Aspire.Playground/SETUP_GUIDE.md diff --git a/tests/Aspire.Playground/BACKPLANE_FLOWS.md b/tests/Aspire.Playground/BACKPLANE_FLOWS.md deleted file mode 100644 index 48744afc..00000000 --- a/tests/Aspire.Playground/BACKPLANE_FLOWS.md +++ /dev/null @@ -1,397 +0,0 @@ -# FusionCache Redis Backplane - Flow Diagrams - -## Scenario 1: First Request (Cache Miss) - -``` -WebApplication1: GET /data -├─ FusionCache checks L1 (Memory Cache) -│ └─ NOT FOUND ❌ -├─ FusionCache checks L2 (Redis) -│ └─ NOT FOUND ❌ -├─ FusionCache executes factory function -│ ├─ Generates: "Data from WebApplication1 at 12:00:00Z" -│ └─ Stores in L1 (Memory) + L2 (Redis) -└─ Returns data to client ✅ - -📊 Performance: Slowest (factory execution) -🔔 Backplane: No message sent -💾 Cache State: - App1: L1=cached, L2=cached - App2: L1=empty, L2=empty -``` - ---- - -## Scenario 2: Subsequent Request in Same App (Cache Hit) - -``` -WebApplication1: GET /data (second call) -├─ FusionCache checks L1 (Memory Cache) -│ └─ FOUND ✅ (still valid, <30s old) -└─ Returns data immediately ⚡ - -📊 Performance: Fastest (memory access) -🔔 Backplane: No message sent -💾 Cache State: - App1: L1=cached ✅, L2=cached ✅ - App2: L1=empty, L2=empty -``` - ---- - -## Scenario 3: Request in Different App (Cache Sharing) - -``` -WebApplication2: GET /data (App1 already warmed the cache) -├─ FusionCache checks L1 (Memory Cache) -│ └─ NOT FOUND ❌ (not accessed yet) -├─ FusionCache checks L2 (Redis) -│ └─ FOUND ✅ (data stored by App1) -│ Returns: "Data from WebApplication1 at 12:00:00Z" -└─ Also caches in L1 for future hits ⚡ - -📊 Performance: Fast (Redis access) -🔔 Backplane: No message sent (no update) -💾 Cache State: - App1: L1=cached ✅, L2=cached ✅ - App2: L1=cached ✅, L2=cached ✅ -``` - ---- - -## Scenario 4: Update from Different App (Invalidation) - -``` -WebApplication2: POST /data with "Updated value" -├─ Call: await cache.SetAsync("shared:data", "Updated value") -│ -├─ FusionCache stores in L1 (Memory) + L2 (Redis) -│ ├─ L1 Update: "Updated value" ✅ -│ └─ L2 Update: "Updated value" ✅ -│ -├─ FusionCache publishes to Redis Pub/Sub: -│ └─ Message: "Invalidate shared:data" -│ Channel: "FusionCache:shared:data" -│ -├─ WebApplication1 subscribes to that channel -│ ├─ Receives: "Invalidate shared:data" -│ ├─ Removes from L1 (Memory Cache) 🗑️ -│ └─ L2 (Redis) still has the value -│ -└─ Returns to client ✅ - -📊 Performance: Moderate (Redis write + Pub/Sub broadcast) -🔔 Backplane: Message sent ✅ -💾 Cache State DURING MESSAGE: - App1: L1=empty ❌, L2=updated ✅ - App2: L1=updated ✅, L2=updated ✅ - -💾 Cache State AFTER MESSAGE (100ms): - App1: L1=empty ❌, L2=updated ✅ - App2: L1=updated ✅, L2=updated ✅ -``` - ---- - -## Scenario 5: Next Request After Invalidation - -``` -WebApplication1: GET /data (after invalidation message received) -├─ FusionCache checks L1 (Memory Cache) -│ └─ NOT FOUND ❌ (invalidated by backplane) -├─ FusionCache checks L2 (Redis) -│ └─ FOUND ✅ "Updated value" -│ Returns immediately without factory execution ⚡ -└─ Also caches in L1 for next hit - -📊 Performance: Fast (Redis hit, no factory) -🔔 Backplane: No message sent -💾 Cache State: - App1: L1=updated ✅, L2=updated ✅ - App2: L1=updated ✅, L2=updated ✅ -``` - ---- - -## Scenario 6: Simultaneous Requests (Race Condition Prevention) - -``` -App1 & App2: Both request GET /data at SAME TIME -Both detect cache miss - -┌─ App1: GetOrSetAsync("shared:data") -│ ├─ L1 miss, L2 miss -│ ├─ Acquires lock 🔒 -│ ├─ Executes factory -│ └─ Stores in Redis -│ -└─ App2: GetOrSetAsync("shared:data") - ├─ L1 miss, L2 miss - ├─ Tries to acquire lock 🔒 - ├─ Waits for lock... - ├─ App1 releases lock after factory - ├─ L2 hit now! Uses App1's value ✅ - └─ Returns same value - -📊 Result: Both return same data ✅ -🔔 Factory executed once (only in App1) -``` - ---- - -## Scenario 7: Cache Expiration (30 seconds) - -``` -Time: 00:00 - WebApplication1: GET /data -├─ Factory executes -├─ Stores with TTL=30s -└─ Cache valid until 00:30 - -Time: 00:15 - WebApplication1: GET /data -├─ Cache still valid (15s remaining) -├─ L1 hit ⚡ -└─ No factory execution - -Time: 00:31 - WebApplication1: GET /data -├─ L1: EXPIRED ❌ (>30s old) -├─ L2 (Redis): EXPIRED ❌ (TTL reached) -├─ Factory executes (generates new data) -└─ New cache valid until 01:01 - -Time: 00:35 - WebApplication2: GET /data -├─ L1: MISS (never cached in this app) -├─ L2: MISS (Redis key expired) -├─ App1's factory already executed at 00:31 -├─ App2 executes its own factory -│ └─ Different timestamp! -└─ Returns: "Data from WebApplication2 at 00:35" - -📊 Result: Different data in each app after expiration -🎯 Reason: Cache expired in L2, each app generated new value -🔔 Backplane: Only broadcasts if manually invalidated -``` - ---- - -## Scenario 8: Manual Cache Clear (RemoveAsync) - -``` -WebApplication2: await cache.RemoveAsync("shared:data") -├─ Removes from L1 (Memory) 🗑️ -├─ Removes from L2 (Redis) 🗑️ -└─ Publishes: "Remove shared:data" - -WebApplication1 receives broadcast: -├─ Removes from L1 (Memory) 🗑️ -├─ Removes from L2 (Redis) 🗑️ -└─ Both apps now have cache miss - -Next request in either app: -├─ L1: MISS, L2: MISS -├─ Factory executes -└─ Cache regenerated - -📊 Result: Forced cache refresh across all apps -🔔 Backplane: Message sent for removal -``` - ---- - -## Scenario 9: Network Latency (Delayed Invalidation) - -``` -WebApplication2: POST /data with new value (12:00:00.000) -├─ Stores immediately in memory ✅ -├─ Stores immediately in Redis ✅ -└─ Publishes invalidation message - -WebApplication1: GET /data (12:00:00.050) -├─ L1 still has old value -│ └─ Invalidation message not yet received -└─ Returns OLD data (50ms race condition) - -WebApplication1: GET /data (12:00:00.100) -├─ Invalidation message received ✅ -├─ L1 cleared 🗑️ -├─ L2 hit with new value ✅ -└─ Returns NEW data - -📊 Edge Case: Small window where different data returned -⚠️ Note: Redis Pub/Sub is fast (~10ms typical) -💡 Mitigation: Critical data can use short TTL + refresh on startup -``` - ---- - -## Scenario 10: Redis Connection Lost - -``` -WebApplication1: GET /data (Redis unavailable) -├─ Try L1 (Memory Cache) -│ └─ FOUND ✅ or MISS ❌ -├─ Try L2 (Redis) -│ └─ CONNECTION ERROR 🔴 -├─ Fallback options: -│ ├─ Use stale L1 value if available ✅ -│ ├─ Execute factory (slow, no cache benefit) ⚡❌ -│ └─ Return error (configured behavior) ❌ -└─ Result depends on configuration - -Configuration: WithFailSafeMaxDuration(minutes: 5) -├─ If error occurred <5 min ago: use last known good value -└─ If error occurred >5 min ago: execute factory - -📊 Resilience: System continues working even without Redis -🔔 Backplane: Messages buffered/queued until Redis recovers -``` - ---- - -## Scenario 11: Multiple Cache Keys - -``` -WebApplication1: Cache three different items -├─ Key 1: "config:app" → "Config from WebApplication1" -├─ Key 2: "users:list" → [User1, User2, User3] -└─ Key 3: "shared:data" → "Common data" - -WebApplication2: Shares Key 3 -├─ Key 1: "config:app" → L2 miss (different data) -├─ Key 2: "users:list" → L2 miss (different data) -└─ Key 3: "shared:data" → L2 hit ✅ - -Update in WebApplication2: -├─ SetAsync("shared:data", "new value") -├─ Publishes: "Invalidate shared:data" -└─ WebApplication1 receives: Clears Key 3 from L1 - -Key 1 & 2 unaffected: -└─ Each app maintains its own cache ✅ - -📊 Result: Selective cache sharing based on key names -``` - ---- - -## Scenario 12: Application Restart - -``` -WebApplication1 stops and restarts -├─ L1 (Memory Cache): Lost 🗑️ -│ └─ New empty in-memory cache -├─ L2 (Redis): Still present ✅ -│ └─ All data preserved in Redis -├─ First request: L1 miss → L2 hit ⚡ -└─ No factory execution, Redis serves stale data - -WebApplication2 (running): Unaffected -├─ Continues serving from L1 cache -├─ After 30s expiration: Fetches from Redis -└─ Gets same data as restarted App1 - -Graceful degradation: -└─ System continues working ✅ - Restart window: ~5 seconds - Cache warm-up: First few requests - -📊 Redis acts as persistent cache layer -🎯 No data loss during app restart -``` - ---- - -## Cache State Transition Diagram - -``` - ┌─────────────────────────┐ - │ EMPTY (No Cache) │ - │ L1: empty L2: empty │ - └────────────┬────────────┘ - │ - GetOrSetAsync() - (cache miss) - │ - ┌────────────▼────────────┐ - │ POPULATED (From App1) │ - │ L1: cached L2: cached │ - └────────────┬────────────┘ - │ - ┌────────┴────────┐ - │ │ - SetAsync() GetOrSetAsync() - (from App2) (same app) - │ │ - │ │ ⚡ L1 hit - │ │ (no change) - │ │ - ┌───▼─────────────────▼──┐ - │ INVALIDATED (In App1) │ - │ L1: empty L2: cached │ ◄── Backplane - │ (awaiting broadcast) │ publishes - └───┬────────────────────┘ - │ - App1 receives backplane message - │ - ┌───▼────────────────────┐ - │ SYNCHRONIZED (App1) │ - │ L1: empty L2: cached │ - └───┬────────────────────┘ - │ - GetOrSetAsync() - │ - ┌───▼────────────────────┐ - │ WARM (Both Apps) │ - │ L1: cached L2: cached │ - └────────────────────────┘ - - (repeats for each update) -``` - ---- - -## Performance Timeline - -``` -Operation Time Network Calls Cache Level -──────────────────────────────────────────────────────────────── -Initial L1 Miss 1-2ms 0 Factory -Initial L2 Hit 5-10ms 1 (Redis read) L2 Redis -L1 Hit (Cached) <1ms 0 L1 Memory -SetAsync (Update) 5-15ms 1 (Redis write) L1+L2 -Backplane Broadcast ~10ms 1 (Pub/Sub) All apps -L2 Hit After Invalid 5-10ms 1 (Redis read) L2 Redis -Lock Contention 50-100ms N/A Lock wait -Expiration Refresh 20-30ms 1 (Factory) Factory result - -Legend: - Fastest: L1 Hit <1ms - Good: L2 Hit 5-10ms - Moderate: Redis write/broadcast 10-15ms - Slow: Factory execution 20-100ms -``` - ---- - -## Summary Table - -| Scenario | L1 Hit | L2 Hit | Factory | Backplane | Other App | -|----------|--------|---------|---------|-----------|-----------| -| First request | ❌ | ❌ | ✅ | ❌ | ❌ | -| Same app, again | ✅ | ❌ | ❌ | ❌ | - | -| Different app | ❌ | ✅ | ❌ | ❌ | - | -| Update from other | ❌* | ✅ | ❌ | ✅ | Notified | -| After expiration | ❌ | ❌ | ✅ | ❌ | - | -| Manual clear | ❌ | ❌ | ✅ | ✅ | Notified | -| Network down | ✅ | ❌ | ✅ | ❌ | Buffered | - -*L1 cleared by invalidation message from backplane - ---- - -**Note:** Timing values are approximate and depend on: -- Network latency -- Redis configuration -- Factory function complexity -- System load - -For production, profile your specific workload! diff --git a/tests/Aspire.Playground/CHANGES_SUMMARY.md b/tests/Aspire.Playground/CHANGES_SUMMARY.md deleted file mode 100644 index 8b0a0204..00000000 --- a/tests/Aspire.Playground/CHANGES_SUMMARY.md +++ /dev/null @@ -1,378 +0,0 @@ -# FusionCache Redis Backplane Example - Changes Summary - -## 📋 Overview -This document summarizes all changes made to create a complete, working example of two applications synchronizing cache data through a Redis backplane using .NET Aspire. - -## 🔄 Files Modified - -### 1. **Playground.AppHost/AppHost.cs** -**Purpose:** Aspire orchestration configuration - -**Changes:** -- Added Redis service: `builder.AddRedis("cache-redis")` -- Both web applications now reference the Redis instance via `.WithReference(redis)` -- This ensures both apps connect to the same Redis server - -```csharp -var redis = builder.AddRedis("cache-redis"); - -builder - .AddProject("webapplication1") - .WithReference(redis); - -builder - .AddProject("webapplication2") - .WithReference(redis); -``` - ---- - -### 2. **WebApplication1/WebApplication1.csproj** -**Purpose:** Project dependencies for App1 - -**Changes:** -- Added project reference to FusionCache core -- Added project reference to FusionCache Redis Backplane -- Added NuGet package: `StackExchange.Redis` v2.8.7 - -```xml - - - - - - - - -``` - ---- - -### 3. **WebApplication1/Program.cs** -**Purpose:** Application startup and API endpoints - -**Key Additions:** -- Redis connection initialization -- FusionCache registration with lazy memory factory (two-level cache) -- Redis backplane configuration -- `SharedDataService` class for cache operations -- Three API endpoints: - - `GET /data` - Retrieve cached data - - `POST /data` - Update cached data (triggers backplane invalidation) - - `GET /cache/info` - Show cache configuration - -**Features:** -- Automatic cache invalidation across apps -- Detailed logging for debugging -- REST API for easy testing - ---- - -### 4. **WebApplication2/WebApplication2.csproj** -**Purpose:** Project dependencies for App2 - -**Changes:** Identical to WebApplication1.csproj - ---- - -### 5. **WebApplication2/Program.cs** -**Purpose:** Application startup and API endpoints - -**Changes:** Identical structure to WebApplication1 but with: -- Unique cache key prefix: `app2:` (instead of `app1:`) -- Same endpoints and `SharedDataService` -- Both apps use the same cache key (`shared:data`) to demonstrate synchronization - ---- - -## 📁 Files Created - -### 1. **README.md** -**Purpose:** High-level overview and quick reference guide - -**Contents:** -- Architecture diagram -- How FusionCache backplane works -- Project structure -- Running instructions -- Example API scenarios -- Troubleshooting tips - -### 2. **SETUP_GUIDE.md** -**Purpose:** Comprehensive setup and testing guide - -**Contents:** -- Prerequisites and installation -- Quick start (5 steps) -- Detailed architecture explanation -- Four complete test scenarios -- File structure breakdown -- Advanced concepts -- Troubleshooting guide -- Common patterns -- Resources and next steps - -### 3. **WebApplication1/api-demo.http** -**Purpose:** REST client file for testing (works in VS Code REST Client or Bruno) - -**Features:** -- Test cache info endpoint -- Test cache hit/miss scenarios -- Test cross-app synchronization -- Ready-to-use requests with examples - -### 4. **WebApplication2/api-demo.http** -**Purpose:** Same as above but for WebApplication2 - -### 5. **CHANGES_SUMMARY.md** -**Purpose:** This file - documents all modifications - ---- - -## 🎯 What Was Implemented - -### Two-Level Cache Architecture -``` -Request → Memory Cache (L1) → Redis Cache (L2) → Factory Function - ↑ Invalidated by ↑ Shared between ↑ Generates data - │ Backplane │ apps │ on L1/L2 miss -``` - -### Cache Synchronization Flow -``` -WebApplication1: SetAsync("shared:data", "value1") - ↓ -FusionCache stores in memory + Redis - ↓ -Publishes to Redis Pub/Sub: "shared:data was modified" - ↓ -WebApplication2 receives message - ↓ -Removes "shared:data" from its memory cache (L1 invalidation) - ↓ -Next request: Cache miss → Fetch from Redis (L2) ✓ -``` - -### Three API Endpoints Per Application - -| Endpoint | Method | Purpose | -|----------|--------|---------| -| `/data` | GET | Retrieve cached data (hit or miss) | -| `/data` | POST | Update cache value (invalidates in all apps) | -| `/cache/info` | GET | Display cache configuration | - ---- - -## 🚀 How to Use - -### Start the Example -```bash -cd eaxmple/Playground/Playground.AppHost -dotnet run -``` - -### Access the Applications -- **WebApplication1:** https://localhost:7001 -- **WebApplication2:** https://localhost:7002 -- **Aspire Dashboard:** http://localhost:15000 - -### Test Cache Synchronization -1. Call `GET https://localhost:7001/data` → Cache miss, generates data -2. Call `GET https://localhost:7002/data` → Same data! (shared via Redis) -3. Call `POST https://localhost:7002/data` with new value → Updates cache -4. Call `GET https://localhost:7001/data` → New value! (backplane invalidated memory cache) - ---- - -## 🔑 Key Configuration Points - -### AppHost Configuration -- Redis service name: `cache-redis` -- Apps referenced Redis: Enables service discovery -- Connection string auto-managed by Aspire - -### FusionCache Configuration -- **Cache Duration:** 30 seconds (both L1 and L2) -- **Memory Factory:** Lazy (created on first use) -- **Redis Backplane:** Enabled for invalidation broadcasts -- **Cache Key Prefix:** `app1:` and `app2:` (unique per app) - -### SharedDataService -- **Cache Key:** `shared:data` (same in both apps) -- **Factory Function:** Returns app name + timestamp when cache misses -- **Logging:** Detailed logs for debugging - ---- - -## 🧪 Testing Scenarios Included - -### Test 1: Basic Cache Hit -✓ Verify same request returns same data quickly - -### Test 2: Cache Sharing -✓ Verify App2 sees data generated by App1 - -### Test 3: Backplane Invalidation -✓ Verify updating in App2 invalidates App1's cache - -### Test 4: Cache Duration -✓ Verify cache expires after 30 seconds - -Each scenario is documented in `SETUP_GUIDE.md` with curl commands. - ---- - -## 📊 Project Dependencies - -### Direct Package References -``` -StackExchange.Redis v2.8.7 - ↓ -ZiggyCreatures.FusionCache (local) -ZiggyCreatures.FusionCache.Backplane.StackExchangeRedis (local) - ↓ -Microsoft.AspNetCore.* (implicit via Web SDK) -``` - -### Service Dependencies (Runtime) -``` -WebApplication1 ┐ - ├→ Redis (cache-redis) -WebApplication2 ┘ -``` - ---- - -## 🎓 Learning Outcomes - -After running this example, you'll understand: - -✅ **FusionCache Basics** -- Two-level caching (memory + distributed) -- GetOrSetAsync pattern -- Cache expiration - -✅ **Redis Backplane** -- How invalidations are broadcast -- Pub/Sub messaging pattern -- Cross-application cache synchronization - -✅ **.NET Aspire** -- Service orchestration -- Service discovery -- Container management - -✅ **Distributed Caching Patterns** -- Cache-aside pattern -- Write-through updates -- Handling cache staleness - ---- - -## 🔧 Next Steps - -### To Extend This Example - -1. **Add Database Integration** - ```csharp - // Fetch data from database in factory - public async Task GetUserAsync(int id) - { - return await _cache.GetOrSetAsync( - $"user:{id}", - async ct => await _db.GetUserAsync(id) - ); - } - ``` - -2. **Add Error Handling** - ```csharp - options.WithOptions(opt => - opt.SetFailSafeMaxDuration(TimeSpan.FromMinutes(5)) - ); - ``` - -3. **Monitor Cache Performance** - ```csharp - options.WithOptions(opt => - opt.SetEagerRefreshThreshold(0.8) // Refresh at 80% of TTL - ); - ``` - -4. **Add More Cache Keys** - ```csharp - private const string CacheKey1 = "shared:data"; - private const string CacheKey2 = "shared:config"; - private const string CacheKey3 = "shared:users"; - ``` - -5. **Implement Distributed Locks** - - Use Redis locks to prevent thundering herd - - See FusionCache documentation for patterns - ---- - -## 📝 Files at a Glance - -| File | Status | Purpose | -|------|--------|---------| -| `Playground.AppHost/AppHost.cs` | ✏️ Modified | Aspire orchestration | -| `WebApplication1/WebApplication1.csproj` | ✏️ Modified | Dependencies | -| `WebApplication1/Program.cs` | ✏️ Modified | App setup & endpoints | -| `WebApplication2/WebApplication2.csproj` | ✏️ Modified | Dependencies | -| `WebApplication2/Program.cs` | ✏️ Modified | App setup & endpoints | -| `README.md` | ✨ New | Quick reference | -| `SETUP_GUIDE.md` | ✨ New | Comprehensive guide | -| `CHANGES_SUMMARY.md` | ✨ New | This document | -| `WebApplication1/api-demo.http` | ✨ New | API test requests | -| `WebApplication2/api-demo.http` | ✨ New | API test requests | - ---- - -## ✅ Verification Checklist - -Before running, ensure: -- [ ] .NET 10.0 SDK installed (`dotnet --version`) -- [ ] Docker Desktop running (`docker ps`) -- [ ] Redis is not already running on port 6379 -- [ ] Port 7001, 7002, 15000 are available -- [ ] Git repository is up to date - -After running: -- [ ] Aspire dashboard loads at http://localhost:15000 -- [ ] All three services show green (redis, webapplication1, webapplication2) -- [ ] Can access https://localhost:7001/cache/info -- [ ] Can access https://localhost:7002/cache/info -- [ ] Cache synchronization test works (see SETUP_GUIDE.md) - ---- - -## 🆘 Common Issues - -**Problem:** Redis connection fails -**Solution:** Ensure Docker is running: `docker ps` - -**Problem:** Port already in use -**Solution:** Change ports in launchSettings.json or kill existing process - -**Problem:** Apps not syncing -**Solution:** Check logs for backplane errors; verify Redis is healthy - -**Problem:** Cache stays stale -**Solution:** Check TTL setting; adjust `WithDefaultDuration()` if needed - ---- - -## 📚 Additional Resources - -- **FusionCache Wiki:** https://github.com/ZiggyCreatures/FusionCache/wiki -- **Redis Pub/Sub:** https://redis.io/docs/interact/pubsub/ -- **.NET Aspire Docs:** https://learn.microsoft.com/aspire -- **StackExchange.Redis:** https://stackexchange.github.io/StackExchange.Redis/ - ---- - -**Version:** 1.0 -**Date:** 2024-06-20 -**Author:** Claude Code -**License:** See repository license diff --git a/tests/Aspire.Playground/QUICK_START.md b/tests/Aspire.Playground/QUICK_START.md deleted file mode 100644 index 28e593ae..00000000 --- a/tests/Aspire.Playground/QUICK_START.md +++ /dev/null @@ -1,255 +0,0 @@ -# Quick Start - FusionCache Redis Backplane Example - -## ⚡ 5-Minute Setup - -### Step 1: Prerequisites Check (30 seconds) -```bash -# Check .NET SDK -dotnet --version -# Expected: 10.0.x or higher - -# Check Docker -docker --version -# Expected: Docker version 20.x or higher - -# Ensure Docker is running -docker ps -# Should show running containers (may be empty) -``` - -### Step 2: Navigate to Project (10 seconds) -```bash -cd D:\Programming\0.Practice\Contributions\FusionCache-AboubakrFork\eaxmple\Playground\Playground.AppHost -``` - -### Step 3: Run Aspire (2 minutes) -```bash -dotnet run -``` - -**Wait for output:** -``` -Building... -Starting services... -Aspire dashboard available at http://localhost:15000 -``` - -### Step 4: Open Aspire Dashboard (10 seconds) -- Open browser: http://localhost:15000 -- You should see: - - ✅ cache-redis (green) - - ✅ webapplication1 (green) - - ✅ webapplication2 (green) - -### Step 5: Test Cache Synchronization (1 minute) - -**Option A: Using PowerShell** -```powershell -# Get data from App1 -Invoke-RestMethod -Uri "https://localhost:7001/data" -SkipCertificateCheck - -# Get data from App2 (should be same!) -Invoke-RestMethod -Uri "https://localhost:7002/data" -SkipCertificateCheck - -# Update from App2 -Invoke-RestMethod -Uri "https://localhost:7002/data" ` - -Method Post ` - -Body '"Updated data"' ` - -ContentType "application/json" ` - -SkipCertificateCheck - -# Check App1 (should have new data!) -Invoke-RestMethod -Uri "https://localhost:7001/data" -SkipCertificateCheck -``` - -**Option B: Using curl** -```bash -# Get data from App1 -curl -k https://localhost:7001/data - -# Get data from App2 -curl -k https://localhost:7002/data - -# Update from App2 -curl -X POST https://localhost:7002/data \ - -H "Content-Type: application/json" \ - -d '"Updated data"' \ - -k - -# Check App1 -curl -k https://localhost:7001/data -``` - -**Option C: Using REST Client in VS Code** -1. Open `WebApplication1/api-demo.http` -2. Run the requests in order -3. Observe cache behavior - ---- - -## 📝 What You Should See - -### Console Output -``` -[Information] WebApplication1: Cache miss for shared:data, generating data -[Information] WebApplication2: Cache miss for shared:data, generating data -[Information] WebApplication2: Setting cache value: Updated data -``` - -### API Responses - -**Get from App1:** -```json -{ - "appName": "WebApplication1", - "timestamp": "2024-06-20T12:00:00Z", - "data": "Data from WebApplication1 at 2024-06-20T12:00:00Z" -} -``` - -**Get from App2 (same data!):** -```json -{ - "appName": "WebApplication2", - "timestamp": "2024-06-20T12:00:05Z", - "data": "Data from WebApplication1 at 2024-06-20T12:00:00Z" -} -``` - -**After Update from App2:** -```json -{ - "appName": "WebApplication1", - "timestamp": "2024-06-20T12:00:10Z", - "data": "Updated data" -} -``` - ---- - -## ✅ Success Criteria - -Your setup is working correctly if: - -- [ ] Aspire dashboard shows all three services green -- [ ] Both apps return the same data timestamp on first access -- [ ] Updating in App2 changes the data in App1 -- [ ] No errors in the console logs -- [ ] You see "Cache miss" logged only once until cache expires - ---- - -## 🔍 Debugging - -### Check Redis is Running -```bash -# In another terminal -docker ps | findstr redis -# Should show: fusioncache_cache-redis -``` - -### Check Logs for Errors -Look at the Aspire dashboard console output for: -``` -[Error] Redis connection failed -[Error] Backplane initialization failed -``` - -### Verify Connections -```powershell -# Check App1 is accessible -Invoke-RestMethod -Uri "https://localhost:7001/cache/info" -SkipCertificateCheck - -# Check App2 is accessible -Invoke-RestMethod -Uri "https://localhost:7002/cache/info" -SkipCertificateCheck -``` - ---- - -## 📚 What Happened - -1. ✅ **Aspire** orchestrated Redis, App1, and App2 -2. ✅ **FusionCache** set up two-level caching in each app -3. ✅ **Redis Backplane** connected both apps to the same Redis instance -4. ✅ **Cache Key** `shared:data` is shared between both apps -5. ✅ **Invalidation** broadcast when App2 updated the cache -6. ✅ **Synchronization** App1's memory cache was cleared automatically - ---- - -## 🎯 Next Steps - -### Learn More -- Read `README.md` for architecture overview -- Read `SETUP_GUIDE.md` for detailed explanation -- Check `CHANGES_SUMMARY.md` for what was implemented - -### Advanced Testing -```bash -# Wait 31 seconds for cache to expire -# Then get data - should generate new timestamp -curl -k https://localhost:7001/data - -# Watch logs - should see "Cache miss" -``` - -### Extend the Example -1. Add database integration -2. Add error handling -3. Add monitoring -4. Add more cache keys -5. Add distributed locks - ---- - -## ⚠️ Troubleshooting - -### "Timeout connecting to cache-redis" -**Fix:** Ensure Docker is running -```bash -docker ps -docker start # if stopped -``` - -### "Port 7001 already in use" -**Fix:** Kill existing process or change port in launchSettings.json - -### "Apps not synchronizing" -**Fix:** -1. Check both apps use same `AddRedis()` reference -2. Wait a moment - Redis Pub/Sub has slight latency -3. Check logs for connection errors - -### "Cache stays stale" -**Fix:** Check TTL - adjust `WithDefaultDuration()` or manually clear - ---- - -## 🆘 Need Help? - -1. **Check logs** - Aspire dashboard shows detailed output -2. **Review `SETUP_GUIDE.md`** - Has troubleshooting section -3. **Run tests manually** - Use `api-demo.http` files -4. **Inspect Redis** - Use `docker exec cache-redis redis-cli keys '*'` - ---- - -## 🎉 You're Done! - -You now have a working example of: -- ✅ FusionCache with two-level caching -- ✅ Redis backplane for cache synchronization -- ✅ Aspire orchestration of multiple services -- ✅ Distributed caching pattern - -**Total time: ~5 minutes** - -Next, explore the code and understand how cache invalidation works! - ---- - -**Tips:** -- Keep Aspire dashboard open to see logs in real-time -- Use the REST Client extension in VS Code for easy testing -- Check Docker stats: `docker stats cache-redis` -- Monitor Redis keys: `docker exec cache-redis redis-cli monitor` diff --git a/tests/Aspire.Playground/SETUP_GUIDE.md b/tests/Aspire.Playground/SETUP_GUIDE.md deleted file mode 100644 index 8bd473a0..00000000 --- a/tests/Aspire.Playground/SETUP_GUIDE.md +++ /dev/null @@ -1,478 +0,0 @@ -# FusionCache Redis Backplane - Complete Setup Guide - -## Overview - -This example demonstrates **FusionCache** with a **Redis backplane** for distributed cache synchronization across multiple applications. Perfect for understanding how FusionCache handles cache invalidation in microservices architectures. - -## What You'll Learn - -✅ How to configure FusionCache with Redis backplane -✅ How cache invalidations are broadcast across applications -✅ How Aspire orchestrates multi-app solutions with Redis -✅ Real-world patterns for distributed caching - -## Prerequisites - -### Required -- **.NET 10.0 SDK** or later ([Download](https://dotnet.microsoft.com/download/dotnet)) -- **Docker Desktop** (for Redis container) - -### Recommended -- Visual Studio 2022 or VS Code with C# extensions -- REST client (Bruno, Postman, or use REST Client extension in VS Code) - -## Quick Start - -### 1. Clone/Navigate to Repository -```bash -cd D:\Programming\0.Practice\Contributions\FusionCache-AboubakrFork -cd eaxmple\Playground -``` - -### 2. Restore and Build -```bash -# Navigate to AppHost directory -cd Playground.AppHost - -# Restore NuGet packages -dotnet restore - -# Build the solution -dotnet build -``` - -### 3. Run with Aspire -```bash -# From Playground.AppHost directory -dotnet run -``` - -**What happens next:** -1. Aspire starts and opens a dashboard (usually `http://localhost:15000`) -2. Redis container is pulled and started -3. WebApplication1 launches (typically `https://localhost:7001`) -4. WebApplication2 launches (typically `https://localhost:7002`) - -### 4. Verify It's Running -Open the Aspire dashboard and you should see: -- ✅ `cache-redis` - Redis container (green) -- ✅ `webapplication1` - First web app (green) -- ✅ `webapplication2` - Second web app (green) - -## Understanding the Setup - -### Architecture Components - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ .NET Aspire Orchestrator │ -│ (Playground.AppHost) │ -├─────────────────────────────────────────────────────────────────┤ -│ │ -│ Application Instance 1 Application Instance 2 │ -│ (WebApplication1) (WebApplication2) │ -│ ┌──────────────────────┐ ┌──────────────────────┐ │ -│ │ FusionCache │ │ FusionCache │ │ -│ │ ┌────────────────┐ │ │ ┌────────────────┐ │ │ -│ │ │ Memory Cache │ │ │ │ Memory Cache │ │ │ -│ │ │ (L1) │ │ │ │ (L1) │ │ │ -│ │ └────────┬───────┘ │ │ └────────┬───────┘ │ │ -│ │ │ │ │ │ │ │ -│ │ ┌────────▼───────┐ │ │ ┌────────▼───────┐ │ │ -│ │ │ Redis Cache │ │ │ │ Redis Cache │ │ │ -│ │ │ (L2) │ │ │ │ (L2) │ │ │ -│ │ └────────┬───────┘ │ │ └────────┬───────┘ │ │ -│ │ │ │ │ │ │ │ -│ │ ┌────────▼──────────────────────────────▼──────┐ │ │ -│ │ │ Redis Backplane (Broadcast Channel) │ │ │ -│ │ └──────────────────────────────────────────────┘ │ │ -│ │ ▲ │ │ -│ │ │ │ │ -│ └──────────────────┼──────────────────────────────────┘ │ -│ │ │ -│ ┌────────▼────────┐ │ -│ │ Redis Server │ │ -│ │ (cache-redis) │ │ -│ │ │ │ -│ │ - Data Store │ │ -│ │ - Pub/Sub │ │ -│ │ - Backplane │ │ -│ │ Messages │ │ -│ └─────────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────────┘ -``` - -### Two-Level Cache (L1/L2) - -**WebApplication1:** -1. Request comes in for "shared:data" -2. L1 (Memory): Check local memory cache first ⚡ (fastest) -3. L1 Miss: Check L2 (Redis) 🚀 (fast) -4. L2 Miss: Execute factory function (slowest, but cached for next request) - -**WebApplication2:** -1. Request comes in for "shared:data" -2. L1 (Memory): Check local memory cache first ⚡ -3. **Key Point:** L1 entry was removed by backplane invalidation -4. L1 Miss: Check L2 (Redis) 🚀 (has the value from App1!) -5. Use value from Redis ✅ - -### Redis Backplane (Pub/Sub) - -When **WebApplication1 updates** a cache key: - -``` -1. SetAsync("shared:data", "new value") - ↓ -2. FusionCache stores in local memory - ↓ -3. FusionCache publishes invalidation message to Redis Pub/Sub - ↓ -4. Redis broadcasts: "shared:data was invalidated" - ↓ -5. WebApplication2 receives message - ↓ -6. WebApplication2 removes "shared:data" from its memory cache - ↓ -7. Next request gets FRESH data from Redis (or factory) -``` - -## Testing the Backplane - -### Test 1: Basic Cache Hit - -**Terminal 1:** -```bash -curl -k https://localhost:7001/data -# First request - cache miss, generates data -``` - -**Terminal 2:** -```bash -curl -k https://localhost:7001/data -# Second request - cache hit from memory (same timestamp) -``` - -**Expected:** Both requests return the same timestamp. - ---- - -### Test 2: Cache Sharing Between Apps - -**Terminal 1:** -```bash -curl -k https://localhost:7001/data -# Generates data at 12:00:00Z -``` - -**Terminal 2:** -```bash -curl -k https://localhost:7002/data -# Should return the SAME data generated by App1 -# Timestamp: 12:00:00Z (not a new timestamp!) -``` - -**Why?** Both apps share the same Redis instance through the backplane. - ---- - -### Test 3: Backplane Invalidation - -**Terminal 1:** -```bash -# App1 has cached data -curl -k https://localhost:7001/data -# Returns: "Data from WebApplication1 at 2024-06-20T12:00:00Z" - -# Cache is warm, second request hits memory -curl -k https://localhost:7001/data -``` - -**Terminal 2:** -```bash -# Update from App2 -curl -X POST https://localhost:7002/data \ - -H "Content-Type: application/json" \ - -d '"Updated by App2"' \ - -k -# Returns: "Data updated to: Updated by App2" -``` - -**Terminal 1 (again):** -```bash -# App1's cache was INVALIDATED by the backplane! -curl -k https://localhost:7001/data -# Returns: "Updated by App2" (fresh value from Redis) -``` - -**What happened:** -1. ✅ App2 called `SetAsync()` with new value -2. ✅ FusionCache stored it in Redis -3. ✅ FusionCache published invalidation to all subscribers -4. ✅ App1 received the invalidation message -5. ✅ App1 removed the key from memory cache -6. ✅ Next request fetches from Redis ✨ - ---- - -### Test 4: Cache Duration - -**Setup:** -```bash -# Get data from App1 -curl -k https://localhost:7001/data -# Returns: "Data from WebApplication1 at 12:00:00Z" -``` - -**Wait 31 seconds** (cache duration is 30s) - -**Check again:** -```bash -# Cache expired in both L1 and L2! -curl -k https://localhost:7001/data -# Returns: NEW timestamp "Data from WebApplication1 at 12:00:31Z" -``` - -**Note:** The factory function is executed again because both cache levels expired. - -## File Structure Explained - -### Playground.AppHost/AppHost.cs -```csharp -var redis = builder.AddRedis("cache-redis"); - -builder - .AddProject("webapplication1") - .WithReference(redis); // App1 gets Redis connection - -builder - .AddProject("webapplication2") - .WithReference(redis); // App2 gets Redis connection -``` - -**Key Points:** -- `AddRedis()` creates a managed Redis container -- `WithReference()` injects the connection string -- Aspire handles service discovery automatically - -### WebApplication1/Program.cs & WebApplication2/Program.cs - -```csharp -// 1. Connect to Redis -var redis = ConnectionMultiplexer.Connect(redisConnectionString); - -// 2. Add FusionCache with lazy memory factory (two-level cache) -builder.Services.AddFusionCache(options => -{ - options - .WithDefaultDuration(TimeSpan.FromSeconds(30)) - .WithLazyMemoryFactory(); -}) -// 3. Add Redis backplane for invalidation broadcasts -.WithBackplane( - new RedisBackplane( - new RedisBackplaneOptions - { - Connection = redis, - CacheKeyPrefix = "app1:" // Unique per app - } - ) -); -``` - -**Configuration Explained:** -- **`WithLazyMemoryFactory()`:** Creates memory cache on first use (two-level) -- **`DefaultDuration`:** 30 seconds - then cache expires -- **`RedisBackplane`:** Enables Pub/Sub for invalidation broadcasts -- **`CacheKeyPrefix`:** Prevents key conflicts between apps - -### SharedDataService Class - -```csharp -class SharedDataService -{ - // Both apps use THE SAME cache key: "shared:data" - private const string CacheKey = "shared:data"; - - public async Task GetDataAsync() - { - // GetOrSetAsync: - // - Returns cached value if present - // - Executes factory and caches result if missing - // - Other apps are notified of L1 invalidation - return await _cache.GetOrSetAsync( - CacheKey, - async ct => $"Data from {AppName} at {DateTime.UtcNow:O}", - options => options.SetDuration(TimeSpan.FromSeconds(30)) - ); - } - - public async Task SetDataAsync(string value) - { - // SetAsync: - // - Sets value in memory and Redis - // - Broadcasts invalidation through backplane - // - Other apps remove the key from memory - await _cache.SetAsync(CacheKey, value, - options => options.SetDuration(TimeSpan.FromSeconds(30)) - ); - } -} -``` - -## Advanced Concepts - -### Cache Key Prefix Strategy - -``` -WebApplication1: app1:shared:data -WebApplication2: app2:shared:data -``` - -Both apps can have their own cache entries, but they both also subscribe to `shared:data` changes through the backplane. - -### When Backplane Invalidation Happens - -✅ **Happens:** -- `SetAsync()` - Set a value -- `RemoveAsync()` - Remove a key -- `ExpireAsync()` - Expire a key -- `ClearAsync()` - Clear all - -❌ **Doesn't Happen:** -- `GetOrSetAsync()` - Only on factory execution failure -- Direct memory cache hits -- Cache expiration (local to each app) - -### Performance Implications - -``` -Cache Scenario | Speed | Network Calls -─────────────────────────┼─────────┼────────────── -L1 Hit (Memory) | ⚡⚡⚡ | None -L2 Hit (Redis) | ⚡⚡ | 1 -Factory Execution | ⚡ | 1 (to store) -Cross-App Invalidation | ⚡ | 1 (backplane message) -``` - -## Troubleshooting - -### Problem: Redis Connection Failed -**Error:** `Timeout connecting to cache-redis:6379` - -**Solution:** -1. Ensure Docker Desktop is running -2. Check Aspire dashboard - is Redis green? -3. Manually test: `docker ps | grep redis` - -### Problem: Apps Not Synchronizing -**Error:** App2 doesn't see the data from App1 - -**Solution:** -1. Check both apps have the same `CacheKey` value -2. Verify Redis backplane is initialized in both -3. Check logs: should see "Cache miss" on first access -4. Wait - Redis Pub/Sub is fast but not instant (~10ms) - -### Problem: Cache Not Expiring -**Symptom:** Data doesn't change even after 30 seconds - -**Solution:** -1. Check the duration setting: `WithDefaultDuration(TimeSpan.FromSeconds(30))` -2. Manually clear: Call the endpoint twice rapidly -3. Check logs for expiration messages - -### Problem: Service Discovery Issues -**Error:** `System.Net.Http.HttpRequestException: No such host is known` - -**Solution:** -1. Ensure you're using the service name from Aspire: `cache-redis` -2. Check `appsettings.json` for correct connection string format -3. Aspire's service discovery converts `cache-redis` → `localhost:6379` - -## Running Without Aspire (Advanced) - -If you need to run without Aspire, update the connection string: - -```csharp -// Replace this: -var redisConnectionString = builder.Configuration.GetConnectionString("cache-redis"); - -// With this: -var redisConnectionString = "localhost:6379"; - -// Make sure Redis is running on localhost:6379 -``` - -## Common Patterns - -### Pattern 1: Write-Through Cache -```csharp -public async Task UpdateUserAsync(int userId, UserData userData) -{ - // Update database - await _db.Users.Update(userData); - - // Update cache (triggers backplane invalidation) - await _cache.SetAsync($"user:{userId}", userData); -} -``` - -### Pattern 2: Cache-Aside -```csharp -public async Task GetUserAsync(int userId) -{ - return await _cache.GetOrSetAsync( - $"user:{userId}", - async ct => await _db.Users.GetAsync(userId) - ); -} -``` - -### Pattern 3: Distributed Cache Warming -```csharp -public async Task PrecacheAsync() -{ - foreach (var key in _importantKeys) - { - await _cache.SetAsync(key, await _generateValue(key)); - } - // All apps now have warmed caches thanks to backplane -} -``` - -## Next Steps - -1. ✅ Run the example -2. ✅ Test cache hit/miss patterns -3. ✅ Observe backplane invalidations -4. ✅ Modify the factory function to see how it works -5. ✅ Add more cache keys -6. ✅ Add error handling and logging - -## Resources - -- **FusionCache GitHub:** https://github.com/ZiggyCreatures/FusionCache -- **Redis Backplane Docs:** https://github.com/ZiggyCreatures/FusionCache/wiki/Backplane -- **.NET Aspire:** https://learn.microsoft.com/en-us/dotnet/aspire/ -- **StackExchange.Redis:** https://github.com/StackExchange/StackExchange.Redis - -## Questions? - -Check the logs! Enable Debug logging to see exactly what's happening: - -```json -{ - "Logging": { - "LogLevel": { - "ZiggyCreatures.Caching.Fusion": "Debug", - "StackExchange.Redis": "Information" - } - } -} -``` - ---- - -Happy caching! 🚀 From 97de55dd80dd6334de0af6a4afb665c975409515 Mon Sep 17 00:00:00 2001 From: AboubakrNasef Date: Sat, 8 Aug 2026 10:27:34 +0300 Subject: [PATCH 13/13] update wrong using of task in sync --- .../L1L2BackplaneTests_Async.cs | 2 +- .../ZiggyCreatures.FusionCache.Tests/L1L2BackplaneTests_Sync.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/ZiggyCreatures.FusionCache.Tests/L1L2BackplaneTests_Async.cs b/tests/ZiggyCreatures.FusionCache.Tests/L1L2BackplaneTests_Async.cs index 6f5b12b2..e833bc5c 100644 --- a/tests/ZiggyCreatures.FusionCache.Tests/L1L2BackplaneTests_Async.cs +++ b/tests/ZiggyCreatures.FusionCache.Tests/L1L2BackplaneTests_Async.cs @@ -820,7 +820,7 @@ public async Task CanClearAsync(SerializerType serializerType) Assert.Equal(0, cache2_foo_4); Assert.Equal(0, cache2_bar_4); - Thread.Sleep(10); + await Task.Delay(10, TestContext.Current.CancellationToken); logger.LogInformation("STEP 10"); diff --git a/tests/ZiggyCreatures.FusionCache.Tests/L1L2BackplaneTests_Sync.cs b/tests/ZiggyCreatures.FusionCache.Tests/L1L2BackplaneTests_Sync.cs index 1ea445c7..9c82fd9f 100644 --- a/tests/ZiggyCreatures.FusionCache.Tests/L1L2BackplaneTests_Sync.cs +++ b/tests/ZiggyCreatures.FusionCache.Tests/L1L2BackplaneTests_Sync.cs @@ -721,7 +721,7 @@ public void RemoveByTagDoesNotRemoveTaggingData(SerializerType serializerType) [Theory] [ClassData(typeof(SerializerTypesClassData))] - public async Task CanClear(SerializerType serializerType) + public void CanClear(SerializerType serializerType) { var logger = CreateXUnitLogger();