From c536c965de7b5e5c184395952ab8ef336fa876e9 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Mon, 20 Jul 2026 09:44:15 +0200 Subject: [PATCH] feat(domain): RegistratieVerlopen worker + processor expire on document timeout (refs #102) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds RegistratieVerlopenJob, IRegistratieVerlopenClient, the ExpireRegistrationWorker application handler, and the RegistratieVerlopenProcessor drain loop — the timeout counterpart to the OpenZaak/escalation worker trios. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ExpireRegistrationWorker.cs | 34 +++++++++++++++ services/domain/Big.Application/Ports.cs | 8 ++++ .../IExternalWorkerClient.cs | 16 ++++++++ .../RegistratieVerlopenProcessor.cs | 41 +++++++++++++++++++ 4 files changed, 99 insertions(+) create mode 100644 services/domain/Big.Application/ExpireRegistrationWorker.cs create mode 100644 services/domain/Big.Infrastructure/RegistratieVerlopenProcessor.cs diff --git a/services/domain/Big.Application/ExpireRegistrationWorker.cs b/services/domain/Big.Application/ExpireRegistrationWorker.cs new file mode 100644 index 0000000..6e900e7 --- /dev/null +++ b/services/domain/Big.Application/ExpireRegistrationWorker.cs @@ -0,0 +1,34 @@ +using Big.Domain; + +namespace Big.Application; + +/// +/// Handles one acquired RegistratieVerlopen external-worker job (S-10a, ADR-0017): load the +/// registration the job correlates to and expire it to VERLOPEN — the 30-day document-wait timer fired +/// before the documents arrived, so the case is cancelled. Pure application logic over ports; it knows +/// nothing of Flowable. The polling loop that feeds it jobs lives in Infrastructure. Mirrors +/// . +/// +public sealed class ExpireRegistrationWorker(IRegistrationStore store) +{ + /// + /// Process the job. Idempotent: a redelivered job whose registration is already VERLOPEN is a + /// no-op — not persisted again (§8.6, at-least-once delivery). An unknown registration is an error: + /// it throws, leaving the job un-completed for Flowable to redeliver. + /// + public async Task HandleAsync(RegistratieVerlopenJob job, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(job); + + var registration = await store.GetAsync(job.RegistrationId, ct) + ?? throw new InvalidOperationException( + $"No registration {job.RegistrationId} for RegistratieVerlopen job {job.JobId}."); + + // A redelivered job whose registration is already VERLOPEN completes without persisting again. + if (registration.Status == RegistrationStatus.Verlopen) + return; + + registration.Expire(); + await store.SaveAsync(registration, ct); + } +} diff --git a/services/domain/Big.Application/Ports.cs b/services/domain/Big.Application/Ports.cs index b89ac82..492b232 100644 --- a/services/domain/Big.Application/Ports.cs +++ b/services/domain/Big.Application/Ports.cs @@ -95,3 +95,11 @@ public sealed record OpenZaakJob(string JobId, RegistrationId RegistrationId); /// once the 14-day boundary timer fires (ADR-0015). /// public sealed record EscalatieJob(string JobId, string ProcessInstanceId); + +/// +/// An acquired RegistratieVerlopen job (S-10a): the Flowable job id and the registration id it +/// carries as a process variable. The 30-day boundary timer on WachtOpDocumenten spawns it when +/// the required documents were not supplied in time; expiring the correlated registration to VERLOPEN +/// cancels the case (ADR-0017). +/// +public sealed record RegistratieVerlopenJob(string JobId, RegistrationId RegistrationId); diff --git a/services/domain/Big.Infrastructure/IExternalWorkerClient.cs b/services/domain/Big.Infrastructure/IExternalWorkerClient.cs index ade8872..912ff59 100644 --- a/services/domain/Big.Infrastructure/IExternalWorkerClient.cs +++ b/services/domain/Big.Infrastructure/IExternalWorkerClient.cs @@ -36,3 +36,19 @@ public interface IBeoordelingEscalatieClient /// Complete an acquired escalation job so its token reaches the escalation end event. Task CompleteBeoordelingEscalatieJobAsync(string jobId, CancellationToken ct = default); } + +/// +/// The document-timeout side of the Workflow Client (S-10a): the RegistratieVerlopen +/// external-worker jobs parked by the 30-day boundary timer on WachtOpDocumenten. Kept separate +/// from the other worker ports (interface segregation) so neither the OpenZaak nor escalation worker +/// sees expiry. Implemented by — the only code that talks to +/// Flowable (§8.2, ADR-0017). +/// +public interface IRegistratieVerlopenClient +{ + /// Acquire and lock up to RegistratieVerlopen jobs. + Task> AcquireRegistratieVerlopenJobsAsync(int maxJobs, CancellationToken ct = default); + + /// Complete an acquired expiry job so its token reaches the endVerlopen end event. + Task CompleteRegistratieVerlopenJobAsync(string jobId, CancellationToken ct = default); +} diff --git a/services/domain/Big.Infrastructure/RegistratieVerlopenProcessor.cs b/services/domain/Big.Infrastructure/RegistratieVerlopenProcessor.cs new file mode 100644 index 0000000..681e765 --- /dev/null +++ b/services/domain/Big.Infrastructure/RegistratieVerlopenProcessor.cs @@ -0,0 +1,41 @@ +using Big.Application; +using Microsoft.Extensions.Logging; + +namespace Big.Infrastructure; + +/// +/// One poll tick of the document-timeout worker (S-10a, ADR-0017): acquire the parked +/// RegistratieVerlopen jobs — the tokens the 30-day boundary timer on WachtOpDocumenten +/// spawns — expire each correlated registration via the , and +/// complete the job so its token reaches endVerlopen. A job that fails is logged and left +/// un-completed so Flowable redelivers it (§8.6). Split out from the hosted pump so the +/// acquire→expire→complete logic is unit-testable without a running host. Mirrors +/// and . +/// +public sealed class RegistratieVerlopenProcessor( + IRegistratieVerlopenClient client, + ExpireRegistrationWorker worker, + ILogger logger) +{ + /// Acquire and process up to jobs. Returns the number acquired. + public async Task PumpOnceAsync(int maxJobs, CancellationToken ct = default) + { + var jobs = await client.AcquireRegistratieVerlopenJobsAsync(maxJobs, ct); + + foreach (var job in jobs) + { + try + { + await worker.HandleAsync(job, ct); + await client.CompleteRegistratieVerlopenJobAsync(job.JobId, ct); + } + catch (Exception ex) + { + // Leave the job un-completed: its lock expires and Flowable redelivers it (§8.6). + logger.LogError(ex, "RegistratieVerlopen job {JobId} failed; leaving it for redelivery.", job.JobId); + } + } + + return jobs.Count; + } +}