package com.common.service; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.beans.factory.annotation.Value; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; import org.springframework.transaction.support.TransactionSynchronizationManager; import java.util.LinkedHashMap; import java.util.Map; import java.util.concurrent.atomic.AtomicLong; /** Writes one aggregated outbox row in the same transaction as the corresponding fact batch. */ @Component public class StatsProjectionOutboxWriter { private final JdbcTemplate jdbc; private final ObjectMapper objectMapper; private final AtomicLong events = new AtomicLong(); private final AtomicLong failures = new AtomicLong(); @Value("${stats.projection-async.enabled:true}") private boolean enabled; @Value("${stats.projection-async.outbox-table:stats_projection_outbox}") private String outboxTable; public StatsProjectionOutboxWriter(JdbcTemplate jdbc, ObjectMapper objectMapper) { this.jdbc = jdbc; this.objectMapper = objectMapper.copy(); this.objectMapper.deactivateDefaultTyping(); } public boolean isEnabled() { return enabled; } public int append(StatsProjectionOutboxEvent event) { if (!enabled || event == null || event.isEmpty()) return 0; if (!TransactionSynchronizationManager.isActualTransactionActive()) { throw new IllegalStateException("Stats projection outbox must be written inside a transaction"); } event.validate(); try { int inserted = jdbc.update("INSERT INTO " + safeTable(outboxTable) + " (event_id,event_schema,payload,status,attempt_count,next_attempt_at," + "created_at,updated_at) VALUES (?,?,?,'PENDING',0,SYSDATE,SYSDATE,SYSDATE)", event.getEventId(), event.getSchema(), objectMapper.writeValueAsString(event)); events.incrementAndGet(); return inserted; } catch (JsonProcessingException serializationFailure) { failures.incrementAndGet(); throw new IllegalStateException("Failed to serialize stats projection outbox event", serializationFailure); } catch (RuntimeException databaseFailure) { failures.incrementAndGet(); throw databaseFailure; } } public Map status() { Map result = new LinkedHashMap<>(); result.put("enabled", enabled); result.put("outbox_table", outboxTable); result.put("events_written_total", events.get()); result.put("write_failures_total", failures.get()); return result; } static String safeTable(String table) { if (table == null || !table.matches("[A-Za-z][A-Za-z0-9_]*")) { throw new IllegalStateException("Invalid stats projection table: " + table); } return table; } }