refactor: consolidate backend into modular monolith
This commit is contained in:
@@ -1,67 +0,0 @@
|
||||
using Tiku.Application;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Application.PlatformBilling;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Infrastructure;
|
||||
using Tiku.Worker;
|
||||
using Tiku.Infrastructure.Messaging;
|
||||
using Tiku.Infrastructure.Security;
|
||||
|
||||
var builder = Host.CreateApplicationBuilder(args);
|
||||
var connectionString = builder.Configuration.GetConnectionString("Database")
|
||||
?? builder.Configuration["DATABASE_URL"]
|
||||
?? throw new InvalidOperationException(
|
||||
"Database connection is required. Configure ConnectionStrings:Database or DATABASE_URL.");
|
||||
builder.Services.AddApplication();
|
||||
builder.Services.AddInfrastructure(connectionString);
|
||||
var redisConnectionString = builder.Configuration.GetConnectionString("Redis") ?? builder.Configuration["REDIS_URL"];
|
||||
builder.Services.AddOptions<RedisSecurityConnectionOptions>()
|
||||
.Configure(options => options.ConnectionString = redisConnectionString ?? string.Empty)
|
||||
.Validate(
|
||||
options => !builder.Environment.IsProduction() || !string.IsNullOrWhiteSpace(options.ConnectionString),
|
||||
"Redis is required in Production.")
|
||||
.ValidateOnStart();
|
||||
if (!string.IsNullOrWhiteSpace(redisConnectionString))
|
||||
{
|
||||
builder.Services.AddRedisSecurity(redisConnectionString, builder.Environment.EnvironmentName);
|
||||
}
|
||||
else if (builder.Environment.IsProduction())
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Redis is required in Production. Configure ConnectionStrings:Redis or REDIS_URL.");
|
||||
}
|
||||
var messaging = builder.Configuration.GetSection("RabbitMq").Get<MessagingOptions>() ?? new MessagingOptions();
|
||||
builder.Services.AddOptions<MessagingOptions>()
|
||||
.Bind(builder.Configuration.GetSection("RabbitMq"))
|
||||
.Validate(
|
||||
options => !builder.Environment.IsProduction() ||
|
||||
(options.IsConfigured &&
|
||||
!string.IsNullOrWhiteSpace(options.Username) &&
|
||||
!string.IsNullOrWhiteSpace(options.Password)),
|
||||
"Production RabbitMQ requires a valid Host, Username and Password.")
|
||||
.Validate(
|
||||
options => options.OutboxBacklogAlertCount > 0 && options.OutboxOldestMessageAlertSeconds > 0,
|
||||
"RabbitMQ outbox alert thresholds must be positive.")
|
||||
.ValidateOnStart();
|
||||
if (messaging.IsConfigured)
|
||||
{
|
||||
messaging.ConfigureConsumers = true;
|
||||
builder.Services.AddReliableMessaging(messaging);
|
||||
}
|
||||
else if (builder.Environment.IsProduction())
|
||||
{
|
||||
throw new InvalidOperationException("RabbitMQ is required in Production. Configure RabbitMq:Host.");
|
||||
}
|
||||
builder.Services.AddOptions<DomainLifecycleOptions>()
|
||||
.Bind(builder.Configuration.GetSection("TenantDomains"));
|
||||
builder.Services.AddOptions<SaasSubscriptionLifecycleOptions>()
|
||||
.Bind(builder.Configuration.GetSection("SaasSubscriptions"));
|
||||
builder.Services.AddOptions<FeatureUsageReconciliationOptions>()
|
||||
.Bind(builder.Configuration.GetSection("FeatureUsageReconciliation"));
|
||||
builder.Services.AddHostedService<TenantDomainWorker>();
|
||||
builder.Services.AddHostedService<SaasSubscriptionWorker>();
|
||||
builder.Services.AddHostedService<FeatureUsageWorker>();
|
||||
builder.Services.AddHostedService<BackgroundJobsWorker>();
|
||||
|
||||
var host = builder.Build();
|
||||
host.Run();
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"Tiku.Worker": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"environmentVariables": {
|
||||
"DOTNET_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Worker">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UserSecretsId>dotnet-Tiku.Worker-585d1830-c302-47fe-8009-595679bcc54c</UserSecretsId>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Tiku.Application\Tiku.Application.csproj" />
|
||||
<ProjectReference Include="..\Tiku.Infrastructure\Tiku.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,96 +0,0 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Application.PlatformBilling;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Infrastructure.Messaging;
|
||||
|
||||
namespace Tiku.Worker;
|
||||
|
||||
public class Worker(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IOptions<DomainLifecycleOptions> options,
|
||||
MessagingOptions messagingOptions,
|
||||
ILogger<Worker> logger) : BackgroundService
|
||||
{
|
||||
private readonly string workerId = $"{Environment.MachineName}:{Guid.NewGuid():N}";
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var domainCount = await ProcessTenantDomainsAsync(stoppingToken);
|
||||
var subscriptionCount = await ProcessSaasSubscriptionsAsync(stoppingToken);
|
||||
var usageReconciliationCount = await ProcessFeatureUsageReconciliationAsync(stoppingToken);
|
||||
var jobCount = await ProcessBackgroundJobsAsync(stoppingToken);
|
||||
if (domainCount > 0 || subscriptionCount > 0 || usageReconciliationCount > 0 || jobCount > 0)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Worker processed {DomainCount} pending tenant domains, {SubscriptionCount} SaaS subscriptions, {UsageReconciliationCount} tenant usage reconciliations, and {JobCount} background jobs.",
|
||||
domainCount,
|
||||
subscriptionCount,
|
||||
usageReconciliationCount,
|
||||
jobCount);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Tenant domain lifecycle processing failed.");
|
||||
}
|
||||
|
||||
await Task.Delay(
|
||||
TimeSpan.FromSeconds(Math.Clamp(options.Value.PollSeconds, 10, 3600)),
|
||||
stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<int> ProcessTenantDomainsAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
scope.ServiceProvider.GetRequiredService<ITenantContextInitializer>()
|
||||
.InitializeSystem(null, "Tenant domain DNS and TLS lifecycle worker");
|
||||
return await scope.ServiceProvider
|
||||
.GetRequiredService<ITenantDomainLifecycleService>()
|
||||
.ProcessPendingAsync(stoppingToken);
|
||||
}
|
||||
|
||||
private async Task<int> ProcessBackgroundJobsAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
scope.ServiceProvider.GetRequiredService<ITenantContextInitializer>()
|
||||
.InitializeSystem(null, "Background job lease worker");
|
||||
return await scope.ServiceProvider
|
||||
.GetRequiredService<IBackgroundJobService>()
|
||||
.ProcessPendingAsync(
|
||||
workerId,
|
||||
20,
|
||||
includeImmediateJobs: !messagingOptions.IsConfigured,
|
||||
cancellationToken: stoppingToken);
|
||||
}
|
||||
|
||||
private async Task<int> ProcessSaasSubscriptionsAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
scope.ServiceProvider.GetRequiredService<ITenantContextInitializer>()
|
||||
.InitializeSystem(null, "SaaS subscription lifecycle discovery worker");
|
||||
return await scope.ServiceProvider
|
||||
.GetRequiredService<ISaasSubscriptionLifecycleService>()
|
||||
.ProcessDueAsync(cancellationToken: stoppingToken);
|
||||
}
|
||||
|
||||
private async Task<int> ProcessFeatureUsageReconciliationAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
scope.ServiceProvider.GetRequiredService<ITenantContextInitializer>()
|
||||
.InitializeSystem(null, "Tenant feature usage reconciliation discovery worker");
|
||||
return await scope.ServiceProvider
|
||||
.GetRequiredService<IFeatureUsageReconciliationService>()
|
||||
.ProcessDueAsync(stoppingToken);
|
||||
}
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Application.PlatformBilling;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Infrastructure.Messaging;
|
||||
|
||||
namespace Tiku.Worker;
|
||||
|
||||
internal abstract class PeriodicWorkerService(
|
||||
ILogger logger,
|
||||
TimeSpan interval) : BackgroundService
|
||||
{
|
||||
protected abstract Task<int> ProcessAsync(CancellationToken cancellationToken);
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var processed = await ProcessAsync(stoppingToken);
|
||||
if (processed > 0)
|
||||
{
|
||||
logger.LogInformation("{Worker} processed {Count} items.", GetType().Name, processed);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "{Worker} iteration failed.", GetType().Name);
|
||||
}
|
||||
|
||||
await Task.Delay(interval, stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
protected static void InitializeSystem(IServiceProvider services, string reason) =>
|
||||
services.GetRequiredService<ITenantContextInitializer>().InitializeSystem(null, reason);
|
||||
}
|
||||
|
||||
internal sealed class TenantDomainWorker(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IOptions<DomainLifecycleOptions> options,
|
||||
ILogger<TenantDomainWorker> logger)
|
||||
: PeriodicWorkerService(logger, TimeSpan.FromSeconds(Math.Clamp(options.Value.PollSeconds, 10, 3600)))
|
||||
{
|
||||
protected override async Task<int> ProcessAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
InitializeSystem(scope.ServiceProvider, "Tenant domain DNS and TLS lifecycle worker");
|
||||
return await scope.ServiceProvider.GetRequiredService<ITenantDomainLifecycleService>()
|
||||
.ProcessPendingAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class SaasSubscriptionWorker(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<SaasSubscriptionWorker> logger)
|
||||
: PeriodicWorkerService(logger, TimeSpan.FromSeconds(60))
|
||||
{
|
||||
protected override async Task<int> ProcessAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
InitializeSystem(scope.ServiceProvider, "SaaS subscription lifecycle discovery worker");
|
||||
return await scope.ServiceProvider.GetRequiredService<ISaasSubscriptionLifecycleService>()
|
||||
.ProcessDueAsync(cancellationToken: cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FeatureUsageWorker(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IOptions<FeatureUsageReconciliationOptions> options,
|
||||
ILogger<FeatureUsageWorker> logger)
|
||||
: PeriodicWorkerService(logger, TimeSpan.FromMinutes(Math.Clamp(options.Value.IntervalMinutes, 1, 1440)))
|
||||
{
|
||||
protected override async Task<int> ProcessAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
InitializeSystem(scope.ServiceProvider, "Tenant feature usage reconciliation worker");
|
||||
return await scope.ServiceProvider.GetRequiredService<IFeatureUsageReconciliationService>()
|
||||
.ProcessDueAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class BackgroundJobsWorker(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
MessagingOptions messagingOptions,
|
||||
ILogger<BackgroundJobsWorker> logger)
|
||||
: PeriodicWorkerService(logger, TimeSpan.FromSeconds(2))
|
||||
{
|
||||
private readonly string workerId = $"{Environment.MachineName}:{Guid.NewGuid():N}";
|
||||
|
||||
protected override async Task<int> ProcessAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var workers = Enumerable.Range(0, 4).Select(index => ProcessPartitionAsync(index, cancellationToken));
|
||||
return (await Task.WhenAll(workers)).Sum();
|
||||
}
|
||||
|
||||
private async Task<int> ProcessPartitionAsync(int index, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
InitializeSystem(scope.ServiceProvider, "Background job lease worker");
|
||||
return await scope.ServiceProvider.GetRequiredService<IBackgroundJobService>()
|
||||
.ProcessPendingAsync(
|
||||
$"{workerId}:{index}",
|
||||
5,
|
||||
includeImmediateJobs: !messagingOptions.IsConfigured,
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.EntityFrameworkCore": "Warning",
|
||||
"Microsoft.Hosting.Lifetime": "Information"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
{
|
||||
"RabbitMq": {
|
||||
"Host": "",
|
||||
"VirtualHost": "/",
|
||||
"Username": "",
|
||||
"Password": "",
|
||||
"OutboxBacklogAlertCount": 1000,
|
||||
"OutboxOldestMessageAlertSeconds": 300
|
||||
},
|
||||
"TenantDomains": {
|
||||
"Enabled": true,
|
||||
"PollSeconds": 60,
|
||||
"BatchSize": 50,
|
||||
"DnsJsonEndpoint": "https://cloudflare-dns.com/dns-query",
|
||||
"VerificationRecordPrefix": "_tiku-verification",
|
||||
"AllowedCnameTargets": [],
|
||||
"GatewayBaseUrl": null,
|
||||
"GatewayApiKey": null
|
||||
},
|
||||
"SaasSubscriptions": {
|
||||
"Enabled": true,
|
||||
"BatchSize": 100,
|
||||
"PastDueGraceDays": 7
|
||||
},
|
||||
"FeatureUsageReconciliation": {
|
||||
"Enabled": true,
|
||||
"BatchSize": 100,
|
||||
"IntervalMinutes": 60
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.EntityFrameworkCore": "Warning",
|
||||
"Microsoft.Hosting.Lifetime": "Information"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user