Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/bit.full.ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down Expand Up @@ -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"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ You will be working with the following key technologies:
<!--#if (signalR == true)-->
* **SignalR**: Real-time communication
<!--#endif-->
<!--#if (redis == true)-->
* **Redis**: Distributed caching storage and backplane, hangfire job storage, signalr backplane and distributed lock.
<!--#endif-->
* **Hangfire**: Background job processing
* **OData**: Advanced querying capabilities
* **Bit.BlazorUI**: The primary UI component library
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
{
"id": "aspire"
},
{
"id": "redis"
},
{
"id": "database"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
<PackageVersion Include="OpenTelemetry.Resources.ProcessRuntime" Version="1.14.0-beta.1" />
<PackageVersion Include="Oscore.Maui.AppStoreInfo" Version="1.3.1" />
<PackageVersion Include="Plugin.Maui.AppRating" Version="1.2.3" />
<PackageVersion Include="Scalar.AspNetCore" Version="2.11.9" />
<PackageVersion Include="Scalar.AspNetCore" Version="2.11.10" />
<PackageVersion Include="Velopack" Version="0.0.1298" />
<PackageVersion Include="Microsoft.Extensions.Logging.Configuration" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.Logging.Debug" Version="10.0.1" />
Expand Down Expand Up @@ -107,6 +107,13 @@
<PackageVersion Condition=" '$(filesStorage)' == 'S3' OR '$(filesStorage)' == '' " Include="FluentStorage.AWS" Version="6.0.1" />
<PackageVersion Condition=" '$(appInsights)' == 'true' OR '$(appInsights)' == '' " Include="Azure.Monitor.OpenTelemetry.AspNetCore" Version="1.4.0" />
<PackageVersion Condition=" '$(appInsights)' == 'true' OR '$(appInsights)' == '' " Include="Azure.Monitor.OpenTelemetry.Profiler" Version="1.0.0-beta6" />
<PackageVersion Condition="'$(redis)' == 'true' OR '$(redis)' == ''" Include="Aspire.Hosting.Redis" Version="13.1.0" />
<PackageVersion Condition="'$(redis)' == 'true' OR '$(redis)' == ''" Include="Aspire.StackExchange.Redis" Version="13.1.0" />
<PackageVersion Condition="'$(redis)' == 'true' OR '$(redis)' == ''" Include="ZiggyCreatures.FusionCache.Backplane.StackExchangeRedis" Version="2.5.0" />
<PackageVersion Condition="'$(redis)' == 'true' OR '$(redis)' == ''" Include="DistributedLock.Redis" Version="1.1.1" />
<PackageVersion Condition="'$(redis)' == 'true' OR '$(redis)' == ''" Include="Hangfire.Redis.StackExchange" Version="1.12.0" />
<PackageVersion Condition=" ('$(redis)' == 'true' OR '$(redis)' == '') AND ('$(signalR)' == 'true' OR '$(signalR)' == '')" Include="Microsoft.AspNetCore.SignalR.StackExchangeRedis" Version="10.0.1" />
<PackageVersion Condition="'$(redis)' == 'true' OR '$(redis)' == ''" Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="10.0.1" />
<!--/-:msbuild-conditional:noEmit -->
<PackageVersion Include="Humanizer" Version="3.0.1" />
<PackageVersion Include="QRCoder" Version="1.7.0" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@
<PackageReference Condition=" ('$(signalR)' == 'true' OR '$(signalR)' == '') OR ('$(database)' == 'PostgreSQL' OR '$(database)' == '') OR ('$(database)' == 'SqlServer' OR '$(database)' == '') " Include="Microsoft.SemanticKernel.Core" />
<PackageReference Condition=" ('$(signalR)' == 'true' OR '$(signalR)' == '') OR ('$(database)' == 'PostgreSQL' OR '$(database)' == '') OR ('$(database)' == 'SqlServer' OR '$(database)' == '') " Include="Microsoft.SemanticKernel.Connectors.HuggingFace" />
<PackageReference Condition=" ('$(database)' == 'PostgreSQL' OR '$(database)' == '') " Include="Pgvector.EntityFrameworkCore" />
<PackageReference Condition="'$(redis)' == 'true' OR '$(redis)' == ''" Include="DistributedLock.Redis" />
<PackageReference Condition="'$(redis)' == 'true' OR '$(redis)' == ''" Include="Hangfire.Redis.StackExchange" />
<PackageReference Condition=" ('$(redis)' == 'true' OR '$(redis)' == '') AND ('$(signalR)' == 'true' OR '$(signalR)' == '')" Include="Microsoft.AspNetCore.SignalR.StackExchangeRedis" />
<Using Include="Microsoft.EntityFrameworkCore.Migrations" />
<Using Include="Microsoft.EntityFrameworkCore.Metadata.Builders" />
<Using Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -161,10 +165,18 @@ public static void AddServerApiProjectServices(this WebApplicationBuilder builde
services.AddScoped<PushNotificationJobRunner>();
//#endif

// Register distributed lock factory
//#if (redis == true)
services.AddTransient(sp => new Func<string, IDistributedLock>((string lockKey) =>
{
return new Medallion.Threading.Redis.RedisDistributedLock(lockKey, sp.GetRequiredService<IConnectionMultiplexer>().GetDatabase());
}));
//#else
services.AddTransient(sp => new Func<string, IDistributedLock>((string lockKey) =>
{
return new Medallion.Threading.FileSystem.FileDistributedLock(new(Path.Combine(Path.GetTempPath(), $"Boilerplate-{lockKey}.lock")));
}));
//#endif

services.AddSingleton<ServerExceptionHandler>();
services.AddSingleton(sp => (IProblemDetailsWriter)sp.GetRequiredService<ServerExceptionHandler>());
Expand Down Expand Up @@ -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 =>
Expand Down Expand Up @@ -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<IConnectionMultiplexer>(), 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)
{
Expand All @@ -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 =>
Expand All @@ -552,7 +582,7 @@ private static void AddIdentity(WebApplicationBuilder builder)
configuration.Bind(appSettings);
var identityOptions = appSettings.Identity;

services.AddIdentity<User, Role>()
services.AddIdentity<User, Models.Identity.Role>()
.AddEntityFrameworkStores<AppDbContext>()
.AddDefaultTokenProviders()
.AddErrorDescriber<AppIdentityErrorDescriber>()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
<PackageReference Include="Aspire.Hosting.Keycloak" />
<PackageReference Include="Aspire.Hosting.Maui" />
<PackageReference Condition=" '$(database)' == 'MySql' OR '$(database)' == '' " Include="Aspire.Hosting.MySql" />
<PackageReference Condition="'$(redis)' == 'true' OR '$(redis)' == ''" Include="Aspire.Hosting.Redis" />
<PackageReference Include="CommunityToolkit.Aspire.Hosting.MailPit" />
<PackageReference Condition=" '$(database)' == 'Sqlite' OR '$(database)' == '' " Include="CommunityToolkit.Aspire.Hosting.Sqlite" />
<PackageReference Condition=" '$(database)' == 'SqlServer' OR '$(database)' == ''" Include="Aspire.Hosting.SqlServer" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down Expand Up @@ -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")
Expand All @@ -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.
Expand All @@ -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())
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
Expand All @@ -23,6 +23,9 @@
<PackageReference Condition=" '$(appInsights)' == 'true' OR '$(appInsights)' == '' " Include="Azure.Monitor.OpenTelemetry.AspNetCore" />
<PackageReference Include="OpenTelemetry.Instrumentation.EntityFrameworkCore" />
<PackageReference Include="OpenTelemetry.Instrumentation.Hangfire" />
<PackageReference Condition="'$(redis)' == 'true' OR '$(redis)' == ''" Include="Microsoft.Extensions.Caching.StackExchangeRedis" />
<PackageReference Condition="'$(redis)' == 'true' OR '$(redis)' == ''" Include="Aspire.StackExchange.Redis" />
<PackageReference Condition="'$(redis)' == 'true' OR '$(redis)' == ''" Include="ZiggyCreatures.FusionCache.Backplane.StackExchangeRedis" />
<PackageReference Include="OpenTelemetry.Resources.Azure" />
<PackageReference Include="OpenTelemetry.Resources.Container" />
<PackageReference Include="OpenTelemetry.Resources.Host" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,33 @@ public static TBuilder AddServerSharedServices<TBuilder>(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<StackExchange.Redis.IConnectionMultiplexer>("redis-cache"),
})
)
.WithBackplane(sp => new ZiggyCreatures.Caching.Fusion.Backplane.StackExchangeRedis.RedisBackplane(
new ZiggyCreatures.Caching.Fusion.Backplane.StackExchangeRedis.RedisBackplaneOptions
{
ConnectionMultiplexerFactory = async () => sp.GetRequiredKeyedService<StackExchange.Redis.IConnectionMultiplexer>("redis-cache"),
}))
//#endif
.WithSerializer(new FusionCacheSystemTextJsonSerializer());

services.AddFusionOutputCache(); // For ASP.NET Core Output Caching with FusionCache

services.AddHttpContextAccessor();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;"
},
Expand Down
Loading