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:
+13
@@ -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; }
|
||||
|
||||
+28
-2
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user