From ccb268bdd526b162dca0904206ac6ca201c25f2a Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Thu, 23 Jul 2026 12:01:36 +0200 Subject: [PATCH] docs: ADR-0022 + demo note + backlog sync for the herregistratie sweep (refs #18) ADR-0022 records using Quartz.NET for time-triggered fleet sweeps (pumps stay as queue-drainers); demo-script and BACKLOG describe S-17's outcome. refs #18 --- BACKLOG.md | 4 +- .../architecture/adr-0022-quartz-scheduler.md | 79 +++++++++++++++++++ docs/demo-script.md | 29 +++++++ 3 files changed, 110 insertions(+), 2 deletions(-) create mode 100644 docs/architecture/adr-0022-quartz-scheduler.md diff --git a/BACKLOG.md b/BACKLOG.md index 29b80d5..5857963 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -257,9 +257,9 @@ Split (issue #11 closed) into two independently-demoable slices per §13 — the **Outcome:** Traces span portal → BFF → Domain → ACL → OpenZaak and portal → BFF → Domain → Flowable. Grafana dashboards pre-built for golden signals. -### S-17 · Quartz.NET scheduler — herregistratie reminder sweep +### S-17 · Quartz.NET scheduler — herregistratie reminder sweep ✅ -**Outcome:** Nightly job that finds entries within 90 days of expiry and emits a domain event. (No outbound notification in v1 — logged.) +**Outcome:** Daily Quartz.NET cron job finds inscriptions within 90 days of their herregistratie deadline and reminds each (flag on the aggregate + log). No outbound notification and no domain event in v1 — the reminder is the persisted flag, surfaced on the read model (ADR-0022, #120). Quartz fires time-triggered sweeps; the existing pumps stay as queue-drainers. --- diff --git a/docs/architecture/adr-0022-quartz-scheduler.md b/docs/architecture/adr-0022-quartz-scheduler.md new file mode 100644 index 0000000..43c5248 --- /dev/null +++ b/docs/architecture/adr-0022-quartz-scheduler.md @@ -0,0 +1,79 @@ +# ADR-0022: Quartz.NET for time-triggered fleet sweeps + +- **Status:** Accepted +- **Date:** 2026-07-23 +- **Deciders:** Respellion engineering +- **Slice:** S-17 (#18) · **Proposal issue:** #120 + +## Context + +A BIG inscription is valid for a fixed term; before it lapses the zorgprofessional +must herregistreren. S-17 adds a **herregistratie reminder sweep**: once a day, +scan the register for inscriptions whose deadline is within the reminder window and +remind each one. + +The Domain Service already runs periodic background work — `OpenZaakJobPump`, +`BeoordelingEscalatiePump`, `RegistratieVerlopenPump`. Those are **continuous job +pollers**: they drain Flowable's external-task/job queues at-least-once, picking up +work as soon as it is parked, on a short poll interval. The reminder sweep is a +different shape of work: **time-triggered**, once a day, over our own store — there +is no queue to drain and no "as soon as possible" requirement. + +The PRD already names the scheduler component: "Scheduler (Quartz.NET): fleet-wide +sweeps (expiry, reminders)" (§39, §94). Adding Quartz.NET is nonetheless a new +dependency, so this decision is recorded before the code lands (CLAUDE.md §14). + +## Decision + +**Use Quartz.NET for time-triggered fleet sweeps, starting with the herregistratie +reminder sweep. Leave the existing pumps as `BackgroundService` job pollers.** + +- `HerregistratieReminderJob` (a Quartz `IJob`) is fired by a cron trigger — daily + at 03:00 by default, overridable with `Quartz__Cron`. It is a thin shell: it + resolves the pure `HerregistratieReminderSweep` (application layer) and logs how + many reminders went out. +- The sweep's rule lives in the domain: `Registration.HerregistratieReminderDue(asOf)`, + which the store query and the sweep both build on. The sweep marks each reminded + inscription (`HerregistratieReminderVerstuurd`), so a re-fire reminds no one twice + (§8.6). + +Two options were rejected: + +1. **A `BackgroundService` with a 24h `Task.Delay`.** No new dependency, but it + drifts to process-start time, has no cron/misfire semantics, and contradicts the + PRD's named component. A daily "run at 03:00" is exactly what cron scheduling is + for. +2. **Migrating the three pumps onto Quartz too, for one mechanism.** Rejected: the + pumps are not schedulers. Forcing a "run at time T" tool onto "drain this queue + continuously" work is churn and a boundary change for negative benefit. The + teachable distinction is worth keeping: **pumps drain queues; Quartz fires + sweeps.** + +## Consequences + +**Positive** + +- Cron scheduling with restart-stable timing and misfire handling, for free. +- The reminder rule is one domain method, reused by the store query and the sweep; + the scheduler owns none of the policy. +- The reference app now demonstrates the intended Scheduler component. + +**Negative / costs** + +- One new dependency (`Quartz`, `Quartz.Extensions.Hosting`) in the Domain Service. +- Two periodic-work mechanisms coexist (pumps + Quartz). Deliberate — they model + two genuinely different concerns, documented here. + +**Follow-up** + +- The validity term (5 years) and reminder lead time (16 weeks) are domain + calibration knobs; promote them to beheer config (S-15) if a demo needs them + per-catalogus. +- The Quartz job stores its schedule in RAM (`RAMJobStore`); a persistent/clustered + store is a later concern if the Domain Service is scaled out. + +## Coupling rules touched (CLAUDE.md §8) + +None. Quartz is internal to the Domain Service and drives an application use case +over the store port. No ZGW or Flowable coupling is added; the sweep talks to no +peer module. diff --git a/docs/demo-script.md b/docs/demo-script.md index c94a4e0..eeebfbb 100644 --- a/docs/demo-script.md +++ b/docs/demo-script.md @@ -5,6 +5,35 @@ copy-pasteable walkthrough against a local `make up` stack. --- +## S-17 — herregistratie reminder sweep on a Quartz cron (#18, ADR-0022) + +**Outcome:** an inscription (INGESCHREVEN) now carries the moment it was entered in the register, from +which its herregistratie deadline is derived (inscription + 5-year validity). A **Quartz.NET** cron job +in the Domain Service sweeps once a day (03:00, overridable via `Quartz__Cron`): every inscription +inside the 90-day window before its deadline is flagged `HerregistratieReminderVerstuurd` and logged. +The sweep is idempotent — a re-fire reminds no one twice — and is a deliberately different mechanism +from the queue-draining pumps (Quartz fires time-triggered sweeps; pumps drain Flowable queues, +ADR-0022). There is no outbound notification in v1: the reminder is the flag on the aggregate plus a +log line. + +```bash +# 1. The domain unit tests prove the rule and the sweep end to end (rule → store query → sweep): +cd services/domain && dotnet test Big.Tests/Big.Tests.csproj \ + --filter "FullyQualifiedName~Herregistratie|FullyQualifiedName~ReminderSweep" +# → the reminder is due once the 90-day window opens, not before; a reminded inscription is skipped +# on the next sweep; the sweep flags + persists every due inscription and returns their ids. + +# 2. The read model surfaces the deadline once a registration is approved — the field the sweep acts on: +curl -s localhost:8000/registrations/ | jq '{status, herregistratieVoor, herregistratieReminderVerstuurd}' +# → after approval: herregistratieVoor is inscription + 5 years; the flag flips true once swept. +``` + +**The path:** `Registration.Approve(now)` stamps `IngeschrevenOp` → daily Quartz `HerregistratieReminderJob` +→ `HerregistratieReminderSweep` → `IRegistrationStore.FindDueForHerregistratieReminderAsync` (filtered by +the aggregate's own `HerregistratieReminderDue` rule) → `MarkHerregistratieReminderVerstuurd` + log. + +--- + ## S-B04 — `make local` completes the whole flow with no manual seeding (#110, ADR-0020) **Outcome:** the host-browser stack (`make local`) now self-seeds at bring-up — it publishes the BIG