fix(notifications): dispatcher race с OutboxPoller — emails never sent

This commit is contained in:
Александр Зимин
2026-05-15 15:34:36 +00:00
parent 11d0846893
commit 9f885e50c2
3 changed files with 43 additions and 10 deletions
@@ -148,16 +148,22 @@ public class NotificationsDispatcher {
/**
* Scan recent outbox events for draft lifecycle types and process each.
* Runs every {@code ordinis.notifications.poll-interval-ms} ms.
*
* <p>Uses {@link OutboxEventRepository#findRecentByEventTypes} (event_type
* + createdAt window, NOT published_at). Раньше использовался
* {@code findUnpublished} что race'илось с OutboxPoller (1s tick) — Poller
* setting {@code published_at} быстрее чем notifications 30s tick → events
* никогда не reached dispatcher. Now: dispatcher polls all recent draft
* events независимо от Kafka publish state, idempotency через
* notification_log UNIQUE (event_id, channel, recipient).
*/
@Scheduled(fixedDelayString = "${ordinis.notifications.poll-interval-ms:30000}")
@Transactional
public void sendTick() {
List<OutboxEvent> events = outboxRepository.findUnpublished(PageRequest.of(0, batchSize));
if (events.isEmpty()) return;
List<OutboxEvent> drafts = events.stream()
.filter(e -> DRAFT_EVENT_TYPES.contains(e.getEventType()))
.toList();
// 24h window — covers max retention scenarios без full table scan.
OffsetDateTime since = OffsetDateTime.now().minusHours(24);
List<OutboxEvent> drafts = outboxRepository.findRecentByEventTypes(
DRAFT_EVENT_TYPES, since, PageRequest.of(0, batchSize));
if (drafts.isEmpty()) return;
log.debug("NotificationsDispatcher sendTick: {} draft events to process", drafts.size());
@@ -108,7 +108,7 @@ class NotificationsDispatcherTest {
String makerEmail = "maker@nstart.space";
OutboxEvent event = buildDraftApprovedEvent(draftId, makerId);
when(outboxRepository.findUnpublished(any(Pageable.class))).thenReturn(List.of(event));
when(outboxRepository.findRecentByEventTypes(any(), any(), any(Pageable.class))).thenReturn(List.of(event));
UserRef makerRef = new UserRef(makerId, makerEmail, null, null, Locale.forLanguageTag("ru"));
when(recipientResolver.resolveById(makerId)).thenReturn(Optional.of(makerRef));
@@ -140,7 +140,7 @@ class NotificationsDispatcherTest {
new ObjectMapper().createObjectNode(),
"ordinis.cuod.events.public", "spacecraft:test");
when(outboxRepository.findUnpublished(any(Pageable.class))).thenReturn(List.of(event));
when(outboxRepository.findRecentByEventTypes(any(), any(), any(Pageable.class))).thenReturn(List.of(event));
dispatcher.sendTick();
@@ -154,7 +154,7 @@ class NotificationsDispatcherTest {
UUID draftId = UUID.randomUUID();
String makerId = "maker-sub-456";
OutboxEvent event = buildDraftApprovedEvent(draftId, makerId);
when(outboxRepository.findUnpublished(any(Pageable.class))).thenReturn(List.of(event));
when(outboxRepository.findRecentByEventTypes(any(), any(), any(Pageable.class))).thenReturn(List.of(event));
UserRef makerRef = new UserRef(makerId, "maker@nstart.space", null, null, Locale.forLanguageTag("ru"));
when(recipientResolver.resolveById(makerId)).thenReturn(Optional.of(makerRef));
@@ -182,7 +182,7 @@ class NotificationsDispatcherTest {
void unknownMaker_noDispatch() {
UUID draftId = UUID.randomUUID();
OutboxEvent event = buildDraftApprovedEvent(draftId, "unknown-sub");
when(outboxRepository.findUnpublished(any(Pageable.class))).thenReturn(List.of(event));
when(outboxRepository.findRecentByEventTypes(any(), any(), any(Pageable.class))).thenReturn(List.of(event));
when(recipientResolver.resolveById("unknown-sub")).thenReturn(Optional.empty());