1
0
mirror of https://github.com/bitwarden/server.git synced 2024-11-21 12:05:42 +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:
Kyle Spearrin 2024-07-03 12:48:23 -04:00 committed by GitHub
parent b8f71271eb
commit 0d3a7b3dd5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 8748 additions and 41 deletions

View File

@ -39,6 +39,7 @@
<PackageReference Include="Microsoft.Azure.NotificationHubs" Version="4.2.0" /> <PackageReference Include="Microsoft.Azure.NotificationHubs" Version="4.2.0" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="5.2.1" /> <PackageReference Include="Microsoft.Data.SqlClient" Version="5.2.1" />
<PackageReference Include="Microsoft.Extensions.Caching.Cosmos" Version="1.6.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.EnvironmentVariables" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Identity.Stores" Version="6.0.31" /> <PackageReference Include="Microsoft.Extensions.Identity.Stores" Version="6.0.31" />

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<GroupSecretAccessPolicy> GroupSecretAccessPolicy { get; set; }
public DbSet<ServiceAccountSecretAccessPolicy> ServiceAccountSecretAccessPolicy { get; set; } public DbSet<ServiceAccountSecretAccessPolicy> ServiceAccountSecretAccessPolicy { get; set; }
public DbSet<ApiKey> ApiKeys { get; set; } public DbSet<ApiKey> ApiKeys { get; set; }
public DbSet<Cache> Cache { get; set; }
public DbSet<Cipher> Ciphers { get; set; } public DbSet<Cipher> Ciphers { get; set; }
public DbSet<Collection> Collections { get; set; } public DbSet<Collection> Collections { get; set; }
public DbSet<CollectionCipher> CollectionCiphers { get; set; } public DbSet<CollectionCipher> CollectionCiphers { get; set; }

View File

@ -69,41 +69,7 @@ public static class ServiceCollectionExtensions
{ {
public static SupportedDatabaseProviders AddDatabaseRepositories(this IServiceCollection services, GlobalSettings globalSettings) public static SupportedDatabaseProviders AddDatabaseRepositories(this IServiceCollection services, GlobalSettings globalSettings)
{ {
var selectedDatabaseProvider = globalSettings.DatabaseProvider; var (provider, connectionString) = GetDatabaseProvider(globalSettings);
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;
}
services.SetupEntityFramework(connectionString, provider); services.SetupEntityFramework(connectionString, provider);
if (provider != SupportedDatabaseProviders.SqlServer) if (provider != SupportedDatabaseProviders.SqlServer)
@ -730,7 +696,20 @@ public static class ServiceCollectionExtensions
} }
else 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)) if (!string.IsNullOrEmpty(globalSettings.DistributedCache?.Cosmos?.ConnectionString))
@ -746,7 +725,7 @@ public static class ServiceCollectionExtensions
} }
else else
{ {
services.AddKeyedSingleton<IDistributedCache, MemoryDistributedCache>("persistent"); services.AddKeyedSingleton("persistent", (s, _) => s.GetRequiredService<IDistributedCache>());
} }
} }
@ -762,4 +741,45 @@ public static class ServiceCollectionExtensions
return services; 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);
}
} }

View 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

View File

@ -3,9 +3,11 @@ using Bit.Core.Enums;
using Bit.Core.Settings; using Bit.Core.Settings;
using Bit.Infrastructure.Dapper; using Bit.Infrastructure.Dapper;
using Bit.Infrastructure.EntityFramework; using Bit.Infrastructure.EntityFramework;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Time.Testing;
using Xunit.Sdk; using Xunit.Sdk;
namespace Bit.Infrastructure.IntegrationTest; namespace Bit.Infrastructure.IntegrationTest;
@ -13,6 +15,7 @@ namespace Bit.Infrastructure.IntegrationTest;
public class DatabaseDataAttribute : DataAttribute public class DatabaseDataAttribute : DataAttribute
{ {
public bool SelfHosted { get; set; } public bool SelfHosted { get; set; }
public bool UseFakeTimeProvider { get; set; }
public override IEnumerable<object[]> GetData(MethodInfo testMethod) public override IEnumerable<object[]> GetData(MethodInfo testMethod)
{ {
@ -52,7 +55,7 @@ public class DatabaseDataAttribute : DataAttribute
if (database.Type == SupportedDatabaseProviders.SqlServer && !database.UseEf) if (database.Type == SupportedDatabaseProviders.SqlServer && !database.UseEf)
{ {
var dapperSqlServerCollection = new ServiceCollection(); var dapperSqlServerCollection = new ServiceCollection();
dapperSqlServerCollection.AddLogging(configureLogging); AddCommonServices(dapperSqlServerCollection, configureLogging);
dapperSqlServerCollection.AddDapperRepositories(SelfHosted); dapperSqlServerCollection.AddDapperRepositories(SelfHosted);
var globalSettings = new GlobalSettings var globalSettings = new GlobalSettings
{ {
@ -65,19 +68,35 @@ public class DatabaseDataAttribute : DataAttribute
dapperSqlServerCollection.AddSingleton(globalSettings); dapperSqlServerCollection.AddSingleton(globalSettings);
dapperSqlServerCollection.AddSingleton<IGlobalSettings>(globalSettings); dapperSqlServerCollection.AddSingleton<IGlobalSettings>(globalSettings);
dapperSqlServerCollection.AddSingleton(database); dapperSqlServerCollection.AddSingleton(database);
dapperSqlServerCollection.AddDataProtection(); dapperSqlServerCollection.AddDistributedSqlServerCache((o) =>
{
o.ConnectionString = database.ConnectionString;
o.SchemaName = "dbo";
o.TableName = "Cache";
});
yield return dapperSqlServerCollection.BuildServiceProvider(); yield return dapperSqlServerCollection.BuildServiceProvider();
} }
else else
{ {
var efCollection = new ServiceCollection(); var efCollection = new ServiceCollection();
efCollection.AddLogging(configureLogging); AddCommonServices(efCollection, configureLogging);
efCollection.SetupEntityFramework(database.ConnectionString, database.Type); efCollection.SetupEntityFramework(database.ConnectionString, database.Type);
efCollection.AddPasswordManagerEFRepositories(SelfHosted); efCollection.AddPasswordManagerEFRepositories(SelfHosted);
efCollection.AddSingleton(database); efCollection.AddSingleton(database);
efCollection.AddDataProtection(); efCollection.AddSingleton<IDistributedCache, EntityFrameworkCache>();
yield return efCollection.BuildServiceProvider(); yield return efCollection.BuildServiceProvider();
} }
} }
} }
private void AddCommonServices(IServiceCollection services, Action<ILoggingBuilder> configureLogging)
{
services.AddLogging(configureLogging);
services.AddDataProtection();
if (UseFakeTimeProvider)
{
services.AddSingleton<TimeProvider, FakeTimeProvider>();
}
}
} }

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

View File

@ -11,6 +11,7 @@
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" 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.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="Microsoft.NET.Test.Sdk" Version="$(MicrosoftNetTestSdkVersion)" />
<PackageReference Include="xunit" Version="2.4.1" /> <PackageReference Include="xunit" Version="2.4.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.3"> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3">

Binary file not shown.

View 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

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -755,6 +755,33 @@ namespace Bit.MySqlMigrations.Migrations
b.ToTable("ProviderPlan", (string)null); 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 => modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -760,6 +760,33 @@ namespace Bit.PostgresMigrations.Migrations
b.ToTable("ProviderPlan", (string)null); 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 => modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -744,6 +744,33 @@ namespace Bit.SqliteMigrations.Migrations
b.ToTable("ProviderPlan", (string)null); 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 => modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")