diff --git a/.github/workflows/bit.full.ci.yml b/.github/workflows/bit.full.ci.yml index aba7fb9a01..03d4f38fc7 100644 --- a/.github/workflows/bit.full.ci.yml +++ b/.github/workflows/bit.full.ci.yml @@ -70,7 +70,7 @@ jobs: run: cd src && dotnet workload install wasm-tools - name: Create project from template with PostgreSQL - run: dotnet new bit-bp --name TestPostgreSQL --database PostgreSQL --module Sales --signalR --aspire + run: dotnet new bit-bp --name TestPostgreSQL --database PostgreSQL --module Sales --signalR --aspire --redis - name: Create appsettings.json for Client.Web run: | @@ -229,13 +229,13 @@ jobs: - name: Build sample configuration 1 run: | - dotnet new bit-bp --name TestProject --database SqlServer --filesStorage AzureBlobStorage --api Integrated --captcha reCaptcha --pipeline Azure --module Admin --offlineDb --appInsights --sentry --signalR --notification --cloudflare --ads --aspire + dotnet new bit-bp --name TestProject --database SqlServer --filesStorage AzureBlobStorage --api Integrated --captcha reCaptcha --pipeline Azure --module Admin --offlineDb --appInsights --sentry --signalR --notification --cloudflare --ads --aspire --redis dotnet build TestProject/TestProject.sln -p:InvariantGlobalization=false -p:Environment=Staging rm -r "TestProject" - name: Build sample configuration 2 run: | - dotnet new bit-bp --name TestProject2 --database Other --filesStorage S3 --api Standalone --captcha None --pipeline None --module None --offlineDb false --appInsights false --sentry false --signalR false --notification false --cloudflare false --ads false --aspire true + dotnet new bit-bp --name TestProject2 --database Other --filesStorage S3 --api Standalone --captcha None --pipeline None --module None --offlineDb false --appInsights false --sentry false --signalR false --notification false --cloudflare false --ads false --aspire true --redis false dotnet build TestProject2/TestProject2.slnx -p:InvariantGlobalization=true -p:Environment=Development rm -r "TestProject2" diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/.docs/14- Response Caching System.md b/src/Templates/Boilerplate/Bit.Boilerplate/.docs/14- Response Caching System.md index 54a08b7e07..855e10f42c 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/.docs/14- Response Caching System.md +++ b/src/Templates/Boilerplate/Bit.Boilerplate/.docs/14- Response Caching System.md @@ -494,6 +494,28 @@ The project uses the **FusionCache** library for server-side caching: --- +## Redis Infrastructure + +The project uses **two separate Redis instances** for different purposes: + +### 1. redis-cache Ephemeral Cache +- **No persistence** (data stored only in memory) +- **Use Cases**: + - **FusionCache** L2 distributed cache and backplane for multi-server cache synchronization + - **SignalR backplane** for real-time messaging across servers +- **Why**: Cache data is regenerable, no need for disk I/O overhead + +### 2. redis-persistent - Persistent Storage +- **AOF enabled** with synchronous disk writes for maximum durability +- **Use Cases**: + - **Hangfire** background job queues and state + - **Distributed locking** for coordinating operations +- **Why**: Critical data that cannot be easily regenerated must survive restarts + +**Benefits**: Separation allows ephemeral cache to run faster while ensuring critical infrastructure data is never lost. + +--- + ### Monitor Cache Headers The system adds custom headers to help debug caching: diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/.github/copilot-instructions.md b/src/Templates/Boilerplate/Bit.Boilerplate/.github/copilot-instructions.md index be29f22378..25ff109adf 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/.github/copilot-instructions.md +++ b/src/Templates/Boilerplate/Bit.Boilerplate/.github/copilot-instructions.md @@ -22,6 +22,9 @@ You will be working with the following key technologies: * **SignalR**: Real-time communication + +* **Redis**: Distributed caching storage and backplane, hangfire job storage, signalr backplane and distributed lock. + * **Hangfire**: Background job processing * **OData**: Advanced querying capabilities * **Bit.BlazorUI**: The primary UI component library diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/.template.config/ide.host.json b/src/Templates/Boilerplate/Bit.Boilerplate/.template.config/ide.host.json index 8000cce67a..9e832fd431 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/.template.config/ide.host.json +++ b/src/Templates/Boilerplate/Bit.Boilerplate/.template.config/ide.host.json @@ -14,6 +14,9 @@ { "id": "aspire" }, + { + "id": "redis" + }, { "id": "database" }, diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/.template.config/template.json b/src/Templates/Boilerplate/Bit.Boilerplate/.template.config/template.json index 87882c4880..15ba9c3ec0 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/.template.config/template.json +++ b/src/Templates/Boilerplate/Bit.Boilerplate/.template.config/template.json @@ -173,6 +173,13 @@ "defaultValue": "true", "description": "Adds aspire" }, + "redis": { + "displayName": "Add redis?", + "type": "parameter", + "datatype": "bool", + "defaultValue": "false", + "description": "Adds redis" + }, "notification": { "displayName": "Add push notification?", "type": "parameter", diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Directory.Packages.props b/src/Templates/Boilerplate/Bit.Boilerplate/src/Directory.Packages.props index 54640b774a..6f2510d1dd 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Directory.Packages.props +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Directory.Packages.props @@ -47,7 +47,7 @@ - + @@ -107,6 +107,13 @@ + + + + + + + diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Boilerplate.Server.Api.csproj b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Boilerplate.Server.Api.csproj index 5a0b38d0c4..f0178c4d41 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Boilerplate.Server.Api.csproj +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Boilerplate.Server.Api.csproj @@ -70,6 +70,9 @@ + + + diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Program.Services.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Program.Services.cs index 4200b0e758..1b6a51ad71 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Program.Services.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Program.Services.cs @@ -29,6 +29,10 @@ using FluentEmail.Core; using FluentStorage.Blobs; using Hangfire.EntityFrameworkCore; +//#if (redis == true) +using StackExchange.Redis; +using Hangfire.Redis.StackExchange; +//#endif //#if (notification == true) using AdsPush; using AdsPush.Abstraction; @@ -161,10 +165,18 @@ public static void AddServerApiProjectServices(this WebApplicationBuilder builde services.AddScoped(); //#endif + // Register distributed lock factory + //#if (redis == true) + services.AddTransient(sp => new Func((string lockKey) => + { + return new Medallion.Threading.Redis.RedisDistributedLock(lockKey, sp.GetRequiredService().GetDatabase()); + })); + //#else services.AddTransient(sp => new Func((string lockKey) => { return new Medallion.Threading.FileSystem.FileDistributedLock(new(Path.Combine(Path.GetTempPath(), $"Boilerplate-{lockKey}.lock"))); })); + //#endif services.AddSingleton(); services.AddSingleton(sp => (IProblemDetailsWriter)sp.GetRequiredService()); @@ -240,6 +252,15 @@ public static void AddServerApiProjectServices(this WebApplicationBuilder builde options.PayloadSerializerOptions.TypeInfoResolverChain.Add(chain); } }); + + // Use Redis as SignalR backplane for scaling out across multiple server instances + //#if (redis == true) + signalRBuilder.AddStackExchangeRedis(configuration.GetRequiredConnectionString("redis-cache"), options => + { + options.Configuration.ChannelPrefix = RedisChannel.Literal("signalr:"); + }); + //#endif + if (string.IsNullOrEmpty(configuration["Azure:SignalR:ConnectionString"]) is false) { signalRBuilder.AddAzureSignalR(options => @@ -503,9 +524,17 @@ void AddDbContext(DbContextOptionsBuilder options) } //#endif - builder.Services.AddHangfire(configuration => + // Configure Hangfire to use Redis for persistent background job storage + builder.Services.AddHangfire((sp, hangfireConfiguration) => { - var efCoreStorage = configuration.UseEFCoreStorage(optionsBuilder => + //#if (redis == true) + hangfireConfiguration.UseRedisStorage(sp.GetRequiredService(), new RedisStorageOptions + { + Prefix = "hangfire:", + Db = 1, // Use a dedicated Redis database for Hangfire + }); + //#else + var efCoreStorage = hangfireConfiguration.UseEFCoreStorage(optionsBuilder => { if (appSettings.Hangfire?.UseIsolatedStorage is true) { @@ -529,11 +558,12 @@ void AddDbContext(DbContextOptionsBuilder options) { efCoreStorage.UseDatabaseCreator(); } + //#endif - configuration.UseRecommendedSerializerSettings(); - configuration.UseSimpleAssemblyNameTypeSerializer(); - configuration.UseIgnoredAssemblyVersionTypeResolver(); - configuration.SetDataCompatibilityLevel(CompatibilityLevel.Version_180); + hangfireConfiguration.UseRecommendedSerializerSettings(); + hangfireConfiguration.UseSimpleAssemblyNameTypeSerializer(); + hangfireConfiguration.UseIgnoredAssemblyVersionTypeResolver(); + hangfireConfiguration.SetDataCompatibilityLevel(CompatibilityLevel.Version_180); }); builder.Services.AddHangfireServer(options => @@ -552,7 +582,7 @@ private static void AddIdentity(WebApplicationBuilder builder) configuration.Bind(appSettings); var identityOptions = appSettings.Identity; - services.AddIdentity() + services.AddIdentity() .AddEntityFrameworkStores() .AddDefaultTokenProviders() .AddErrorDescriber() diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/appsettings.json b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/appsettings.json index d89c14fe96..4b0d25809e 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/appsettings.json +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/appsettings.json @@ -23,6 +23,12 @@ "s3__DigitalOceanSample": "Endpoint=https://ams3.digitaloceanspaces.com;BucketName=myBucketName;Region=ams3;AccessKey=XXX;SecretKey=XXX;", "s3__CloudflareR2Sample": "Not supported yet: https://github.com/robinrodricks/FluentStorage/issues/114", //#endif + //#if (redis == true) + "redis-cache": "localhost:6379", + "redis-cache_Comment": "Redis for FusionCache L2 distributed cache layer and SignalR backplane - no persistence needed, acts as fast ephemeral cache", + "redis-persistent": "localhost:6379", + "redis-persistent_Comment": "Redis for Hangfire background jobs and distributed locking - requires persistence for durability", + //#endif "smtp": "Endpoint=smtp://smtp.ethereal.email:587;UserName=madisen7@ethereal.email;Password=QYcYfjBXjqxMAZfZya", "smtp_Comment": "You can also use https://ethereal.email/create for testing purposes.", "ConnectionStrings_Comment": "The ConnectionStrings section contains database connection strings used by the application." diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.AppHost/Boilerplate.Server.AppHost.csproj b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.AppHost/Boilerplate.Server.AppHost.csproj index 9953470f73..aa4a612288 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.AppHost/Boilerplate.Server.AppHost.csproj +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.AppHost/Boilerplate.Server.AppHost.csproj @@ -14,6 +14,7 @@ + diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.AppHost/Program.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.AppHost/Program.cs index 682dd986cd..b9dd2f17b7 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.AppHost/Program.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.AppHost/Program.cs @@ -5,6 +5,25 @@ // Check out appsettings.Development.json for credentials/passwords settings. +//#if(redis == true) +// Redis cache for FusionCache hybrid caching (L2 cache) and SignalR backplane - no persistence needed +var redisCache = builder.AddRedis("redis-cache") + .WithRedisInsight() + .WithRedisCommander(); + +// Redis for Hangfire background jobs, and distributed locking - persistent with AOF for durability +var redisPersistent = builder.AddRedis("redis-persistent") + .WithRedisInsight() + .WithRedisCommander() + .WithDataVolume() + .WithArgs( + "--appendonly", "yes", // Enable AOF (Append only file) for data durability + "--appendfsync", "always", // Sync to disk on every write for maximum durability. Temporarily disable it programmatically using C# code during bulk operations if needed. + "--save", "", // Disables RDB snapshots + "--maxmemory-policy", "noeviction" // Raise error when memory limit is reached instead of evicting keys + ); +//#endif + //#if (database == "SqlServer") var sqlDatabase = builder.AddSqlServer("sqlserver") .WithDbGate(config => config.WithDataVolume()) @@ -84,6 +103,10 @@ serverApiProject.WithReference(s3Storage); //#endif serverApiProject.WithReference(keycloak); +//#if (redis == true) +serverApiProject.WithReference(redisCache).WaitFor(redisCache); +serverApiProject.WithReference(redisPersistent).WaitFor(redisPersistent); +//#endif //#else //#if (database == "SqlServer") @@ -101,6 +124,10 @@ serverWebProject.WithReference(s3Storage); //#endif serverWebProject.WithReference(keycloak); +//#if (redis == true) +serverWebProject.WithReference(redisCache).WaitFor(redisCache); +serverWebProject.WithReference(redisPersistent).WaitFor(redisPersistent); +//#endif //#endif if (builder.ExecutionContext.IsRunMode) // The following project is only added for testing purposes. @@ -121,12 +148,12 @@ //#if (api == "Standalone") builder.AddDevTunnel("api-dev-tunnel") .WithAnonymousAccess() - .WithReference(serverApiProject.WithHttpEndpoint(name: "devTunnel").GetEndpoint("devTunnel")); + .WithReference(serverApiProject.WithHttpEndpoint(name: "devTunnel", port: 5031).GetEndpoint("devTunnel")); //#endif var tunnel = builder.AddDevTunnel("web-dev-tunnel") .WithAnonymousAccess() - .WithReference(serverWebProject.WithHttpEndpoint(name: "devTunnel").GetEndpoint("devTunnel")); + .WithReference(serverWebProject.WithHttpEndpoint(name: "devTunnel", port: 5000).GetEndpoint("devTunnel")); if (OperatingSystem.IsWindows()) { diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Shared/Boilerplate.Server.Shared.csproj b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Shared/Boilerplate.Server.Shared.csproj index 48f3946f50..13ff70e31f 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Shared/Boilerplate.Server.Shared.csproj +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Shared/Boilerplate.Server.Shared.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -23,6 +23,9 @@ + + + diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Shared/Extensions/WebApplicationBuilderExtensions.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Shared/Extensions/WebApplicationBuilderExtensions.cs index 6060bc2ddc..135275d941 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Shared/Extensions/WebApplicationBuilderExtensions.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Shared/Extensions/WebApplicationBuilderExtensions.cs @@ -46,10 +46,33 @@ public static TBuilder AddServerSharedServices(this TBuilder builder) }, excludeDefaultPolicy: true); }); + //#if(redis == true) + // Add default Redis connection for Hangfire, SignalR backplane, and distributed locking (persistence Redis with AOF) + builder.AddRedisClient("redis-persistent", config => config.DisableTracing = true); + + // Add optional Redis connection for caching (ephemeral Redis without persistence) + builder.AddKeyedRedisClient("redis-cache", config => config.DisableTracing = true /*FusionCache is already handling cache traces*/); + //#endif + services.AddFusionCache() // Auto-clone cached objects to avoid further issues after scaling out and switching to distributed caching. .WithOptions(opt => opt.DefaultEntryOptions.EnableAutoClone = true) + //#if(redis == true) + // Use Redis backplane for cache synchronization across multiple server instances + .WithDistributedCache(sp => + new Caching.StackExchangeRedis.RedisCache(new Caching.StackExchangeRedis.RedisCacheOptions + { + ConnectionMultiplexerFactory = async () => sp.GetRequiredKeyedService("redis-cache"), + }) + ) + .WithBackplane(sp => new ZiggyCreatures.Caching.Fusion.Backplane.StackExchangeRedis.RedisBackplane( + new ZiggyCreatures.Caching.Fusion.Backplane.StackExchangeRedis.RedisBackplaneOptions + { + ConnectionMultiplexerFactory = async () => sp.GetRequiredKeyedService("redis-cache"), + })) + //#endif .WithSerializer(new FusionCacheSystemTextJsonSerializer()); + services.AddFusionOutputCache(); // For ASP.NET Core Output Caching with FusionCache services.AddHttpContextAccessor(); diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Web/appsettings.json b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Web/appsettings.json index f84cabd261..80268bcaaf 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Web/appsettings.json +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Web/appsettings.json @@ -6,6 +6,8 @@ "postgresdb": "User ID=postgres;Password=postgres;Host=localhost;Database=BoilerplateDb;", "mysqldb": "Server=localhost;Port=3306;Database=BoilerplateDb;Uid=root;Pwd=123456;", "azureblobstorage": "UseDevelopmentStorage=true", + "redis-cache": "localhost:6379", + "redis-persistent": "localhost:6379", "smtp": "Endpoint=smtp://smtp.ethereal.email:587;UserName=madisen7@ethereal.email;Password=QYcYfjBXjqxMAZfZya", "s3": "Endpoint=http://localhost:9000;AccessKey=minioadmin;SecretKey=minioadmin;" },