mirror of
https://github.com/bitwarden/server.git
synced 2025-07-02 00:22:50 -05: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:
@ -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; }
|
||||
|
Reference in New Issue
Block a user