mirror of
https://github.com/bitwarden/server.git
synced 2024-11-24 12:35:25 +01:00
[PM-5518] Sql-backed IDistributedCache (#3791)
* Sql-backed IDistributedCache * sqlserver cache table * remove unused using * setup EF entity * cache indexes * add back cipher * revert SetupEntityFramework change * ef cache * EntityFrameworkCache * IServiceScopeFactory for db context * implement EntityFrameworkCache * move to _serviceScopeFactory * move to config file * ef migrations * fixes * datetime and error codes * revert migrations * migrations * format * static and namespace fix * use time provider * Move SQL migration and remove EF one for the moment * Add clean migration of just the new table * Formatting * Test Custom `IDistributedCache` Implementation * Add Back Logging * Remove Double Logging * Skip Test When Not EntityFrameworkCache * Format --------- Co-authored-by: Matt Bishop <mbishop@bitwarden.com> Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com>
This commit is contained in:
parent
b8f71271eb
commit
0d3a7b3dd5
@ -39,6 +39,7 @@
|
||||
<PackageReference Include="Microsoft.Azure.NotificationHubs" Version="4.2.0" />
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="5.2.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Cosmos" Version="1.6.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.SqlServer" Version="8.0.6" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Identity.Stores" Version="6.0.31" />
|
||||
|
@ -0,0 +1,25 @@
|
||||
using Bit.Infrastructure.EntityFramework.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Bit.Infrastructure.EntityFramework.Configurations;
|
||||
|
||||
public class CacheEntityTypeConfiguration : IEntityTypeConfiguration<Cache>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Cache> builder)
|
||||
{
|
||||
builder
|
||||
.HasKey(s => s.Id)
|
||||
.IsClustered();
|
||||
|
||||
builder
|
||||
.Property(s => s.Id)
|
||||
.ValueGeneratedNever();
|
||||
|
||||
builder
|
||||
.HasIndex(s => s.ExpiresAtTime)
|
||||
.IsClustered(false);
|
||||
|
||||
builder.ToTable(nameof(Cache));
|
||||
}
|
||||
}
|
315
src/Infrastructure.EntityFramework/EntityFrameworkCache.cs
Normal file
315
src/Infrastructure.EntityFramework/EntityFrameworkCache.cs
Normal file
@ -0,0 +1,315 @@
|
||||
using Bit.Infrastructure.EntityFramework.Models;
|
||||
using Bit.Infrastructure.EntityFramework.Repositories;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Distributed;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Bit.Infrastructure.EntityFramework;
|
||||
|
||||
public class EntityFrameworkCache : IDistributedCache
|
||||
{
|
||||
#if DEBUG
|
||||
// Used for debugging in tests
|
||||
public Task scanTask;
|
||||
#endif
|
||||
private static readonly TimeSpan _defaultSlidingExpiration = TimeSpan.FromMinutes(20);
|
||||
private static readonly TimeSpan _expiredItemsDeletionInterval = TimeSpan.FromMinutes(30);
|
||||
private DateTimeOffset _lastExpirationScan;
|
||||
private readonly Action _deleteExpiredCachedItemsDelegate;
|
||||
private readonly object _mutex = new();
|
||||
private readonly IServiceScopeFactory _serviceScopeFactory;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public EntityFrameworkCache(
|
||||
IServiceScopeFactory serviceScopeFactory,
|
||||
TimeProvider timeProvider = null)
|
||||
{
|
||||
_deleteExpiredCachedItemsDelegate = DeleteExpiredCacheItems;
|
||||
_serviceScopeFactory = serviceScopeFactory;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public byte[] Get(string key)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
|
||||
using var scope = _serviceScopeFactory.CreateScope();
|
||||
var dbContext = GetDatabaseContext(scope);
|
||||
var cache = dbContext.Cache
|
||||
.Where(c => c.Id == key && _timeProvider.GetUtcNow().DateTime <= c.ExpiresAtTime)
|
||||
.SingleOrDefault();
|
||||
|
||||
if (cache == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (UpdateCacheExpiration(cache))
|
||||
{
|
||||
dbContext.SaveChanges();
|
||||
}
|
||||
|
||||
ScanForExpiredItemsIfRequired();
|
||||
return cache?.Value;
|
||||
}
|
||||
|
||||
public async Task<byte[]> GetAsync(string key, CancellationToken token = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
using var scope = _serviceScopeFactory.CreateScope();
|
||||
var dbContext = GetDatabaseContext(scope);
|
||||
var cache = await dbContext.Cache
|
||||
.Where(c => c.Id == key && _timeProvider.GetUtcNow().DateTime <= c.ExpiresAtTime)
|
||||
.SingleOrDefaultAsync(cancellationToken: token);
|
||||
|
||||
if (cache == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (UpdateCacheExpiration(cache))
|
||||
{
|
||||
await dbContext.SaveChangesAsync(token);
|
||||
}
|
||||
|
||||
ScanForExpiredItemsIfRequired();
|
||||
return cache?.Value;
|
||||
}
|
||||
|
||||
public void Refresh(string key) => Get(key);
|
||||
|
||||
public Task RefreshAsync(string key, CancellationToken token = default) => GetAsync(key, token);
|
||||
|
||||
public void Remove(string key)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
|
||||
using var scope = _serviceScopeFactory.CreateScope();
|
||||
GetDatabaseContext(scope).Cache
|
||||
.Where(c => c.Id == key)
|
||||
.ExecuteDelete();
|
||||
|
||||
ScanForExpiredItemsIfRequired();
|
||||
}
|
||||
|
||||
public async Task RemoveAsync(string key, CancellationToken token = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
|
||||
token.ThrowIfCancellationRequested();
|
||||
using var scope = _serviceScopeFactory.CreateScope();
|
||||
await GetDatabaseContext(scope).Cache
|
||||
.Where(c => c.Id == key)
|
||||
.ExecuteDeleteAsync(cancellationToken: token);
|
||||
|
||||
ScanForExpiredItemsIfRequired();
|
||||
}
|
||||
|
||||
public void Set(string key, byte[] value, DistributedCacheEntryOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
ArgumentNullException.ThrowIfNull(value);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
using var scope = _serviceScopeFactory.CreateScope();
|
||||
var dbContext = GetDatabaseContext(scope);
|
||||
var cache = dbContext.Cache.Find(key);
|
||||
var insert = cache == null;
|
||||
cache = SetCache(cache, key, value, options);
|
||||
if (insert)
|
||||
{
|
||||
dbContext.Add(cache);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
dbContext.SaveChanges();
|
||||
}
|
||||
catch (DbUpdateException e)
|
||||
{
|
||||
if (IsDuplicateKeyException(e))
|
||||
{
|
||||
// There is a possibility that multiple requests can try to add the same item to the cache, in
|
||||
// which case we receive a 'duplicate key' exception on the primary key column.
|
||||
}
|
||||
else
|
||||
{
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
ScanForExpiredItemsIfRequired();
|
||||
}
|
||||
|
||||
public async Task SetAsync(string key, byte[] value, DistributedCacheEntryOptions options, CancellationToken token = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
ArgumentNullException.ThrowIfNull(value);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
using var scope = _serviceScopeFactory.CreateScope();
|
||||
var dbContext = GetDatabaseContext(scope);
|
||||
var cache = await dbContext.Cache.FindAsync(new object[] { key }, cancellationToken: token);
|
||||
var insert = cache == null;
|
||||
cache = SetCache(cache, key, value, options);
|
||||
if (insert)
|
||||
{
|
||||
await dbContext.AddAsync(cache, token);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await dbContext.SaveChangesAsync(token);
|
||||
}
|
||||
catch (DbUpdateException e)
|
||||
{
|
||||
if (IsDuplicateKeyException(e))
|
||||
{
|
||||
// There is a possibility that multiple requests can try to add the same item to the cache, in
|
||||
// which case we receive a 'duplicate key' exception on the primary key column.
|
||||
}
|
||||
else
|
||||
{
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
ScanForExpiredItemsIfRequired();
|
||||
}
|
||||
|
||||
private Cache SetCache(Cache cache, string key, byte[] value, DistributedCacheEntryOptions options)
|
||||
{
|
||||
var utcNow = _timeProvider.GetUtcNow().DateTime;
|
||||
|
||||
// resolve options
|
||||
if (!options.AbsoluteExpiration.HasValue &&
|
||||
!options.AbsoluteExpirationRelativeToNow.HasValue &&
|
||||
!options.SlidingExpiration.HasValue)
|
||||
{
|
||||
options = new DistributedCacheEntryOptions
|
||||
{
|
||||
SlidingExpiration = _defaultSlidingExpiration
|
||||
};
|
||||
}
|
||||
|
||||
if (cache == null)
|
||||
{
|
||||
// do an insert
|
||||
cache = new Cache { Id = key };
|
||||
}
|
||||
|
||||
var slidingExpiration = (long?)options.SlidingExpiration?.TotalSeconds;
|
||||
|
||||
// calculate absolute expiration
|
||||
DateTime? absoluteExpiration = null;
|
||||
if (options.AbsoluteExpirationRelativeToNow.HasValue)
|
||||
{
|
||||
absoluteExpiration = utcNow.Add(options.AbsoluteExpirationRelativeToNow.Value);
|
||||
}
|
||||
else if (options.AbsoluteExpiration.HasValue)
|
||||
{
|
||||
if (options.AbsoluteExpiration.Value <= utcNow)
|
||||
{
|
||||
throw new InvalidOperationException("The absolute expiration value must be in the future.");
|
||||
}
|
||||
|
||||
absoluteExpiration = options.AbsoluteExpiration.Value.DateTime;
|
||||
}
|
||||
|
||||
// set values on cache
|
||||
cache.Value = value;
|
||||
cache.SlidingExpirationInSeconds = slidingExpiration;
|
||||
cache.AbsoluteExpiration = absoluteExpiration;
|
||||
if (slidingExpiration.HasValue)
|
||||
{
|
||||
cache.ExpiresAtTime = utcNow.AddSeconds(slidingExpiration.Value);
|
||||
}
|
||||
else if (absoluteExpiration.HasValue)
|
||||
{
|
||||
cache.ExpiresAtTime = absoluteExpiration.Value;
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new InvalidOperationException("Either absolute or sliding expiration needs to be provided.");
|
||||
}
|
||||
|
||||
return cache;
|
||||
}
|
||||
|
||||
private bool UpdateCacheExpiration(Cache cache)
|
||||
{
|
||||
var utcNow = _timeProvider.GetUtcNow().DateTime;
|
||||
if (cache.SlidingExpirationInSeconds.HasValue && (cache.AbsoluteExpiration.HasValue || cache.AbsoluteExpiration != cache.ExpiresAtTime))
|
||||
{
|
||||
if (cache.AbsoluteExpiration.HasValue && (cache.AbsoluteExpiration.Value - utcNow).TotalSeconds <= cache.SlidingExpirationInSeconds)
|
||||
{
|
||||
cache.ExpiresAtTime = cache.AbsoluteExpiration.Value;
|
||||
}
|
||||
else
|
||||
{
|
||||
cache.ExpiresAtTime = utcNow.AddSeconds(cache.SlidingExpirationInSeconds.Value);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void ScanForExpiredItemsIfRequired()
|
||||
{
|
||||
lock (_mutex)
|
||||
{
|
||||
var utcNow = _timeProvider.GetUtcNow().DateTime;
|
||||
if ((utcNow - _lastExpirationScan) > _expiredItemsDeletionInterval)
|
||||
{
|
||||
_lastExpirationScan = utcNow;
|
||||
#if DEBUG
|
||||
scanTask =
|
||||
#endif
|
||||
Task.Run(_deleteExpiredCachedItemsDelegate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void DeleteExpiredCacheItems()
|
||||
{
|
||||
using var scope = _serviceScopeFactory.CreateScope();
|
||||
GetDatabaseContext(scope).Cache
|
||||
.Where(c => _timeProvider.GetUtcNow().DateTime > c.ExpiresAtTime)
|
||||
.ExecuteDelete();
|
||||
}
|
||||
|
||||
private DatabaseContext GetDatabaseContext(IServiceScope serviceScope)
|
||||
{
|
||||
return serviceScope.ServiceProvider.GetRequiredService<DatabaseContext>();
|
||||
}
|
||||
|
||||
private static bool IsDuplicateKeyException(DbUpdateException e)
|
||||
{
|
||||
// MySQL
|
||||
if (e.InnerException is MySqlConnector.MySqlException myEx)
|
||||
{
|
||||
return myEx.ErrorCode == MySqlConnector.MySqlErrorCode.DuplicateKeyEntry;
|
||||
}
|
||||
// SQL Server
|
||||
else if (e.InnerException is Microsoft.Data.SqlClient.SqlException msEx)
|
||||
{
|
||||
return msEx.Errors != null &&
|
||||
msEx.Errors.Cast<Microsoft.Data.SqlClient.SqlError>().Any(error => error.Number == 2627);
|
||||
}
|
||||
// Postgres
|
||||
else if (e.InnerException is Npgsql.PostgresException pgEx)
|
||||
{
|
||||
return pgEx.SqlState == "23505";
|
||||
}
|
||||
// Sqlite
|
||||
else if (e.InnerException is Microsoft.Data.Sqlite.SqliteException liteEx)
|
||||
{
|
||||
return liteEx.SqliteErrorCode == 19 && liteEx.SqliteExtendedErrorCode == 1555;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
13
src/Infrastructure.EntityFramework/Models/Cache.cs
Normal file
13
src/Infrastructure.EntityFramework/Models/Cache.cs
Normal file
@ -0,0 +1,13 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Bit.Infrastructure.EntityFramework.Models;
|
||||
|
||||
public class Cache
|
||||
{
|
||||
[StringLength(449)]
|
||||
public string Id { get; set; }
|
||||
public byte[] Value { get; set; }
|
||||
public DateTime ExpiresAtTime { get; set; }
|
||||
public long? SlidingExpirationInSeconds { get; set; }
|
||||
public DateTime? AbsoluteExpiration { get; set; }
|
||||
}
|
@ -32,6 +32,7 @@ public class DatabaseContext : DbContext
|
||||
public DbSet<GroupSecretAccessPolicy> GroupSecretAccessPolicy { get; set; }
|
||||
public DbSet<ServiceAccountSecretAccessPolicy> ServiceAccountSecretAccessPolicy { get; set; }
|
||||
public DbSet<ApiKey> ApiKeys { get; set; }
|
||||
public DbSet<Cache> Cache { get; set; }
|
||||
public DbSet<Cipher> Ciphers { get; set; }
|
||||
public DbSet<Collection> Collections { get; set; }
|
||||
public DbSet<CollectionCipher> CollectionCiphers { get; set; }
|
||||
|
@ -69,41 +69,7 @@ public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static SupportedDatabaseProviders AddDatabaseRepositories(this IServiceCollection services, GlobalSettings globalSettings)
|
||||
{
|
||||
var selectedDatabaseProvider = globalSettings.DatabaseProvider;
|
||||
var provider = SupportedDatabaseProviders.SqlServer;
|
||||
var connectionString = string.Empty;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(selectedDatabaseProvider))
|
||||
{
|
||||
switch (selectedDatabaseProvider.ToLowerInvariant())
|
||||
{
|
||||
case "postgres":
|
||||
case "postgresql":
|
||||
provider = SupportedDatabaseProviders.Postgres;
|
||||
connectionString = globalSettings.PostgreSql.ConnectionString;
|
||||
break;
|
||||
case "mysql":
|
||||
case "mariadb":
|
||||
provider = SupportedDatabaseProviders.MySql;
|
||||
connectionString = globalSettings.MySql.ConnectionString;
|
||||
break;
|
||||
case "sqlite":
|
||||
provider = SupportedDatabaseProviders.Sqlite;
|
||||
connectionString = globalSettings.Sqlite.ConnectionString;
|
||||
break;
|
||||
case "sqlserver":
|
||||
connectionString = globalSettings.SqlServer.ConnectionString;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Default to attempting to use SqlServer connection string if globalSettings.DatabaseProvider has no value.
|
||||
connectionString = globalSettings.SqlServer.ConnectionString;
|
||||
}
|
||||
|
||||
var (provider, connectionString) = GetDatabaseProvider(globalSettings);
|
||||
services.SetupEntityFramework(connectionString, provider);
|
||||
|
||||
if (provider != SupportedDatabaseProviders.SqlServer)
|
||||
@ -730,7 +696,20 @@ public static class ServiceCollectionExtensions
|
||||
}
|
||||
else
|
||||
{
|
||||
services.AddDistributedMemoryCache();
|
||||
var (databaseProvider, databaseConnectionString) = GetDatabaseProvider(globalSettings);
|
||||
if (databaseProvider == SupportedDatabaseProviders.SqlServer)
|
||||
{
|
||||
services.AddDistributedSqlServerCache(o =>
|
||||
{
|
||||
o.ConnectionString = databaseConnectionString;
|
||||
o.SchemaName = "dbo";
|
||||
o.TableName = "Cache";
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
services.AddSingleton<IDistributedCache, EntityFrameworkCache>();
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(globalSettings.DistributedCache?.Cosmos?.ConnectionString))
|
||||
@ -746,7 +725,7 @@ public static class ServiceCollectionExtensions
|
||||
}
|
||||
else
|
||||
{
|
||||
services.AddKeyedSingleton<IDistributedCache, MemoryDistributedCache>("persistent");
|
||||
services.AddKeyedSingleton("persistent", (s, _) => s.GetRequiredService<IDistributedCache>());
|
||||
}
|
||||
}
|
||||
|
||||
@ -762,4 +741,45 @@ public static class ServiceCollectionExtensions
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
private static (SupportedDatabaseProviders provider, string connectionString)
|
||||
GetDatabaseProvider(GlobalSettings globalSettings)
|
||||
{
|
||||
var selectedDatabaseProvider = globalSettings.DatabaseProvider;
|
||||
var provider = SupportedDatabaseProviders.SqlServer;
|
||||
var connectionString = string.Empty;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(selectedDatabaseProvider))
|
||||
{
|
||||
switch (selectedDatabaseProvider.ToLowerInvariant())
|
||||
{
|
||||
case "postgres":
|
||||
case "postgresql":
|
||||
provider = SupportedDatabaseProviders.Postgres;
|
||||
connectionString = globalSettings.PostgreSql.ConnectionString;
|
||||
break;
|
||||
case "mysql":
|
||||
case "mariadb":
|
||||
provider = SupportedDatabaseProviders.MySql;
|
||||
connectionString = globalSettings.MySql.ConnectionString;
|
||||
break;
|
||||
case "sqlite":
|
||||
provider = SupportedDatabaseProviders.Sqlite;
|
||||
connectionString = globalSettings.Sqlite.ConnectionString;
|
||||
break;
|
||||
case "sqlserver":
|
||||
connectionString = globalSettings.SqlServer.ConnectionString;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Default to attempting to use SqlServer connection string if globalSettings.DatabaseProvider has no value.
|
||||
connectionString = globalSettings.SqlServer.ConnectionString;
|
||||
}
|
||||
|
||||
return (provider, connectionString);
|
||||
}
|
||||
}
|
||||
|
14
src/Sql/dbo/Tables/Cache.sql
Normal file
14
src/Sql/dbo/Tables/Cache.sql
Normal file
@ -0,0 +1,14 @@
|
||||
CREATE TABLE [dbo].[Cache]
|
||||
(
|
||||
[Id] NVARCHAR (449) NOT NULL,
|
||||
[Value] VARBINARY (MAX) NOT NULL,
|
||||
[ExpiresAtTime] DATETIMEOFFSET (7) NOT NULL,
|
||||
[SlidingExpirationInSeconds] BIGINT NULL,
|
||||
[AbsoluteExpiration] DATETIMEOFFSET (7) NULL,
|
||||
CONSTRAINT [PK_Cache] PRIMARY KEY CLUSTERED ([Id] ASC)
|
||||
);
|
||||
GO
|
||||
|
||||
CREATE NONCLUSTERED INDEX [IX_Cache_ExpiresAtTime]
|
||||
ON [dbo].[Cache]([ExpiresAtTime] ASC);
|
||||
GO
|
@ -3,9 +3,11 @@ using Bit.Core.Enums;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Infrastructure.Dapper;
|
||||
using Bit.Infrastructure.EntityFramework;
|
||||
using Microsoft.Extensions.Caching.Distributed;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Xunit.Sdk;
|
||||
|
||||
namespace Bit.Infrastructure.IntegrationTest;
|
||||
@ -13,6 +15,7 @@ namespace Bit.Infrastructure.IntegrationTest;
|
||||
public class DatabaseDataAttribute : DataAttribute
|
||||
{
|
||||
public bool SelfHosted { get; set; }
|
||||
public bool UseFakeTimeProvider { get; set; }
|
||||
|
||||
public override IEnumerable<object[]> GetData(MethodInfo testMethod)
|
||||
{
|
||||
@ -52,7 +55,7 @@ public class DatabaseDataAttribute : DataAttribute
|
||||
if (database.Type == SupportedDatabaseProviders.SqlServer && !database.UseEf)
|
||||
{
|
||||
var dapperSqlServerCollection = new ServiceCollection();
|
||||
dapperSqlServerCollection.AddLogging(configureLogging);
|
||||
AddCommonServices(dapperSqlServerCollection, configureLogging);
|
||||
dapperSqlServerCollection.AddDapperRepositories(SelfHosted);
|
||||
var globalSettings = new GlobalSettings
|
||||
{
|
||||
@ -65,19 +68,35 @@ public class DatabaseDataAttribute : DataAttribute
|
||||
dapperSqlServerCollection.AddSingleton(globalSettings);
|
||||
dapperSqlServerCollection.AddSingleton<IGlobalSettings>(globalSettings);
|
||||
dapperSqlServerCollection.AddSingleton(database);
|
||||
dapperSqlServerCollection.AddDataProtection();
|
||||
dapperSqlServerCollection.AddDistributedSqlServerCache((o) =>
|
||||
{
|
||||
o.ConnectionString = database.ConnectionString;
|
||||
o.SchemaName = "dbo";
|
||||
o.TableName = "Cache";
|
||||
});
|
||||
yield return dapperSqlServerCollection.BuildServiceProvider();
|
||||
}
|
||||
else
|
||||
{
|
||||
var efCollection = new ServiceCollection();
|
||||
efCollection.AddLogging(configureLogging);
|
||||
AddCommonServices(efCollection, configureLogging);
|
||||
efCollection.SetupEntityFramework(database.ConnectionString, database.Type);
|
||||
efCollection.AddPasswordManagerEFRepositories(SelfHosted);
|
||||
efCollection.AddSingleton(database);
|
||||
efCollection.AddDataProtection();
|
||||
efCollection.AddSingleton<IDistributedCache, EntityFrameworkCache>();
|
||||
yield return efCollection.BuildServiceProvider();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void AddCommonServices(IServiceCollection services, Action<ILoggingBuilder> configureLogging)
|
||||
{
|
||||
services.AddLogging(configureLogging);
|
||||
services.AddDataProtection();
|
||||
|
||||
if (UseFakeTimeProvider)
|
||||
{
|
||||
services.AddSingleton<TimeProvider, FakeTimeProvider>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
71
test/Infrastructure.IntegrationTest/DistributedCacheTests.cs
Normal file
71
test/Infrastructure.IntegrationTest/DistributedCacheTests.cs
Normal file
@ -0,0 +1,71 @@
|
||||
using Bit.Infrastructure.EntityFramework;
|
||||
using Microsoft.Extensions.Caching.Distributed;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Infrastructure.IntegrationTest;
|
||||
|
||||
public class DistributedCacheTests
|
||||
{
|
||||
[DatabaseTheory, DatabaseData(UseFakeTimeProvider = true)]
|
||||
public async Task Simple_NotExpiredItem_StartsScan(IDistributedCache cache, TimeProvider timeProvider)
|
||||
{
|
||||
if (cache is not EntityFrameworkCache efCache)
|
||||
{
|
||||
// We don't write the SqlServer cache implementation so we don't need to test it
|
||||
// also it doesn't use TimeProvider under the hood so we'd have to delay the test
|
||||
// for 30 minutes to get it to work. So just skip it.
|
||||
return;
|
||||
}
|
||||
|
||||
var fakeTimeProvider = (FakeTimeProvider)timeProvider;
|
||||
|
||||
cache.Set("test-key", "some-value"u8.ToArray(), new DistributedCacheEntryOptions
|
||||
{
|
||||
SlidingExpiration = TimeSpan.FromMinutes(20),
|
||||
});
|
||||
|
||||
// Should have expired and not be returned
|
||||
var firstValue = cache.Get("test-key");
|
||||
|
||||
// Scan for expired items is supposed to run every 30 minutes
|
||||
fakeTimeProvider.Advance(TimeSpan.FromMinutes(31));
|
||||
|
||||
var secondValue = cache.Get("test-key");
|
||||
|
||||
// This should have forced the EF cache to start a scan task
|
||||
Assert.NotNull(efCache.scanTask);
|
||||
// We don't want the scan task to throw an exception, unwrap it.
|
||||
await efCache.scanTask;
|
||||
|
||||
Assert.NotNull(firstValue);
|
||||
Assert.Null(secondValue);
|
||||
}
|
||||
|
||||
[DatabaseTheory, DatabaseData(UseFakeTimeProvider = true)]
|
||||
public async Task ParallelReadsAndWrites_Work(IDistributedCache cache, TimeProvider timeProvider)
|
||||
{
|
||||
var fakeTimeProvider = (FakeTimeProvider)timeProvider;
|
||||
|
||||
await Parallel.ForEachAsync(Enumerable.Range(1, 100), async (index, _) =>
|
||||
{
|
||||
await cache.SetAsync($"test-{index}", "some-value"u8.ToArray(), new DistributedCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(index),
|
||||
});
|
||||
});
|
||||
|
||||
await Parallel.ForEachAsync(Enumerable.Range(1, 100), async (index, _) =>
|
||||
{
|
||||
var value = await cache.GetAsync($"test-{index}");
|
||||
Assert.NotNull(value);
|
||||
});
|
||||
}
|
||||
|
||||
[DatabaseTheory, DatabaseData]
|
||||
public async Task MultipleWritesOnSameKey_ShouldNotThrow(IDistributedCache cache)
|
||||
{
|
||||
await cache.SetAsync("test-duplicate", "some-value"u8.ToArray());
|
||||
await cache.SetAsync("test-duplicate", "some-value"u8.ToArray());
|
||||
}
|
||||
}
|
@ -11,6 +11,7 @@
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="8.6.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="$(MicrosoftNetTestSdkVersion)" />
|
||||
<PackageReference Include="xunit" Version="2.4.1" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.3">
|
||||
|
BIN
test/Infrastructure.IntegrationTest/test.db
Normal file
BIN
test/Infrastructure.IntegrationTest/test.db
Normal file
Binary file not shown.
15
util/Migrator/DbScripts/2024-07-01_00_DistributedCache.sql
Normal file
15
util/Migrator/DbScripts/2024-07-01_00_DistributedCache.sql
Normal file
@ -0,0 +1,15 @@
|
||||
IF OBJECT_ID('[dbo].[Cache]') IS NULL
|
||||
BEGIN
|
||||
CREATE TABLE [dbo].[Cache] (
|
||||
[Id] [nvarchar](449) NOT NULL,
|
||||
[Value] [varbinary](max) NOT NULL,
|
||||
[ExpiresAtTime] [datetimeoffset](7) NOT NULL,
|
||||
[SlidingExpirationInSeconds] [bigint] NULL,
|
||||
[AbsoluteExpiration] [datetimeoffset](7) NULL,
|
||||
CONSTRAINT [PK_Cache] PRIMARY KEY CLUSTERED ([Id] ASC)
|
||||
);
|
||||
|
||||
CREATE NONCLUSTERED INDEX [IX_Cache_ExpiresAtTime]
|
||||
ON [dbo].[Cache]([ExpiresAtTime] ASC);
|
||||
END
|
||||
GO
|
2671
util/MySqlMigrations/Migrations/20240702142224_DistributedCache.Designer.cs
generated
Normal file
2671
util/MySqlMigrations/Migrations/20240702142224_DistributedCache.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,42 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Bit.MySqlMigrations.Migrations;
|
||||
|
||||
/// <inheritdoc />
|
||||
public partial class DistributedCache : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Cache",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "varchar(449)", maxLength: 449, nullable: false)
|
||||
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||
Value = table.Column<byte[]>(type: "longblob", nullable: true),
|
||||
ExpiresAtTime = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||
SlidingExpirationInSeconds = table.Column<long>(type: "bigint", nullable: true),
|
||||
AbsoluteExpiration = table.Column<DateTime>(type: "datetime(6)", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Cache", x => x.Id);
|
||||
})
|
||||
.Annotation("MySql:CharSet", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Cache_ExpiresAtTime",
|
||||
table: "Cache",
|
||||
column: "ExpiresAtTime");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "Cache");
|
||||
}
|
||||
}
|
@ -755,6 +755,33 @@ namespace Bit.MySqlMigrations.Migrations
|
||||
b.ToTable("ProviderPlan", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Cache", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(449)
|
||||
.HasColumnType("varchar(449)");
|
||||
|
||||
b.Property<DateTime?>("AbsoluteExpiration")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<DateTime>("ExpiresAtTime")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<long?>("SlidingExpirationInSeconds")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<byte[]>("Value")
|
||||
.HasColumnType("longblob");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasAnnotation("SqlServer:Clustered", true);
|
||||
|
||||
b.HasIndex("ExpiresAtTime")
|
||||
.HasAnnotation("SqlServer:Clustered", false);
|
||||
|
||||
b.ToTable("Cache", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
|
2678
util/PostgresMigrations/Migrations/20240702142233_DistributedCache.Designer.cs
generated
Normal file
2678
util/PostgresMigrations/Migrations/20240702142233_DistributedCache.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,40 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Bit.PostgresMigrations.Migrations;
|
||||
|
||||
/// <inheritdoc />
|
||||
public partial class DistributedCache : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Cache",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "character varying(449)", maxLength: 449, nullable: false),
|
||||
Value = table.Column<byte[]>(type: "bytea", nullable: true),
|
||||
ExpiresAtTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
SlidingExpirationInSeconds = table.Column<long>(type: "bigint", nullable: true),
|
||||
AbsoluteExpiration = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Cache", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Cache_ExpiresAtTime",
|
||||
table: "Cache",
|
||||
column: "ExpiresAtTime");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "Cache");
|
||||
}
|
||||
}
|
@ -760,6 +760,33 @@ namespace Bit.PostgresMigrations.Migrations
|
||||
b.ToTable("ProviderPlan", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Cache", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(449)
|
||||
.HasColumnType("character varying(449)");
|
||||
|
||||
b.Property<DateTime?>("AbsoluteExpiration")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime>("ExpiresAtTime")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<long?>("SlidingExpirationInSeconds")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<byte[]>("Value")
|
||||
.HasColumnType("bytea");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasAnnotation("SqlServer:Clustered", true);
|
||||
|
||||
b.HasIndex("ExpiresAtTime")
|
||||
.HasAnnotation("SqlServer:Clustered", false);
|
||||
|
||||
b.ToTable("Cache", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
|
2660
util/SqliteMigrations/Migrations/20240702142228_DistributedCache.Designer.cs
generated
Normal file
2660
util/SqliteMigrations/Migrations/20240702142228_DistributedCache.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,40 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Bit.SqliteMigrations.Migrations;
|
||||
|
||||
/// <inheritdoc />
|
||||
public partial class DistributedCache : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Cache",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "TEXT", maxLength: 449, nullable: false),
|
||||
Value = table.Column<byte[]>(type: "BLOB", nullable: true),
|
||||
ExpiresAtTime = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
SlidingExpirationInSeconds = table.Column<long>(type: "INTEGER", nullable: true),
|
||||
AbsoluteExpiration = table.Column<DateTime>(type: "TEXT", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Cache", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Cache_ExpiresAtTime",
|
||||
table: "Cache",
|
||||
column: "ExpiresAtTime");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "Cache");
|
||||
}
|
||||
}
|
@ -744,6 +744,33 @@ namespace Bit.SqliteMigrations.Migrations
|
||||
b.ToTable("ProviderPlan", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Cache", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(449)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("AbsoluteExpiration")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("ExpiresAtTime")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("SlidingExpirationInSeconds")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<byte[]>("Value")
|
||||
.HasColumnType("BLOB");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasAnnotation("SqlServer:Clustered", true);
|
||||
|
||||
b.HasIndex("ExpiresAtTime")
|
||||
.HasAnnotation("SqlServer:Clustered", false);
|
||||
|
||||
b.ToTable("Cache", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
|
Loading…
Reference in New Issue
Block a user