1
0
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:
Kyle Spearrin
2024-07-03 12:48:23 -04:00
committed by GitHub
parent b8f71271eb
commit 0d3a7b3dd5
21 changed files with 8748 additions and 41 deletions

View File

@ -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));
}
}

View 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;
}
}

View 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; }
}

View File

@ -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; }