Files
tiku-backend.net/Tiku.IntegrationTests/Api/MonolithBackgroundProcessingTests.cs
xiong 4793ad1832
Some checks failed
ci / release-gate (push) Has been cancelled
fix(worker): enable safe multi-instance job processing
2026-08-04 14:29:11 +08:00

237 lines
10 KiB
C#

using System.Net;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using Tiku.Application.Jobs;
using Tiku.Domain.Operations;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Jobs;
using Tiku.Infrastructure.Persistence;
namespace Tiku.IntegrationTests.Api;
public sealed class MonolithBackgroundProcessingTests
{
[Fact]
public async Task Anonymous_readiness_is_minimal_and_does_not_expose_dependency_topology()
{
await using var factory = new ApiTestFactory();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/system/health/ready");
using var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("ready", document.RootElement.GetProperty("status").GetString());
Assert.False(document.RootElement.TryGetProperty("database", out _));
Assert.False(document.RootElement.TryGetProperty("redis", out _));
Assert.False(document.RootElement.TryGetProperty("rabbitMq", out _));
Assert.False(document.RootElement.TryGetProperty("outbox", out _));
}
[Fact]
public async Task Api_host_does_not_register_background_processors()
{
await using var factory = new ApiTestFactory();
using var client = factory.CreateClient();
var hostedServiceNames = factory.Services.GetServices<IHostedService>()
.Select(service => service.GetType().Name)
.ToArray();
Assert.DoesNotContain("TenantDomainWorker", hostedServiceNames);
Assert.DoesNotContain("SaasSubscriptionWorker", hostedServiceNames);
Assert.DoesNotContain("FeatureUsageWorker", hostedServiceNames);
Assert.DoesNotContain("BackgroundJobsWorker", hostedServiceNames);
}
[Fact]
public async Task Postgres_leases_recover_expired_work_without_duplicate_claims()
{
await using var factory = new ApiTestFactory();
var tenantId = Guid.NewGuid();
await factory.SeedAsync(new Tenant
{
Id = tenantId,
Slug = tenantId.ToString("N"),
Name = "Monolith Lease Recovery"
});
Guid expiredJobId;
Guid pendingJobId;
using (var scope = factory.CreateSystemScope("Seed lease recovery jobs"))
{
var jobs = scope.ServiceProvider.GetRequiredService<IBackgroundJobService>();
expiredJobId = (await jobs.EnqueueAsync(new CreateBackgroundJobCommand(
tenantId,
"statistics_aggregation",
JsonSerializer.SerializeToElement(new { scope = "tenant" })))).Id;
pendingJobId = (await jobs.EnqueueAsync(new CreateBackgroundJobCommand(
tenantId,
"statistics_aggregation",
JsonSerializer.SerializeToElement(new { scope = "tenant" })))).Id;
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
await dbContext.BackgroundJobs
.Where(job => job.Id == expiredJobId)
.ExecuteUpdateAsync(setters => setters
.SetProperty(job => job.Status, BackgroundJobStatus.Processing)
.SetProperty(job => job.LockedBy, "stopped-worker")
.SetProperty(job => job.LockExpiresAt, DateTimeOffset.UtcNow.AddSeconds(-1)));
}
var processed = await Task.WhenAll(
ProcessPendingAsync(factory, "monolith-lease-a"),
ProcessPendingAsync(factory, "monolith-lease-b"));
Assert.Equal(2, processed.Sum());
Assert.Equal(BackgroundJobStatus.Succeeded, await ReadStatusAsync(factory, expiredJobId));
Assert.Equal(BackgroundJobStatus.Succeeded, await ReadStatusAsync(factory, pendingJobId));
}
[Fact]
public async Task Active_job_lease_is_renewed_and_cannot_be_reclaimed()
{
var handler = new BlockingStatisticsHandler();
await using var factory = CreateLeaseFactory(handler);
var jobId = await SeedJobAsync(factory, "Lease Renewal");
var firstWorker = ProcessPendingAsync(factory, "lease-renewal-a");
await handler.Started.Task.WaitAsync(TimeSpan.FromSeconds(5));
await Task.Delay(TimeSpan.FromMilliseconds(700));
Assert.Equal(0, await ProcessPendingAsync(factory, "lease-renewal-b"));
handler.Release.TrySetResult();
Assert.Equal(1, await firstWorker);
Assert.Equal(BackgroundJobStatus.Succeeded, await ReadStatusAsync(factory, jobId));
}
[Fact]
public async Task Lost_job_lease_cancels_old_worker_without_overwriting_new_owner()
{
var handler = new BlockingStatisticsHandler();
await using var factory = CreateLeaseFactory(handler);
var jobId = await SeedJobAsync(factory, "Lease Ownership Loss");
var firstWorker = ProcessPendingAsync(factory, "lease-owner-a");
await handler.Started.Task.WaitAsync(TimeSpan.FromSeconds(5));
using (var scope = factory.CreateSystemScope("Transfer job lease ownership"))
{
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
await dbContext.BackgroundJobs.Where(job => job.Id == jobId)
.ExecuteUpdateAsync(setters => setters
.SetProperty(job => job.LockedBy, "lease-owner-b")
.SetProperty(job => job.LockExpiresAt, DateTimeOffset.UtcNow.AddMinutes(1)));
}
Assert.Equal(0, await firstWorker.WaitAsync(TimeSpan.FromSeconds(5)));
using var verificationScope = factory.CreateSystemScope("Verify transferred job lease ownership");
var stored = await verificationScope.ServiceProvider.GetRequiredService<TikuDbContext>()
.BackgroundJobs.AsNoTracking()
.SingleAsync(job => job.Id == jobId);
Assert.Equal(BackgroundJobStatus.Processing, stored.Status);
Assert.Equal("lease-owner-b", stored.LockedBy);
}
[Fact]
public async Task Cancelled_worker_leaves_processing_lease_for_expiry_recovery()
{
var handler = new BlockingStatisticsHandler();
await using var factory = CreateLeaseFactory(handler);
var jobId = await SeedJobAsync(factory, "Lease Cancellation Recovery");
using var cancellation = new CancellationTokenSource();
var firstWorker = ProcessPendingAsync(factory, "lease-cancelled-a", cancellation.Token);
await handler.Started.Task.WaitAsync(TimeSpan.FromSeconds(5));
cancellation.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => firstWorker);
using (var scope = factory.CreateSystemScope("Verify cancelled worker lease"))
{
var stored = await scope.ServiceProvider.GetRequiredService<TikuDbContext>()
.BackgroundJobs.AsNoTracking()
.SingleAsync(job => job.Id == jobId);
Assert.Equal(BackgroundJobStatus.Processing, stored.Status);
Assert.Equal("lease-cancelled-a", stored.LockedBy);
Assert.NotNull(stored.LockExpiresAt);
}
await Task.Delay(TimeSpan.FromMilliseconds(700));
Assert.Equal(1, await ProcessPendingAsync(factory, "lease-recovery-b"));
Assert.Equal(BackgroundJobStatus.Succeeded, await ReadStatusAsync(factory, jobId));
}
private static async Task<int> ProcessPendingAsync(
ApiTestFactory factory,
string workerId,
CancellationToken cancellationToken = default)
{
using var scope = factory.CreateSystemScope($"Process jobs with {workerId}");
return await scope.ServiceProvider.GetRequiredService<IBackgroundJobService>()
.ProcessPendingAsync(workerId, 1, cancellationToken: cancellationToken);
}
private static ApiTestFactory CreateLeaseFactory(BlockingStatisticsHandler handler)
{
return new ApiTestFactory(configureTestServices: services =>
{
services.RemoveAll<IBackgroundJobHandler>();
services.AddSingleton<IBackgroundJobHandler>(handler);
services.RemoveAll<BackgroundJobLeaseTiming>();
services.AddSingleton(new BackgroundJobLeaseTiming(
TimeSpan.FromMilliseconds(400),
TimeSpan.FromMilliseconds(50),
TimeSpan.FromMilliseconds(20)));
});
}
private static async Task<Guid> SeedJobAsync(ApiTestFactory factory, string tenantName)
{
var tenantId = Guid.NewGuid();
await factory.SeedAsync(new Tenant
{
Id = tenantId,
Slug = tenantId.ToString("N"),
Name = tenantName
});
using var scope = factory.CreateSystemScope($"Seed {tenantName} job");
return (await scope.ServiceProvider.GetRequiredService<IBackgroundJobService>()
.EnqueueAsync(new CreateBackgroundJobCommand(
tenantId,
"statistics_aggregation",
JsonSerializer.SerializeToElement(new { scope = "tenant" })))).Id;
}
private static async Task<BackgroundJobStatus> ReadStatusAsync(ApiTestFactory factory, Guid jobId)
{
using var scope = factory.CreateSystemScope("Read monolith background job status");
return await scope.ServiceProvider.GetRequiredService<TikuDbContext>()
.BackgroundJobs.AsNoTracking()
.Where(job => job.Id == jobId)
.Select(job => job.Status)
.SingleAsync();
}
private sealed class BlockingStatisticsHandler : IBackgroundJobHandler
{
private int calls;
public string JobType => "statistics_aggregation";
public TaskCompletionSource Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public TaskCompletionSource Release { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public async Task<BackgroundJobHandlerResult> HandleAsync(
BackgroundJobExecutionContext context,
CancellationToken cancellationToken = default)
{
if (Interlocked.Increment(ref calls) == 1)
{
Started.TrySetResult();
await Release.Task.WaitAsync(cancellationToken);
}
return new BackgroundJobHandlerResult(JsonSerializer.SerializeToElement(new { completed = true }));
}
}
}