Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion sdks/sandbox/kotlin/gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Duration>()

/** Per pool: (map = sandboxId -> entry for idempotent put + expiry, queue = FIFO order for take). */
private val pools = ConcurrentHashMap<String, PoolIdleState>()
Expand All @@ -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)
Expand Down Expand Up @@ -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<String, IdleEntry>()
val queue = ConcurrentLinkedQueue<String>()
}

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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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].
Expand Down Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading