feat: strengthen P0 security and operations
This commit is contained in:
24
Tiku.Worker/Dockerfile
Normal file
24
Tiku.Worker/Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
|
||||
USER $APP_UID
|
||||
WORKDIR /app
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
WORKDIR /src
|
||||
COPY ["Directory.Build.props", "Directory.Packages.props", "./"]
|
||||
COPY ["Tiku.Domain/Tiku.Domain.csproj", "Tiku.Domain/"]
|
||||
COPY ["Tiku.Application/Tiku.Application.csproj", "Tiku.Application/"]
|
||||
COPY ["Tiku.Infrastructure/Tiku.Infrastructure.csproj", "Tiku.Infrastructure/"]
|
||||
COPY ["Tiku.Worker/Tiku.Worker.csproj", "Tiku.Worker/"]
|
||||
RUN dotnet restore "Tiku.Worker/Tiku.Worker.csproj"
|
||||
COPY . .
|
||||
RUN dotnet publish "Tiku.Worker/Tiku.Worker.csproj" \
|
||||
-c $BUILD_CONFIGURATION \
|
||||
-o /app/publish \
|
||||
--no-restore \
|
||||
/p:UseAppHost=false
|
||||
|
||||
FROM base AS final
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/publish .
|
||||
ENTRYPOINT ["dotnet", "Tiku.Worker.dll"]
|
||||
26
Tiku.Worker/Program.cs
Normal file
26
Tiku.Worker/Program.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
using Serilog;
|
||||
using Serilog.Events;
|
||||
using Tiku.Worker;
|
||||
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
|
||||
.Enrich.FromLogContext()
|
||||
.WriteTo.Console()
|
||||
.CreateBootstrapLogger();
|
||||
|
||||
try
|
||||
{
|
||||
Log.Information("Starting TIKU Worker");
|
||||
var builder = Host.CreateApplicationBuilder(args);
|
||||
builder.AddWorkerServices();
|
||||
await builder.Build().RunAsync();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Log.Fatal(exception, "TIKU Worker terminated unexpectedly");
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
await Log.CloseAndFlushAsync();
|
||||
}
|
||||
24
Tiku.Worker/Tiku.Worker.csproj
Normal file
24
Tiku.Worker/Tiku.Worker.csproj
Normal file
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Worker">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Tiku.Application\Tiku.Application.csproj" />
|
||||
<ProjectReference Include="..\Tiku.Infrastructure\Tiku.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" />
|
||||
<PackageReference Include="Serilog.AspNetCore" />
|
||||
<PackageReference Include="Serilog.Settings.Configuration" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
119
Tiku.Worker/WorkerDependencyInjection.cs
Normal file
119
Tiku.Worker/WorkerDependencyInjection.cs
Normal file
@@ -0,0 +1,119 @@
|
||||
using Serilog;
|
||||
using Tiku.Application;
|
||||
using Tiku.Application.PlatformBilling;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Infrastructure;
|
||||
using Tiku.Infrastructure.Assets;
|
||||
using Tiku.Infrastructure.Storage;
|
||||
using Tiku.Infrastructure.Observability;
|
||||
using OpenTelemetry.Metrics;
|
||||
|
||||
namespace Tiku.Worker;
|
||||
|
||||
internal static class WorkerDependencyInjection
|
||||
{
|
||||
internal static HostApplicationBuilder AddWorkerServices(this HostApplicationBuilder builder)
|
||||
{
|
||||
builder.Services.AddSerilog((services, configuration) => configuration
|
||||
.ReadFrom.Configuration(builder.Configuration)
|
||||
.ReadFrom.Services(services)
|
||||
.Enrich.FromLogContext(),
|
||||
preserveStaticLogger: true);
|
||||
|
||||
var connectionString = builder.Configuration.GetConnectionString("Database") ??
|
||||
builder.Configuration["DATABASE_URL"] ??
|
||||
(builder.Environment.IsDevelopment()
|
||||
? $"Host=localhost;Database=tiku;Username={Environment.UserName}"
|
||||
: throw new InvalidOperationException(
|
||||
"Database connection is required outside Development. Configure ConnectionStrings:Database or DATABASE_URL."));
|
||||
builder.Services.AddApplication();
|
||||
builder.Services.AddInfrastructure(connectionString);
|
||||
var otlpEndpoint = builder.Configuration["OpenTelemetry:OtlpEndpoint"];
|
||||
var telemetry = builder.Services.AddOpenTelemetry().WithMetrics(metrics => metrics.AddMeter(WorkerTelemetry.MeterName));
|
||||
if (Uri.TryCreate(otlpEndpoint, UriKind.Absolute, out var endpoint))
|
||||
{
|
||||
telemetry.WithMetrics(metrics => metrics.AddOtlpExporter(options => options.Endpoint = endpoint));
|
||||
}
|
||||
builder.Services.Configure<ObjectStorageOptions>(builder.Configuration.GetSection(ObjectStorageOptions.SectionName));
|
||||
builder.Services.Configure<AliyunOssOptions>(builder.Configuration.GetSection(AliyunOssOptions.SectionName));
|
||||
builder.Services.PostConfigure<ObjectStorageOptions>(options =>
|
||||
{
|
||||
options.DefaultProvider = builder.Configuration["STORAGE_DEFAULT_PROVIDER"] ?? options.DefaultProvider;
|
||||
options.DefaultBucket = builder.Configuration["STORAGE_DEFAULT_BUCKET"] ?? options.DefaultBucket;
|
||||
options.PublicBaseUrl = builder.Configuration["STORAGE_PUBLIC_BASE_URL"] ?? options.PublicBaseUrl;
|
||||
options.AllowedMimePrefixes = SplitLegacyList(
|
||||
builder.Configuration["STORAGE_ALLOWED_MIME_PREFIXES"],
|
||||
options.AllowedMimePrefixes);
|
||||
options.AllowedMimeTypes = SplitLegacyList(
|
||||
builder.Configuration["STORAGE_ALLOWED_MIME_TYPES"],
|
||||
options.AllowedMimeTypes);
|
||||
options.RequireTenantPrefix = bool.TryParse(
|
||||
builder.Configuration["STORAGE_REQUIRE_TENANT_PREFIX"],
|
||||
out var requireTenantPrefix)
|
||||
? requireTenantPrefix
|
||||
: options.RequireTenantPrefix;
|
||||
options.MaxUploadBytes = long.TryParse(
|
||||
builder.Configuration["STORAGE_MAX_UPLOAD_BYTES"],
|
||||
out var maxUploadBytes)
|
||||
? maxUploadBytes
|
||||
: options.MaxUploadBytes;
|
||||
});
|
||||
builder.Services.PostConfigure<AliyunOssOptions>(options =>
|
||||
{
|
||||
options.Region = builder.Configuration["ALIYUN_OSS_REGION"] ?? options.Region;
|
||||
options.Endpoint = builder.Configuration["ALIYUN_OSS_ENDPOINT"] ?? options.Endpoint;
|
||||
options.AccessKeyId = builder.Configuration["ALIYUN_OSS_ACCESS_KEY_ID"] ?? options.AccessKeyId;
|
||||
options.AccessKeySecret = builder.Configuration["ALIYUN_OSS_ACCESS_KEY_SECRET"] ?? options.AccessKeySecret;
|
||||
options.SecurityToken = builder.Configuration["ALIYUN_OSS_STS_TOKEN"] ?? options.SecurityToken;
|
||||
options.UseInternalEndpoint = bool.TryParse(
|
||||
builder.Configuration["ALIYUN_OSS_INTERNAL"],
|
||||
out var useInternalEndpoint)
|
||||
? useInternalEndpoint
|
||||
: options.UseInternalEndpoint;
|
||||
});
|
||||
builder.Services.AddOptions<ObjectStorageOptions>()
|
||||
.Validate(
|
||||
options => !builder.Environment.IsProduction() ||
|
||||
options.DefaultProvider == Tiku.Application.Storage.ObjectStorageProviders.AliyunOss,
|
||||
"Production managed storage must use the configured Aliyun OSS provider.")
|
||||
.ValidateOnStart();
|
||||
builder.Services.AddOptions<AliyunOssOptions>()
|
||||
.Validate<Microsoft.Extensions.Options.IOptions<ObjectStorageOptions>>(
|
||||
(aliyun, storage) =>
|
||||
!builder.Environment.IsProduction() ||
|
||||
storage.Value.DefaultProvider != Tiku.Application.Storage.ObjectStorageProviders.AliyunOss ||
|
||||
aliyun.IsConfigured,
|
||||
"Aliyun OSS credentials and region or endpoint are required when it is the default provider.")
|
||||
.ValidateOnStart();
|
||||
builder.Services.AddOptions<ClamAvOptions>()
|
||||
.Bind(builder.Configuration.GetSection(ClamAvOptions.SectionName))
|
||||
.Validate(ClamAvOptions.BeValid, "ClamAV settings are invalid.")
|
||||
.Validate<Microsoft.Extensions.Options.IOptions<ObjectStorageOptions>>(
|
||||
(clamAv, storage) => clamAv.StreamMaxLength >= storage.Value.MaxUploadBytes,
|
||||
"ClamAV StreamMaxLength must cover the storage max upload size.")
|
||||
.ValidateOnStart();
|
||||
builder.Services.AddOptions<WorkerOptions>()
|
||||
.Bind(builder.Configuration.GetSection(WorkerOptions.SectionName))
|
||||
.Validate(WorkerOptions.BeValid, "Worker settings are invalid.")
|
||||
.ValidateOnStart();
|
||||
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.AddSingleton<IPeriodicProcessorLock, PostgresPeriodicProcessorLock>();
|
||||
builder.Services.AddSingleton<WorkerStateReporter>();
|
||||
builder.Services.AddHostedService<TenantDomainWorker>();
|
||||
builder.Services.AddHostedService<SaasSubscriptionWorker>();
|
||||
builder.Services.AddHostedService<FeatureUsageWorker>();
|
||||
builder.Services.AddHostedService<BackgroundJobsWorker>();
|
||||
return builder;
|
||||
}
|
||||
|
||||
private static string[] SplitLegacyList(string? value, string[] fallback) =>
|
||||
string.IsNullOrWhiteSpace(value)
|
||||
? fallback
|
||||
: value.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
|
||||
}
|
||||
264
Tiku.Worker/WorkerServices.cs
Normal file
264
Tiku.Worker/WorkerServices.cs
Normal file
@@ -0,0 +1,264 @@
|
||||
using System.Data;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Npgsql;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Application.PlatformBilling;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Infrastructure.Observability;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace Tiku.Worker;
|
||||
|
||||
public sealed class WorkerOptions
|
||||
{
|
||||
public const string SectionName = "Worker";
|
||||
|
||||
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(WorkerOptions options) =>
|
||||
options.JobPollSeconds is >= 1 and <= 3600 &&
|
||||
options.JobParallelism is >= 1 and <= 32 &&
|
||||
options.JobBatchSize is >= 1 and <= 100;
|
||||
}
|
||||
|
||||
internal interface IPeriodicProcessorLock
|
||||
{
|
||||
Task<IAsyncDisposable?> TryAcquireAsync(string processor, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
internal sealed class WorkerStateReporter(NpgsqlDataSource dataSource)
|
||||
{
|
||||
private readonly string workerId = $"{Environment.MachineName}:{Environment.ProcessId}";
|
||||
private readonly DateTimeOffset startedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
public Task StartedAsync(string processor, CancellationToken cancellationToken) =>
|
||||
UpsertAsync(processor, true, null, cancellationToken);
|
||||
|
||||
public Task CompletedAsync(string processor, Exception? error, CancellationToken cancellationToken) =>
|
||||
UpsertAsync(processor, false, error?.Message, cancellationToken);
|
||||
|
||||
private async Task UpsertAsync(
|
||||
string processor,
|
||||
bool running,
|
||||
string? error,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
INSERT INTO worker_heartbeats
|
||||
(id, worker_id, processor, started_at, last_heartbeat_at, last_iteration_started_at,
|
||||
last_iteration_completed_at, last_succeeded_at, last_error, is_running)
|
||||
VALUES
|
||||
(gen_random_uuid(), @worker_id, @processor, @started_at, @now,
|
||||
CASE WHEN @running THEN @now ELSE NULL END,
|
||||
CASE WHEN @running THEN NULL ELSE @now END,
|
||||
CASE WHEN NOT @running AND @error IS NULL THEN @now ELSE NULL END,
|
||||
@error, @running)
|
||||
ON CONFLICT (worker_id, processor) DO UPDATE SET
|
||||
last_heartbeat_at = EXCLUDED.last_heartbeat_at,
|
||||
last_iteration_started_at = CASE WHEN EXCLUDED.is_running THEN EXCLUDED.last_heartbeat_at ELSE worker_heartbeats.last_iteration_started_at END,
|
||||
last_iteration_completed_at = CASE WHEN EXCLUDED.is_running THEN worker_heartbeats.last_iteration_completed_at ELSE EXCLUDED.last_heartbeat_at END,
|
||||
last_succeeded_at = CASE WHEN NOT EXCLUDED.is_running AND EXCLUDED.last_error IS NULL THEN EXCLUDED.last_heartbeat_at ELSE worker_heartbeats.last_succeeded_at END,
|
||||
last_error = EXCLUDED.last_error,
|
||||
is_running = EXCLUDED.is_running
|
||||
""";
|
||||
command.Parameters.AddWithValue("worker_id", workerId);
|
||||
command.Parameters.AddWithValue("processor", processor);
|
||||
command.Parameters.AddWithValue("started_at", startedAt);
|
||||
command.Parameters.AddWithValue("now", now);
|
||||
command.Parameters.AddWithValue("running", running);
|
||||
command.Parameters.AddWithValue("error", (object?)error ?? DBNull.Value);
|
||||
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class PostgresPeriodicProcessorLock(NpgsqlDataSource dataSource) : IPeriodicProcessorLock
|
||||
{
|
||||
public async Task<IAsyncDisposable?> TryAcquireAsync(string processor, CancellationToken cancellationToken)
|
||||
{
|
||||
var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = "SELECT pg_try_advisory_lock(hashtextextended(@processor, 0))";
|
||||
command.Parameters.AddWithValue("processor", processor);
|
||||
var acquired = (bool)(await command.ExecuteScalarAsync(cancellationToken) ?? false);
|
||||
if (!acquired)
|
||||
{
|
||||
await connection.DisposeAsync();
|
||||
return null;
|
||||
}
|
||||
|
||||
return new AdvisoryLockLease(connection, processor);
|
||||
}
|
||||
|
||||
private sealed class AdvisoryLockLease(NpgsqlConnection connection, string processor) : IAsyncDisposable
|
||||
{
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (connection.State == ConnectionState.Open)
|
||||
{
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = "SELECT pg_advisory_unlock(hashtextextended(@processor, 0))";
|
||||
command.Parameters.AddWithValue("processor", processor);
|
||||
await command.ExecuteScalarAsync();
|
||||
}
|
||||
await connection.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal abstract class PeriodicWorker(
|
||||
ILogger logger,
|
||||
IPeriodicProcessorLock processorLock,
|
||||
WorkerStateReporter stateReporter,
|
||||
string processorName,
|
||||
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
|
||||
{
|
||||
await using var lease = await processorLock.TryAcquireAsync(processorName, stoppingToken);
|
||||
if (lease is not null)
|
||||
{
|
||||
var iterationTimestamp = Stopwatch.GetTimestamp();
|
||||
await stateReporter.StartedAsync(processorName, stoppingToken);
|
||||
try
|
||||
{
|
||||
var processed = await ProcessAsync(stoppingToken);
|
||||
await stateReporter.CompletedAsync(processorName, null, stoppingToken);
|
||||
WorkerTelemetry.RecordIteration(processorName, true, Stopwatch.GetElapsedTime(iterationTimestamp).TotalMilliseconds);
|
||||
if (processed > 0)
|
||||
{
|
||||
logger.LogInformation("{Worker} processed {Count} items.", GetType().Name, processed);
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
await stateReporter.CompletedAsync(processorName, exception, CancellationToken.None);
|
||||
WorkerTelemetry.RecordIteration(processorName, false, Stopwatch.GetElapsedTime(iterationTimestamp).TotalMilliseconds);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
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 TenantDomainWorker(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IPeriodicProcessorLock processorLock,
|
||||
WorkerStateReporter stateReporter,
|
||||
IOptions<DomainLifecycleOptions> domainOptions,
|
||||
IOptions<WorkerOptions> workerOptions,
|
||||
ILogger<TenantDomainWorker> logger)
|
||||
: PeriodicWorker(logger, processorLock, stateReporter, "tenant-domain-lifecycle",
|
||||
TimeSpan.FromSeconds(Math.Clamp(domainOptions.Value.PollSeconds, 10, 3600)), workerOptions.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 worker");
|
||||
return await scope.ServiceProvider.GetRequiredService<ITenantDomainLifecycleService>()
|
||||
.ProcessPendingAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class SaasSubscriptionWorker(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IPeriodicProcessorLock processorLock,
|
||||
WorkerStateReporter stateReporter,
|
||||
IOptions<WorkerOptions> options,
|
||||
ILogger<SaasSubscriptionWorker> logger)
|
||||
: PeriodicWorker(logger, processorLock, stateReporter, "saas-subscription-lifecycle", 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 worker");
|
||||
return await scope.ServiceProvider.GetRequiredService<ISaasSubscriptionLifecycleService>()
|
||||
.ProcessDueAsync(cancellationToken: cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FeatureUsageWorker(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IPeriodicProcessorLock processorLock,
|
||||
WorkerStateReporter stateReporter,
|
||||
IOptions<FeatureUsageReconciliationOptions> featureOptions,
|
||||
IOptions<WorkerOptions> workerOptions,
|
||||
ILogger<FeatureUsageWorker> logger)
|
||||
: PeriodicWorker(logger, processorLock, stateReporter, "feature-usage-reconciliation",
|
||||
TimeSpan.FromMinutes(Math.Clamp(featureOptions.Value.IntervalMinutes, 1, 1440)), workerOptions.Value.Enabled)
|
||||
{
|
||||
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,
|
||||
IPeriodicProcessorLock processorLock,
|
||||
WorkerStateReporter stateReporter,
|
||||
IOptions<WorkerOptions> options,
|
||||
ILogger<BackgroundJobsWorker> logger)
|
||||
: PeriodicWorker(logger, processorLock, stateReporter, "background-jobs", 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 worker");
|
||||
return await scope.ServiceProvider.GetRequiredService<IBackgroundJobService>()
|
||||
.ProcessPendingAsync($"{workerId}:{index}", batchSize, includeImmediateJobs: true, cancellationToken: cancellationToken);
|
||||
}
|
||||
}
|
||||
7
Tiku.Worker/appsettings.Development.json
Normal file
7
Tiku.Worker/appsettings.Development.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"Serilog": {
|
||||
"MinimumLevel": {
|
||||
"Default": "Debug"
|
||||
}
|
||||
}
|
||||
}
|
||||
90
Tiku.Worker/appsettings.json
Normal file
90
Tiku.Worker/appsettings.json
Normal file
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"Serilog": {
|
||||
"MinimumLevel": {
|
||||
"Default": "Information",
|
||||
"Override": {
|
||||
"Microsoft": "Warning",
|
||||
"Microsoft.EntityFrameworkCore.Database.Command": "Warning",
|
||||
"System.Net.Http.HttpClient": "Warning"
|
||||
}
|
||||
},
|
||||
"WriteTo": [
|
||||
{
|
||||
"Name": "Console"
|
||||
}
|
||||
],
|
||||
"Properties": {
|
||||
"Application": "Tiku.Worker"
|
||||
}
|
||||
},
|
||||
"OpenTelemetry": {
|
||||
"OtlpEndpoint": ""
|
||||
},
|
||||
"Worker": {
|
||||
"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
|
||||
},
|
||||
"Storage": {
|
||||
"DefaultProvider": "aliyun_oss",
|
||||
"DefaultBucket": "tenant-assets",
|
||||
"PublicBaseUrl": "",
|
||||
"MaxUploadBytes": 524288000,
|
||||
"AllowedMimePrefixes": [
|
||||
"image/",
|
||||
"video/",
|
||||
"audio/"
|
||||
],
|
||||
"AllowedMimeTypes": [
|
||||
"application/pdf",
|
||||
"application/json",
|
||||
"application/gzip",
|
||||
"text/csv",
|
||||
"application/vnd.ms-excel",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
],
|
||||
"RequireTenantPrefix": true,
|
||||
"AliyunOss": {
|
||||
"Region": "",
|
||||
"Endpoint": "",
|
||||
"AccessKeyId": "",
|
||||
"AccessKeySecret": "",
|
||||
"SecurityToken": "",
|
||||
"UseInternalEndpoint": false,
|
||||
"UsePathStyle": false,
|
||||
"UseCName": false,
|
||||
"PresignDefaultMinutes": 15
|
||||
}
|
||||
},
|
||||
"Security": {
|
||||
"ClamAV": {
|
||||
"Host": "localhost",
|
||||
"Port": 3310,
|
||||
"TimeoutSeconds": 30,
|
||||
"ChunkBytes": 65536,
|
||||
"StreamMaxLength": 524288000
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user