[PM-32069] Add ExtendedProviderAbilityCacheService

This commit is contained in:
Jimmy Vo
2026-04-10 15:44:13 -04:00
parent 7c4fa752c7
commit 76d5c4f598
7 changed files with 382 additions and 7 deletions

View File

@@ -0,0 +1,33 @@
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Models.Data.Provider;
using Bit.Core.AdminConsole.Repositories;
using Microsoft.Extensions.DependencyInjection;
using ZiggyCreatures.Caching.Fusion;
namespace Bit.Core.AdminConsole.AbilitiesCache;
public class ExtendedProviderAbilityCacheService(
[FromKeyedServices(ExtendedProviderAbilityCacheService.CacheName)] IFusionCache cache,
IProviderRepository providerRepository)
: IProviderAbilityCacheService
{
public const string CacheName = "ProviderAbilities";
public async Task<ProviderAbility?> GetProviderAbilityAsync(Guid providerId)
{
return await cache.GetOrSetAsync<ProviderAbility?>(
$"{providerId}",
async _ => await providerRepository.GetAbilityAsync(providerId)
);
}
public async Task UpsertProviderAbilityAsync(Provider provider)
{
await cache.SetAsync($"{provider.Id}", new ProviderAbility(provider));
}
public async Task DeleteProviderAbilityAsync(Guid providerId)
{
await cache.RemoveAsync($"{providerId}");
}
}

View File

@@ -0,0 +1,11 @@
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Models.Data.Provider;
namespace Bit.Core.AdminConsole.AbilitiesCache;
public interface IProviderAbilityCacheService
{
Task<ProviderAbility?> GetProviderAbilityAsync(Guid providerId);
Task UpsertProviderAbilityAsync(Provider provider);
Task DeleteProviderAbilityAsync(Guid providerId);
}

View File

@@ -165,6 +165,7 @@ public static class FeatureFlagKeys
public const string PublicMembersInviteRefactor = "pm-33398-refactor-members-invite-org-users-command";
public const string GenerateInviteLink = "pm-32497-generate-invite-link";
public const string OrgAbilityExtendedCache = "pm-32104-org-ability-extended-cache";
public const string ProviderAbilityExtendedCache = "pm-32111-provider-ability-extended-cache";
/* Architecture */
public const string DesktopMigrationMilestone1 = "desktop-ui-migration-milestone-1";

View File

@@ -7,7 +7,9 @@ using Bit.Core.Models.Data.Organizations;
namespace Bit.Core.Services.Implementations;
public class FeatureRoutedCacheService(
IVCurrentInMemoryApplicationCacheService inMemoryApplicationCacheService)
IVCurrentInMemoryApplicationCacheService inMemoryApplicationCacheService,
IProviderAbilityCacheService providerAbilityCacheService,
IFeatureService featureService)
: IApplicationCacheService
{
public Task<IDictionary<Guid, OrganizationAbility>> GetOrganizationAbilitiesAsync() =>
@@ -21,6 +23,11 @@ public class FeatureRoutedCacheService(
public async Task<ProviderAbility?> GetProviderAbilityAsync(Guid providerId)
{
if (featureService.IsEnabled(FeatureFlagKeys.ProviderAbilityExtendedCache))
{
return await providerAbilityCacheService.GetProviderAbilityAsync(providerId);
}
(await GetProviderAbilitiesAsync([providerId])).TryGetValue(providerId, out var providerAbility);
return providerAbility;
}
@@ -46,14 +53,28 @@ public class FeatureRoutedCacheService(
public Task UpsertOrganizationAbilityAsync(Organization organization) =>
inMemoryApplicationCacheService.UpsertOrganizationAbilityAsync(organization);
public Task UpsertProviderAbilityAsync(Provider provider) =>
inMemoryApplicationCacheService.UpsertProviderAbilityAsync(provider);
public Task UpsertProviderAbilityAsync(Provider provider)
{
if (featureService.IsEnabled(FeatureFlagKeys.ProviderAbilityExtendedCache))
{
return providerAbilityCacheService.UpsertProviderAbilityAsync(provider);
}
return inMemoryApplicationCacheService.UpsertProviderAbilityAsync(provider);
}
public Task DeleteOrganizationAbilityAsync(Guid organizationId) =>
inMemoryApplicationCacheService.DeleteOrganizationAbilityAsync(organizationId);
public Task DeleteProviderAbilityAsync(Guid providerId) =>
inMemoryApplicationCacheService.DeleteProviderAbilityAsync(providerId);
public Task DeleteProviderAbilityAsync(Guid providerId)
{
if (featureService.IsEnabled(FeatureFlagKeys.ProviderAbilityExtendedCache))
{
return providerAbilityCacheService.DeleteProviderAbilityAsync(providerId);
}
return inMemoryApplicationCacheService.DeleteProviderAbilityAsync(providerId);
}
public async Task BaseUpsertOrganizationAbilityAsync(Organization organization)
{

View File

@@ -294,6 +294,10 @@ public static class ServiceCollectionExtensions
services.AddOptionality();
services.AddTokenizers();
services.AddDistributedCache(globalSettings);
services.AddExtendedCache(ExtendedProviderAbilityCacheService.CacheName, globalSettings);
services.AddSingleton<IProviderAbilityCacheService, ExtendedProviderAbilityCacheService>();
services.AddScoped<IApplicationCacheService, FeatureRoutedCacheService>();
if (CoreHelpers.SettingHasValue(globalSettings.ServiceBus.ConnectionString) &&

View File

@@ -0,0 +1,157 @@
using Bit.Core.AdminConsole.AbilitiesCache;
using Bit.Core.AdminConsole.Models.Data.Provider;
using Bit.Core.AdminConsole.Repositories;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
using Xunit;
using ZiggyCreatures.Caching.Fusion;
using ProviderEntity = Bit.Core.AdminConsole.Entities.Provider.Provider;
namespace Bit.Core.Test.AdminConsole.AbilitiesCache;
[SutProviderCustomize]
public class ExtendedProviderAbilityCacheServiceTests
{
[Theory, BitAutoData]
public async Task GetProviderAbilityAsync_OnCacheHit_ReturnsWithoutCallingRepository(
SutProvider<ExtendedProviderAbilityCacheService> sutProvider,
ProviderAbility cachedAbility)
{
// Arrange
sutProvider.GetDependency<IFusionCache>()
.GetOrSetAsync(
key: Arg.Any<string>(),
factory: Arg.Any<Func<FusionCacheFactoryExecutionContext<ProviderAbility?>, CancellationToken, Task<ProviderAbility?>>>(),
options: Arg.Any<FusionCacheEntryOptions?>(),
tags: Arg.Any<IEnumerable<string>?>())
.Returns(new ValueTask<ProviderAbility?>(cachedAbility));
// Act
var result = await sutProvider.Sut.GetProviderAbilityAsync(cachedAbility.Id);
// Assert
Assert.Equal(cachedAbility, result);
await sutProvider.GetDependency<IProviderRepository>()
.DidNotReceive()
.GetAbilityAsync(Arg.Any<Guid>());
await sutProvider.GetDependency<IFusionCache>()
.Received(1)
.GetOrSetAsync<ProviderAbility?>(
Arg.Is<string>(k => k == cachedAbility.Id.ToString()),
Arg.Any<Func<FusionCacheFactoryExecutionContext<ProviderAbility?>, CancellationToken, Task<ProviderAbility?>>>(),
Arg.Any<FusionCacheEntryOptions?>(),
Arg.Any<IEnumerable<string>?>(),
Arg.Any<CancellationToken>());
}
[Theory, BitAutoData]
public async Task GetProviderAbilityAsync_OnCacheMiss_QueriesRepositoryAndCaches(
SutProvider<ExtendedProviderAbilityCacheService> sutProvider,
Guid providerId,
ProviderAbility repositoryAbility)
{
// Arrange
sutProvider.GetDependency<IProviderRepository>()
.GetAbilityAsync(providerId)
.Returns(repositoryAbility);
sutProvider.GetDependency<IFusionCache>()
.GetOrSetAsync(
key: Arg.Any<string>(),
factory: Arg.Any<Func<FusionCacheFactoryExecutionContext<ProviderAbility?>, CancellationToken, Task<ProviderAbility?>>>(),
options: Arg.Any<FusionCacheEntryOptions?>(),
tags: Arg.Any<IEnumerable<string>?>())
.Returns(callInfo =>
{
var factory = callInfo.ArgAt<Func<FusionCacheFactoryExecutionContext<ProviderAbility?>, CancellationToken, Task<ProviderAbility?>>>(1);
return new ValueTask<ProviderAbility?>(factory.Invoke(null!, CancellationToken.None));
});
// Act
var result = await sutProvider.Sut.GetProviderAbilityAsync(providerId);
// Assert
Assert.Equal(repositoryAbility, result);
await sutProvider.GetDependency<IProviderRepository>()
.Received(1)
.GetAbilityAsync(providerId);
await sutProvider.GetDependency<IFusionCache>()
.Received(1)
.GetOrSetAsync<ProviderAbility?>(
Arg.Is<string>(k => k == providerId.ToString()),
Arg.Any<Func<FusionCacheFactoryExecutionContext<ProviderAbility?>, CancellationToken, Task<ProviderAbility?>>>(),
Arg.Any<FusionCacheEntryOptions?>(),
Arg.Any<IEnumerable<string>?>(),
Arg.Any<CancellationToken>());
}
[Theory, BitAutoData]
public async Task GetProviderAbilityAsync_WhenProviderDoesNotExist_ReturnsNullAndCachesIt(
SutProvider<ExtendedProviderAbilityCacheService> sutProvider,
Guid providerId)
{
// Arrange
sutProvider.GetDependency<IProviderRepository>()
.GetAbilityAsync(providerId)
.Returns((ProviderAbility?)null);
sutProvider.GetDependency<IFusionCache>()
.GetOrSetAsync(
key: Arg.Any<string>(),
factory: Arg.Any<Func<FusionCacheFactoryExecutionContext<ProviderAbility?>, CancellationToken, Task<ProviderAbility?>>>(),
options: Arg.Any<FusionCacheEntryOptions?>(),
tags: Arg.Any<IEnumerable<string>?>())
.Returns(callInfo =>
{
var factory = callInfo.ArgAt<Func<FusionCacheFactoryExecutionContext<ProviderAbility?>, CancellationToken, Task<ProviderAbility?>>>(1);
return new ValueTask<ProviderAbility?>(factory.Invoke(null!, CancellationToken.None));
});
// Act
var result = await sutProvider.Sut.GetProviderAbilityAsync(providerId);
// Assert - null is cached to prevent database thrashing for non-existent providers
Assert.Null(result);
await sutProvider.GetDependency<IProviderRepository>()
.Received(1)
.GetAbilityAsync(providerId);
}
[Theory, BitAutoData]
public async Task UpsertProviderAbilityAsync_SetsUpdatedAbilityInCache(
SutProvider<ExtendedProviderAbilityCacheService> sutProvider,
ProviderEntity provider)
{
// Act
await sutProvider.Sut.UpsertProviderAbilityAsync(provider);
// Assert - SetAsync updates the cache entry directly with the new data
await sutProvider.GetDependency<IFusionCache>()
.Received(1)
.SetAsync(
Arg.Is<string>(k => k == provider.Id.ToString()),
Arg.Is<ProviderAbility>(a => a.Id == provider.Id),
Arg.Any<FusionCacheEntryOptions?>(),
Arg.Any<IEnumerable<string>?>(),
Arg.Any<CancellationToken>());
}
[Theory, BitAutoData]
public async Task DeleteProviderAbilityAsync_RemovesCacheEntry(
SutProvider<ExtendedProviderAbilityCacheService> sutProvider,
Guid providerId)
{
// Act
await sutProvider.Sut.DeleteProviderAbilityAsync(providerId);
// Assert
await sutProvider.GetDependency<IFusionCache>()
.Received(1)
.RemoveAsync(
Arg.Is<string>(k => k == providerId.ToString()),
Arg.Any<FusionCacheEntryOptions?>(),
Arg.Any<CancellationToken>());
}
}

View File

@@ -297,7 +297,10 @@ public class FeatureRoutedCacheServiceTests
{
// Arrange
var currentCacheService = CreateCurrentCacheMockService();
var sut = new FeatureRoutedCacheService(currentCacheService);
var sut = new FeatureRoutedCacheService(
currentCacheService,
Substitute.For<IProviderAbilityCacheService>(),
Substitute.For<IFeatureService>());
// Act
await sut.BaseUpsertOrganizationAbilityAsync(organization);
@@ -327,7 +330,10 @@ public class FeatureRoutedCacheServiceTests
{
// Arrange
var currentCacheService = CreateCurrentCacheMockService();
var sut = new FeatureRoutedCacheService(currentCacheService);
var sut = new FeatureRoutedCacheService(
currentCacheService,
Substitute.For<IProviderAbilityCacheService>(),
Substitute.For<IFeatureService>());
// Act
await sut.BaseDeleteOrganizationAbilityAsync(organizationId);
@@ -351,6 +357,148 @@ public class FeatureRoutedCacheServiceTests
Assert.Equal(ExpectedErrorMessage, ex.Message);
}
[Theory, BitAutoData]
public async Task GetProviderAbilityAsync_WhenFeatureFlagEnabled_UsesExtendedCacheService(
SutProvider<FeatureRoutedCacheService> sutProvider,
Guid providerId,
ProviderAbility expectedAbility)
{
// Arrange
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.ProviderAbilityExtendedCache)
.Returns(true);
sutProvider.GetDependency<IProviderAbilityCacheService>()
.GetProviderAbilityAsync(providerId)
.Returns(expectedAbility);
// Act
var result = await sutProvider.Sut.GetProviderAbilityAsync(providerId);
// Assert
Assert.Equal(expectedAbility, result);
await sutProvider.GetDependency<IProviderAbilityCacheService>()
.Received(1)
.GetProviderAbilityAsync(providerId);
await sutProvider.GetDependency<IVCurrentInMemoryApplicationCacheService>()
.DidNotReceive()
.GetProviderAbilitiesAsync();
}
[Theory, BitAutoData]
public async Task GetProviderAbilityAsync_WhenFeatureFlagDisabled_UsesInMemoryService(
SutProvider<FeatureRoutedCacheService> sutProvider,
ProviderAbility providerAbility)
{
// Arrange
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.ProviderAbilityExtendedCache)
.Returns(false);
var allAbilities = new Dictionary<Guid, ProviderAbility> { [providerAbility.Id] = providerAbility };
sutProvider.GetDependency<IVCurrentInMemoryApplicationCacheService>()
.GetProviderAbilitiesAsync()
.Returns(allAbilities);
// Act
var result = await sutProvider.Sut.GetProviderAbilityAsync(providerAbility.Id);
// Assert
Assert.Equal(providerAbility, result);
await sutProvider.GetDependency<IProviderAbilityCacheService>()
.DidNotReceive()
.GetProviderAbilityAsync(Arg.Any<Guid>());
await sutProvider.GetDependency<IVCurrentInMemoryApplicationCacheService>()
.Received(1)
.GetProviderAbilitiesAsync();
}
[Theory, BitAutoData]
public async Task UpsertProviderAbilityAsync_WhenFeatureFlagEnabled_UsesExtendedCacheService(
SutProvider<FeatureRoutedCacheService> sutProvider,
Provider provider)
{
// Arrange
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.ProviderAbilityExtendedCache)
.Returns(true);
// Act
await sutProvider.Sut.UpsertProviderAbilityAsync(provider);
// Assert
await sutProvider.GetDependency<IProviderAbilityCacheService>()
.Received(1)
.UpsertProviderAbilityAsync(provider);
await sutProvider.GetDependency<IVCurrentInMemoryApplicationCacheService>()
.DidNotReceive()
.UpsertProviderAbilityAsync(Arg.Any<Provider>());
}
[Theory, BitAutoData]
public async Task UpsertProviderAbilityAsync_WhenFeatureFlagDisabled_UsesInMemoryService(
SutProvider<FeatureRoutedCacheService> sutProvider,
Provider provider)
{
// Arrange
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.ProviderAbilityExtendedCache)
.Returns(false);
// Act
await sutProvider.Sut.UpsertProviderAbilityAsync(provider);
// Assert
await sutProvider.GetDependency<IVCurrentInMemoryApplicationCacheService>()
.Received(1)
.UpsertProviderAbilityAsync(provider);
await sutProvider.GetDependency<IProviderAbilityCacheService>()
.DidNotReceive()
.UpsertProviderAbilityAsync(Arg.Any<Provider>());
}
[Theory, BitAutoData]
public async Task DeleteProviderAbilityAsync_WhenFeatureFlagEnabled_UsesExtendedCacheService(
SutProvider<FeatureRoutedCacheService> sutProvider,
Guid providerId)
{
// Arrange
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.ProviderAbilityExtendedCache)
.Returns(true);
// Act
await sutProvider.Sut.DeleteProviderAbilityAsync(providerId);
// Assert
await sutProvider.GetDependency<IProviderAbilityCacheService>()
.Received(1)
.DeleteProviderAbilityAsync(providerId);
await sutProvider.GetDependency<IVCurrentInMemoryApplicationCacheService>()
.DidNotReceive()
.DeleteProviderAbilityAsync(Arg.Any<Guid>());
}
[Theory, BitAutoData]
public async Task DeleteProviderAbilityAsync_WhenFeatureFlagDisabled_UsesInMemoryService(
SutProvider<FeatureRoutedCacheService> sutProvider,
Guid providerId)
{
// Arrange
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.ProviderAbilityExtendedCache)
.Returns(false);
// Act
await sutProvider.Sut.DeleteProviderAbilityAsync(providerId);
// Assert
await sutProvider.GetDependency<IVCurrentInMemoryApplicationCacheService>()
.Received(1)
.DeleteProviderAbilityAsync(providerId);
await sutProvider.GetDependency<IProviderAbilityCacheService>()
.DidNotReceive()
.DeleteProviderAbilityAsync(Arg.Any<Guid>());
}
/// <summary>
/// Our SUT uses a method that is not part of IVCurrentInMemoryApplicationCacheService,
/// so AutoFixture's auto-created mock won't work.