добавить масштабируемый outbox worker для паспортов маршрутов

- добавить properties и config values для route passport outbox worker
- включить Spring scheduling в route-processing service
- добавить транзакционный сервис claim и status transitions для outbox
- публиковать claimed outbox rows через существующий Kafka publisher
- обрабатывать publish failures по каждой row с bounded exponential backoff
- обеспечить безопасность при нескольких pod через FOR UPDATE SKIP LOCKED и lockedUntil
- добавить unit tests для claim, publish transitions, retry/fail behavior и batch-обработки worker
This commit is contained in:
Дмитрий Соловьев
2026-05-25 15:05:04 +03:00
parent 9d6f838692
commit b5990a55ac
5 changed files with 694 additions and 0 deletions
@@ -0,0 +1,44 @@
package space.nstart.pcp.routepassportconsumer.config
import jakarta.validation.constraints.AssertTrue
import jakarta.validation.constraints.Min
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.validation.annotation.Validated
/**
* Operational settings for the route passport outbox worker.
*
* Values control scheduling cadence, DB claim size, lock lease time and bounded retry backoff.
*/
@Validated
@ConfigurationProperties(prefix = "app.route-passport.outbox")
data class RoutePassportOutboxProperties(
/** Enables scheduled publication of pending route passport outbox rows. */
val enabled: Boolean = true,
/** Maximum number of due rows claimed by one worker tick. */
@field:Min(1)
val batchSize: Int = 50,
/** Delay between scheduled worker runs. */
@field:Min(100)
val fixedDelayMs: Long = 1_000,
/** Lease duration for a claimed row; expired locks can be claimed by another instance. */
@field:Min(1_000)
val lockTimeoutMs: Long = 60_000,
/** Backoff after the first failed publication attempt. */
@field:Min(100)
val initialBackoffMs: Long = 1_000,
/** Maximum backoff between publication attempts. */
@field:Min(100)
val maxBackoffMs: Long = 60_000,
) {
@get:AssertTrue(message = "maxBackoffMs must be greater than or equal to initialBackoffMs")
val isMaxBackoffGreaterThanOrEqualToInitialBackoff: Boolean
get() = maxBackoffMs >= initialBackoffMs
}
@@ -0,0 +1,107 @@
package space.nstart.pcp.routepassportconsumer.service
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import space.nstart.pcp.routepassportconsumer.config.RoutePassportOutboxProperties
import space.nstart.pcp.routepassportconsumer.entity.RoutePassportOutboxEntity
import space.nstart.pcp.routepassportconsumer.entity.RoutePassportOutboxStatus
import space.nstart.pcp.routepassportconsumer.repository.RoutePassportOutboxRepository
import java.time.Duration
import java.time.LocalDateTime
import java.util.UUID
/**
* Owns transactional claim and status transitions for route passport outbox rows.
*/
@Service
class RoutePassportOutboxService(
private val routePassportOutboxRepository: RoutePassportOutboxRepository,
private val outboxProperties: RoutePassportOutboxProperties,
) {
/**
* Claims due pending rows in one DB transaction using SELECT FOR UPDATE SKIP LOCKED.
*/
@Transactional
fun claimDueBatch(): List<RoutePassportOutboxEntity> {
if (!outboxProperties.enabled) {
return emptyList()
}
val now = LocalDateTime.now()
val lockedUntil = now.plus(Duration.ofMillis(outboxProperties.lockTimeoutMs))
val claimedRows = routePassportOutboxRepository.findDueForUpdateSkipLocked(outboxProperties.batchSize)
claimedRows.forEach { outbox ->
outbox.lockedUntil = lockedUntil
outbox.updatedAt = now
}
return routePassportOutboxRepository.saveAll(claimedRows).toList()
}
/**
* Marks a successfully published row as terminally published.
*/
@Transactional
fun markPublished(outboxId: UUID) {
val now = LocalDateTime.now()
val outbox = findOutbox(outboxId)
outbox.status = RoutePassportOutboxStatus.PUBLISHED
outbox.publishedAt = now
outbox.lockedUntil = null
outbox.lastError = null
outbox.updatedAt = now
routePassportOutboxRepository.save(outbox)
}
/**
* Records a publish failure and either schedules bounded retry or marks the row as failed.
*/
@Transactional
fun markRetryOrFailed(outboxId: UUID, exception: Exception) {
val now = LocalDateTime.now()
val outbox = findOutbox(outboxId)
outbox.attempts += 1
outbox.lastError = compactError(exception)
outbox.lockedUntil = null
outbox.updatedAt = now
if (outbox.attempts >= outbox.maxAttempts) {
outbox.status = RoutePassportOutboxStatus.FAILED
} else {
outbox.status = RoutePassportOutboxStatus.PENDING
outbox.nextAttemptAt = now.plus(Duration.ofMillis(calculateBackoffMs(outbox.attempts)))
}
routePassportOutboxRepository.save(outbox)
}
private fun findOutbox(outboxId: UUID): RoutePassportOutboxEntity =
routePassportOutboxRepository.findById(outboxId)
.orElseThrow { IllegalStateException("Route passport outbox row not found: $outboxId") }
private fun calculateBackoffMs(attempts: Int): Long {
var backoffMs = outboxProperties.initialBackoffMs.coerceAtMost(outboxProperties.maxBackoffMs)
repeat((attempts - 1).coerceAtLeast(0)) {
backoffMs = if (backoffMs >= outboxProperties.maxBackoffMs / 2) {
outboxProperties.maxBackoffMs
} else {
(backoffMs * 2).coerceAtMost(outboxProperties.maxBackoffMs)
}
}
return backoffMs
}
private fun compactError(exception: Exception): String =
"${exception::class.java.name}: ${exception.message ?: ""}".take(MAX_ERROR_LENGTH)
private companion object {
const val MAX_ERROR_LENGTH = 2_000
}
}
@@ -0,0 +1,100 @@
package space.nstart.pcp.routepassportconsumer.service
import org.slf4j.LoggerFactory
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Component
import space.nstart.pcp.routepassportconsumer.config.RoutePassportOutboxProperties
import space.nstart.pcp.routepassportconsumer.entity.RoutePassportOutboxEntity
import space.nstart.pcp.routepassportconsumer.entity.RoutePassportOutboxStatus
/**
* Scheduled worker that publishes already persisted route passport outbox rows.
*/
@Component
class RoutePassportOutboxWorker(
private val routePassportOutboxService: RoutePassportOutboxService,
private val routePassportKafkaPublisherService: RoutePassportKafkaPublisherService,
private val outboxProperties: RoutePassportOutboxProperties,
) {
private val log = LoggerFactory.getLogger(this::class.java)
@Scheduled(fixedDelayString = "\${app.route-passport.outbox.fixed-delay-ms:1000}")
fun publishDueOutboxRows() {
if (!outboxProperties.enabled) {
return
}
val outboxRows = routePassportOutboxService.claimDueBatch()
outboxRows.forEach { outbox ->
logClaimed(outbox)
}
outboxRows.forEach { outbox ->
try {
publishOne(outbox)
} catch (exception: Exception) {
log.error(
"Kafka outbox row processing failed: outboxId={}, routeId={}, eventType={}, topic={}, attempts={}, status={}, exceptionClass={}, exceptionMessage={}",
outbox.id,
outbox.routeId,
outbox.eventType,
outbox.topic,
outbox.attempts,
outbox.status,
exception::class.java.name,
exception.message,
)
}
}
}
private fun publishOne(outbox: RoutePassportOutboxEntity) {
try {
routePassportKafkaPublisherService.publish(outbox)
routePassportOutboxService.markPublished(outbox.id)
log.info(
"Kafka outbox row marked published: outboxId={}, routeId={}, eventType={}, topic={}, attempts={}, status={}",
outbox.id,
outbox.routeId,
outbox.eventType,
outbox.topic,
outbox.attempts,
RoutePassportOutboxStatus.PUBLISHED,
)
} catch (exception: Exception) {
routePassportOutboxService.markRetryOrFailed(outbox.id, exception)
val attemptsAfterFailure = outbox.attempts + 1
val statusAfterFailure = if (attemptsAfterFailure >= outbox.maxAttempts) {
RoutePassportOutboxStatus.FAILED
} else {
RoutePassportOutboxStatus.PENDING
}
log.warn(
"Kafka outbox row publish failed: outboxId={}, routeId={}, eventType={}, topic={}, attempts={}, status={}, exceptionClass={}, exceptionMessage={}",
outbox.id,
outbox.routeId,
outbox.eventType,
outbox.topic,
attemptsAfterFailure,
statusAfterFailure,
exception::class.java.name,
exception.message,
)
}
}
private fun logClaimed(outbox: RoutePassportOutboxEntity) {
log.info(
"Kafka outbox row claimed: outboxId={}, routeId={}, eventType={}, topic={}, attempts={}, status={}",
outbox.id,
outbox.routeId,
outbox.eventType,
outbox.topic,
outbox.attempts,
outbox.status,
)
}
}