публикация одной outbox-записи паспорта маршрута

- добавить надёжные настройки Kafka producer
- публиковать один record из RoutePassportOutboxEntity с timeout
- сериализовать JsonNode payload напрямую как JSON object
- пробрасывать ошибки Kafka send вызывающему коду
- обновить тесты publisher и producer config
This commit is contained in:
Дмитрий Соловьев
2026-05-25 14:52:26 +03:00
parent d48ddd2657
commit 9d6f838692
6 changed files with 246 additions and 196 deletions
@@ -21,6 +21,18 @@ import org.springframework.kafka.core.ProducerFactory
class KafkaProducerConfig(
@param:Value("\${spring.kafka.bootstrap-servers}")
private val bootstrapServers: String,
@param:Value("\${spring.kafka.producer.acks:all}")
private val acks: String,
@param:Value("\${spring.kafka.producer.retries:10}")
private val retries: Int,
@param:Value("\${spring.kafka.producer.properties.enable.idempotence:true}")
private val enableIdempotence: Boolean,
@param:Value("\${spring.kafka.producer.properties.max.in.flight.requests.per.connection:5}")
private val maxInFlightRequestsPerConnection: Int,
) {
@Bean
@@ -30,6 +42,10 @@ class KafkaProducerConfig(
ProducerConfig.BOOTSTRAP_SERVERS_CONFIG to bootstrapServers,
ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG to StringSerializer::class.java,
ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG to StringSerializer::class.java,
ProducerConfig.ACKS_CONFIG to acks,
ProducerConfig.RETRIES_CONFIG to retries,
ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG to enableIdempotence,
ProducerConfig.MAX_IN_FLIGHT_REQUESTS_PER_CONNECTION to maxInFlightRequestsPerConnection,
),
)
@@ -3,111 +3,78 @@ package space.nstart.pcp.routepassportconsumer.service
import org.apache.kafka.clients.producer.ProducerRecord
import org.slf4j.LoggerFactory
import org.springframework.kafka.core.KafkaTemplate
import org.springframework.kafka.support.SendResult
import org.springframework.stereotype.Service
import space.nstart.pcp.routepassportconsumer.config.KafkaTopicsProperties
import space.nstart.pcp.pcp_types_lib.dto.routes.ParsedRoutePassportMessage
import space.nstart.pcp.pcp_types_lib.message.KafkaMessage
import space.nstart.pcp.pcp_types_lib.message.PcpKafkaEvent
import space.nstart.pcp.routepassportconsumer.entity.RoutePassportOutboxEntity
import tools.jackson.databind.ObjectMapper
import java.nio.charset.StandardCharsets
import java.util.concurrent.CompletableFuture
import java.util.concurrent.ExecutionException
import java.util.concurrent.TimeUnit
/**
* Сервис публикации обработанного маршрута в Kafka.
* Сервис физической публикации одной готовой outbox-записи в Kafka.
*/
@Service
class RoutePassportKafkaPublisherService(
/** ObjectMapper сериализует итоговый DTO в JSON перед отправкой. */
/** ObjectMapper сериализует сохранённый JsonNode payload в JSON object string перед отправкой. */
private val objectMapper: ObjectMapper,
/** KafkaTemplate отправляет одно и то же сообщение в несколько topic'ов. */
/** KafkaTemplate отправляет готовую outbox-запись в её целевой topic. */
private val kafkaTemplate: KafkaTemplate<String, String>,
/** Набор Kafka topic'ов, в которые публикуется результат обработки. */
private val kafkaTopicsProperties: KafkaTopicsProperties,
) {
private val log = LoggerFactory.getLogger(this::class.java)
/**
* Публикует полный RoutePassportDto в оба выходных topic'а.
* Публикует ровно одну outbox-запись и пробрасывает ошибку отправки вызывающему коду.
*/
fun publish(parsedMessage: ParsedRoutePassportMessage) {
val routePassportDto = parsedMessage.routePassportDto
fun publish(outbox: RoutePassportOutboxEntity) {
val payload = objectMapper.writeValueAsString(outbox.payload)
val record = ProducerRecord(outbox.topic, outbox.messageKey, payload)
record.headers().add(TYPE_HEADER, outbox.eventType.toByteArray(StandardCharsets.UTF_8))
log.info(
"Publishing route passport: routeNameFull={}, processedRouteTopic={}, routeGeoreferenceTopic={}",
routePassportDto.routeNameFull,
kafkaTopicsProperties.processedRoute,
kafkaTopicsProperties.routeGeoreference,
)
try {
val result = kafkaTemplate.send(record)
.get(DEFAULT_PUBLISH_TIMEOUT_SECONDS, TimeUnit.SECONDS)
sendWithLogging(
topic = kafkaTopicsProperties.processedRoute,
eventType = PcpKafkaEvent.ModeStatusChangedEvent,
payload = buildPayload(
eventType = PcpKafkaEvent.ModeStatusChangedEvent,
parsedMessage = parsedMessage,
),
traceId = parsedMessage.metadata.traceId,
routeNameFull = routePassportDto.routeNameFull,
)
sendWithLogging(
topic = kafkaTopicsProperties.routeGeoreference,
eventType = PcpKafkaEvent.RouteGeoRefEvent,
payload = buildPayload(
eventType = PcpKafkaEvent.RouteGeoRefEvent,
parsedMessage = parsedMessage,
),
traceId = parsedMessage.metadata.traceId,
routeNameFull = routePassportDto.routeNameFull,
)
}
log.info(
"Kafka outbox message published: outboxId={}, routeId={}, eventType={}, topic={}, partition={}, offset={}",
outbox.id,
outbox.routeId,
outbox.eventType,
outbox.topic,
result.recordMetadata.partition(),
result.recordMetadata.offset(),
)
} catch (exception: InterruptedException) {
Thread.currentThread().interrupt()
logPublishFailure(outbox, exception)
throw exception
} catch (exception: ExecutionException) {
val failure = exception.cause
logPublishFailure(outbox, failure ?: exception)
private fun buildPayload(eventType: PcpKafkaEvent, parsedMessage: ParsedRoutePassportMessage): String {
val message = KafkaMessage(
type = eventType,
data = parsedMessage.routePassportDto,
traceId = parsedMessage.metadata.traceId,
correlationId = parsedMessage.metadata.correlationId,
group = "pcp",
).apply {
source = "route-processing"
}
return objectMapper.writeValueAsString(message)
}
private fun sendWithLogging(
topic: String,
eventType: PcpKafkaEvent,
payload: String,
traceId: String,
routeNameFull: String,
) {
val record = ProducerRecord(topic, traceId, payload)
record.headers().add("type", eventType.name.toByteArray(StandardCharsets.UTF_8))
val future: CompletableFuture<SendResult<String, String>> = kafkaTemplate.send(record)
future.whenComplete { result, exception ->
if (exception == null) {
log.info(
"Kafka message published: routeNameFull={}, topic={}, type={}, partition={}, offset={}",
routeNameFull,
topic,
eventType,
result.recordMetadata.partition(),
result.recordMetadata.offset(),
)
} else {
log.error(
"Failed to publish kafka message: routeNameFull={}, topic={}, type={}",
routeNameFull,
topic,
eventType,
exception,
)
when (failure) {
is Exception -> throw failure
else -> throw exception
}
} catch (exception: Exception) {
logPublishFailure(outbox, exception)
throw exception
}
}
private fun logPublishFailure(outbox: RoutePassportOutboxEntity, exception: Throwable) {
log.error(
"Failed to publish kafka outbox message: outboxId={}, routeId={}, eventType={}, topic={}",
outbox.id,
outbox.routeId,
outbox.eventType,
outbox.topic,
exception,
)
}
private companion object {
const val TYPE_HEADER = "type"
const val DEFAULT_PUBLISH_TIMEOUT_SECONDS = 10L
}
}