ADR-0037: Task scheduler — in-process tick, advisory-lock election, SQL sweep¶
- Status: accepted
- Date: 2026-07-02
- Deciders: Nicolas Burri
- Refines: ADR-0019 (REQ-NOT-004 reminders/escalation), ADR-0028 (multi-instance constraint), ADR-0034/ADR-0035 (the system-context SQL pattern the sweep reuses)
- Refined by: ADR-0039 (the sweep & periodic-review contract as shipped — grants, guards, addressing)
Context¶
Overdue work must surface without user interaction: task reminders and escalations now
(REQ-NOT-004), periodic-review-due and disposal-due tasks later (REQ-NOT-008, ADR-0015/0016). The
app runs as N stateless instances (ADR-0028) — time-driven work must not double-fire. A sweep has
no user, so it cannot pass through runAs; there is deliberately no privileged app datasource
path for content (RISK-002).
Decision¶
- Mechanism: Quarkus
@Scheduledtick + PostgreSQL advisory lock. Every instance ticks (configurable interval, default 5 min); the tick runs the sweep inside a transaction that first takespg_try_advisory_xact_lockon a fixed key — exactly one instance sweeps per tick, the rest skip silently. Rejected: Quartz JDBC-clustering (dependency + schema for what a lock gives us), external cron endpoint (couples correctness to deployment infra). - Detection and writes happen in an idempotent SECURITY DEFINER function (the V021 pattern):
the sweep crosses all scopes, so
lqms_sweep_overdue_tasks(now, escalate_after)runs as the migration owner, stamps each task (reminded_at,escalated_at) so every reminder/escalation fires exactly once, and writes notifications — the stamps are the idempotency guard, the advisory lock only prevents wasted concurrent work. Reminders/escalations are operational signals, not audited events (the notifications themselves are the record). - Explicit
due_aton tasks (Nicolas's pick over age-based windows): V022 addstask.due_at, set at creation by the producers — the event handler and the fan-out callers compute it from configuration (lqms.tasks.due-after, default P14D) because the DEFINER fan-out function cannot read app config; it gains ap_due_atparameter. Existing rows are not backfilled (due_atnullable; the sweep ignores null). The UI can show real due dates; per-type/per-policy windows can layer on without rework. - Recipients: a reminder notifies the task's addressee — the assignee user, or every
effective holder of the assignee role (the V005 cascade mirrored in SQL). An escalation
(due +
lqms.tasks.escalate-after, default P7D) additionally notifies the scope's administrators — effective holders ofASSIGN_ROLESthere ("a higher role or administrator", REQ-NOT-004).
Consequences¶
- Periodic-review-due and disposal-due detection (REQ-NOT-008) become additional statements in the same sweep once their schemas exist (ADR-0015/0016 realization) — the mechanism is done once.
- The sweep function joins the fan-out under the BYPASSRLS deployment requirement and the startup check (ADR-0035 §7).
- Notification message text remains SQL-composed English (same limitation as V021; i18n with the delivery-channel work).
- A skipped tick (all instances down) is caught up by the next tick — reminders are late, never
lost, because detection is state-based (
due_at+ stamps), not event-based.