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:
+73
@@ -0,0 +1,73 @@
|
||||
package cloud.nstart.terravault.ordinis.restapi.web;
|
||||
|
||||
import cloud.nstart.terravault.ordinis.domain.outbox.OutboxEvent;
|
||||
import cloud.nstart.terravault.ordinis.domain.outbox.OutboxEventRepository;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Admin endpoints для outbox observability. Только чтение — replay делается
|
||||
* вручную через SQL (UPDATE outbox_events SET dlq_at=NULL, attempt_count=0 WHERE id=...).
|
||||
* Авто-replay сделаем когда появится первый реальный кейс DLQ.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/admin/outbox")
|
||||
public class OutboxAdminController {
|
||||
|
||||
private final OutboxEventRepository repository;
|
||||
|
||||
public OutboxAdminController(OutboxEventRepository repository) {
|
||||
this.repository = repository;
|
||||
}
|
||||
|
||||
@GetMapping("/stats")
|
||||
public Map<String, Long> stats() {
|
||||
return Map.of(
|
||||
"pending", repository.countPending(),
|
||||
"dlq", repository.countByDlqAtIsNotNull()
|
||||
);
|
||||
}
|
||||
|
||||
@GetMapping("/dlq")
|
||||
public Page<DlqEntry> dlq(
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "50") int size) {
|
||||
Page<OutboxEvent> events = repository.findByDlqAtIsNotNull(
|
||||
PageRequest.of(page, Math.min(size, 200), Sort.by(Sort.Direction.DESC, "dlqAt")));
|
||||
return events.map(DlqEntry::from);
|
||||
}
|
||||
|
||||
public record DlqEntry(
|
||||
Long id,
|
||||
String eventType,
|
||||
String dictionaryName,
|
||||
String aggregateId,
|
||||
String kafkaTopic,
|
||||
Integer attemptCount,
|
||||
String lastError,
|
||||
OffsetDateTime createdAt,
|
||||
OffsetDateTime dlqAt) {
|
||||
|
||||
static DlqEntry from(OutboxEvent e) {
|
||||
return new DlqEntry(
|
||||
e.getId(),
|
||||
e.getEventType(),
|
||||
e.getDictionaryName(),
|
||||
e.getAggregateId(),
|
||||
e.getKafkaTopic(),
|
||||
e.getAttemptCount(),
|
||||
e.getLastError(),
|
||||
e.getCreatedAt(),
|
||||
e.getDlqAt());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user