Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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}.
*
* <p>Publishing an event has two sides:
*
* <ul>
* <li>The persistence write goes through {@link AuditLogService#record}
* on a bounded background {@link Executor}, so request threads return
* without waiting for the DB round-trip.</li>
* <li>The Spring {@link ApplicationEventPublisher} fanout stays
* synchronous, so {@code @EventListener}s on the request thread
* (e.g. metrics counters) see the event before the request returns.</li>
* </ul>
*
* <p>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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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() {
Expand All @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -193,12 +193,30 @@ 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;
}

public void setEnabled(boolean enabled) {
this.enabled = enabled;
}

public int getAsyncQueueCapacity() {
return asyncQueueCapacity;
}

public void setAsyncQueueCapacity(int asyncQueueCapacity) {
this.asyncQueueCapacity = asyncQueueCapacity;
}
}
}
Loading