From dbfa109a666dc60f6a4819f4e8c1e8b7d8b469a4 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 11 Jan 2026 16:49:04 +0100 Subject: [PATCH 1/3] feat: async payrun job processing to prevent HTTP timeouts Implement asynchronous payrun job processing using Channel and BackgroundService: - Return HTTP 202 Accepted immediately with Location header for status polling - Process employees in background worker service - Prevent HTTP 524 timeout errors when processing large payrolls (500+ employees) Breaking change: POST /api/tenants/{id}/payruns/jobs now returns HTTP 202 instead of 201 --- Api/Api.Controller/PayrunJobController.cs | 110 ++++----- Api/Api.Core/AcceptedObjectResult.cs | 11 + Backend.Controller/PayrunJobController.cs | 13 +- Backend.Server/PayrunJobWorkerService.cs | 210 ++++++++++++++++++ Backend.Server/ProviderStartupExtensions.cs | 1 + Backend.Server/Startup.cs | 6 + Domain/Domain.Application/PayrunJobQueue.cs | 43 ++++ .../Service/IPayrunJobQueue.cs | 23 ++ .../Service/PayrunJobQueueItem.cs | 34 +++ PR_DESCRIPTION.md | 74 ++++++ 10 files changed, 470 insertions(+), 55 deletions(-) create mode 100644 Api/Api.Core/AcceptedObjectResult.cs create mode 100644 Backend.Server/PayrunJobWorkerService.cs create mode 100644 Domain/Domain.Application/PayrunJobQueue.cs create mode 100644 Domain/Domain.Application/Service/IPayrunJobQueue.cs create mode 100644 Domain/Domain.Application/Service/PayrunJobQueueItem.cs create mode 100644 PR_DESCRIPTION.md diff --git a/Api/Api.Controller/PayrunJobController.cs b/Api/Api.Controller/PayrunJobController.cs index 6b88e32..6039538 100644 --- a/Api/Api.Controller/PayrunJobController.cs +++ b/Api/Api.Controller/PayrunJobController.cs @@ -1,6 +1,6 @@ using System; using System.Collections.Generic; -using System.Diagnostics; +using System.Globalization; using System.Linq; using System.Threading.Tasks; using PayrollEngine.Api.Core; @@ -19,12 +19,13 @@ namespace PayrollEngine.Api.Controller; /// API controller for the payrun jobs /// public abstract class PayrunJobController(ITenantService tenantService, IPayrunJobService payrunJobService, - IWebhookDispatchService webhookDispatcher, IControllerRuntime runtime) + IWebhookDispatchService webhookDispatcher, IPayrunJobQueue payrunJobQueue, IControllerRuntime runtime) : RepositoryChildObjectController(tenantService, payrunJobService, runtime, new PayrunJobMap()) { private IWebhookDispatchService WebhookDispatcher { get; } = webhookDispatcher ?? throw new ArgumentNullException(nameof(webhookDispatcher)); + private IPayrunJobQueue PayrunJobQueue { get; } = payrunJobQueue ?? throw new ArgumentNullException(nameof(payrunJobQueue)); private PayrunJobServiceSettings ServiceSettings => Service.Settings; public virtual async Task QueryEmployeePayrunJobsAsync(int tenantId, int employeeId, Query query) @@ -75,16 +76,15 @@ private async Task> QueryEmployeeJobsCountAsync(int tenantId, } /// - /// Start a new payrun job + /// Start a new payrun job (asynchronously). + /// The job is queued for background processing and returns immediately with HTTP 202 Accepted. + /// Use the Location header to poll for job status. /// /// The tenant id - /// The payrun jobs to add - /// The started payrun job + /// The payrun job invocation + /// HTTP 202 Accepted with the payrun job and Location header for status polling public virtual async Task> StartPayrunJobAsync(int tenantId, ApiObject.PayrunJobInvocation jobInvocation) { - var stopwatch = new Stopwatch(); - stopwatch.Start(); - // tenant var tenant = await ParentService.GetAsync(Runtime.DbContext, tenantId); if (tenant == null) @@ -135,53 +135,63 @@ private async Task> QueryEmployeeJobsCountAsync(int tenantId, } } - // processor try { - // settings - var serverConfiguration = Runtime.Configuration.GetConfiguration(); - - var processor = new PayrunProcessor( - tenant, - payrun, - new() - { - DbContext = Runtime.DbContext, - UserRepository = ServiceSettings.UserRepository, - DivisionRepository = ServiceSettings.DivisionRepository, - TaskRepository = ServiceSettings.TaskRepository, - LogRepository = ServiceSettings.LogRepository, - EmployeeRepository = ServiceSettings.EmployeeRepository, - GlobalCaseValueRepository = ServiceSettings.GlobalCaseValueRepository, - NationalCaseValueRepository = ServiceSettings.NationalCaseValueRepository, - CompanyCaseValueRepository = ServiceSettings.CompanyCaseValueRepository, - EmployeeCaseValueRepository = ServiceSettings.EmployeeCaseValueRepository, - PayrunRepository = ServiceSettings.PayrunRepository, - PayrunJobRepository = ServiceSettings.PayrunJobRepository, - RegulationLookupSetRepository = ServiceSettings.RegulationLookupSetRepository, - RegulationRepository = ServiceSettings.RegulationRepository, - RegulationShareRepository = ServiceSettings.RegulationShareRepository, - PayrollRepository = ServiceSettings.PayrollRepository, - PayrollResultRepository = ServiceSettings.PayrollResultRepository, - PayrollConsolidatedResultRepository = ServiceSettings.PayrollConsolidatedResultRepository, - PayrollResultSetRepository = ServiceSettings.PayrollResultSetRepository, - CalendarRepository = ServiceSettings.CalendarRepository, - PayrollCalculatorProvider = ServiceSettings.PayrollCalculatorProvider, - WebhookDispatchService = WebhookDispatcher, - FunctionLogTimeout = serverConfiguration.FunctionLogTimeout, - AssemblyCacheTimeout = serverConfiguration.AssemblyCacheTimeout, - ScriptProvider = Runtime.ScriptProvider, - }); - - // job + // Map API model to domain model var domainJobInvocation = new PayrunJobInvocationMap().ToDomain(jobInvocation); - var payrunJob = await processor.Process(domainJobInvocation); - stopwatch.Stop(); - Log.Debug($"Created job {payrunJob.Name}: {stopwatch.ElapsedMilliseconds} ms"); + // Get payroll and division for job creation + var payroll = await ServiceSettings.PayrollRepository.GetAsync(Runtime.DbContext, tenantId, payrollId); + var division = await ServiceSettings.DivisionRepository.GetAsync(Runtime.DbContext, tenantId, payroll.DivisionId); + + // Get calendar for period info + var calendarName = division.Calendar ?? tenant.Calendar; + Domain.Model.Calendar calendar = null; + if (!string.IsNullOrWhiteSpace(calendarName)) + { + calendar = await ServiceSettings.CalendarRepository.GetByNameAsync(Runtime.DbContext, tenantId, calendarName); + } + calendar ??= new Domain.Model.Calendar(); // default calendar + + // Get calculator for period info + var calculator = ServiceSettings.PayrollCalculatorProvider.CreateCalculator( + tenantId, domainJobInvocation.UserId, + CultureInfo.CurrentCulture, + calendar); + + // Create the payrun job + var payrunJob = PayrunJobFactory.CreatePayrunJob( + jobInvocation: domainJobInvocation, + divisionId: division.Id, + payrollId: payrollId, + payrollCalculator: calculator); + + // Set initial status for async processing + payrunJob.JobStart = Date.Now; + payrunJob.JobStatus = PayrunJobStatus.Process; + payrunJob.Message = "Payrun job queued for background processing"; + + // Persist the job to database + await ServiceSettings.PayrunJobRepository.CreateAsync(Runtime.DbContext, tenantId, payrunJob); + + // Update invocation with job ID + domainJobInvocation.PayrunJobId = payrunJob.Id; + + // Enqueue for background processing + await PayrunJobQueue.EnqueueAsync(new PayrunJobQueueItem + { + TenantId = tenantId, + PayrunJobId = payrunJob.Id, + Tenant = tenant, + Payrun = payrun, + JobInvocation = domainJobInvocation + }); + + Log.Information($"Queued payrun job {payrunJob.Id} for background processing"); - // created resource - return new CreatedObjectResult(Request.Path, MapDomainToApi(payrunJob)); + // Return HTTP 202 Accepted with Location header + var statusUrl = $"{Request.Path}/{payrunJob.Id}/status"; + return new AcceptedObjectResult(statusUrl, MapDomainToApi(payrunJob)); } catch (PayrunException exception) { diff --git a/Api/Api.Core/AcceptedObjectResult.cs b/Api/Api.Core/AcceptedObjectResult.cs new file mode 100644 index 0000000..0592024 --- /dev/null +++ b/Api/Api.Core/AcceptedObjectResult.cs @@ -0,0 +1,11 @@ +using System; +using Microsoft.AspNetCore.Mvc; + +namespace PayrollEngine.Api.Core; + +/// +/// API controller accepted result for async operations. +/// Returns HTTP 202 Accepted with a Location header for status polling. +/// +public class AcceptedObjectResult(string statusLocationPath, object value) + : AcceptedResult(new Uri(statusLocationPath, UriKind.Relative), value); diff --git a/Backend.Controller/PayrunJobController.cs b/Backend.Controller/PayrunJobController.cs index bc5582c..c1b2afe 100644 --- a/Backend.Controller/PayrunJobController.cs +++ b/Backend.Controller/PayrunJobController.cs @@ -1,5 +1,6 @@ using System.ComponentModel.DataAnnotations; using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using PayrollEngine.Api.Core; using PayrollEngine.Domain.Application.Service; @@ -15,8 +16,8 @@ public class PayrunJobController : Api.Controller.PayrunJobController { /// public PayrunJobController(ITenantService tenantService, IPayrunJobService payrunJobService, - IWebhookDispatchService webhookDispatcher, IControllerRuntime runtime) : - base(tenantService, payrunJobService, webhookDispatcher, runtime) + IWebhookDispatchService webhookDispatcher, IPayrunJobQueue payrunJobQueue, IControllerRuntime runtime) : + base(tenantService, payrunJobService, webhookDispatcher, payrunJobQueue, runtime) { } @@ -89,13 +90,15 @@ public override async Task QueryEmployeePayrunJobsAsync( } /// - /// Start a new payrun job + /// Start a new payrun job (asynchronously). + /// The job is queued for background processing and returns immediately. + /// Use the Location header to poll for job status. /// /// The tenant id /// The payrun job invocation - /// The started payrun job + /// HTTP 202 Accepted with the payrun job and Location header for status polling [HttpPost] - [CreatedResponse] + [ProducesResponseType(StatusCodes.Status202Accepted)] [NotFoundResponse] [UnprocessableEntityResponse] [ApiOperationId("StartPayrunJob")] diff --git a/Backend.Server/PayrunJobWorkerService.cs b/Backend.Server/PayrunJobWorkerService.cs new file mode 100644 index 0000000..0a0bffa --- /dev/null +++ b/Backend.Server/PayrunJobWorkerService.cs @@ -0,0 +1,210 @@ +using System; +using System.Threading; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using PayrollEngine.Api.Core; +using PayrollEngine.Domain.Application; +using PayrollEngine.Domain.Application.Service; +using PayrollEngine.Domain.Model; +using PayrollEngine.Domain.Model.Repository; +using Task = System.Threading.Tasks.Task; + +namespace PayrollEngine.Backend.Server; + +/// +/// Background service that processes payrun jobs from the queue +/// +public class PayrunJobWorkerService : BackgroundService +{ + private readonly IPayrunJobQueue _queue; + private readonly IServiceScopeFactory _scopeFactory; + + /// + /// Initializes a new instance of the PayrunJobWorkerService + /// + public PayrunJobWorkerService( + IPayrunJobQueue queue, + IServiceScopeFactory scopeFactory) + { + _queue = queue ?? throw new ArgumentNullException(nameof(queue)); + _scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory)); + } + + /// + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + Log.Information("Payrun Job Worker Service started"); + + while (!stoppingToken.IsCancellationRequested) + { + PayrunJobQueueItem queueItem = null; + try + { + // Wait for a job to be available + queueItem = await _queue.DequeueAsync(stoppingToken); + + Log.Information( + $"Processing payrun job {queueItem.PayrunJobId} for tenant {queueItem.TenantId}"); + + await ProcessJobAsync(queueItem, stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + // Graceful shutdown - mark in-progress job as aborted if exists + if (queueItem != null) + { + await MarkJobAbortedAsync(queueItem, "Service shutdown"); + } + break; + } + catch (Exception ex) + { + Log.Error(ex, + $"Error processing payrun job {queueItem?.PayrunJobId}: {ex.Message}"); + + if (queueItem != null) + { + await MarkJobAbortedAsync(queueItem, ex.Message); + } + } + } + + Log.Information("Payrun Job Worker Service stopped"); + } + + private async Task ProcessJobAsync( + PayrunJobQueueItem queueItem, + CancellationToken cancellationToken) + { + // Create a new scope for each job to get fresh scoped services + using var scope = _scopeFactory.CreateScope(); + var serviceProvider = scope.ServiceProvider; + + // Get required services + var dbContext = serviceProvider.GetRequiredService(); + var configuration = serviceProvider.GetRequiredService(); + var serverConfiguration = configuration.GetConfiguration(); + var scriptProvider = serviceProvider.GetRequiredService(); + var webhookDispatcher = serviceProvider.GetRequiredService(); + + // Build processor settings from scoped services + var processorSettings = BuildProcessorSettings(serviceProvider, serverConfiguration, scriptProvider); + + // Create and run processor + var processor = new PayrunProcessor( + queueItem.Tenant, + queueItem.Payrun, + processorSettings); + + // Process the job (this is the long-running operation) + var payrunJob = await processor.Process(queueItem.JobInvocation); + + Log.Information( + $"Completed payrun job {payrunJob.Id} with status {payrunJob.JobStatus}"); + + // Send webhook notification for job completion + await SendJobCompletionWebhookAsync( + dbContext, + webhookDispatcher, + queueItem.TenantId, + payrunJob, + queueItem.JobInvocation.UserId); + } + + private static PayrunProcessorSettings BuildProcessorSettings( + IServiceProvider serviceProvider, + PayrollServerConfiguration serverConfiguration, + IScriptProvider scriptProvider) + { + return new PayrunProcessorSettings + { + DbContext = serviceProvider.GetRequiredService(), + UserRepository = serviceProvider.GetRequiredService(), + DivisionRepository = serviceProvider.GetRequiredService(), + TaskRepository = serviceProvider.GetRequiredService(), + LogRepository = serviceProvider.GetRequiredService(), + EmployeeRepository = serviceProvider.GetRequiredService(), + GlobalCaseValueRepository = serviceProvider.GetRequiredService(), + NationalCaseValueRepository = serviceProvider.GetRequiredService(), + CompanyCaseValueRepository = serviceProvider.GetRequiredService(), + EmployeeCaseValueRepository = serviceProvider.GetRequiredService(), + PayrunRepository = serviceProvider.GetRequiredService(), + PayrunJobRepository = serviceProvider.GetRequiredService(), + RegulationLookupSetRepository = serviceProvider.GetRequiredService(), + RegulationRepository = serviceProvider.GetRequiredService(), + RegulationShareRepository = serviceProvider.GetRequiredService(), + PayrollRepository = serviceProvider.GetRequiredService(), + PayrollResultRepository = serviceProvider.GetRequiredService(), + PayrollConsolidatedResultRepository = serviceProvider.GetRequiredService(), + PayrollResultSetRepository = serviceProvider.GetRequiredService(), + CalendarRepository = serviceProvider.GetRequiredService(), + PayrollCalculatorProvider = serviceProvider.GetRequiredService(), + WebhookDispatchService = serviceProvider.GetRequiredService(), + FunctionLogTimeout = serverConfiguration.FunctionLogTimeout, + AssemblyCacheTimeout = serverConfiguration.AssemblyCacheTimeout, + ScriptProvider = scriptProvider + }; + } + + private async Task MarkJobAbortedAsync(PayrunJobQueueItem queueItem, string reason) + { + try + { + using var scope = _scopeFactory.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var payrunJobRepository = scope.ServiceProvider.GetRequiredService(); + + var job = await payrunJobRepository.GetAsync( + dbContext, queueItem.TenantId, queueItem.PayrunJobId); + + if (job != null && !job.JobStatus.IsFinal()) + { + job.JobStatus = PayrunJobStatus.Abort; + job.JobEnd = Date.Now; + job.ErrorMessage = reason; + job.Message = $"Job aborted: {reason}"; + + await payrunJobRepository.UpdateAsync(dbContext, queueItem.TenantId, job); + + Log.Warning( + $"Marked payrun job {queueItem.PayrunJobId} as aborted: {reason}"); + } + } + catch (Exception ex) + { + Log.Error(ex, + $"Failed to mark job {queueItem.PayrunJobId} as aborted"); + } + } + + private static async Task SendJobCompletionWebhookAsync( + IDbContext dbContext, + IWebhookDispatchService webhookDispatcher, + int tenantId, + PayrunJob payrunJob, + int userId) + { + try + { + var action = payrunJob.JobStatus == PayrunJobStatus.Complete + ? WebhookAction.PayrunJobFinish + : WebhookAction.PayrunJobProcess; + + var json = PayrollEngine.Serialization.DefaultJsonSerializer.Serialize(payrunJob); + + await webhookDispatcher.SendMessageAsync(dbContext, tenantId, + new() + { + Action = action, + RequestMessage = json + }, + userId: userId); + } + catch (Exception ex) + { + // Webhook failure should not fail the job + Log.Warning(ex, $"Failed to send webhook for job {payrunJob.Id}"); + } + } +} diff --git a/Backend.Server/ProviderStartupExtensions.cs b/Backend.Server/ProviderStartupExtensions.cs index e6e8fa1..c41d813 100644 --- a/Backend.Server/ProviderStartupExtensions.cs +++ b/Backend.Server/ProviderStartupExtensions.cs @@ -187,6 +187,7 @@ public static IServiceCollection AddLocalApiServices(this IServiceCollection ser ctx.GetRequiredService(), ctx.GetRequiredService(), ctx.GetRequiredService(), + ctx.GetRequiredService(), ctx.GetRequiredService())); // regulation controllers diff --git a/Backend.Server/Startup.cs b/Backend.Server/Startup.cs index 42b730a..e196cc0 100644 --- a/Backend.Server/Startup.cs +++ b/Backend.Server/Startup.cs @@ -5,6 +5,8 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using PayrollEngine.Api.Core; +using PayrollEngine.Domain.Application; +using PayrollEngine.Domain.Application.Service; namespace PayrollEngine.Backend.Server; @@ -53,6 +55,10 @@ public void ConfigureServices(IServiceCollection services) Convert.ToInt32(serverConfiguration.DbCommandTimeout.TotalSeconds)); services.AddApiServices(Configuration, apiSpecification, dbContext); services.AddLocalApiServices(); + + // Payrun job background processing + services.AddSingleton(); + services.AddHostedService(); } /// diff --git a/Domain/Domain.Application/PayrunJobQueue.cs b/Domain/Domain.Application/PayrunJobQueue.cs new file mode 100644 index 0000000..637f9ae --- /dev/null +++ b/Domain/Domain.Application/PayrunJobQueue.cs @@ -0,0 +1,43 @@ +using System; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using PayrollEngine.Domain.Application.Service; + +namespace PayrollEngine.Domain.Application; + +/// +/// Background queue for payrun job processing using System.Threading.Channels +/// +public class PayrunJobQueue : IPayrunJobQueue +{ + private readonly Channel _queue; + + /// + /// Initializes a new instance of the PayrunJobQueue + /// + public PayrunJobQueue() + { + // Unbounded channel - jobs will queue without blocking + // This is appropriate since job creation is already validated + var options = new UnboundedChannelOptions + { + SingleReader = true, // Only one worker reads + SingleWriter = false // Multiple requests can write + }; + _queue = Channel.CreateUnbounded(options); + } + + /// + public async ValueTask EnqueueAsync(PayrunJobQueueItem item) + { + ArgumentNullException.ThrowIfNull(item); + await _queue.Writer.WriteAsync(item); + } + + /// + public async ValueTask DequeueAsync(CancellationToken cancellationToken) + { + return await _queue.Reader.ReadAsync(cancellationToken); + } +} diff --git a/Domain/Domain.Application/Service/IPayrunJobQueue.cs b/Domain/Domain.Application/Service/IPayrunJobQueue.cs new file mode 100644 index 0000000..0ff4298 --- /dev/null +++ b/Domain/Domain.Application/Service/IPayrunJobQueue.cs @@ -0,0 +1,23 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace PayrollEngine.Domain.Application.Service; + +/// +/// Interface for the payrun job background processing queue +/// +public interface IPayrunJobQueue +{ + /// + /// Enqueue a payrun job for background processing + /// + /// The queue item containing job details + ValueTask EnqueueAsync(PayrunJobQueueItem item); + + /// + /// Dequeue a payrun job for processing + /// + /// Cancellation token + /// The next job to process + ValueTask DequeueAsync(CancellationToken cancellationToken); +} diff --git a/Domain/Domain.Application/Service/PayrunJobQueueItem.cs b/Domain/Domain.Application/Service/PayrunJobQueueItem.cs new file mode 100644 index 0000000..4b75b80 --- /dev/null +++ b/Domain/Domain.Application/Service/PayrunJobQueueItem.cs @@ -0,0 +1,34 @@ +using PayrollEngine.Domain.Model; + +namespace PayrollEngine.Domain.Application.Service; + +/// +/// Represents a payrun job queued for background processing +/// +public class PayrunJobQueueItem +{ + /// + /// The tenant ID + /// + public int TenantId { get; init; } + + /// + /// The created payrun job ID + /// + public int PayrunJobId { get; init; } + + /// + /// The tenant entity + /// + public Tenant Tenant { get; init; } + + /// + /// The payrun entity + /// + public Payrun Payrun { get; init; } + + /// + /// The original job invocation request + /// + public PayrunJobInvocation JobInvocation { get; init; } +} diff --git a/PR_DESCRIPTION.md b/PR_DESCRIPTION.md new file mode 100644 index 0000000..cfca090 --- /dev/null +++ b/PR_DESCRIPTION.md @@ -0,0 +1,74 @@ +# feat: Async payrun job processing to prevent HTTP timeouts + +## Summary +- Implement asynchronous payrun job processing using `Channel` and `BackgroundService` +- Return HTTP 202 Accepted immediately with Location header for status polling +- Prevent HTTP 524 timeout errors when processing large payrolls (500+ employees) + +## Problem +When starting a payrun job via `POST /tenants/{tenantId}/payruns/jobs` with a large number of employees, the HTTP request blocks until all payroll calculations are complete. For 1,000 employees at ~0.5-1s each, this results in 8-15+ minutes of blocking time, far exceeding typical gateway timeouts (e.g., Cloudflare's 100s limit results in HTTP 524 errors). + +## Solution +Decouple the HTTP response from job completion: +1. Create the PayrunJob record immediately in "Process" status +2. Enqueue the job for background processing via `Channel` +3. Return HTTP 202 Accepted with `Location` header pointing to status endpoint +4. `BackgroundService` processes jobs from the queue asynchronously +5. Clients poll `GET /api/tenants/{id}/payruns/jobs/{jobId}/status` for completion + +## Changes + +### New files +- `Domain/Domain.Application/Service/IPayrunJobQueue.cs` - Queue interface +- `Domain/Domain.Application/Service/PayrunJobQueueItem.cs` - Queue item DTO +- `Domain/Domain.Application/PayrunJobQueue.cs` - Channel-based queue implementation +- `Backend.Server/PayrunJobWorkerService.cs` - Background worker service +- `Api/Api.Core/AcceptedObjectResult.cs` - HTTP 202 result helper + +### Modified files +- `Api/Api.Controller/PayrunJobController.cs` - Async job creation + queue integration +- `Backend.Controller/PayrunJobController.cs` - Updated constructor and HTTP attributes +- `Backend.Server/ProviderStartupExtensions.cs` - DI registration +- `Backend.Server/Startup.cs` - Queue and worker service registration + +## Breaking Changes +- `POST /api/tenants/{id}/payruns/jobs` now returns **HTTP 202 Accepted** instead of HTTP 201 Created +- Response includes `Location` header for status polling +- Clients must poll status endpoint to determine job completion +- Job is returned in "Process" status, not "Complete" + +## Migration Guide +Clients should update their integration to: + +```csharp +// 1. Create job - now returns immediately +var response = await client.PostAsync("/api/tenants/{id}/payruns/jobs", content); +if (response.StatusCode == HttpStatusCode.Accepted) +{ + var statusUrl = response.Headers.Location; + var job = await response.Content.ReadFromJsonAsync(); + + // 2. Poll for completion + while (job.JobStatus == PayrunJobStatus.Process) + { + await Task.Delay(TimeSpan.FromSeconds(5)); + job = await client.GetFromJsonAsync(statusUrl); + } + + // 3. Check final status + if (job.JobStatus == PayrunJobStatus.Complete) + // Success + else if (job.JobStatus == PayrunJobStatus.Abort) + // Check job.ErrorMessage +} +``` + +## Test plan +- [ ] Build succeeds: `dotnet build PayrollEngine.Backend.sln` +- [ ] Unit tests pass: `dotnet test Domain/Domain.Model.Tests/` +- [ ] POST new payrun job returns HTTP 202 with Location header +- [ ] GET status endpoint returns current job status +- [ ] ProcessedEmployeeCount increments during processing +- [ ] Job status changes to Complete when finished +- [ ] Error scenario: job marked as Abort with ErrorMessage +- [ ] Graceful shutdown: in-progress job marked as Abort From 0e3ce921f44b6e46d0eb3e684c005529ecaaca96 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 12 Jan 2026 14:17:41 +0100 Subject: [PATCH 2/3] fix: handle pre-created payrun jobs in async processing PayrunProcessor now checks if a job was pre-created by the async controller (PayrunJobId > 0) and loads/updates it instead of always creating a new job. This fixes the bug where: - Controller creates job ID=3, enqueues with PayrunJobId=3 - Processor ignored PayrunJobId, created new job ID=4 - Result: job 3 stuck in "Process", job 4 orphaned with results Changes: - PayrunProcessor: detect pre-created jobs and load them - PayrunProcessor: use UpdateAsync instead of CreateAsync for existing jobs - PayrunJobFactory: add UpdatePayrunJob() method for async flow --- Domain/Domain.Application/PayrunProcessor.cs | 60 ++++++++++++++++---- Domain/Domain.Model/PayrunJobFactory.cs | 49 ++++++++++++++++ 2 files changed, 98 insertions(+), 11 deletions(-) diff --git a/Domain/Domain.Application/PayrunProcessor.cs b/Domain/Domain.Application/PayrunProcessor.cs index e1205ed..defacf0 100644 --- a/Domain/Domain.Application/PayrunProcessor.cs +++ b/Domain/Domain.Application/PayrunProcessor.cs @@ -110,18 +110,42 @@ private async Task Process(PayrunJobInvocation jobInvocation, PayrunS calendarName: context.CalendarName); // create payrun job and retro payrun jobs - context.PayrunJob = PayrunJobFactory.CreatePayrunJob( - jobInvocation: jobInvocation, - divisionId: context.Division.Id, - payrollId: payrollId, - payrollCalculator: context.Calculator); + // Check if job was pre-created by async controller + if (jobInvocation.PayrunJobId > 0) + { + // Job already created by async controller - load and update it + context.PayrunJob = await processorRepositories.LoadPayrunJobAsync(jobInvocation.PayrunJobId); + if (context.PayrunJob == null) + { + throw new PayrunException($"Payrun job with id {jobInvocation.PayrunJobId} not found"); + } + // Update job with calculated period/cycle info from calculator + PayrunJobFactory.UpdatePayrunJob( + payrunJob: context.PayrunJob, + jobInvocation: jobInvocation, + divisionId: context.Division.Id, + payrollId: payrollId, + payrollCalculator: context.Calculator); + } + else + { + // Create new job (sync mode or retro jobs) + context.PayrunJob = PayrunJobFactory.CreatePayrunJob( + jobInvocation: jobInvocation, + divisionId: context.Division.Id, + payrollId: payrollId, + payrollCalculator: context.Calculator); + } if (context.PayrunJob.ParentJobId.HasValue) { context.ParentPayrunJob = await processorRepositories.LoadPayrunJobAsync(context.PayrunJob.ParentJobId.Value); context.RetroPayrunJobs = jobInvocation.RetroJobs; } - // update invocation - jobInvocation.PayrunJobId = context.PayrunJob.Id; + // update invocation (only needed for new jobs) + if (jobInvocation.PayrunJobId == 0) + { + jobInvocation.PayrunJobId = context.PayrunJob.Id; + } // context dates context.EvaluationDate = context.PayrunJob.EvaluationDate; @@ -225,10 +249,24 @@ private async Task Process(PayrunJobInvocation jobInvocation, PayrunS context.PayrunJob.Message = $"Started payrun calculation with payroll {payrollId} on period {context.PayrunJob.PeriodName} for cycle {context.PayrunJob.CycleName}"; Log.Debug(context.PayrunJob.Message); - await Settings.PayrunJobRepository.CreateAsync( - context: Settings.DbContext, - parentId: Tenant.Id, - item: context.PayrunJob); + + // Create new job or update existing pre-created job + if (context.PayrunJob.Id == 0) + { + // New job (sync mode or retro jobs) - insert + await Settings.PayrunJobRepository.CreateAsync( + context: Settings.DbContext, + parentId: Tenant.Id, + item: context.PayrunJob); + } + else + { + // Pre-created job (async mode) - update + await Settings.PayrunJobRepository.UpdateAsync( + context: Settings.DbContext, + parentId: Tenant.Id, + item: context.PayrunJob); + } // validate payroll regulations var validation = await new PayrollValidator(Settings.PayrollRepository).ValidateRegulations( diff --git a/Domain/Domain.Model/PayrunJobFactory.cs b/Domain/Domain.Model/PayrunJobFactory.cs index 1d4ce0e..3264da4 100644 --- a/Domain/Domain.Model/PayrunJobFactory.cs +++ b/Domain/Domain.Model/PayrunJobFactory.cs @@ -65,4 +65,53 @@ public static PayrunJob CreatePayrunJob(PayrunJobInvocation jobInvocation, int d return payrunJob; } + + /// + /// Update an existing payrun job with calculated values. + /// Used for async processing when the job was pre-created by the controller. + /// + /// The existing payrun job to update + /// The job invocation + /// The division id + /// The payroll id + /// The payroll calculator + public static void UpdatePayrunJob(PayrunJob payrunJob, PayrunJobInvocation jobInvocation, + int divisionId, int payrollId, IPayrollCalculator payrollCalculator) + { + if (payrunJob == null) + { + throw new ArgumentNullException(nameof(payrunJob)); + } + + // evaluation date: treat undefined as now + if (!jobInvocation.EvaluationDate.HasValue || jobInvocation.EvaluationDate.Value == Date.MinValue) + { + jobInvocation.EvaluationDate = Date.Now; + } + // fix local times + if (!jobInvocation.EvaluationDate.Value.IsUtc()) + { + jobInvocation.EvaluationDate = DateTime.SpecifyKind(jobInvocation.EvaluationDate.Value, DateTimeKind.Utc); + } + if (!jobInvocation.PeriodStart.IsUtc()) + { + jobInvocation.PeriodStart = DateTime.SpecifyKind(jobInvocation.PeriodStart, DateTimeKind.Utc); + } + + var evaluationCycle = payrollCalculator.GetPayrunCycle(jobInvocation.PeriodStart); + var evaluationPeriod = payrollCalculator.GetPayrunPeriod(jobInvocation.PeriodStart); + + // Update calculated cycle/period values + payrunJob.CycleName = evaluationCycle.Name; + payrunJob.CycleStart = evaluationCycle.Start; + payrunJob.CycleEnd = evaluationCycle.End; + payrunJob.PeriodName = evaluationPeriod.Name; + payrunJob.PeriodStart = evaluationPeriod.Start; + payrunJob.PeriodEnd = evaluationPeriod.End; + payrunJob.EvaluationDate = jobInvocation.EvaluationDate.Value; + + // Ensure division and payroll IDs are correct + payrunJob.DivisionId = divisionId; + payrunJob.PayrollId = payrollId; + } } \ No newline at end of file From 450cd59c86fe8b1c2e35beb20808a6fcef1a6225 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 12 Jan 2026 14:22:47 +0100 Subject: [PATCH 3/3] fix: set job status to Draft on completion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CompleteJobAsync was missing the JobStatus update, causing jobs to remain stuck in "Process" status after successful completion. Now sets JobStatus = Draft to match the original sync behavior where completed jobs await user review before Release → Complete. --- Domain/Domain.Application/PayrunProcessor.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Domain/Domain.Application/PayrunProcessor.cs b/Domain/Domain.Application/PayrunProcessor.cs index defacf0..fb1aa03 100644 --- a/Domain/Domain.Application/PayrunProcessor.cs +++ b/Domain/Domain.Application/PayrunProcessor.cs @@ -1292,6 +1292,7 @@ private async Task AbortJobAsync(PayrunJob payrunJob, string message, private async Task CompleteJobAsync(PayrunJob payrunJob) { // setup + payrunJob.JobStatus = PayrunJobStatus.Draft; payrunJob.JobEnd = Date.Now; payrunJob.Message = "Completed payrun calculation successfully"; Log.Debug(payrunJob.Message);