forked from gongxuegit/tiku-backend.net
refactor: consolidate backend into modular monolith
This commit is contained in:
154
Tiku.Api/BackgroundProcessing/BackgroundProcessingServices.cs
Normal file
154
Tiku.Api/BackgroundProcessing/BackgroundProcessingServices.cs
Normal file
@@ -0,0 +1,154 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Application.PlatformBilling;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
|
||||
namespace Tiku.Api.BackgroundProcessing;
|
||||
|
||||
public sealed class BackgroundProcessingOptions
|
||||
{
|
||||
public const string SectionName = "BackgroundProcessing";
|
||||
|
||||
public bool Enabled { get; set; } = true;
|
||||
public int JobPollSeconds { get; set; } = 2;
|
||||
public int JobParallelism { get; set; } = 4;
|
||||
public int JobBatchSize { get; set; } = 5;
|
||||
|
||||
public static bool BeValid(BackgroundProcessingOptions options) =>
|
||||
options.JobPollSeconds is >= 1 and <= 3600 &&
|
||||
options.JobParallelism is >= 1 and <= 32 &&
|
||||
options.JobBatchSize is >= 1 and <= 100;
|
||||
}
|
||||
|
||||
internal abstract class PeriodicBackgroundService(
|
||||
ILogger logger,
|
||||
TimeSpan interval,
|
||||
bool enabled) : BackgroundService
|
||||
{
|
||||
protected abstract Task<int> ProcessAsync(CancellationToken cancellationToken);
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
if (!enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var processed = await ProcessAsync(stoppingToken);
|
||||
if (processed > 0)
|
||||
{
|
||||
logger.LogInformation("{Worker} processed {Count} items.", GetType().Name, processed);
|
||||
}
|
||||
|
||||
await Task.Delay(interval, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "{Worker} iteration failed.", GetType().Name);
|
||||
try
|
||||
{
|
||||
await Task.Delay(interval, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected static void InitializeSystem(IServiceProvider services, string reason) =>
|
||||
services.GetRequiredService<ITenantContextInitializer>().InitializeSystem(null, reason);
|
||||
}
|
||||
|
||||
internal sealed class TenantDomainBackgroundService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IOptions<DomainLifecycleOptions> domainOptions,
|
||||
IOptions<BackgroundProcessingOptions> backgroundOptions,
|
||||
ILogger<TenantDomainBackgroundService> logger)
|
||||
: PeriodicBackgroundService(
|
||||
logger,
|
||||
TimeSpan.FromSeconds(Math.Clamp(domainOptions.Value.PollSeconds, 10, 3600)),
|
||||
backgroundOptions.Value.Enabled)
|
||||
{
|
||||
protected override async Task<int> ProcessAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
InitializeSystem(scope.ServiceProvider, "Tenant domain DNS and TLS lifecycle background service");
|
||||
return await scope.ServiceProvider.GetRequiredService<ITenantDomainLifecycleService>()
|
||||
.ProcessPendingAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class SaasSubscriptionBackgroundService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IOptions<BackgroundProcessingOptions> options,
|
||||
ILogger<SaasSubscriptionBackgroundService> logger)
|
||||
: PeriodicBackgroundService(logger, TimeSpan.FromSeconds(60), options.Value.Enabled)
|
||||
{
|
||||
protected override async Task<int> ProcessAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
InitializeSystem(scope.ServiceProvider, "SaaS subscription lifecycle background service");
|
||||
return await scope.ServiceProvider.GetRequiredService<ISaasSubscriptionLifecycleService>()
|
||||
.ProcessDueAsync(cancellationToken: cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FeatureUsageBackgroundService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IOptions<FeatureUsageReconciliationOptions> featureOptions,
|
||||
IOptions<BackgroundProcessingOptions> backgroundOptions,
|
||||
ILogger<FeatureUsageBackgroundService> logger)
|
||||
: PeriodicBackgroundService(
|
||||
logger,
|
||||
TimeSpan.FromMinutes(Math.Clamp(featureOptions.Value.IntervalMinutes, 1, 1440)),
|
||||
backgroundOptions.Value.Enabled)
|
||||
{
|
||||
protected override async Task<int> ProcessAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
InitializeSystem(scope.ServiceProvider, "Tenant feature usage reconciliation background service");
|
||||
return await scope.ServiceProvider.GetRequiredService<IFeatureUsageReconciliationService>()
|
||||
.ProcessDueAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class BackgroundJobsBackgroundService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IOptions<BackgroundProcessingOptions> options,
|
||||
ILogger<BackgroundJobsBackgroundService> logger)
|
||||
: PeriodicBackgroundService(logger, TimeSpan.FromSeconds(options.Value.JobPollSeconds), options.Value.Enabled)
|
||||
{
|
||||
private readonly string workerId = $"{Environment.MachineName}:{Guid.NewGuid():N}";
|
||||
private readonly int parallelism = options.Value.JobParallelism;
|
||||
private readonly int batchSize = options.Value.JobBatchSize;
|
||||
|
||||
protected override async Task<int> ProcessAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var workers = Enumerable.Range(0, parallelism)
|
||||
.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 service");
|
||||
return await scope.ServiceProvider.GetRequiredService<IBackgroundJobService>()
|
||||
.ProcessPendingAsync(
|
||||
$"{workerId}:{index}",
|
||||
batchSize,
|
||||
includeImmediateJobs: true,
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
using Serilog;
|
||||
using Tiku.Application;
|
||||
using Tiku.Infrastructure;
|
||||
using Tiku.Infrastructure.Messaging;
|
||||
using Tiku.Infrastructure.Security;
|
||||
using Tiku.Api.Caching;
|
||||
using Tiku.Api.BackgroundProcessing;
|
||||
using Microsoft.AspNetCore.ResponseCompression;
|
||||
using System.IO.Compression;
|
||||
using Tiku.Api.Observability;
|
||||
using Tiku.Application.PlatformBilling;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
|
||||
@@ -25,8 +26,6 @@ public static class DependencyInjection
|
||||
builder.Services.AddApiPresentation();
|
||||
builder.Services.AddHealthChecks();
|
||||
builder.Services.AddApiObservability(builder.Configuration, builder.Environment);
|
||||
builder.Services.AddSingleton<OutboxBacklogSnapshot>();
|
||||
builder.Services.AddHostedService<OutboxBacklogMonitor>();
|
||||
builder.Services.AddApplication();
|
||||
builder.Services.AddNetworkConfiguration(builder.Configuration, builder.Environment);
|
||||
builder.Services.AddApiRateLimiting(builder.Configuration);
|
||||
@@ -76,29 +75,20 @@ public static class DependencyInjection
|
||||
});
|
||||
builder.Services.Configure<BrotliCompressionProviderOptions>(options => options.Level = CompressionLevel.Fastest);
|
||||
builder.Services.Configure<GzipCompressionProviderOptions>(options => options.Level = CompressionLevel.Fastest);
|
||||
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.")
|
||||
builder.Services.AddOptions<BackgroundProcessingOptions>()
|
||||
.Bind(builder.Configuration.GetSection(BackgroundProcessingOptions.SectionName))
|
||||
.Validate(BackgroundProcessingOptions.BeValid, "Background processing settings are invalid.")
|
||||
.ValidateOnStart();
|
||||
builder.Services.AddSingleton(messaging);
|
||||
if (messaging.IsConfigured)
|
||||
{
|
||||
messaging.ConfigureConsumers = false;
|
||||
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<TenantDomainBackgroundService>();
|
||||
builder.Services.AddHostedService<SaasSubscriptionBackgroundService>();
|
||||
builder.Services.AddHostedService<FeatureUsageBackgroundService>();
|
||||
builder.Services.AddHostedService<BackgroundJobsBackgroundService>();
|
||||
|
||||
builder.Services.AddApiDataProtection(builder.Configuration, builder.Environment);
|
||||
builder.Services.AddExternalServiceOptions(builder.Configuration, builder.Environment);
|
||||
|
||||
@@ -28,7 +28,7 @@ internal static class ObservabilityExtensions
|
||||
.WithMetrics(metrics => metrics
|
||||
.AddAspNetCoreInstrumentation()
|
||||
.AddHttpClientInstrumentation()
|
||||
.AddMeter(DatabasePerformanceTelemetry.MeterName, "Tiku.Security.Redis", "Tiku.Messaging", "Npgsql")
|
||||
.AddMeter(DatabasePerformanceTelemetry.MeterName, "Tiku.Security.Redis", "Npgsql")
|
||||
.ApplyIf(hasOtlpEndpoint, builder => builder.AddOtlpExporter(options => options.Endpoint = endpointUri!)));
|
||||
|
||||
return services;
|
||||
|
||||
@@ -4,9 +4,6 @@ using Tiku.Api.Contracts;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Tiku.Infrastructure.Messaging;
|
||||
using Tiku.Api.Observability;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
@@ -17,10 +14,7 @@ namespace Tiku.Api.Controllers;
|
||||
[Route("api/health")]
|
||||
public sealed class HealthController(
|
||||
TikuDbContext dbContext,
|
||||
IRedisSecurityStore redisSecurityStore,
|
||||
MessagingOptions messagingOptions,
|
||||
HealthCheckService healthCheckService,
|
||||
OutboxBacklogSnapshot outboxSnapshot) : ControllerBase
|
||||
IRedisSecurityStore redisSecurityStore) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[EndpointSummary("健康检查")]
|
||||
@@ -40,29 +34,12 @@ public sealed class HealthController(
|
||||
{
|
||||
var database = await dbContext.Database.CanConnectAsync(cancellationToken);
|
||||
var redis = !redisSecurityStore.IsConfigured || await redisSecurityStore.PingAsync(cancellationToken);
|
||||
var rabbitHealth = await healthCheckService.CheckHealthAsync(
|
||||
registration => registration.Tags.Contains("ready"),
|
||||
cancellationToken);
|
||||
var rabbitMq = !messagingOptions.IsConfigured || rabbitHealth.Status == HealthStatus.Healthy;
|
||||
var outboxPending = outboxSnapshot.Pending;
|
||||
var outboxOldestAgeSeconds = outboxSnapshot.OldestAgeSeconds;
|
||||
var outboxAlert = outboxPending >= messagingOptions.OutboxBacklogAlertCount ||
|
||||
outboxOldestAgeSeconds >= messagingOptions.OutboxOldestMessageAlertSeconds;
|
||||
var ready = database && redis && rabbitMq;
|
||||
var ready = database && redis;
|
||||
var response = new
|
||||
{
|
||||
status = ready ? "ready" : "not_ready",
|
||||
database,
|
||||
redis = new { configured = redisSecurityStore.IsConfigured, ready = redis },
|
||||
rabbitMq = new { configured = messagingOptions.IsConfigured, ready = rabbitMq },
|
||||
outbox = new
|
||||
{
|
||||
pending = outboxPending,
|
||||
oldestAgeSeconds = Math.Round(outboxOldestAgeSeconds, 1),
|
||||
alert = outboxAlert,
|
||||
backlogAlertCount = messagingOptions.OutboxBacklogAlertCount,
|
||||
oldestMessageAlertSeconds = messagingOptions.OutboxOldestMessageAlertSeconds
|
||||
},
|
||||
checkedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
return ready ? Ok(response) : StatusCode(StatusCodes.Status503ServiceUnavailable, response);
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
using System.Diagnostics.Metrics;
|
||||
using Npgsql;
|
||||
using Tiku.Infrastructure.Messaging;
|
||||
|
||||
namespace Tiku.Api.Observability;
|
||||
|
||||
public sealed class OutboxBacklogSnapshot
|
||||
{
|
||||
private static readonly Meter Meter = new("Tiku.Messaging", "1.0.0");
|
||||
private long pending;
|
||||
private double oldestAgeSeconds;
|
||||
|
||||
public OutboxBacklogSnapshot()
|
||||
{
|
||||
Meter.CreateObservableGauge("tiku.outbox.pending", () => Interlocked.Read(ref pending));
|
||||
Meter.CreateObservableGauge("tiku.outbox.oldest_age", () => Volatile.Read(ref oldestAgeSeconds), "s");
|
||||
}
|
||||
|
||||
internal long Pending => Interlocked.Read(ref pending);
|
||||
internal double OldestAgeSeconds => Volatile.Read(ref oldestAgeSeconds);
|
||||
|
||||
internal void Update(long count, DateTime? oldestSentTime)
|
||||
{
|
||||
Interlocked.Exchange(ref pending, count);
|
||||
Volatile.Write(ref oldestAgeSeconds, oldestSentTime is null
|
||||
? 0
|
||||
: Math.Max(0, (DateTimeOffset.UtcNow - new DateTimeOffset(oldestSentTime.Value)).TotalSeconds));
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class OutboxBacklogMonitor(
|
||||
NpgsqlDataSource dataSource,
|
||||
OutboxBacklogSnapshot snapshot,
|
||||
MessagingOptions messagingOptions,
|
||||
ILogger<OutboxBacklogMonitor> logger) : BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (messagingOptions.IsConfigured)
|
||||
{
|
||||
await using var command = dataSource.CreateCommand(
|
||||
"SELECT count(*), min(sent_time) FROM outbox_message;");
|
||||
await using var reader = await command.ExecuteReaderAsync(stoppingToken);
|
||||
if (await reader.ReadAsync(stoppingToken))
|
||||
{
|
||||
snapshot.Update(reader.GetInt64(0), reader.IsDBNull(1) ? null : reader.GetDateTime(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(exception, "Outbox backlog metric collection failed.");
|
||||
}
|
||||
|
||||
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -71,13 +71,31 @@
|
||||
"Redis": {
|
||||
"KeyPrefix": "tiku"
|
||||
},
|
||||
"RabbitMq": {
|
||||
"Host": "",
|
||||
"VirtualHost": "/",
|
||||
"Username": "",
|
||||
"Password": "",
|
||||
"OutboxBacklogAlertCount": 1000,
|
||||
"OutboxOldestMessageAlertSeconds": 300
|
||||
"BackgroundProcessing": {
|
||||
"Enabled": true,
|
||||
"JobPollSeconds": 2,
|
||||
"JobParallelism": 4,
|
||||
"JobBatchSize": 5
|
||||
},
|
||||
"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
|
||||
},
|
||||
"BrowserAuth": {
|
||||
"AllowedOrigins": []
|
||||
|
||||
Reference in New Issue
Block a user