Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ package com.alibaba.opensandbox.sandbox
import com.alibaba.opensandbox.sandbox.config.ConnectionConfig
import com.alibaba.opensandbox.sandbox.domain.exceptions.InvalidArgumentException
import com.alibaba.opensandbox.sandbox.domain.exceptions.SandboxException
import com.alibaba.opensandbox.sandbox.domain.exceptions.SandboxReadyTimeoutException
import com.alibaba.opensandbox.sandbox.domain.exceptions.SnapshotFailedException
import com.alibaba.opensandbox.sandbox.domain.models.diagnostics.DiagnosticContent
import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.PagedSandboxInfos
import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.PagedSnapshotInfos
Expand All @@ -27,6 +29,7 @@ import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxInfo
import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxRenewResponse
import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SnapshotFilter
import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SnapshotInfo
import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SnapshotState
import com.alibaba.opensandbox.sandbox.domain.services.Diagnostics
import com.alibaba.opensandbox.sandbox.domain.services.Sandboxes
import com.alibaba.opensandbox.sandbox.infrastructure.factory.AdapterFactory
Expand Down Expand Up @@ -224,6 +227,71 @@ class SandboxManager internal constructor(

fun deleteSnapshot(snapshotId: String) = sandboxService.deleteSnapshot(snapshotId)

/**
* Waits for a snapshot to reach the [SnapshotState.READY] state, polling at a fixed interval.
*
* Snapshot creation is asynchronous: [createSnapshot] returns as soon as the snapshot record
* exists (typically in the [SnapshotState.CREATING] state). This helper polls [getSnapshot]
* until the snapshot becomes ready, fails, or the timeout elapses.
*
* @param snapshotId Unique identifier of the snapshot to wait for
* @param timeout Maximum time to wait for the snapshot to become ready
* @param pollingInterval Time between successive [getSnapshot] polls
* @return The ready [SnapshotInfo]
* @throws SnapshotFailedException if the snapshot reaches the [SnapshotState.FAILED] state
* @throws SandboxReadyTimeoutException if the snapshot is not ready within [timeout]
* @throws InvalidArgumentException if [pollingInterval] is not positive
*/
@JvmOverloads
fun waitForSnapshotReady(
snapshotId: String,
timeout: Duration = Duration.ofMinutes(5),
Comment thread
ferponse marked this conversation as resolved.
Outdated
pollingInterval: Duration = Duration.ofSeconds(2),
): SnapshotInfo {
if (pollingInterval.isNegative || pollingInterval.isZero) {
throw InvalidArgumentException("Polling interval must be positive, got: $pollingInterval")
}
logger.info("Waiting for snapshot {} to become ready (timeout: {}s)", snapshotId, timeout.seconds)

val deadline = System.currentTimeMillis() + timeout.toMillis()
var attempt = 0
while (true) {
// Enforce the deadline before each poll so a snapshot that only turns Ready after the
// timeout is reported as a timeout rather than a late success.
if (System.currentTimeMillis() >= deadline) {
throw SandboxReadyTimeoutException(
"Snapshot $snapshotId did not become ready within ${timeout.seconds}s ($attempt attempts)",
)
}
attempt++
val snapshot = getSnapshot(snapshotId)
when (snapshot.status.state) {
SnapshotState.READY -> {
logger.info("Snapshot {} is ready after {} attempts", snapshotId, attempt)
return snapshot
Comment thread
ferponse marked this conversation as resolved.
Comment thread
ferponse marked this conversation as resolved.
}
SnapshotState.FAILED -> {
val detail = snapshot.status.message ?: snapshot.status.reason ?: "no detail provided"
throw SnapshotFailedException("Snapshot $snapshotId failed: $detail")
}
else ->
logger.debug(
"Snapshot {} not ready yet (state: {}, attempt #{})",
snapshotId,
snapshot.status.state,
attempt,
)
}

// Sleep for at most the remaining window so we keep polling until the real deadline
// instead of giving up a full interval early, and never sleep past it.
val remaining = deadline - System.currentTimeMillis()
if (remaining > 0) {
Thread.sleep(minOf(pollingInterval.toMillis(), remaining))
}
}
}

/**
* Closes this resource, relinquishing any underlying resources.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,18 @@ class SandboxReadyTimeoutException(
error = SandboxError(SandboxError.READY_TIMEOUT, message),
)

/**
* Thrown when a snapshot reaches the `Failed` state while waiting for it to become ready.
*/
class SnapshotFailedException(
message: String? = null,
cause: Throwable? = null,
) : SandboxException(
message = message,
cause = cause,
error = SandboxError(SandboxError.SNAPSHOT_FAILED, message),
)

/**
* Thrown when an invalid argument is provided to an SDK method.
* Similar to [IllegalArgumentException] but within the SDK's exception hierarchy.
Expand Down Expand Up @@ -180,6 +192,9 @@ data class SandboxError(
const val INVALID_ARGUMENT = "INVALID_ARGUMENT"
const val UNEXPECTED_RESPONSE = "UNEXPECTED_RESPONSE"

/** A snapshot reached the `Failed` state while waiting for it to become ready. */
const val SNAPSHOT_FAILED = "SNAPSHOT_FAILED"

/** The requested file or directory does not exist (server responds with HTTP 404). */
const val FILE_NOT_FOUND = "FILE_NOT_FOUND"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -753,6 +753,30 @@ class SandboxCreateResponse(
val platform: PlatformSpec? = null,
)

/**
* Lifecycle state of a snapshot.
*
* Common state values:
* - Creating: Snapshot is being captured from the sandbox
* - Ready: Snapshot has been captured and can be used to restore a sandbox
* - Failed: Snapshot capture encountered a critical error
* - Deleting: Snapshot is being deleted
*
* State transitions:
* - Creating → Ready (capture completes successfully)
* - Creating → Failed (on error)
*
* Note: New state values may be added in future versions.
* Clients should handle unknown state values gracefully.
*/
object SnapshotState {
const val CREATING = "Creating"
const val READY = "Ready"
const val FAILED = "Failed"
const val DELETING = "Deleting"
const val UNKNOWN = "Unknown"
}

class SnapshotStatus(
val state: String,
val reason: String?,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@

package com.alibaba.opensandbox.sandbox

import com.alibaba.opensandbox.sandbox.domain.exceptions.InvalidArgumentException
import com.alibaba.opensandbox.sandbox.domain.exceptions.SandboxReadyTimeoutException
import com.alibaba.opensandbox.sandbox.domain.exceptions.SnapshotFailedException
import com.alibaba.opensandbox.sandbox.domain.models.diagnostics.DiagnosticContent
import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.PagedSandboxInfos
import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.PaginationInfo
Expand All @@ -25,6 +28,9 @@ import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxInfo
import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxRenewResponse
import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxState
import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxStatus
import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SnapshotInfo
import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SnapshotState
import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SnapshotStatus
import com.alibaba.opensandbox.sandbox.domain.services.Diagnostics
import com.alibaba.opensandbox.sandbox.domain.services.Sandboxes
import io.mockk.Runs
Expand All @@ -36,6 +42,7 @@ import io.mockk.mockk
import io.mockk.verify
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertSame
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
Expand Down Expand Up @@ -190,4 +197,91 @@ class SandboxManagerTest {

verify { httpClientProvider.close() }
}

private fun snapshot(state: String): SnapshotInfo =
SnapshotInfo(
id = "snapshot-id",
sandboxId = "sandbox-id",
name = "snap",
status = SnapshotStatus(state = state, reason = null, message = null, lastTransitionAt = null),
createdAt = OffsetDateTime.now(),
)

@Test
fun `waitForSnapshotReady returns once the snapshot becomes ready`() {
val sequence = listOf(snapshot(SnapshotState.CREATING), snapshot(SnapshotState.READY))
var index = 0
every { sandboxService.getSnapshot("snapshot-id") } answers { sequence[index++] }

val result =
sandboxManager.waitForSnapshotReady(
"snapshot-id",
Duration.ofSeconds(5),
Duration.ofMillis(10),
)

assertEquals(SnapshotState.READY, result.status.state)
verify(exactly = 2) { sandboxService.getSnapshot("snapshot-id") }
}

@Test
fun `waitForSnapshotReady throws SnapshotFailedException when the snapshot fails`() {
every { sandboxService.getSnapshot("snapshot-id") } returns snapshot(SnapshotState.FAILED)

assertThrows(SnapshotFailedException::class.java) {
sandboxManager.waitForSnapshotReady("snapshot-id", Duration.ofSeconds(5), Duration.ofMillis(10))
}
}

@Test
fun `waitForSnapshotReady throws SandboxReadyTimeoutException when it never becomes ready`() {
every { sandboxService.getSnapshot("snapshot-id") } returns snapshot(SnapshotState.CREATING)

assertThrows(SandboxReadyTimeoutException::class.java) {
sandboxManager.waitForSnapshotReady("snapshot-id", Duration.ofMillis(30), Duration.ofMillis(10))
}
}

@Test
fun `waitForSnapshotReady rejects a non-positive polling interval`() {
assertThrows(InvalidArgumentException::class.java) {
sandboxManager.waitForSnapshotReady("snapshot-id", Duration.ofSeconds(5), Duration.ZERO)
}
}

@Test
fun `waitForSnapshotReady keeps polling within the window instead of giving up early`() {
// Several non-ready polls within a generous window must not trigger a premature timeout.
val sequence =
listOf(
snapshot(SnapshotState.CREATING),
snapshot(SnapshotState.CREATING),
snapshot(SnapshotState.READY),
)
var index = 0
every { sandboxService.getSnapshot("snapshot-id") } answers { sequence[index++] }

val result =
sandboxManager.waitForSnapshotReady(
"snapshot-id",
Duration.ofSeconds(1),
Duration.ofMillis(20),
)

assertEquals(SnapshotState.READY, result.status.state)
verify(exactly = 3) { sandboxService.getSnapshot("snapshot-id") }
}

@Test
fun `waitForSnapshotReady does not accept a snapshot that turns ready only after the deadline`() {
// The interval (100ms) outlasts the timeout (80ms): after the single sleep the deadline has
// passed, so the late READY must be rejected with a timeout rather than returned as success.
val sequence = listOf(snapshot(SnapshotState.CREATING), snapshot(SnapshotState.READY))
var index = 0
every { sandboxService.getSnapshot("snapshot-id") } answers { sequence[index++] }

assertThrows(SandboxReadyTimeoutException::class.java) {
sandboxManager.waitForSnapshotReady("snapshot-id", Duration.ofMillis(80), Duration.ofMillis(100))
}
}
}
Loading