@@ -41,6 +41,15 @@ import kotlin.math.ceil
4141 * ready (default: 200ms).
4242 * @property acquireHealthCheck Optional custom health check for sandboxes returned by acquire.
4343 * @property acquireSkipHealthCheck When true, skip readiness checks for sandboxes returned by acquire (default: false).
44+ * @property acquireMinRemainingTtl Minimum remaining TTL an idle sandbox must have to be returned
45+ * by acquire. Idle entries closer to expiry than this threshold are discarded so the subsequent
46+ * ready-check and any user-side renew have time to run before server-side expiry. Set to
47+ * [Duration.ZERO] to opt out and restore the pre-existing binary-expiry behavior.
48+ *
49+ * Default is auto-derived from [idleTimeout] so existing users with short idle timeouts are not
50+ * silently broken: 60s when [idleTimeout] > 60s, otherwise `idleTimeout / 2` (rounded down). The
51+ * resolved value is always strictly less than [idleTimeout]. Pass an explicit value to the builder
52+ * to override.
4453 * @property warmupReadyTimeout Max time to wait for a pool-created sandbox to become ready (default: 30s).
4554 * @property warmupHealthCheckPollingInterval Poll interval while waiting for a pool-created sandbox to become ready
4655 * (default: 200ms).
@@ -65,6 +74,7 @@ class PoolConfig private constructor(
6574 val acquireHealthCheckPollingInterval : Duration ,
6675 val acquireHealthCheck : ((Sandbox ) -> Boolean )? ,
6776 val acquireSkipHealthCheck : Boolean ,
77+ val acquireMinRemainingTtl : Duration ,
6878 val warmupReadyTimeout : Duration ,
6979 val warmupHealthCheckPollingInterval : Duration ,
7080 val warmupHealthCheck : ((Sandbox ) -> Boolean )? ,
@@ -87,6 +97,11 @@ class PoolConfig private constructor(
8797 require(! acquireHealthCheckPollingInterval.isNegative && ! acquireHealthCheckPollingInterval.isZero) {
8898 " acquireHealthCheckPollingInterval must be positive"
8999 }
100+ require(! acquireMinRemainingTtl.isNegative) { " acquireMinRemainingTtl must be non-negative" }
101+ require(acquireMinRemainingTtl < idleTimeout) {
102+ " acquireMinRemainingTtl ($acquireMinRemainingTtl ) must be strictly less than " +
103+ " idleTimeout ($idleTimeout ); otherwise every warmed idle entry would be rejected"
104+ }
90105 require(! warmupReadyTimeout.isNegative && ! warmupReadyTimeout.isZero) { " warmupReadyTimeout must be positive" }
91106 require(! warmupHealthCheckPollingInterval.isNegative && ! warmupHealthCheckPollingInterval.isZero) {
92107 " warmupHealthCheckPollingInterval must be positive"
@@ -101,13 +116,29 @@ class PoolConfig private constructor(
101116 private const val DEFAULT_DEGRADED_THRESHOLD = 3
102117 private val DEFAULT_ACQUIRE_READY_TIMEOUT = Duration .ofSeconds(30 )
103118 private val DEFAULT_ACQUIRE_HEALTH_CHECK_POLLING_INTERVAL = Duration .ofMillis(200 )
119+ private val DEFAULT_ACQUIRE_MIN_REMAINING_TTL_CAP : Duration = Duration .ofSeconds(60 )
104120 private val DEFAULT_WARMUP_READY_TIMEOUT = Duration .ofSeconds(30 )
105121 private val DEFAULT_WARMUP_HEALTH_CHECK_POLLING_INTERVAL = Duration .ofMillis(200 )
106122 private val DEFAULT_IDLE_TIMEOUT = Duration .ofHours(24 )
107123 private val DEFAULT_DRAIN_TIMEOUT = Duration .ofSeconds(30 )
108124
109125 @JvmStatic
110126 fun builder (): Builder = Builder ()
127+
128+ /* *
129+ * Resolves the default `acquireMinRemainingTtl` from the user's [idleTimeout]:
130+ * `min(60s, idleTimeout / 2)`. The result is always strictly less than [idleTimeout],
131+ * so users with short idle timeouts get an automatically scaled threshold instead of a
132+ * config-time error.
133+ */
134+ internal fun defaultAcquireMinRemainingTtl (idleTimeout : Duration ): Duration {
135+ val half = idleTimeout.dividedBy(2L )
136+ return if (DEFAULT_ACQUIRE_MIN_REMAINING_TTL_CAP < half) {
137+ DEFAULT_ACQUIRE_MIN_REMAINING_TTL_CAP
138+ } else {
139+ half
140+ }
141+ }
111142 }
112143
113144 internal fun withMaxIdle (maxIdle : Int ): PoolConfig {
@@ -126,6 +157,7 @@ class PoolConfig private constructor(
126157 acquireHealthCheckPollingInterval = acquireHealthCheckPollingInterval,
127158 acquireHealthCheck = acquireHealthCheck,
128159 acquireSkipHealthCheck = acquireSkipHealthCheck,
160+ acquireMinRemainingTtl = acquireMinRemainingTtl,
129161 warmupReadyTimeout = warmupReadyTimeout,
130162 warmupHealthCheckPollingInterval = warmupHealthCheckPollingInterval,
131163 warmupHealthCheck = warmupHealthCheck,
@@ -151,6 +183,7 @@ class PoolConfig private constructor(
151183 private var acquireHealthCheckPollingInterval: Duration = DEFAULT_ACQUIRE_HEALTH_CHECK_POLLING_INTERVAL
152184 private var acquireHealthCheck: ((Sandbox ) -> Boolean )? = null
153185 private var acquireSkipHealthCheck: Boolean = false
186+ private var acquireMinRemainingTtl: Duration ? = null
154187 private var warmupReadyTimeout: Duration = DEFAULT_WARMUP_READY_TIMEOUT
155188 private var warmupHealthCheckPollingInterval: Duration = DEFAULT_WARMUP_HEALTH_CHECK_POLLING_INTERVAL
156189 private var warmupHealthCheck: ((Sandbox ) -> Boolean )? = null
@@ -229,6 +262,21 @@ class PoolConfig private constructor(
229262 return this
230263 }
231264
265+ /* *
266+ * Sets the minimum remaining TTL an idle sandbox must have to be returned by acquire.
267+ * Idle entries closer to expiry than [acquireMinRemainingTtl] are discarded so the
268+ * subsequent ready-check and any user-side renew have time to run before the server-side
269+ * expiry kicks in.
270+ *
271+ * Must be non-negative and strictly less than `idleTimeout`. If not set, the resolved
272+ * default is `min(60s, idleTimeout / 2)`. Pass [Duration.ZERO] to opt out and restore the
273+ * pre-existing binary-expiry behavior.
274+ */
275+ fun acquireMinRemainingTtl (acquireMinRemainingTtl : Duration ): Builder {
276+ this .acquireMinRemainingTtl = acquireMinRemainingTtl
277+ return this
278+ }
279+
232280 fun warmupReadyTimeout (warmupReadyTimeout : Duration ): Builder {
233281 this .warmupReadyTimeout = warmupReadyTimeout
234282 return this
@@ -277,6 +325,8 @@ class PoolConfig private constructor(
277325 val spec = creationSpec ? : throw IllegalArgumentException (" creationSpec is required" )
278326
279327 val warmup = warmupConcurrency ? : ceil(max * 0.2 ).toInt().coerceAtLeast(1 )
328+ val resolvedAcquireMinRemainingTtl =
329+ acquireMinRemainingTtl ? : defaultAcquireMinRemainingTtl(idleTimeout)
280330
281331 return PoolConfig (
282332 poolName = name,
@@ -293,6 +343,7 @@ class PoolConfig private constructor(
293343 acquireHealthCheckPollingInterval = acquireHealthCheckPollingInterval,
294344 acquireHealthCheck = acquireHealthCheck,
295345 acquireSkipHealthCheck = acquireSkipHealthCheck,
346+ acquireMinRemainingTtl = resolvedAcquireMinRemainingTtl,
296347 warmupReadyTimeout = warmupReadyTimeout,
297348 warmupHealthCheckPollingInterval = warmupHealthCheckPollingInterval,
298349 warmupHealthCheck = warmupHealthCheck,
0 commit comments