From 4edf9d7aa831af77581953c3fac3a227b4267a36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Tue, 26 May 2026 12:41:08 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=20=D0=BA=D1=8D=D1=88,=20=D0=B7=D0=B0=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D1=81=D1=8B=20=D0=B2=20=D0=9D=D0=A1=D0=98=20=D1=80=D0=B0=D0=B7?= =?UTF-8?q?=20=D0=B2=2015=20=D0=BC=D0=B8=D0=BD=D1=83=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config-repo/application-docker-local.yaml | 1 + config-repo/pcp-tgu-service.yaml | 1 + .../config/ClassifierProperties.kt | 4 + .../service/PlatformService.kt | 43 ++++++++- .../api/ExternalPointsClientTest.kt | 1 + .../api/PlatformsClassifierClientTest.kt | 3 + .../service/PlatformServiceTest.kt | 90 +++++++++++++++++-- .../SatelliteMissionProcessServiceTest.kt | 1 + 8 files changed, 135 insertions(+), 9 deletions(-) diff --git a/config-repo/application-docker-local.yaml b/config-repo/application-docker-local.yaml index 0991144..6d8b159 100644 --- a/config-repo/application-docker-local.yaml +++ b/config-repo/application-docker-local.yaml @@ -66,6 +66,7 @@ planning: classifier: platforms-url: https://ordinis.nstart.cloud/api/v1/spacecraft/records + platforms-cache-ttl: 15m allowed-platform-statuses: - OPERATIONAL - STANDBY diff --git a/config-repo/pcp-tgu-service.yaml b/config-repo/pcp-tgu-service.yaml index 912548f..6076925 100644 --- a/config-repo/pcp-tgu-service.yaml +++ b/config-repo/pcp-tgu-service.yaml @@ -39,6 +39,7 @@ topics: classifier: platforms-url: https://ordinis.nstart.cloud/api/v1/spacecraft/records + platforms-cache-ttl: 15m allowed-platform-statuses: - OPERATIONAL - STANDBY diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/ClassifierProperties.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/ClassifierProperties.kt index 5a734ef..853bc8f 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/ClassifierProperties.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/ClassifierProperties.kt @@ -1,6 +1,7 @@ package space.nstart.pcp_tgu_service.config import org.springframework.boot.context.properties.ConfigurationProperties +import java.time.Duration /** * Настройки внешних классификаторов. @@ -11,6 +12,9 @@ data class ClassifierProperties( /** URL классификатора космических аппаратов. */ val platformsUrl: String, + /** TTL in-memory cache списка платформ НСИ. */ + val platformsCacheTtl: Duration, + /** Статусы КА из НСИ, с которыми сервису разрешено работать. */ val allowedPlatformStatuses: Set ) diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlatformService.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlatformService.kt index 8ce9a99..bfa8b5d 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlatformService.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlatformService.kt @@ -7,19 +7,24 @@ import space.nstart.pcp_tgu_service.dto.NsiPlatformDto import space.nstart.pcp_tgu_service.integration.api.PlatformsClassifierClient import org.slf4j.LoggerFactory import org.springframework.stereotype.Service +import java.time.Clock import java.time.Instant @Service class PlatformService( 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 cacheLock = Any() + + @Volatile + private var cachedPlatforms: PlatformsCache? = null /** Возвращает платформы из классификатора с необязательной фильтрацией по статусу НСИ. */ fun loadPlatforms(status: String? = null): List { - val platforms = platformsClassifierClient.fetchPlatforms() - .mapNotNull { platform -> platform.toCatalogItem() } + val platforms = loadCachedPlatforms() return platforms.filter { platform -> status.isNullOrBlank() || platform.status == status } } @@ -35,6 +40,31 @@ class PlatformService( now.isBefore(platform.validTo) } + private fun loadCachedPlatforms(): List { + 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 { + val platforms = platformsClassifierClient.fetchPlatforms() + .mapNotNull { platform -> platform.toCatalogItem() } + + cachedPlatforms = PlatformsCache( + platforms = platforms, + expiresAt = now.plus(classifierProperties.platformsCacheTtl) + ) + + return platforms + } + private fun NsiPlatformDto.toCatalogItem(): PlatformCatalogItem? { val nsiId = id if (nsiId == null) { @@ -53,4 +83,11 @@ class PlatformService( validTo = validTo ) } + + private data class PlatformsCache( + val platforms: List, + val expiresAt: Instant + ) { + fun isActual(now: Instant): Boolean = now.isBefore(expiresAt) + } } diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/integration/api/ExternalPointsClientTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/integration/api/ExternalPointsClientTest.kt index 034b8f0..3971f90 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/integration/api/ExternalPointsClientTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/integration/api/ExternalPointsClientTest.kt @@ -53,6 +53,7 @@ class ExternalPointsClientTest { mock(PlatformsClassifierClient::class.java), ClassifierProperties( platformsUrl = "http://localhost", + platformsCacheTtl = java.time.Duration.ofMinutes(15), allowedPlatformStatuses = setOf("OPERATIONAL", "STANDBY") ) ) { diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/integration/api/PlatformsClassifierClientTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/integration/api/PlatformsClassifierClientTest.kt index a688285..f50f791 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/integration/api/PlatformsClassifierClientTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/integration/api/PlatformsClassifierClientTest.kt @@ -11,6 +11,7 @@ import space.nstart.pcp_tgu_service.dto.NsiPlatformDto import java.net.InetSocketAddress import java.nio.file.Files import java.nio.file.Path +import java.time.Duration class PlatformsClassifierClientTest { @@ -37,6 +38,7 @@ class PlatformsClassifierClientTest { val client = PlatformsClassifierClient( ClassifierProperties( platformsUrl = baseUrl, + platformsCacheTtl = Duration.ofMinutes(15), allowedPlatformStatuses = setOf("OPERATIONAL", "STANDBY") ) ) @@ -59,6 +61,7 @@ class PlatformsClassifierClientTest { val client = PlatformsClassifierClient( ClassifierProperties( platformsUrl = baseUrl, + platformsCacheTtl = Duration.ofMinutes(15), allowedPlatformStatuses = setOf("OPERATIONAL", "STANDBY") ) ) diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlatformServiceTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlatformServiceTest.kt index 676b5ae..473e258 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlatformServiceTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlatformServiceTest.kt @@ -3,12 +3,18 @@ package space.nstart.pcp_tgu_service.service import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test import org.mockito.Mockito.mock +import org.mockito.Mockito.times +import org.mockito.Mockito.verify import org.mockito.Mockito.`when` import space.nstart.pcp_tgu_service.config.ClassifierProperties import space.nstart.pcp_tgu_service.dto.NsiPlatformDataDto import space.nstart.pcp_tgu_service.dto.NsiPlatformDto import space.nstart.pcp_tgu_service.integration.api.PlatformsClassifierClient +import java.time.Clock +import java.time.Duration import java.time.Instant +import java.time.ZoneId +import java.time.ZoneOffset class PlatformServiceTest { @@ -38,15 +44,64 @@ class PlatformServiceTest { assertEquals("MISSION", platforms.single().mission) assertEquals(Instant.parse("2026-01-01T00:00:00Z"), platforms.single().validFrom) assertEquals(Instant.parse("2026-12-31T00:00:00Z"), platforms.single().validTo) + verify(client, times(1)).fetchPlatforms() } @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 client = mock(PlatformsClassifierClient::class.java) `when`(client.fetchPlatforms()).thenReturn( 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 = "lost-id", status = "LOST", noradId = 3L), platformDto(id = "without-norad-id", status = "OPERATIONAL", noradId = null), @@ -58,18 +113,26 @@ class PlatformServiceTest { ) val service = service(client) + val cachedPlatforms = service.loadPlatforms() val allowedPlatforms = service.loadAllowedPlatforms(now) + assertEquals(8, cachedPlatforms.size) 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( platformsClassifierClient = client, classifierProperties = ClassifierProperties( platformsUrl = "http://localhost", + platformsCacheTtl = Duration.ofMinutes(15), allowedPlatformStatuses = setOf("OPERATIONAL", "STANDBY") - ) + ), + clock = clock ) private fun platformDto( @@ -77,7 +140,8 @@ class PlatformServiceTest { status: String, noradId: Long?, 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( id = id, @@ -86,9 +150,23 @@ class PlatformServiceTest { name = id?.let { "Satellite $it" }, status = status, noradId = noradId, - mission = "MISSION" + mission = mission ), validFrom = validFrom?.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) + } + } } diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/SatelliteMissionProcessServiceTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/SatelliteMissionProcessServiceTest.kt index e25fa60..f3170dd 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/SatelliteMissionProcessServiceTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/SatelliteMissionProcessServiceTest.kt @@ -226,6 +226,7 @@ class SatelliteMissionProcessServiceTest { mock(PlatformsClassifierClient::class.java), ClassifierProperties( platformsUrl = "http://localhost", + platformsCacheTtl = java.time.Duration.ofMinutes(15), allowedPlatformStatuses = setOf("OPERATIONAL", "STANDBY") ) ) {