Skip to content

Commit 5edd9fe

Browse files
authoredMar 14, 2025··
fix: fix cache store leaving orphan tenant on some update scenarios (#946)
1 parent c058141 commit 5edd9fe

File tree

2 files changed

+78
-29
lines changed

2 files changed

+78
-29
lines changed
 

‎src/Finbuckle.MultiTenant/Stores/DistributedCacheStore/DistributedCacheStore.cs

+15-5
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ namespace Finbuckle.MultiTenant.Stores.DistributedCacheStore;
1111
/// Basic store that uses an IDistributedCache instance as its backing. Note that GetAllAsync is not implemented.
1212
/// </summary>
1313
/// <typeparam name="TTenantInfo">The ITenantInfo implementation type.</typeparam>
14-
public class DistributedCacheStore<TTenantInfo> : IMultiTenantStore<TTenantInfo> where TTenantInfo : class, ITenantInfo, new()
14+
public class DistributedCacheStore<TTenantInfo> : IMultiTenantStore<TTenantInfo>
15+
where TTenantInfo : class, ITenantInfo, new()
1516
{
1617
private readonly IDistributedCache cache;
1718
private readonly string keyPrefix;
@@ -38,7 +39,8 @@ public async Task<bool> TryAddAsync(TTenantInfo tenantInfo)
3839
var bytes = JsonSerializer.Serialize(tenantInfo);
3940

4041
await cache.SetStringAsync($"{keyPrefix}id__{tenantInfo.Id}", bytes, options).ConfigureAwait(false);
41-
await cache.SetStringAsync($"{keyPrefix}identifier__{tenantInfo.Identifier}", bytes, options).ConfigureAwait(false);
42+
await cache.SetStringAsync($"{keyPrefix}identifier__{tenantInfo.Identifier}", bytes, options)
43+
.ConfigureAwait(false);
4244

4345
return true;
4446
}
@@ -96,9 +98,17 @@ public async Task<bool> TryRemoveAsync(string identifier)
9698
}
9799

98100
/// <inheritdoc />
99-
public Task<bool> TryUpdateAsync(TTenantInfo tenantInfo)
101+
public async Task<bool> TryUpdateAsync(TTenantInfo tenantInfo)
100102
{
101-
// Same as adding for distributed cache.
102-
return TryAddAsync(tenantInfo);
103+
if (tenantInfo.Id is null)
104+
return false;
105+
106+
var current = await TryGetAsync(tenantInfo.Id).ConfigureAwait(false);
107+
108+
if (current is null)
109+
return false;
110+
111+
return await TryRemoveAsync(current.Identifier!).ConfigureAwait(false) &&
112+
await TryAddAsync(tenantInfo).ConfigureAwait(false);
103113
}
104114
}

‎test/Finbuckle.MultiTenant.Test/Stores/DistributedCacheStoreShould.cs

+63-24
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,25 @@
11
// Copyright Finbuckle LLC, Andrew White, and Contributors.
22
// Refer to the solution LICENSE file for more information.
33

4+
using System.Text;
5+
using System.Text.Json;
46
using Finbuckle.MultiTenant.Abstractions;
57
using Finbuckle.MultiTenant.Internal;
68
using Finbuckle.MultiTenant.Stores.DistributedCacheStore;
79
using Microsoft.Extensions.Caching.Distributed;
810
using Microsoft.Extensions.DependencyInjection;
11+
using Moq;
912
using Xunit;
1013

1114
namespace Finbuckle.MultiTenant.Test.Stores;
1215

1316
public class DistributedCacheStoreShould : MultiTenantStoreTestBase
1417
{
1518
[Fact]
16-
public void ThrownOnGetAllTenantsFromStoreAsync()
19+
public async Task ThrowOnGetAllTenantsFromStoreAsync()
1720
{
1821
var store = CreateTestStore();
19-
Assert.Throws<NotImplementedException>(() => store.GetAllAsync().Wait());
22+
await Assert.ThrowsAsync<NotImplementedException>(async () => await store.GetAllAsync());
2023
}
2124

2225
[Fact]
@@ -45,7 +48,7 @@ public async Task RemoveReturnsFalseWhenNoMatchingIdentifierFound()
4548
}
4649

4750
[Fact]
48-
public async Task AddDualEntriesOnAddOrUpdate()
51+
public async Task AddDualEntriesOnAdd()
4952
{
5053
var store = CreateTestStore();
5154

@@ -61,34 +64,69 @@ public async Task AddDualEntriesOnAddOrUpdate()
6164
}
6265

6366
[Fact]
64-
public async Task RefreshDualEntriesOnAddOrUpdate()
67+
public async Task RefreshDualEntriesOnTryGet()
6568
{
66-
var store = CreateTestStore();
67-
await Task.Delay(1500);
69+
var cache = new Mock<IDistributedCache>();
70+
cache.Setup(c => c.GetAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
71+
.ReturnsAsync(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(new TenantInfo
72+
{ Id = "lol-id", Identifier = "lol" })));
73+
74+
var store = new DistributedCacheStore<TenantInfo>(cache.Object, Constants.TenantToken, TimeSpan.FromSeconds(1));
75+
6876
var t1 = await store.TryGetAsync("lol-id");
69-
await Task.Delay(1500);
70-
var t2 = await store.TryGetByIdentifierAsync("lol");
71-
await Task.Delay(1500);
72-
var t3 = await store.TryGetAsync("lol-id");
77+
cache.Verify(c => c.RefreshAsync(It.IsAny<string>(), CancellationToken.None), Times.Once);
78+
}
7379

74-
Assert.NotNull(t1);
75-
Assert.NotNull(t2);
76-
Assert.NotNull(t3);
77-
Assert.Equal("lol-id", t1.Id);
78-
Assert.Equal("lol-id", t2.Id);
79-
Assert.Equal("lol-id", t3.Id);
80+
[Fact]
81+
public async Task RefreshDualEntriesOnTryGetByIdentifier()
82+
{
83+
var cache = new Mock<IDistributedCache>();
84+
cache.Setup(c => c.GetAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
85+
.ReturnsAsync(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(new TenantInfo
86+
{ Id = "lol-id", Identifier = "lol" })));
87+
88+
var store = new DistributedCacheStore<TenantInfo>(cache.Object, Constants.TenantToken, TimeSpan.FromSeconds(1));
89+
90+
var t1 = await store.TryGetByIdentifierAsync("lol-id");
91+
cache.Verify(c => c.RefreshAsync(It.IsAny<string>(), CancellationToken.None), Times.Once);
8092
}
93+
94+
[Fact]
95+
public async Task SetSlidingExpirationOnAdd()
96+
{
97+
var cache = new Mock<IDistributedCache>();
98+
var options = new DistributedCacheEntryOptions { SlidingExpiration = TimeSpan.FromSeconds(1) };
99+
100+
cache.Setup(c => c.SetAsync(It.IsAny<string>(), It.IsAny<byte[]>(),
101+
It.IsAny<DistributedCacheEntryOptions>(), It.IsAny<CancellationToken>()))
102+
.Callback<string, byte[], DistributedCacheEntryOptions, CancellationToken>((key, value, opts, token) =>
103+
{
104+
Assert.Equal(options.SlidingExpiration, opts.SlidingExpiration);
105+
})
106+
.Returns(Task.CompletedTask);
107+
108+
var store = new DistributedCacheStore<TenantInfo>(cache.Object, Constants.TenantToken, TimeSpan.FromSeconds(1));
81109

110+
await store.TryAddAsync(new TenantInfo { Id = "test-id", Identifier = "test", Name = "Test Tenant" });
111+
}
112+
82113
[Fact]
83-
public async Task ExpireDualEntriesAfterTimespan()
114+
public async Task SetSlidingExpirationOnUpdate()
84115
{
85-
var store = CreateTestStore();
86-
await Task.Delay(2100);
87-
var t1 = await store.TryGetAsync("lol-id");
88-
var t2 = await store.TryGetByIdentifierAsync("lol");
116+
var cache = new Mock<IDistributedCache>();
117+
var options = new DistributedCacheEntryOptions { SlidingExpiration = TimeSpan.FromSeconds(1) };
89118

90-
Assert.Null(t1);
91-
Assert.Null(t2);
119+
cache.Setup(c => c.SetAsync(It.IsAny<string>(), It.IsAny<byte[]>(),
120+
It.IsAny<DistributedCacheEntryOptions>(), It.IsAny<CancellationToken>()))
121+
.Callback<string, byte[], DistributedCacheEntryOptions, CancellationToken>((key, value, opts, token) =>
122+
{
123+
Assert.Equal(options.SlidingExpiration, opts.SlidingExpiration);
124+
})
125+
.Returns(Task.CompletedTask);
126+
127+
var store = new DistributedCacheStore<TenantInfo>(cache.Object, Constants.TenantToken, TimeSpan.FromSeconds(1));
128+
129+
await store.TryAddAsync(new TenantInfo { Id = "test-id", Identifier = "test", Name = "Test Tenant" });
92130
}
93131

94132
// Basic store functionality tested in MultiTenantStoresShould.cs
@@ -99,7 +137,8 @@ protected override IMultiTenantStore<TenantInfo> CreateTestStore()
99137
services.AddOptions().AddDistributedMemoryCache();
100138
var sp = services.BuildServiceProvider();
101139

102-
var store = new DistributedCacheStore<TenantInfo>(sp.GetRequiredService<IDistributedCache>(), Constants.TenantToken, TimeSpan.FromMilliseconds(2000));
140+
var store = new DistributedCacheStore<TenantInfo>(sp.GetRequiredService<IDistributedCache>(),
141+
Constants.TenantToken, TimeSpan.MaxValue);
103142

104143
return PopulateTestStore(store);
105144
}

0 commit comments

Comments
 (0)
Please sign in to comment.