Menyimpan data sekunder seperti audit trail, log riwayat, atau sinkronisasi metrik sering kali diintegrasikan menggunakan @TransactionalEventListener Spring Boot. Namun, masalah umum yang kerap ditemui adalah silent failure: method listener terpanggil, log aplikasi tercetak normal, tidak ada Exception yang dilempar, tetapi entitas yang disimpan menggunakan Spring Data JPA sama sekali tidak pernah masuk ke dalam database.
Gejala Masalah: Silent Failure pada Fase AFTER_COMMIT
Secara default, @TransactionalEventListener berjalan pada fase TransactionPhase.AFTER_COMMIT. Gejala yang muncul pada skenario ini sangat konsisten:
- Event listener terpicu tepat setelah transaksi pemanggil (caller) selesai melakukan commit.
- Proses di dalam method listener dieksekusi dari awal hingga akhir tanpa hambatan.
- Database tidak mencatat row baru dari operasi
save()di dalam listener. - Log console tidak menampilkan error atau rollback warning dari Spring maupun database engine.
Kode Reproduksi Masalah
Kode di bawah ini mereproduksi bug tersebut secara langsung. Ketika order dibuat, event dipublikasikan untuk mencatat audit trail.
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final ApplicationEventPublisher eventPublisher;
public OrderService(OrderRepository orderRepository, ApplicationEventPublisher eventPublisher) {
this.orderRepository = orderRepository;
this.eventPublisher = eventPublisher;
}
@Transactional
public Order createOrder(String product, BigDecimal amount) {
Order order = orderRepository.save(new Order(product, amount));
eventPublisher.publishEvent(new OrderCreatedEvent(order.getId()));
return order;
}
}Listener bermasalah yang mencoba menyimpan entitas audit:
@Component
public class AuditEventListener {
private final AuditRepository auditRepository;
private static final Logger log = LoggerFactory.getLogger(AuditEventListener.class);
public AuditEventListener(AuditRepository auditRepository) {
this.auditRepository = auditRepository;
}
// MASALAH: Default phase adalah AFTER_COMMIT tanpa transaksi baru
@TransactionalEventListener
public void onOrderCreated(OrderCreatedEvent event) {
log.info("Processing audit for order: {}", event.orderId());
AuditLog audit = new AuditLog("ORDER_CREATED", event.orderId());
auditRepository.save(audit);
log.info("Audit log saved successfully"); // Tercetak di log, tetapi row tidak tersimpan di DB
}
}Analisis Root Cause: Siklus Transaksi dan Hibernate Flush
Penyebab utama dari masalah ini berkaitan dengan siklus hidup transaksi Spring dan mekanisme flush JPA/Hibernate:
- Fase AFTER_COMMIT: Listener dipanggil setelah transaksi utama berhasil melakukan
commit()ke database fisik. Koneksi database utama telah ditutup atau dikembalikan ke connection pool. - Status Transaksi Aktif: Karena dieksekusi pada thread yang sama persis dengan caller, konteks transaksi Spring yang tersisa masih dianggap aktif tetapi berada dalam status completed (atau sinkronisasi transaksi dinonaktifkan).
- Propagation Behavior: Default method
save()milikSimpleJpaRepositorydianotasikan dengan@Transactional(propagation = Propagation.REQUIRED). Karena Spring melihat masih ada jejak konteks transaksi lama (yang sudah selesai), method ini tidak membuat transaksi baru. - Hibernate Session Flush Batal: Hibernate mengantongi operasi
INSERTdi dalam persistence context level 1 cache. Tanpa adanya trigger commit transaksi fisik yang baru, Hibernate tidak pernah memicuflush()ke database. Persistence context kemudian langsung dibuang saat thread selesai, menghapus seluruh perubahan secara senyap.
Langkah Diagnosis Menggunakan Logging
Untuk memverifikasi perilaku tersebut, aktifkan detail logging transaksi Spring dan Hibernate pada application.properties:
logging.level.org.springframework.transaction=DEBUG
logging.level.org.springframework.orm.jpa=DEBUG
logging.level.org.hibernate.SQL=DEBUGKetika kode bermasalah dieksekusi, periksa urutan log transaksi:
o.s.t.i.TransactionInterceptor : Getting transaction for [OrderService.createOrder]
o.s.t.i.TransactionInterceptor : Completing transaction for [OrderService.createOrder]
o.s.j.d.DataSourceTransactionManager : Initiating transaction commit
AuditEventListener : Processing audit for order: 101
AuditEventListener : Audit log saved successfullyPerhatikan bahwa setelah pesan Initiating transaction commit dari OrderService, tidak pernah ada log inisiasi transaksi baru untuk auditRepository.save(), dan tidak ada statement insert into audit_log yang dicetak oleh Hibernate.
Solusi 1: Gunakan Propagation.REQUIRES_NEW (Sinkron)
Jika operasi write harus dijalankan secara sinkron pada thread yang sama (misal harus selesai sebelum response HTTP dikembalikan), paksa pembuatan transaksi fisik baru yang independen menggunakan Propagation.REQUIRES_NEW.
@Component
public class AuditEventListener {
private final AuditRepository auditRepository;
public AuditEventListener(AuditRepository auditRepository) {
this.auditRepository = auditRepository;
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void onOrderCreated(OrderCreatedEvent event) {
AuditLog audit = new AuditLog("ORDER_CREATED", event.orderId());
auditRepository.save(audit);
}
}Catatan: Membuka transaksi REQUIRES_NEW akan meminjam koneksi database kedua dari connection pool (HikariCP) pada thread yang sama. Pastikan pool size cukup agar tidak terjadi pool deadlocking pada traffic tinggi.
Solusi 2: Gunakan @Async (Asinkron / Non-Blocking)
Untuk operasi audit atau notifikasi yang tidak perlu membebani waktu eksekusi request utama, gunakan @Async bersama @Transactional. Dengan @Async, event diproses di thread pool terpisah yang sepenuhnya bebas dari konteks transaksi thread utama.
@Configuration
@EnableAsync
public class AsyncConfig {
// Konfigurasi TaskExecutor sesuai kebutuhan sistem
}@Component
public class AsyncAuditEventListener {
private final AuditRepository auditRepository;
public AsyncAuditEventListener(AuditRepository auditRepository) {
this.auditRepository = auditRepository;
}
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional
public void onOrderCreated(OrderCreatedEvent event) {
AuditLog audit = new AuditLog("ORDER_CREATED", event.orderId());
auditRepository.save(audit);
}
}Karena berjalan di thread terpisah, @Transactional default (REQUIRED) akan langsung membuka transaksi baru tanpa perlu REQUIRES_NEW.
Verifikasi dengan Integration Test
Buat integration test menggunakan @SpringBootTest untuk memastikan bahwa persistence terjadi secara nyata.
@SpringBootTest
class OrderServiceIntegrationTest {
@Autowired
private OrderService orderService;
@Autowired
private AuditRepository auditRepository;
@Autowired
private OrderRepository orderRepository;
@BeforeEach
void setUp() {
auditRepository.deleteAll();
orderRepository.deleteAll();
}
@Test
void shouldPersistAuditLogWhenOrderCreated() {
// Given
BigDecimal amount = new BigDecimal("150000.00");
// When
Order createdOrder = orderService.createOrder("Laptop Desk", amount);
// Then
assertNotNull(createdOrder.getId());
assertEquals(1, orderRepository.count());
// Verifikasi audit log berhasil disimpan ke database fisik
List<AuditLog> audits = auditRepository.findByOrderId(createdOrder.getId());
assertEquals(1, audits.size());
assertEquals("ORDER_CREATED", audits.get(0).getAction());
}
}Panduan Pemilihan Solusi
- Gunakan
@Transactional(propagation = Propagation.REQUIRES_NEW)jika kegagalan penulisan audit harus melempar error langsung ke pemanggil atau membutuhkan integritas urutan sekuensial. - Gunakan
@Async + @Transactionaljika operasi penulisan sekunder bersifat pelengkap (telemetri, email log, audit non-kritis) agar throughput API utama tetap optimal.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!