Добавлен кэш, запросы в НСИ раз в 15 минут

This commit is contained in:
Дмитрий Соловьев
2026-05-26 12:41:08 +03:00
parent cbbafebf79
commit 4edf9d7aa8
8 changed files with 135 additions and 9 deletions
@@ -66,6 +66,7 @@ planning:
classifier: classifier:
platforms-url: https://ordinis.nstart.cloud/api/v1/spacecraft/records platforms-url: https://ordinis.nstart.cloud/api/v1/spacecraft/records
platforms-cache-ttl: 15m
allowed-platform-statuses: allowed-platform-statuses:
- OPERATIONAL - OPERATIONAL
- STANDBY - STANDBY
+1
View File
@@ -39,6 +39,7 @@ topics:
classifier: classifier:
platforms-url: https://ordinis.nstart.cloud/api/v1/spacecraft/records platforms-url: https://ordinis.nstart.cloud/api/v1/spacecraft/records
platforms-cache-ttl: 15m
allowed-platform-statuses: allowed-platform-statuses:
- OPERATIONAL - OPERATIONAL
- STANDBY - STANDBY
@@ -1,6 +1,7 @@
package space.nstart.pcp_tgu_service.config package space.nstart.pcp_tgu_service.config
import org.springframework.boot.context.properties.ConfigurationProperties import org.springframework.boot.context.properties.ConfigurationProperties
import java.time.Duration
/** /**
* Настройки внешних классификаторов. * Настройки внешних классификаторов.
@@ -11,6 +12,9 @@ data class ClassifierProperties(
/** URL классификатора космических аппаратов. */ /** URL классификатора космических аппаратов. */
val platformsUrl: String, val platformsUrl: String,
/** TTL in-memory cache списка платформ НСИ. */
val platformsCacheTtl: Duration,
/** Статусы КА из НСИ, с которыми сервису разрешено работать. */ /** Статусы КА из НСИ, с которыми сервису разрешено работать. */
val allowedPlatformStatuses: Set<String> val allowedPlatformStatuses: Set<String>
) )
@@ -7,19 +7,24 @@ import space.nstart.pcp_tgu_service.dto.NsiPlatformDto
import space.nstart.pcp_tgu_service.integration.api.PlatformsClassifierClient import space.nstart.pcp_tgu_service.integration.api.PlatformsClassifierClient
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import java.time.Clock
import java.time.Instant import java.time.Instant
@Service @Service
class PlatformService( class PlatformService(
private val platformsClassifierClient: PlatformsClassifierClient, private val platformsClassifierClient: PlatformsClassifierClient,
private val classifierProperties: ClassifierProperties private val classifierProperties: ClassifierProperties,
private val clock: Clock = Clock.systemUTC()
) { ) {
private val log = LoggerFactory.getLogger(javaClass) private val log = LoggerFactory.getLogger(javaClass)
private val cacheLock = Any()
@Volatile
private var cachedPlatforms: PlatformsCache? = null
/** Возвращает платформы из классификатора с необязательной фильтрацией по статусу НСИ. */ /** Возвращает платформы из классификатора с необязательной фильтрацией по статусу НСИ. */
fun loadPlatforms(status: String? = null): List<PlatformCatalogItem> { fun loadPlatforms(status: String? = null): List<PlatformCatalogItem> {
val platforms = platformsClassifierClient.fetchPlatforms() val platforms = loadCachedPlatforms()
.mapNotNull { platform -> platform.toCatalogItem() }
return platforms.filter { platform -> status.isNullOrBlank() || platform.status == status } return platforms.filter { platform -> status.isNullOrBlank() || platform.status == status }
} }
@@ -35,6 +40,31 @@ class PlatformService(
now.isBefore(platform.validTo) now.isBefore(platform.validTo)
} }
private fun loadCachedPlatforms(): List<PlatformCatalogItem> {
val now = Instant.now(clock)
cachedPlatforms?.takeIf { cache -> cache.isActual(now) }?.let { cache -> return cache.platforms }
return synchronized(cacheLock) {
val currentNow = Instant.now(clock)
cachedPlatforms
?.takeIf { cache -> cache.isActual(currentNow) }
?.platforms
?: fetchAndCachePlatforms(currentNow)
}
}
private fun fetchAndCachePlatforms(now: Instant): List<PlatformCatalogItem> {
val platforms = platformsClassifierClient.fetchPlatforms()
.mapNotNull { platform -> platform.toCatalogItem() }
cachedPlatforms = PlatformsCache(
platforms = platforms,
expiresAt = now.plus(classifierProperties.platformsCacheTtl)
)
return platforms
}
private fun NsiPlatformDto.toCatalogItem(): PlatformCatalogItem? { private fun NsiPlatformDto.toCatalogItem(): PlatformCatalogItem? {
val nsiId = id val nsiId = id
if (nsiId == null) { if (nsiId == null) {
@@ -53,4 +83,11 @@ class PlatformService(
validTo = validTo validTo = validTo
) )
} }
private data class PlatformsCache(
val platforms: List<PlatformCatalogItem>,
val expiresAt: Instant
) {
fun isActual(now: Instant): Boolean = now.isBefore(expiresAt)
}
} }
@@ -53,6 +53,7 @@ class ExternalPointsClientTest {
mock(PlatformsClassifierClient::class.java), mock(PlatformsClassifierClient::class.java),
ClassifierProperties( ClassifierProperties(
platformsUrl = "http://localhost", platformsUrl = "http://localhost",
platformsCacheTtl = java.time.Duration.ofMinutes(15),
allowedPlatformStatuses = setOf("OPERATIONAL", "STANDBY") allowedPlatformStatuses = setOf("OPERATIONAL", "STANDBY")
) )
) { ) {
@@ -11,6 +11,7 @@ import space.nstart.pcp_tgu_service.dto.NsiPlatformDto
import java.net.InetSocketAddress import java.net.InetSocketAddress
import java.nio.file.Files import java.nio.file.Files
import java.nio.file.Path import java.nio.file.Path
import java.time.Duration
class PlatformsClassifierClientTest { class PlatformsClassifierClientTest {
@@ -37,6 +38,7 @@ class PlatformsClassifierClientTest {
val client = PlatformsClassifierClient( val client = PlatformsClassifierClient(
ClassifierProperties( ClassifierProperties(
platformsUrl = baseUrl, platformsUrl = baseUrl,
platformsCacheTtl = Duration.ofMinutes(15),
allowedPlatformStatuses = setOf("OPERATIONAL", "STANDBY") allowedPlatformStatuses = setOf("OPERATIONAL", "STANDBY")
) )
) )
@@ -59,6 +61,7 @@ class PlatformsClassifierClientTest {
val client = PlatformsClassifierClient( val client = PlatformsClassifierClient(
ClassifierProperties( ClassifierProperties(
platformsUrl = baseUrl, platformsUrl = baseUrl,
platformsCacheTtl = Duration.ofMinutes(15),
allowedPlatformStatuses = setOf("OPERATIONAL", "STANDBY") allowedPlatformStatuses = setOf("OPERATIONAL", "STANDBY")
) )
) )
@@ -3,12 +3,18 @@ package space.nstart.pcp_tgu_service.service
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.mockito.Mockito.mock import org.mockito.Mockito.mock
import org.mockito.Mockito.times
import org.mockito.Mockito.verify
import org.mockito.Mockito.`when` import org.mockito.Mockito.`when`
import space.nstart.pcp_tgu_service.config.ClassifierProperties import space.nstart.pcp_tgu_service.config.ClassifierProperties
import space.nstart.pcp_tgu_service.dto.NsiPlatformDataDto import space.nstart.pcp_tgu_service.dto.NsiPlatformDataDto
import space.nstart.pcp_tgu_service.dto.NsiPlatformDto import space.nstart.pcp_tgu_service.dto.NsiPlatformDto
import space.nstart.pcp_tgu_service.integration.api.PlatformsClassifierClient import space.nstart.pcp_tgu_service.integration.api.PlatformsClassifierClient
import java.time.Clock
import java.time.Duration
import java.time.Instant import java.time.Instant
import java.time.ZoneId
import java.time.ZoneOffset
class PlatformServiceTest { class PlatformServiceTest {
@@ -38,15 +44,64 @@ class PlatformServiceTest {
assertEquals("MISSION", platforms.single().mission) assertEquals("MISSION", platforms.single().mission)
assertEquals(Instant.parse("2026-01-01T00:00:00Z"), platforms.single().validFrom) assertEquals(Instant.parse("2026-01-01T00:00:00Z"), platforms.single().validFrom)
assertEquals(Instant.parse("2026-12-31T00:00:00Z"), platforms.single().validTo) assertEquals(Instant.parse("2026-12-31T00:00:00Z"), platforms.single().validTo)
verify(client, times(1)).fetchPlatforms()
} }
@Test @Test
fun `loadAllowedPlatforms uses configured statuses noradId and validity interval`() { fun `first loadPlatforms call fetches platforms from client`() {
val client = mock(PlatformsClassifierClient::class.java)
`when`(client.fetchPlatforms()).thenReturn(
listOf(platformDto(id = "operational-id", status = "OPERATIONAL", noradId = 1L))
)
val service = service(client)
val platforms = service.loadPlatforms()
assertEquals(listOf("operational-id"), platforms.map { it.nsiId })
verify(client, times(1)).fetchPlatforms()
}
@Test
fun `second loadPlatforms call within ttl uses cached platforms`() {
val client = mock(PlatformsClassifierClient::class.java)
`when`(client.fetchPlatforms()).thenReturn(
listOf(platformDto(id = "cached-id", status = "OPERATIONAL", noradId = 1L))
)
val service = service(client)
val firstCall = service.loadPlatforms()
val secondCall = service.loadPlatforms()
assertEquals(listOf("cached-id"), firstCall.map { it.nsiId })
assertEquals(listOf("cached-id"), secondCall.map { it.nsiId })
verify(client, times(1)).fetchPlatforms()
}
@Test
fun `loadPlatforms refreshes cache after fifteen minutes`() {
val clock = MutableClock(Instant.parse("2026-05-26T12:00:00Z"))
val client = mock(PlatformsClassifierClient::class.java)
`when`(client.fetchPlatforms())
.thenReturn(listOf(platformDto(id = "first-id", status = "OPERATIONAL", noradId = 1L)))
.thenReturn(listOf(platformDto(id = "refreshed-id", status = "OPERATIONAL", noradId = 2L)))
val service = service(client, clock)
val firstCall = service.loadPlatforms()
clock.advance(Duration.ofMinutes(15))
val refreshedCall = service.loadPlatforms()
assertEquals(listOf("first-id"), firstCall.map { it.nsiId })
assertEquals(listOf("refreshed-id"), refreshedCall.map { it.nsiId })
verify(client, times(2)).fetchPlatforms()
}
@Test
fun `loadAllowedPlatforms uses cached platforms and configured filtering`() {
val now = Instant.parse("2026-05-26T12:00:00Z") val now = Instant.parse("2026-05-26T12:00:00Z")
val client = mock(PlatformsClassifierClient::class.java) val client = mock(PlatformsClassifierClient::class.java)
`when`(client.fetchPlatforms()).thenReturn( `when`(client.fetchPlatforms()).thenReturn(
listOf( listOf(
platformDto(id = "operational-id", status = "OPERATIONAL", noradId = 1L), platformDto(id = "operational-id", status = "OPERATIONAL", noradId = 1L, mission = null),
platformDto(id = "standby-id", status = "STANDBY", noradId = 2L), platformDto(id = "standby-id", status = "STANDBY", noradId = 2L),
platformDto(id = "lost-id", status = "LOST", noradId = 3L), platformDto(id = "lost-id", status = "LOST", noradId = 3L),
platformDto(id = "without-norad-id", status = "OPERATIONAL", noradId = null), platformDto(id = "without-norad-id", status = "OPERATIONAL", noradId = null),
@@ -58,18 +113,26 @@ class PlatformServiceTest {
) )
val service = service(client) val service = service(client)
val cachedPlatforms = service.loadPlatforms()
val allowedPlatforms = service.loadAllowedPlatforms(now) val allowedPlatforms = service.loadAllowedPlatforms(now)
assertEquals(8, cachedPlatforms.size)
assertEquals(listOf("operational-id", "standby-id"), allowedPlatforms.map { it.nsiId }) assertEquals(listOf("operational-id", "standby-id"), allowedPlatforms.map { it.nsiId })
verify(client, times(1)).fetchPlatforms()
} }
private fun service(client: PlatformsClassifierClient): PlatformService = private fun service(
client: PlatformsClassifierClient,
clock: Clock = Clock.fixed(Instant.parse("2026-05-26T12:00:00Z"), ZoneOffset.UTC)
): PlatformService =
PlatformService( PlatformService(
platformsClassifierClient = client, platformsClassifierClient = client,
classifierProperties = ClassifierProperties( classifierProperties = ClassifierProperties(
platformsUrl = "http://localhost", platformsUrl = "http://localhost",
platformsCacheTtl = Duration.ofMinutes(15),
allowedPlatformStatuses = setOf("OPERATIONAL", "STANDBY") allowedPlatformStatuses = setOf("OPERATIONAL", "STANDBY")
) ),
clock = clock
) )
private fun platformDto( private fun platformDto(
@@ -77,7 +140,8 @@ class PlatformServiceTest {
status: String, status: String,
noradId: Long?, noradId: Long?,
validFrom: String? = "2026-01-01T00:00:00Z", validFrom: String? = "2026-01-01T00:00:00Z",
validTo: String? = "2026-12-31T00:00:00Z" validTo: String? = "2026-12-31T00:00:00Z",
mission: String? = "MISSION"
): NsiPlatformDto = ): NsiPlatformDto =
NsiPlatformDto( NsiPlatformDto(
id = id, id = id,
@@ -86,9 +150,23 @@ class PlatformServiceTest {
name = id?.let { "Satellite $it" }, name = id?.let { "Satellite $it" },
status = status, status = status,
noradId = noradId, noradId = noradId,
mission = "MISSION" mission = mission
), ),
validFrom = validFrom?.let(Instant::parse), validFrom = validFrom?.let(Instant::parse),
validTo = validTo?.let(Instant::parse) validTo = validTo?.let(Instant::parse)
) )
private class MutableClock(
private var currentInstant: Instant
) : Clock() {
override fun getZone(): ZoneId = ZoneOffset.UTC
override fun withZone(zone: ZoneId): Clock = this
override fun instant(): Instant = currentInstant
fun advance(duration: Duration) {
currentInstant = currentInstant.plus(duration)
}
}
} }
@@ -226,6 +226,7 @@ class SatelliteMissionProcessServiceTest {
mock(PlatformsClassifierClient::class.java), mock(PlatformsClassifierClient::class.java),
ClassifierProperties( ClassifierProperties(
platformsUrl = "http://localhost", platformsUrl = "http://localhost",
platformsCacheTtl = java.time.Duration.ofMinutes(15),
allowedPlatformStatuses = setOf("OPERATIONAL", "STANDBY") allowedPlatformStatuses = setOf("OPERATIONAL", "STANDBY")
) )
) { ) {