From 8c34879ff5f3f0581642311863ac096dafe701f3 Mon Sep 17 00:00:00 2001 From: helpful-sam Date: Thu, 1 Oct 2026 23:27:16 -0400 Subject: [PATCH] Emails: make EmailDrainer batch size configurable --- .../patchats/email/EmailDrainer.java | 16 +++++++------ .../patchats/email/EmailProperties.java | 7 ++++++ src/main/resources/application.yml | 1 + .../patchats/email/EmailDrainerTest.java | 23 ++++++++++++++++++- 4 files changed, 39 insertions(+), 8 deletions(-) diff --git a/src/main/java/org/patinanetwork/patchats/email/EmailDrainer.java b/src/main/java/org/patinanetwork/patchats/email/EmailDrainer.java index b1e29b8a..d602f0d6 100644 --- a/src/main/java/org/patinanetwork/patchats/email/EmailDrainer.java +++ b/src/main/java/org/patinanetwork/patchats/email/EmailDrainer.java @@ -16,8 +16,9 @@ import org.springframework.stereotype.Component; /** - * On-demand background runner (decision #6). Drains the {@code emails} outbox: claims a small batch, renders each row - * from its template, sends over SMTP, and records a terminal status — one attempt, no retry (decision #8). + * On-demand background runner (decision #6). Drains the {@code emails} outbox: claims a small batch (decision #7), + * renders each row from its template, sends over SMTP, and records a terminal status — one attempt, no retry (decision + * #8). * *

Started only by an explicit kick ({@link #trigger()}, from {@code POST /api/email/process}) or the startup drain; * there is no enqueue-time auto-trigger and no polling. Runs single-threaded so overlapping triggers coalesce. @@ -26,13 +27,12 @@ @Slf4j public class EmailDrainer { - private static final int BATCH_SIZE = 50; - private final EmailRepo emailRepo; private final EmailTemplateRepo templateRepo; private final EmailRenderer renderer; private final EmailSender sender; private final Executor executor; + private final EmailProperties properties; /** True while a drain job is running; guards against launching a second overlapping drain. */ private final AtomicBoolean running = new AtomicBoolean(false); @@ -44,12 +44,14 @@ public EmailDrainer( final EmailTemplateRepo templateRepo, final EmailRenderer renderer, final EmailSender sender, - @Qualifier("emailDrainExecutor") final Executor executor) { + @Qualifier("emailDrainExecutor") final Executor executor, + final EmailProperties properties) { this.emailRepo = emailRepo; this.templateRepo = templateRepo; this.renderer = renderer; this.sender = sender; this.executor = executor; + this.properties = properties; } /** @@ -81,12 +83,12 @@ private void drainLoop() { private void drainAll() { // Cache templates for the life of one drain so a batch of the same template loads it once. final Map templateCache = new HashMap<>(); - List batch = emailRepo.claimBatch(BATCH_SIZE); + List batch = emailRepo.claimBatch(properties.getDrainBatchSize()); while (!batch.isEmpty()) { for (final Email email : batch) { sendOne(email, templateCache); } - batch = emailRepo.claimBatch(BATCH_SIZE); + batch = emailRepo.claimBatch(properties.getDrainBatchSize()); } } diff --git a/src/main/java/org/patinanetwork/patchats/email/EmailProperties.java b/src/main/java/org/patinanetwork/patchats/email/EmailProperties.java index 7518e85f..89fecadf 100644 --- a/src/main/java/org/patinanetwork/patchats/email/EmailProperties.java +++ b/src/main/java/org/patinanetwork/patchats/email/EmailProperties.java @@ -1,11 +1,14 @@ package org.patinanetwork.patchats.email; +import jakarta.validation.constraints.Min; import lombok.Getter; import lombok.Setter; import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; /** Email sender configuration, bound from {@code app.email.*}. */ @ConfigurationProperties(prefix = "app.email") +@Validated @Getter @Setter public class EmailProperties { @@ -16,6 +19,10 @@ public class EmailProperties { /** Optional display name shown alongside the From address. */ private String fromName; + /** The amount of emails sent out per batch (decision #7). */ + @Min(1) + private int drainBatchSize = 50; + /** Builds the From header: {@code "Name "} when a display name is set, else the bare address. */ public String getFromHeader() { if (fromName == null || fromName.isBlank()) { diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 9121e7be..1d19d4ca 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -32,6 +32,7 @@ app: email: from: ${EMAIL_FROM:coffeechats@patinanetwork.org} from-name: ${EMAIL_FROM_NAME:PatChats} + drain-batch-size: 50 auth: # Public origin of the SPA; magic links point at /auth/verify?token=... base-url: ${APP_BASE_URL:http://patchats.patinanetwork.org} diff --git a/src/test/java/org/patinanetwork/patchats/email/EmailDrainerTest.java b/src/test/java/org/patinanetwork/patchats/email/EmailDrainerTest.java index efa9e19b..db9c091c 100644 --- a/src/test/java/org/patinanetwork/patchats/email/EmailDrainerTest.java +++ b/src/test/java/org/patinanetwork/patchats/email/EmailDrainerTest.java @@ -35,12 +35,13 @@ class EmailDrainerTest { private final EmailTemplateRepo templateRepo = mock(EmailTemplateRepo.class); private final EmailSender sender = mock(EmailSender.class); private final EmailRenderer renderer = new EmailRenderer(new TemplateRenderer()); + private final EmailProperties properties = new EmailProperties(); /** Runs submitted jobs inline on the calling thread, so a drain completes synchronously within trigger(). */ private static final Executor SYNC = Runnable::run; private EmailDrainer drainer(final Executor executor) { - return new EmailDrainer(emailRepo, templateRepo, renderer, sender, executor); + return new EmailDrainer(emailRepo, templateRepo, renderer, sender, executor, properties); } private EmailTemplate template(final String subject, final String body) { @@ -128,6 +129,26 @@ void renderFailureIsTerminalError() { verify(emailRepo, never()).markSent(any()); } + @Test + void claimsBatchesOfTheConfiguredSize() { + properties.setDrainBatchSize(2); + final Email ann = email(Map.of("per1.name", "Ann"), null); + final Email bob = email(Map.of("per1.name", "Bob"), null); + final Email cy = email(Map.of("per1.name", "Cy"), null); + when(emailRepo.claimBatch(2)).thenReturn(List.of(ann, bob), List.of(cy), List.of()); + when(templateRepo.findById(TEMPLATE_ID)).thenReturn(Optional.of(template("Hi ${per1.name}", "b"))); + + drainer(SYNC).trigger(); + + final ArgumentCaptor limits = ArgumentCaptor.forClass(Integer.class); + verify(emailRepo, atLeastOnce()).claimBatch(limits.capture()); + // A full batch, then the remainder, then the empty claim that ends the drain. + assertEquals(List.of(2, 2, 2), limits.getAllValues()); + verify(emailRepo).markSent(ann.getId()); + verify(emailRepo).markSent(bob.getId()); + verify(emailRepo).markSent(cy.getId()); + } + @Test void overlappingTriggersCoalesceToOneDrain() { // A manual executor that captures jobs without running them, to observe submission count.