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
@@ -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());
}
}
}