- Shipped
- August 7, 2026 at 12:06 AM UTC
- Author
- Kamo
- Commit
- fdea75b
The bucket is a pure function of (dueDate, today) and never of "when we last sent": UPCOMING_D{n} for the configured offsets, DUE_TODAY, then PAST_DUE_P{floor(daysOverdue / interval)}. Running the sweep five times on day 9 therefore yields one bucket, and the ledger claim is what deduplicates it — no state is remembered anywhere. The offsets are data; nothing in the class knows the number 3. past-due-max-periods defaults to 0 meaning UNLIMITED, because decision #4 says the past-due reminder recurs and a silent cap looks exactly like a working sweep while abandoning the member most in need of chasing. The cutoff is today + max(upcomingDays) + 1, not now(): the repository predicate is dueAt BEFORE, so a now() cutoff would find only overdue rows and the entire upcoming half of the ladder would never fire. One short read-only transaction per Slice, fully materialising each batch into plain records — Organization and Member are LAZY, so the fqdn, the label, the name and the address are resolved inside it — and the transaction is CLOSED before the first send. EmailService's send is blocking HTTP into blocking SMTP, and a batch held open pins a Hikari connection for the length of every round-trip in it, in a service also serving WOPI. The sweep performs ZERO writes. It cannot serialization-retry, cannot deadlock against the wizard, and paging is stable because it is not mutating the rows it is paging over. dryRun resolves and counts and claims nothing — a dry run that claimed would burn every bucket it inspected and silently suppress the real run. The recorder gains dispatchAllCounted beside the unchanged dispatchAll, because LegalReminderSweepResultDto already carries claimed and failed and a single sent count cannot fill them: on a re-run "claimed 0 of 400" is the correct, healthy answer and "claimed 400, sent 0" is an outage, and the daemon log has to be able to tell those apart.