Skip to content

Commit bf5f84b

Browse files
committed
Add Service Bus distributed cache expiration
1 parent 5e25bd4 commit bf5f84b

19 files changed

Lines changed: 1579 additions & 50 deletions

.github/workflows/dotnet.yml

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,22 @@ jobs:
4646
run: dotnet build --no-restore --configuration Release
4747

4848
- name: Run Test
49-
run: dotnet test --no-build --configuration Release
49+
run: dotnet test --no-build --configuration Release --treenode-filter "/*/*/*/*[Category!=LocalOnly]" -- --coverage
50+
51+
- name: Collect Coverage
52+
if: success()
53+
id: coverage
54+
run: |
55+
FILES=$(find ${{github.workspace}} -name '*.coverage' -print | tr '\n' ' ')
56+
echo "files=$FILES" >> $GITHUB_OUTPUT
57+
58+
- name: Report Coverage
59+
if: success() && steps.coverage.outputs.files != ''
60+
uses: coverallsapp/github-action@v2
61+
continue-on-error: true
62+
with:
63+
files: ${{ steps.coverage.outputs.files }}
64+
format: cobertura
5065

5166
- name: Create Packages
5267
if: success() && github.event_name != 'pull_request'

docs/guide/behaviors/caching.md

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,87 @@ public override string? GetCacheTag() => $"Products Category:{CategoryId}";
122122
public string? GetCacheTag() => $"Products Category:{CategoryId}";
123123
```
124124

125+
### Multiple Cache Tags
126+
127+
`ICacheExpire` exposes `GetCacheTags()` for commands that need to expire more than one tag for a single change. The default implementation returns the single value from `GetCacheTag()`, so existing implementations continue to work without modification. Override `GetCacheTags()` to invalidate multiple tags at once:
128+
129+
```csharp
130+
public record MoveProductCommand : PrincipalCommandBase<ProductReadModel>, ICacheExpire
131+
{
132+
public MoveProductCommand(ClaimsPrincipal principal, int fromCategoryId, int toCategoryId)
133+
: base(principal)
134+
{
135+
FromCategoryId = fromCategoryId;
136+
ToCategoryId = toCategoryId;
137+
}
138+
139+
public int FromCategoryId { get; }
140+
141+
public int ToCategoryId { get; }
142+
143+
public string? GetCacheKey() => null;
144+
145+
public string? GetCacheTag() => $"Products Category:{FromCategoryId}";
146+
147+
// expire both the source and destination category tags
148+
public override IEnumerable<string> GetCacheTags() =>
149+
[
150+
$"Products Category:{FromCategoryId}",
151+
$"Products Category:{ToCategoryId}",
152+
];
153+
}
154+
```
155+
156+
### Distributed Cache Expiration over Azure Service Bus
157+
158+
When the same application is hosted across multiple processes (for example, a load-balanced web farm or several independent services), each process keeps its own local `HybridCache` tier. The `Arbiter.Messaging.ServiceBus` package provides an opt-in feature that publishes cache expiration messages to a Service Bus topic so every subscribed process expires the matching entries.
159+
160+
The feature is built on top of the local `HybridCacheExpireBehavior`:
161+
162+
- The publisher behavior (`ServiceBusCacheExpireBehavior`) expires the local cache first, then publishes a lightweight `CacheExpireMessage` (cache key, tags, and a per-process `SourceId`) to the configured topic.
163+
- The subscriber processor (`CacheExpireProcessor`) receives messages from a topic subscription and expires the matching key and tags from its own local cache. Messages originating from the same process are skipped using the `SourceId`, since that process already expired its cache locally.
164+
165+
The topic and subscription must be declared in the `AddServiceBus` configuration so the initializer provisions them and registers a sender for the topic:
166+
167+
```csharp
168+
services.AddServiceBus(
169+
serviceName: "Default",
170+
nameOrConnectionString: "ServiceBus",
171+
configureBus: bus => bus.AddTopic("cache-expire", "app-instance"));
172+
```
173+
174+
Three extension methods opt in to the feature:
175+
176+
```csharp
177+
// Publisher only: expire local cache and publish expiration messages
178+
services.AddServiceBusCacheExpirePublisher("cache-expire");
179+
180+
// Subscriber only: receive expiration messages and expire local cache
181+
services.AddServiceBusCacheExpireSubscriber(
182+
serviceName: "Default",
183+
topicName: "cache-expire",
184+
subscriptionName: "app-instance");
185+
186+
// Both publisher and subscriber
187+
services.AddServiceBusCacheExpire(
188+
serviceName: "Default",
189+
topicName: "cache-expire",
190+
subscriptionName: "app-instance");
191+
```
192+
193+
Each process should use a unique topic subscription so it receives its own copy of every expiration message. The per-process `SourceId` defaults to a unique value, but can be set explicitly through the optional `configureOptions` delegate:
194+
195+
```csharp
196+
services.AddServiceBusCacheExpire(
197+
serviceName: "Default",
198+
topicName: "cache-expire",
199+
subscriptionName: "app-instance",
200+
configureOptions: options => options.SourceId = Environment.MachineName);
201+
```
202+
203+
> [!NOTE]
204+
> Distributed expiration complements, and does not replace, the shared distributed cache tier (such as Redis). It is most useful for expiring the fast local L1 cache in each process when data changes elsewhere.
205+
125206
## Configuration Options
126207

127208
### Hybrid Cache Configuration

src/Arbiter.CommandQuery/Behaviors/HybridCacheExpireBehavior.cs

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,6 @@ namespace Arbiter.CommandQuery.Behaviors;
1414
public class HybridCacheExpireBehavior<TRequest, TResponse> : PipelineBehaviorBase<TRequest, TResponse>
1515
where TRequest : class, IRequest<TResponse>, ICacheExpire
1616
{
17-
private readonly HybridCache _hybridCache;
18-
1917
/// <summary>
2018
/// Initializes a new instance of the <see cref="HybridCacheExpireBehavior{TRequest, TResponse}"/> class.
2119
/// </summary>
@@ -29,9 +27,14 @@ public HybridCacheExpireBehavior(
2927
{
3028
ArgumentNullException.ThrowIfNull(hybridCache);
3129

32-
_hybridCache = hybridCache;
30+
HybridCache = hybridCache;
3331
}
3432

33+
/// <summary>
34+
/// Gets the <see cref="HybridCache"/> used to expire cache entries.
35+
/// </summary>
36+
protected HybridCache HybridCache { get; }
37+
3538
/// <inheritdoc />
3639
protected override async ValueTask<TResponse?> Process(
3740
TRequest request,
@@ -44,17 +47,31 @@ public HybridCacheExpireBehavior(
4447
var response = await next(cancellationToken).ConfigureAwait(false);
4548

4649
// expire cache
47-
if (request is not ICacheExpire cacheRequest)
48-
return response;
50+
if (request is ICacheExpire cacheRequest)
51+
await ExpireCache(cacheRequest, cancellationToken).ConfigureAwait(false);
52+
53+
return response;
54+
}
55+
56+
/// <summary>
57+
/// Removes the cache key and cache tags of the specified <paramref name="cacheRequest"/> from the local <see cref="HybridCache"/>.
58+
/// </summary>
59+
/// <param name="cacheRequest">The request describing the cache key and tags to expire.</param>
60+
/// <param name="cancellationToken">The token to monitor for cancellation requests.</param>
61+
/// <returns>A <see cref="ValueTask"/> that represents the asynchronous expire operation.</returns>
62+
/// <exception cref="ArgumentNullException">When <paramref name="cacheRequest"/> is null</exception>
63+
protected async ValueTask ExpireCache(ICacheExpire cacheRequest, CancellationToken cancellationToken)
64+
{
65+
ArgumentNullException.ThrowIfNull(cacheRequest);
4966

5067
var cacheKey = cacheRequest.GetCacheKey();
5168
if (!string.IsNullOrEmpty(cacheKey))
52-
await _hybridCache.RemoveAsync(cacheKey, cancellationToken).ConfigureAwait(false);
53-
54-
var cacheTag = cacheRequest.GetCacheTag();
55-
if (!string.IsNullOrEmpty(cacheTag))
56-
await _hybridCache.RemoveByTagAsync(cacheTag, cancellationToken).ConfigureAwait(false);
69+
await HybridCache.RemoveAsync(cacheKey, cancellationToken).ConfigureAwait(false);
5770

58-
return response;
71+
foreach (var cacheTag in cacheRequest.GetCacheTags())
72+
{
73+
if (!string.IsNullOrEmpty(cacheTag))
74+
await HybridCache.RemoveByTagAsync(cacheTag, cancellationToken).ConfigureAwait(false);
75+
}
5976
}
6077
}

src/Arbiter.CommandQuery/Definitions/ICacheExpire.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,22 @@ public interface ICacheExpire
1616
/// </summary>
1717
/// <returns>The cache tag for this entity</returns>
1818
string? GetCacheTag();
19+
20+
/// <summary>
21+
/// Gets the cache tags for this entity.
22+
/// </summary>
23+
/// <returns>
24+
/// The cache tags for this entity. The default implementation returns the single tag from
25+
/// <see cref="GetCacheTag"/> as a one-item sequence, or an empty sequence when no tag is available.
26+
/// </returns>
27+
/// <remarks>
28+
/// Override this member to expire multiple cache tags for a single change. The default implementation
29+
/// preserves backward compatibility with <see cref="GetCacheTag"/>, so existing implementers do not
30+
/// need to change.
31+
/// </remarks>
32+
IEnumerable<string> GetCacheTags()
33+
{
34+
var tag = GetCacheTag();
35+
return string.IsNullOrEmpty(tag) ? [] : [tag];
36+
}
1937
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
using System.Text.Json.Serialization;
2+
3+
namespace Arbiter.Messaging.ServiceBus.Cache;
4+
5+
/// <summary>
6+
/// A message describing a cache entry to expire across applications subscribed to a Service Bus topic.
7+
/// </summary>
8+
/// <remarks>
9+
/// The message is published when a command expires the local cache and is received by other applications so they can
10+
/// remove the matching key and tags from their own <see cref="Microsoft.Extensions.Caching.Hybrid.HybridCache"/>.
11+
/// </remarks>
12+
public sealed record CacheExpireMessage
13+
{
14+
/// <summary>
15+
/// Gets the cache key to remove, or <see langword="null"/> when no specific key should be removed.
16+
/// </summary>
17+
[JsonPropertyName("key")]
18+
public string? Key { get; init; }
19+
20+
/// <summary>
21+
/// Gets the cache tags to remove. Empty when no tags should be removed.
22+
/// </summary>
23+
[JsonPropertyName("tags")]
24+
public IReadOnlyList<string> Tags { get; init; } = [];
25+
26+
/// <summary>
27+
/// Gets the identifier of the application instance that published this message.
28+
/// </summary>
29+
/// <remarks>
30+
/// Used by receivers to skip expiration for messages they originally published, since the publishing
31+
/// application has already expired its own local cache.
32+
/// </remarks>
33+
[JsonPropertyName("sourceId")]
34+
public string? SourceId { get; init; }
35+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
namespace Arbiter.Messaging.ServiceBus.Cache;
2+
3+
/// <summary>
4+
/// Options that control distributed cache expiration over Azure Service Bus.
5+
/// </summary>
6+
public sealed class CacheExpireOptions
7+
{
8+
/// <summary>
9+
/// Gets or sets the name of the Service Bus topic used to publish and receive cache expiration messages.
10+
/// </summary>
11+
public required string TopicName { get; set; }
12+
13+
/// <summary>
14+
/// Gets or sets the identifier of this application instance.
15+
/// </summary>
16+
/// <remarks>
17+
/// Stamped on outgoing messages and compared on incoming messages so an application does not redundantly
18+
/// expire cache entries it already expired locally. Defaults to a unique value per process.
19+
/// </remarks>
20+
public string SourceId { get; set; } = Guid.NewGuid().ToString("N");
21+
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
using System.Text.Json;
2+
3+
using Azure.Messaging.ServiceBus;
4+
5+
using Microsoft.Extensions.Caching.Hybrid;
6+
using Microsoft.Extensions.Logging;
7+
8+
namespace Arbiter.Messaging.ServiceBus.Cache;
9+
10+
/// <summary>
11+
/// Processes <see cref="CacheExpireMessage"/> messages from a Service Bus topic subscription and expires the
12+
/// matching entries from the local <see cref="HybridCache"/>.
13+
/// </summary>
14+
public sealed partial class CacheExpireProcessor : ServiceBusProcessorBase
15+
{
16+
private readonly HybridCache _hybridCache;
17+
private readonly CacheExpireOptions _options;
18+
private readonly ILogger<CacheExpireProcessor> _logger;
19+
20+
/// <summary>
21+
/// Initializes a new instance of the <see cref="CacheExpireProcessor"/> class.
22+
/// </summary>
23+
/// <param name="processor">The Service Bus processor used to receive messages.</param>
24+
/// <param name="logger">The logger used to write processing messages.</param>
25+
/// <param name="hybridCache">The hybrid cache to expire.</param>
26+
/// <param name="options">The cache expiration options.</param>
27+
/// <exception cref="ArgumentNullException">When <paramref name="hybridCache"/> or <paramref name="options"/> is null.</exception>
28+
public CacheExpireProcessor(
29+
ServiceBusProcessor processor,
30+
ILogger<CacheExpireProcessor> logger,
31+
HybridCache hybridCache,
32+
CacheExpireOptions options)
33+
: base(processor, logger)
34+
{
35+
ArgumentNullException.ThrowIfNull(hybridCache);
36+
ArgumentNullException.ThrowIfNull(options);
37+
38+
_hybridCache = hybridCache;
39+
_options = options;
40+
_logger = logger;
41+
}
42+
43+
/// <inheritdoc />
44+
/// <remarks>
45+
/// Message settlement is handled automatically by the processor (<see cref="ServiceBusProcessorOptions.AutoCompleteMessages" />);
46+
/// returning successfully completes the message, while throwing abandons it for redelivery. A message with an
47+
/// unparsable body is non-retryable and is dead-lettered explicitly rather than retried until the delivery count is exhausted.
48+
/// </remarks>
49+
protected override async Task ProcessMessageAsync(ProcessMessageEventArgs args)
50+
{
51+
ArgumentNullException.ThrowIfNull(args);
52+
53+
CacheExpireMessage? message;
54+
55+
try
56+
{
57+
message = args.ReadFromJson<CacheExpireMessage>();
58+
}
59+
catch (JsonException ex)
60+
{
61+
// non-retryable: a malformed body never succeeds, so dead-letter immediately instead of
62+
// abandoning until MaxDeliveryCount is reached
63+
LogDeadLetteringMessage(_logger, ex, args.Message.MessageId);
64+
65+
await args.DeadLetterMessageAsync(
66+
args.Message,
67+
deadLetterReason: "DeserializationFailed",
68+
deadLetterErrorDescription: ex.Message,
69+
args.CancellationToken).ConfigureAwait(false);
70+
71+
return;
72+
}
73+
74+
if (message is null)
75+
return;
76+
77+
// skip messages this application published; it already expired its own local cache
78+
if (!string.IsNullOrEmpty(message.SourceId)
79+
&& string.Equals(message.SourceId, _options.SourceId, StringComparison.Ordinal))
80+
{
81+
LogSkippingOwnMessage(_logger, message.SourceId);
82+
return;
83+
}
84+
85+
if (!string.IsNullOrEmpty(message.Key))
86+
await _hybridCache.RemoveAsync(message.Key, args.CancellationToken).ConfigureAwait(false);
87+
88+
foreach (var tag in message.Tags)
89+
{
90+
if (!string.IsNullOrEmpty(tag))
91+
await _hybridCache.RemoveByTagAsync(tag, args.CancellationToken).ConfigureAwait(false);
92+
}
93+
94+
LogExpiredCache(_logger, message.Key, message.Tags.Count);
95+
}
96+
97+
[LoggerMessage(Level = LogLevel.Debug, Message = "Skipping cache expiration message from own source '{SourceId}'")]
98+
private static partial void LogSkippingOwnMessage(ILogger logger, string sourceId);
99+
100+
[LoggerMessage(Level = LogLevel.Warning, Message = "Dead-lettering cache expiration message '{MessageId}' with unparsable body")]
101+
private static partial void LogDeadLetteringMessage(ILogger logger, Exception exception, string messageId);
102+
103+
[LoggerMessage(Level = LogLevel.Debug, Message = "Expired local cache for key '{Key}' and {TagCount} tag(s)")]
104+
private static partial void LogExpiredCache(ILogger logger, string? key, int tagCount);
105+
}

0 commit comments

Comments
 (0)