From 68dd947324d75057d943294f14957992a98971d2 Mon Sep 17 00:00:00 2001 From: Sin-Kang Date: Thu, 28 May 2026 06:33:24 +0900 Subject: [PATCH] feat(audit): wire async-queue-capacity property to a bounded executor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up #4/5 to the contract-gap series. #18 exposed `devslab.kit.audit.async-queue-capacity` on the Settings endpoint but the property wasn't actually wired to anything — the audit publisher was synchronous. This PR makes it real. Property -------- - DevslabKitProperties.Audit gains asyncQueueCapacity (default 1024). Settings endpoint already surfaces this value, so the number admins see on the Settings page is now the number the runtime actually uses. Executor -------- - AuditAutoConfiguration registers a ThreadPoolTaskExecutor bean named "devslabKitAuditPersistenceExecutor": * core=1 / max=1 — audit writes are tiny, one worker is enough and avoids starving the main pool. * bounded LinkedBlockingQueue sized from devslab.kit.audit.async-queue-capacity. * CallerRunsPolicy on rejection: when the queue saturates, the request thread runs the write inline. Slow request, but no audit row is ever lost. * waitForTasksToCompleteOnShutdown=true with a 10s grace, so in-flight events flush during graceful shutdown. Publisher --------- - DefaultAuditEventPublisher now takes an Executor in its constructor: * persistence write (AuditLogService.record) runs on the executor — request thread returns without the DB round-trip. * Spring ApplicationEventPublisher fanout stays synchronous so @EventListener handlers on the request thread (e.g. metrics counters) still see the event before the request returns. Backward compatibility ---------------------- - DefaultAuditEventPublisher constructor signature changed (added Executor). The only call-site is AuditAutoConfiguration which is updated in the same PR. No consumer code outside this repo constructs DefaultAuditEventPublisher directly today. --- .../service/DefaultAuditEventPublisher.java | 27 +++++++++++++-- .../autoconfigure/AuditAutoConfiguration.java | 33 +++++++++++++++++-- .../autoconfigure/DevslabKitProperties.java | 18 ++++++++++ 3 files changed, 74 insertions(+), 4 deletions(-) diff --git a/devslab-kit-audit-core/src/main/java/kr/devslab/kit/audit/core/service/DefaultAuditEventPublisher.java b/devslab-kit-audit-core/src/main/java/kr/devslab/kit/audit/core/service/DefaultAuditEventPublisher.java index 60db727..b0994ab 100644 --- a/devslab-kit-audit-core/src/main/java/kr/devslab/kit/audit/core/service/DefaultAuditEventPublisher.java +++ b/devslab-kit-audit-core/src/main/java/kr/devslab/kit/audit/core/service/DefaultAuditEventPublisher.java @@ -1,25 +1,48 @@ package kr.devslab.kit.audit.core.service; +import java.util.concurrent.Executor; import kr.devslab.kit.audit.AuditEvent; import kr.devslab.kit.audit.AuditEventPublisher; import org.springframework.context.ApplicationEventPublisher; +/** + * Default {@link AuditEventPublisher}. + * + *

Publishing an event has two sides: + * + *

+ * + *

Backpressure: the executor is configured by {@code AuditAutoConfiguration} + * with a single worker thread + a bounded LinkedBlockingQueue + a + * {@code CallerRunsPolicy}. When the queue is saturated the caller runs the + * persistence write inline — slow, but no audit row is ever lost. + */ public class DefaultAuditEventPublisher implements AuditEventPublisher { private final AuditLogService auditLogService; private final ApplicationEventPublisher applicationEventPublisher; + private final Executor persistenceExecutor; public DefaultAuditEventPublisher( AuditLogService auditLogService, - ApplicationEventPublisher applicationEventPublisher + ApplicationEventPublisher applicationEventPublisher, + Executor persistenceExecutor ) { this.auditLogService = auditLogService; this.applicationEventPublisher = applicationEventPublisher; + this.persistenceExecutor = persistenceExecutor; } @Override public void publish(AuditEvent event) { - auditLogService.record(event); + persistenceExecutor.execute(() -> auditLogService.record(event)); applicationEventPublisher.publishEvent(event); } } diff --git a/devslab-kit-autoconfigure/src/main/java/kr/devslab/kit/autoconfigure/AuditAutoConfiguration.java b/devslab-kit-autoconfigure/src/main/java/kr/devslab/kit/autoconfigure/AuditAutoConfiguration.java index 744260f..7684bd0 100644 --- a/devslab-kit-autoconfigure/src/main/java/kr/devslab/kit/autoconfigure/AuditAutoConfiguration.java +++ b/devslab-kit-autoconfigure/src/main/java/kr/devslab/kit/autoconfigure/AuditAutoConfiguration.java @@ -2,16 +2,20 @@ import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.persistence.EntityManager; +import java.util.concurrent.Executor; import kr.devslab.kit.audit.AuditEventPublisher; import kr.devslab.kit.audit.core.repository.JpaPlatformAuditLogRepository; import kr.devslab.kit.audit.core.service.AuditLogService; import kr.devslab.kit.audit.core.service.DefaultAuditEventPublisher; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.annotation.Bean; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; @AutoConfiguration(afterName = { "org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration", @@ -25,8 +29,11 @@ havingValue = "true", matchIfMissing = true ) +@EnableConfigurationProperties(DevslabKitProperties.class) public class AuditAutoConfiguration { + static final String AUDIT_PERSISTENCE_EXECUTOR = "devslabKitAuditPersistenceExecutor"; + @Bean @ConditionalOnMissingBean public ObjectMapper objectMapper() { @@ -39,12 +46,34 @@ public AuditLogService auditLogService(JpaPlatformAuditLogRepository repository, return new AuditLogService(repository, objectMapper); } + /** + * Single-threaded executor with a bounded queue + CallerRunsPolicy. One + * worker is enough — audit writes are tiny and we don't want them + * starving the main pool. The queue size is taken from + * {@code devslab.kit.audit.async-queue-capacity} (default 1024). + */ + @Bean(name = AUDIT_PERSISTENCE_EXECUTOR) + @ConditionalOnMissingBean(name = AUDIT_PERSISTENCE_EXECUTOR) + public Executor auditPersistenceExecutor(DevslabKitProperties properties) { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(1); + executor.setMaxPoolSize(1); + executor.setQueueCapacity(properties.getAudit().getAsyncQueueCapacity()); + executor.setThreadNamePrefix("devslab-kit-audit-"); + executor.setRejectedExecutionHandler(new java.util.concurrent.ThreadPoolExecutor.CallerRunsPolicy()); + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.setAwaitTerminationSeconds(10); + executor.initialize(); + return executor; + } + @Bean @ConditionalOnMissingBean public AuditEventPublisher auditEventPublisher( AuditLogService auditLogService, - ApplicationEventPublisher applicationEventPublisher + ApplicationEventPublisher applicationEventPublisher, + @Qualifier(AUDIT_PERSISTENCE_EXECUTOR) Executor persistenceExecutor ) { - return new DefaultAuditEventPublisher(auditLogService, applicationEventPublisher); + return new DefaultAuditEventPublisher(auditLogService, applicationEventPublisher, persistenceExecutor); } } diff --git a/devslab-kit-autoconfigure/src/main/java/kr/devslab/kit/autoconfigure/DevslabKitProperties.java b/devslab-kit-autoconfigure/src/main/java/kr/devslab/kit/autoconfigure/DevslabKitProperties.java index 0c74602..dd810f3 100644 --- a/devslab-kit-autoconfigure/src/main/java/kr/devslab/kit/autoconfigure/DevslabKitProperties.java +++ b/devslab-kit-autoconfigure/src/main/java/kr/devslab/kit/autoconfigure/DevslabKitProperties.java @@ -193,6 +193,16 @@ public static class Audit { private boolean enabled = true; + /** + * Bounded capacity of the per-publisher work queue that + * {@link kr.devslab.kit.audit.core.service.DefaultAuditEventPublisher} + * uses to write events without blocking the request thread. When the + * queue is saturated the publisher falls back to running the write + * inline on the caller (CallerRunsPolicy) so events are never lost, + * trading a slow request for a dropped audit row. + */ + private int asyncQueueCapacity = 1024; + public boolean isEnabled() { return enabled; } @@ -200,5 +210,13 @@ public boolean isEnabled() { public void setEnabled(boolean enabled) { this.enabled = enabled; } + + public int getAsyncQueueCapacity() { + return asyncQueueCapacity; + } + + public void setAsyncQueueCapacity(int asyncQueueCapacity) { + this.asyncQueueCapacity = asyncQueueCapacity; + } } }