feat(outbox): attempt cap → DLQ + admin endpoint + 6 unit-тестов

После N (default 1000) неудачных попыток публикации событие маркируется
dlq_at и исключается из polling. Без cap poller бесконечно ретраил мёртвые
сообщения (orphan topic, ACL deny), забивая логи и lag-метрику.

- 0011-outbox-dlq.xml: dlq_at column + перестроенный idx_outbox_unpublished
  (исключает DLQ) + idx_outbox_dlq для admin-листинга.
- OutboxPoller: ordinis.outbox.attempt-max (default 1000), markDlq() при
  достижении cap, новый counter ordinis_outbox_dlq_total.
- OutboxLagGauge: ordinis_outbox_dlq_size gauge — алерт на > 0.
- OutboxEventRepository: countPending() (без DLQ), findByDlqAtIsNotNull(Pageable).
- OutboxAdminController: GET /admin/outbox/{stats,dlq} для UI.
- 6 unit-тестов через mock-maker-subclass (JDK 25 ломает Mockito inline).
This commit is contained in:
Zimin A.N.
2026-05-04 15:43:56 +03:00
parent f7efb23e09
commit e182b30c58
10 changed files with 391 additions and 9 deletions
@@ -73,6 +73,9 @@ public class OutboxEvent {
@Column(name = "last_error", columnDefinition = "TEXT")
private String lastError;
@Column(name = "dlq_at")
private OffsetDateTime dlqAt;
protected OutboxEvent() {}
public OutboxEvent(
@@ -103,6 +106,15 @@ public class OutboxEvent {
this.lastError = error;
}
public void markDlq(String error) {
this.lastError = error;
this.dlqAt = OffsetDateTime.now();
}
public boolean isDlq() {
return dlqAt != null;
}
public Long getId() { return id; }
public String getEventType() { return eventType; }
public String getAggregateType() { return aggregateType; }
@@ -118,6 +130,7 @@ public class OutboxEvent {
public String getSpanId() { return spanId; }
public Integer getAttemptCount() { return attemptCount; }
public String getLastError() { return lastError; }
public OffsetDateTime getDlqAt() { return dlqAt; }
public void setDictionaryName(String v) { this.dictionaryName = v; }
public void setTraceId(String v) { this.traceId = v; }
@@ -1,5 +1,6 @@
package cloud.nstart.terravault.ordinis.domain.outbox;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
@@ -9,10 +10,35 @@ import java.util.List;
public interface OutboxEventRepository extends JpaRepository<OutboxEvent, Long> {
/**
* Unpublished events для polling. Использует partial index {@code idx_outbox_unpublished}.
* Unpublished events для polling. Использует partial index
* {@code idx_outbox_unpublished} (where published_at IS NULL AND dlq_at IS NULL).
*/
@Query("SELECT o FROM OutboxEvent o WHERE o.publishedAt IS NULL ORDER BY o.id ASC")
@Query("""
SELECT o FROM OutboxEvent o
WHERE o.publishedAt IS NULL AND o.dlqAt IS NULL
ORDER BY o.id ASC
""")
List<OutboxEvent> findUnpublished(Pageable pageable);
/**
* Сколько ожидает публикации (без DLQ). Метрика
* {@code ordinis_outbox_pending_events}.
*/
@Query("SELECT COUNT(o) FROM OutboxEvent o WHERE o.publishedAt IS NULL AND o.dlqAt IS NULL")
long countPending();
/**
* Сколько в DLQ. Метрика {@code ordinis_outbox_dlq_size} — растёт ⇒ алерт.
*/
long countByDlqAtIsNotNull();
/**
* DLQ-листинг для admin UI. Партиальный индекс {@code idx_outbox_dlq} по
* {@code dlq_at DESC}.
*/
Page<OutboxEvent> findByDlqAtIsNotNull(Pageable pageable);
/** @deprecated используйте {@link #countPending()} (не считает DLQ). */
@Deprecated
long countByPublishedAtIsNull();
}