1 Commits

Author SHA1 Message Date
Ivan I. Ovchinnikov b895a1682f refetch cache and ttl 2026-07-16 20:23:50 +03:00
4 changed files with 272 additions and 19 deletions
+11 -10
View File
@@ -33,6 +33,17 @@ management:
info: info:
enabled: true enabled: true
classifier:
stations-url: ${CLASSIFIER_STATIONS_URL:https://ordinis.k8s.265.nstart.cloud/api/v1/ground_station/records}
# cache TTL
stations-cache-ttl: ${CLASSIFIER_STATIONS_CACHE_TTL:15m}
# MDM retries
stations-refresh-attempts: ${CLASSIFIER_STATIONS_REFRESH_ATTEMPTS:3}
# MDM retry delay
stations-refresh-retry-delay: ${CLASSIFIER_STATIONS_REFRESH_RETRY_DELAY:300ms}
# MDM failure cooldown
stations-refresh-failure-cooldown: ${CLASSIFIER_STATIONS_REFRESH_FAILURE_COOLDOWN:5s}
--- ---
spring: spring:
config: config:
@@ -89,13 +100,3 @@ spring:
server: server:
port: ${pcp.ports.stations:8080} port: ${pcp.ports.stations:8080}
classifier:
stations-url: ${CLASSIFIER_STATIONS_URL:https://ordinis.k8s.265.nstart.cloud/api/v1/ground_station/records}
# cache TTL
stations-cache-ttl: ${CLASSIFIER_STATIONS_CACHE_TTL:15m}
# MDM retries
stations-refresh-attempts: ${CLASSIFIER_STATIONS_REFRESH_ATTEMPTS:3}
# MDM retry delay
stations-refresh-retry-delay: ${CLASSIFIER_STATIONS_REFRESH_RETRY_DELAY:300ms}
# MDM failure cooldown
stations-refresh-failure-cooldown: ${CLASSIFIER_STATIONS_REFRESH_FAILURE_COOLDOWN:5s}
@@ -0,0 +1,43 @@
package space.nstart.pcp.pcp_stations_service.service
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.ExceptionHandler
import space.nstart.pcp.pcp_stations_service.integration.NsiStationsResponseException
class GlobalExceptionHandle {
@ExceptionHandler(StationsDataUnavailableException::class)
fun handleStationsDataUnavailable(
ex: StationsDataUnavailableException
): ResponseEntity<Map<String, String>> =
ResponseEntity
.status(HttpStatus.SERVICE_UNAVAILABLE)
.header(
HttpHeaders.RETRY_AFTER,
ex.retryAfterSeconds.toString()
)
.body(
mapOf(
"error" to "data_unavailable",
"message" to (
ex.message
?: "Stations are temporarily unavailable"
)
)
)
@ExceptionHandler(NsiStationsResponseException::class)
fun handleNsiStationsResponseException(
ex: NsiStationsResponseException
): ResponseEntity<Map<String, String>> =
ResponseEntity
.status(HttpStatus.BAD_GATEWAY)
.body(
mapOf(
"error" to "upstream_response_error",
"message" to "НСИ вернула неожиданный HTTP-статус ${ex.statusCode}"
)
)
}
@@ -1,32 +1,231 @@
package space.nstart.pcp.pcp_stations_service.service package space.nstart.pcp.pcp_stations_service.service
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Autowired
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import space.nstart.pcp.pcp_stations_service.configuration.ClassifierProperties
import space.nstart.pcp.pcp_stations_service.configuration.CustomErrorException import space.nstart.pcp.pcp_stations_service.configuration.CustomErrorException
import space.nstart.pcp.pcp_stations_service.entity.StationEntity import space.nstart.pcp.pcp_stations_service.entity.StationEntity
import space.nstart.pcp.pcp_stations_service.integration.NsiStationClient import space.nstart.pcp.pcp_stations_service.integration.NsiStationClient
import space.nstart.pcp.pcp_stations_service.integration.NsiStationsUnavailableException
import space.nstart.pcp.pcp_stations_service.repository.StationRepository import space.nstart.pcp.pcp_stations_service.repository.StationRepository
import space.nstart.pcp.pcp_types_lib.dto.ballistics.StationDTO import space.nstart.pcp.pcp_types_lib.dto.ballistics.StationDTO
import java.util.UUID import java.util.UUID
import java.time.Clock
import java.time.Instant
@Service @Service
class StationService( class StationService(
private val stationRepository: StationRepository,
private val nsiStationClient: NsiStationClient,
private val classifierProperties: ClassifierProperties,
private val clock: Clock = Clock.systemUTC()
) { ) {
@Autowired private val log = LoggerFactory.getLogger(javaClass)
private lateinit var stationRepository: StationRepository
@Autowired /*
private lateinit var nsiStationClient: NsiStationClient * В каждый момент времени реальный refresh выполняет только один поток.
* Остальные запросы ожидают результат этого refresh.
*/
private val cacheLock = Any()
fun all(): List<StationDTO> = nsiStationClient.fetchStations() @Volatile
private var cachedStations: StationsCache? = null
/*
* После полного провала refresh временно не создаем нового лидера,
* чтобы очередь запросов не начала последовательно долбить лежащую НСИ.
*/
@Volatile
private var refreshBlockedUntil: Instant = Instant.EPOCH
fun all(): List<StationDTO> =
loadStationsCache().stations
fun byId(id: UUID): StationDTO = fun byId(id: UUID): StationDTO =
nsiStationClient.fetchStations() loadStationsCache().byId[id]
.firstOrNull { it.id == id }
?: throw CustomErrorException("Станция $id не найдена") ?: throw CustomErrorException("Станция $id не найдена")
fun add(station : StationDTO): StationDTO = stationRepository.save(StationEntity(station)).toDTO() fun add(station : StationDTO): StationDTO = stationRepository.save(StationEntity(station)).toDTO()
fun delete(id : UUID): Int = stationRepository.deleteByStationId(id) fun delete(id : UUID): Int = stationRepository.deleteByStationId(id)
private fun loadStationsCache(): StationsCache {
val now = Instant.now(clock)
/*
* быстрый путь без блокировки
*/
cachedStations
?.takeIf { cache -> cache.isFresh(now) }
?.let { cache ->
log.debug(
"Stations cache hit. cachedCount={}, expiresAt={}",
cache.stations.size,
cache.expiresAt
)
return cache
} }
/*
* прошлый рефреш полностью провалился.
* во время колдауна НСИ повторно не вызываем.
*/
if (now.isBefore(refreshBlockedUntil)) {
return cachedStations ?: throwDataUnavailable()
}
return synchronized(cacheLock) {
val lockedNow = Instant.now(clock)
/*
* двойная проверка пока запрос ожидал лок, другой поток мог
* уже успешно обновить кэш.
*/
cachedStations
?.takeIf { cache -> cache.isFresh(lockedNow) }
?: if (lockedNow.isBefore(refreshBlockedUntil)) {
cachedStations ?: throwDataUnavailable()
} else {
refreshStationsOrFallback()
}
}
}
private fun refreshStationsOrFallback(): StationsCache {
return try {
val stations = fetchStationsWithRetry()
val now = Instant.now(clock)
val snapshotStations = stations.toList()
val refreshedCache = StationsCache(
stations = snapshotStations,
byId = snapshotStations
.mapNotNull { station ->
station.id?.let { id -> id to station }
}
.toMap(),
expiresAt = now.plus(classifierProperties.stationsCacheTtl)
)
cachedStations = refreshedCache
refreshBlockedUntil = Instant.EPOCH
log.info(
"Stations cache refreshed. cachedCount={}, expiresAt={}",
refreshedCache.stations.size,
refreshedCache.expiresAt
)
refreshedCache
} catch (ex: NsiStationsUnavailableException) {
refreshBlockedUntil = Instant.now(clock)
.plus(classifierProperties.stationsRefreshFailureCooldown)
val staleCache = cachedStations
if (staleCache != null) {
log.warn(
"NSI stations are unavailable after refresh attempts. " +
"Returning stale cache. cachedCount={}, cacheExpiredAt={}, " +
"nextRefreshAllowedAt={}, status={}",
staleCache.stations.size,
staleCache.expiresAt,
refreshBlockedUntil,
ex.statusCode,
ex
)
staleCache
} else {
log.warn(
"NSI stations are unavailable and local cache is empty. " +
"nextRefreshAllowedAt={}, status={}",
refreshBlockedUntil,
ex.statusCode,
ex
)
throwDataUnavailable(ex)
}
}
}
private fun fetchStationsWithRetry(): List<StationDTO> {
val attempts = classifierProperties.stationsRefreshAttempts
.coerceAtLeast(1)
var lastFailure: NsiStationsUnavailableException? = null
for (attempt in 1..attempts) {
try {
log.info(
"Refreshing stations from NSI. attempt={}/{}",
attempt,
attempts
)
return nsiStationClient.fetchStations()
} catch (ex: NsiStationsUnavailableException) {
lastFailure = ex
if (attempt == attempts) {
break
}
log.warn(
"NSI stations refresh attempt failed. " +
"attempt={}/{}, status={}, retryDelay={}",
attempt,
attempts,
ex.statusCode,
classifierProperties.stationsRefreshRetryDelay
)
try {
Thread.sleep(
classifierProperties.stationsRefreshRetryDelay
.toMillis()
.coerceAtLeast(0)
)
} catch (interrupted: InterruptedException) {
Thread.currentThread().interrupt()
throw ex
}
}
}
throw checkNotNull(lastFailure)
}
private fun throwDataUnavailable(
cause: Throwable? = null
): Nothing {
val retryAfterSeconds = classifierProperties
.stationsRefreshFailureCooldown
.seconds
.coerceAtLeast(1)
throw StationsDataUnavailableException(
message = "Станции временно недоступны: НСИ не отвечает, локальный кэш пуст",
retryAfterSeconds = retryAfterSeconds,
cause = cause
)
}
private data class StationsCache(
val stations: List<StationDTO>,
val byId: Map<UUID, StationDTO>,
val expiresAt: Instant
) {
fun isFresh(now: Instant): Boolean =
now.isBefore(expiresAt)
}
}
@@ -0,0 +1,10 @@
package space.nstart.pcp.pcp_stations_service.service
/**
* Будет использоваться только при недоступности НСИ и без валидного кэша
* */
class StationsDataUnavailableException(
message: String,
val retryAfterSeconds: Long,
cause: Throwable? = null
) : RuntimeException(message, cause)