From bff63d9782087708b404f5f8d4c1051fdb40e3c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=AC=E7=A5=89?= Date: Mon, 13 Apr 2026 14:46:11 +0800 Subject: [PATCH] fix(sdks): fix in memory store idle ttl --- sdks/sandbox/kotlin/gradle.properties | 2 +- .../sandbox/config/ConnectionConfig.kt | 2 +- .../sandbox/domain/pool/PoolStateStore.kt | 11 ++++++++ .../pool/InMemoryPoolStateStore.kt | 22 +++++++++++++--- .../opensandbox/sandbox/pool/SandboxPool.kt | 1 + .../pool/InMemoryPoolStateStoreTest.kt | 21 ++++++++++++++- .../sandbox/pool/SandboxPoolTest.kt | 26 +++++++++++++++++++ 7 files changed, 78 insertions(+), 7 deletions(-) diff --git a/sdks/sandbox/kotlin/gradle.properties b/sdks/sandbox/kotlin/gradle.properties index f6685975f..a2c6c0b7b 100644 --- a/sdks/sandbox/kotlin/gradle.properties +++ b/sdks/sandbox/kotlin/gradle.properties @@ -5,5 +5,5 @@ org.gradle.parallel=true # Project metadata project.group=com.alibaba.opensandbox -project.version=1.0.8 +project.version=1.0.9 project.description=A Kotlin SDK for Open Sandbox API diff --git a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/config/ConnectionConfig.kt b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/config/ConnectionConfig.kt index cbfe912a6..55db9b05b 100644 --- a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/config/ConnectionConfig.kt +++ b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/config/ConnectionConfig.kt @@ -71,7 +71,7 @@ class ConnectionConfig private constructor( private const val ENV_API_KEY = "OPEN_SANDBOX_API_KEY" private const val ENV_DOMAIN = "OPEN_SANDBOX_DOMAIN" - private const val DEFAULT_USER_AGENT = "OpenSandbox-Kotlin-SDK/1.0.8" + private const val DEFAULT_USER_AGENT = "OpenSandbox-Kotlin-SDK/1.0.9" private const val API_VERSION = "v1" @JvmStatic diff --git a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/pool/PoolStateStore.kt b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/pool/PoolStateStore.kt index de3c7ef79..3467df820 100644 --- a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/pool/PoolStateStore.kt +++ b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/pool/PoolStateStore.kt @@ -119,4 +119,15 @@ interface PoolStateStore { poolName: String, maxIdle: Int, ) + + /** + * Configures idle-entry TTL semantics for the given pool. + * Default is no-op so existing distributed stores can opt in explicitly. + */ + fun setIdleEntryTtl( + poolName: String, + idleTtl: Duration, + ) { + // Default no-op. + } } diff --git a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/pool/InMemoryPoolStateStore.kt b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/pool/InMemoryPoolStateStore.kt index f9b8fa6a8..d5f33fd9c 100644 --- a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/pool/InMemoryPoolStateStore.kt +++ b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/pool/InMemoryPoolStateStore.kt @@ -33,12 +33,12 @@ import java.util.concurrent.ConcurrentLinkedQueue * - Primary lock is a no-op: single-node mode always treats the caller as leader * ([tryAcquirePrimaryLock]/[renewPrimaryLock] return true, [releasePrimaryLock] is no-op). * - * Idle entries use a fixed 24h TTL; expired entries are removed on take, put, reap, or snapshot. + * Idle entries use a configurable TTL (default 24h); expired entries are removed on take, put, reap, or snapshot. * [tryTakeIdle] returns oldest (FIFO) non-expired idle sandbox ID. */ class InMemoryPoolStateStore : PoolStateStore { - /** Fixed idle TTL per OSEP (24h). */ - private val idleTtl: Duration = Duration.ofHours(24) + private val defaultIdleTtl: Duration = Duration.ofHours(24) + private val idleTtlByPool = ConcurrentHashMap() /** Per pool: (map = sandboxId -> entry for idempotent put + expiry, queue = FIFO order for take). */ private val pools = ConcurrentHashMap() @@ -59,7 +59,7 @@ class InMemoryPoolStateStore : PoolStateStore { sandboxId: String, ) { val state = pools.computeIfAbsent(poolName) { PoolIdleState() } - val expiresAt = Instant.now().plus(idleTtl) + val expiresAt = Instant.now().plus(resolveIdleTtl(poolName)) val entry = IdleEntry(sandboxId, expiresAt) if (state.map.putIfAbsent(sandboxId, entry) == null) { state.queue.add(sandboxId) @@ -133,8 +133,22 @@ class InMemoryPoolStateStore : PoolStateStore { // Single-node: no shared state; pool uses local currentMaxIdle. } + override fun setIdleEntryTtl( + poolName: String, + idleTtl: Duration, + ) { + idleTtlByPool[poolName] = validateIdleTtl(idleTtl) + } + private class PoolIdleState { val map = ConcurrentHashMap() val queue = ConcurrentLinkedQueue() } + + private fun validateIdleTtl(idleTtl: Duration): Duration { + require(!idleTtl.isNegative && !idleTtl.isZero) { "idleTtl must be positive" } + return idleTtl + } + + private fun resolveIdleTtl(poolName: String): Duration = idleTtlByPool[poolName] ?: defaultIdleTtl } diff --git a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/pool/SandboxPool.kt b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/pool/SandboxPool.kt index 8f6a5895d..785928786 100644 --- a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/pool/SandboxPool.kt +++ b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/pool/SandboxPool.kt @@ -118,6 +118,7 @@ class SandboxPool internal constructor( lifecycleState.set(LifecycleState.STARTING) try { sandboxManager = createSandboxManager() + stateStore.setIdleEntryTtl(config.poolName, config.idleTimeout) if (stateStore.getMaxIdle(config.poolName) == null) { stateStore.setMaxIdle(config.poolName, config.maxIdle) } diff --git a/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/pool/InMemoryPoolStateStoreTest.kt b/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/pool/InMemoryPoolStateStoreTest.kt index d5e3402c8..61cbed351 100644 --- a/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/pool/InMemoryPoolStateStoreTest.kt +++ b/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/pool/InMemoryPoolStateStoreTest.kt @@ -19,10 +19,12 @@ package com.alibaba.opensandbox.sandbox.infrastructure.pool import com.alibaba.opensandbox.sandbox.domain.pool.PoolStateStore import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import java.time.Duration +import java.time.Instant /** * Contract and behavior tests for [InMemoryPoolStateStore]. @@ -144,10 +146,27 @@ class InMemoryPoolStateStoreTest { @Test fun `reapExpiredIdle removes expired entries`() { store.putIdle(poolName, "id-1") - store.reapExpiredIdle(poolName, java.time.Instant.now().plus(java.time.Duration.ofHours(25))) + store.reapExpiredIdle(poolName, Instant.now().plus(Duration.ofHours(25))) assertEquals(0, store.snapshotCounters(poolName).idleCount) } + @Test + fun `custom idle ttl expires entries accordingly`() { + val inMemoryStore = InMemoryPoolStateStore() + inMemoryStore.setIdleEntryTtl(poolName, Duration.ofSeconds(10)) + inMemoryStore.putIdle(poolName, "id-1") + inMemoryStore.reapExpiredIdle(poolName, Instant.now().plus(Duration.ofSeconds(11))) + assertEquals(0, inMemoryStore.snapshotCounters(poolName).idleCount) + } + + @Test + fun `setIdleEntryTtl validates positive duration`() { + val inMemoryStore = InMemoryPoolStateStore() + assertThrows(IllegalArgumentException::class.java) { + inMemoryStore.setIdleEntryTtl(poolName, Duration.ZERO) + } + } + @Test fun `getMaxIdle returns null and setMaxIdle is no-op in single-node`() { assertNull(store.getMaxIdle(poolName)) diff --git a/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/pool/SandboxPoolTest.kt b/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/pool/SandboxPoolTest.kt index 7eb2d47bb..a4a2bb423 100644 --- a/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/pool/SandboxPoolTest.kt +++ b/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/pool/SandboxPoolTest.kt @@ -450,6 +450,32 @@ class SandboxPoolTest { assertEquals(Duration.ofMinutes(15), config.idleTimeout) } + @Test + fun `start aligns state store idle ttl hook with idleTimeout`() { + val store = InMemoryPoolStateStore() + val pool = + SandboxPool.builder() + .poolName("test-pool") + .ownerId("test-owner") + .maxIdle(2) + .stateStore(store) + .connectionConfig(ConnectionConfig.builder().build()) + .creationSpec(PoolCreationSpec.builder().image("ubuntu:22.04").build()) + .idleTimeout(Duration.ofMinutes(10)) + .drainTimeout(Duration.ofMillis(50)) + .reconcileInterval(Duration.ofSeconds(30)) + .build() + + pool.start() + try { + store.putIdle("test-pool", "id-1") + store.reapExpiredIdle("test-pool", java.time.Instant.now().plus(Duration.ofMinutes(11))) + assertEquals(0, store.snapshotCounters("test-pool").idleCount) + } finally { + pool.shutdown(graceful = false) + } + } + private fun buildPool(): SandboxPool { val config = ConnectionConfig.builder().build() val spec = PoolCreationSpec.builder().image("ubuntu:22.04").build()