From 8e83c617af17c2e99f77196a10f261bd543fb817 Mon Sep 17 00:00:00 2001 From: Edouard Bonlieu Date: Wed, 19 Nov 2025 09:13:56 -0500 Subject: [PATCH] feat: add experimental support for light sleep --- koyeb/sandbox/sandbox.py | 43 +++++----- koyeb/sandbox/utils.py | 169 +++++---------------------------------- 2 files changed, 42 insertions(+), 170 deletions(-) diff --git a/koyeb/sandbox/sandbox.py b/koyeb/sandbox/sandbox.py index 8701fc1a..cc2dfeb5 100644 --- a/koyeb/sandbox/sandbox.py +++ b/koyeb/sandbox/sandbox.py @@ -24,7 +24,6 @@ IdleTimeout, SandboxError, SandboxTimeoutError, - _is_light_sleep_enabled, async_wrapper, build_env_vars, create_deployment_definition, @@ -111,9 +110,10 @@ def create( region: Optional[str] = None, api_token: Optional[str] = None, timeout: int = 300, - idle_timeout: Optional[IdleTimeout] = None, + idle_timeout: Optional[IdleTimeout] = 300, enable_tcp_proxy: bool = False, privileged: bool = False, + _experimental_enable_light_sleep: bool = False, ) -> Sandbox: """ Create a new sandbox instance. @@ -130,13 +130,15 @@ def create( region: Region to deploy to (default: "na") api_token: Koyeb API token (if None, will try to get from KOYEB_API_TOKEN env var) timeout: Timeout for sandbox creation in seconds - idle_timeout: Idle timeout configuration for scale-to-zero - - None: Auto-enable (light_sleep=300s, deep_sleep=600s) - - 0: Disable scale-to-zero (keep always-on) - - int > 0: Deep sleep only (e.g., 600 for 600s deep sleep) - - dict: Explicit configuration with {"light_sleep": 300, "deep_sleep": 600} + idle_timeout: Sleep timeout in seconds. Behavior depends on _experimental_enable_light_sleep: + - If _experimental_enable_light_sleep is True: sets light_sleep value (deep_sleep=3900) + - If _experimental_enable_light_sleep is False: sets deep_sleep value + - If 0: disables scale-to-zero (keep always-on) + - If None: uses default values enable_tcp_proxy: If True, enables TCP proxy for direct TCP access to port 3031 privileged: If True, run the container in privileged mode (default: False) + _experimental_enable_light_sleep: If True, uses idle_timeout for light_sleep and sets + deep_sleep=3900. If False, uses idle_timeout for deep_sleep (default: False) Returns: Sandbox: A new Sandbox instance @@ -164,6 +166,7 @@ def create( idle_timeout=idle_timeout, enable_tcp_proxy=enable_tcp_proxy, privileged=privileged, + _experimental_enable_light_sleep=_experimental_enable_light_sleep, ) if wait_ready: @@ -188,9 +191,10 @@ def _create_sync( region: Optional[str] = None, api_token: Optional[str] = None, timeout: int = 300, - idle_timeout: Optional[IdleTimeout] = None, + idle_timeout: Optional[IdleTimeout] = 300, enable_tcp_proxy: bool = False, privileged: bool = False, + _experimental_enable_light_sleep: bool = False, ) -> Sandbox: """ Synchronous creation method that returns creation parameters. @@ -209,11 +213,6 @@ def _create_sync( env = {} env["SANDBOX_SECRET"] = sandbox_secret - # Check if light sleep is enabled for this instance type - light_sleep_enabled = _is_light_sleep_enabled( - instance_type, catalog_instances_api - ) - app_name = f"sandbox-app-{name}-{int(time.time())}" app_response = apps_api.create_app(app=CreateApp(name=app_name)) app_id = app_response.app.id @@ -229,8 +228,8 @@ def _create_sync( region=region, routes=routes, idle_timeout=idle_timeout, - light_sleep_enabled=light_sleep_enabled, enable_tcp_proxy=enable_tcp_proxy, + _experimental_enable_light_sleep=_experimental_enable_light_sleep, ) create_service = CreateService(app_id=app_id, definition=deployment_definition) @@ -850,9 +849,10 @@ async def create( region: Optional[str] = None, api_token: Optional[str] = None, timeout: int = 300, - idle_timeout: Optional[IdleTimeout] = None, + idle_timeout: Optional[IdleTimeout] = 300, enable_tcp_proxy: bool = False, privileged: bool = False, + _experimental_enable_light_sleep: bool = False, ) -> AsyncSandbox: """ Create a new sandbox instance with async support. @@ -869,13 +869,15 @@ async def create( region: Region to deploy to (default: "na") api_token: Koyeb API token (if None, will try to get from KOYEB_API_TOKEN env var) timeout: Timeout for sandbox creation in seconds - idle_timeout: Idle timeout configuration for scale-to-zero - - None: Auto-enable (light_sleep=300s, deep_sleep=600s) - - 0: Disable scale-to-zero (keep always-on) - - int > 0: Deep sleep only (e.g., 600 for 600s deep sleep) - - dict: Explicit configuration with {"light_sleep": 300, "deep_sleep": 600} + idle_timeout: Sleep timeout in seconds. Behavior depends on _experimental_enable_light_sleep: + - If _experimental_enable_light_sleep is True: sets light_sleep value (deep_sleep=3900) + - If _experimental_enable_light_sleep is False: sets deep_sleep value + - If 0: disables scale-to-zero (keep always-on) + - If None: uses default values enable_tcp_proxy: If True, enables TCP proxy for direct TCP access to port 3031 privileged: If True, run the container in privileged mode (default: False) + _experimental_enable_light_sleep: If True, uses idle_timeout for light_sleep and sets + deep_sleep=3900. If False, uses idle_timeout for deep_sleep (default: False) Returns: AsyncSandbox: A new AsyncSandbox instance @@ -906,6 +908,7 @@ async def create( idle_timeout=idle_timeout, enable_tcp_proxy=enable_tcp_proxy, privileged=privileged, + _experimental_enable_light_sleep=_experimental_enable_light_sleep, ), ) diff --git a/koyeb/sandbox/utils.py b/koyeb/sandbox/utils.py index cf07edbe..9c216322 100644 --- a/koyeb/sandbox/utils.py +++ b/koyeb/sandbox/utils.py @@ -236,150 +236,6 @@ def create_koyeb_sandbox_routes() -> List[DeploymentRoute]: ] -def _validate_idle_timeout(idle_timeout: Optional[IdleTimeout]) -> None: - """ - Validate idle_timeout parameter according to spec. - - Raises: - ValueError: If validation fails - """ - if idle_timeout is None: - return - - if isinstance(idle_timeout, int): - if idle_timeout < 0: - raise ValueError("idle_timeout must be >= 0") - if idle_timeout > 0: - # Deep sleep only - valid - return - # idle_timeout == 0 means disable scale-to-zero - valid - return - - if isinstance(idle_timeout, dict): - if "deep_sleep" not in idle_timeout: - raise ValueError( - "idle_timeout dict must contain 'deep_sleep' key (at minimum)" - ) - - deep_sleep = idle_timeout.get("deep_sleep") - if deep_sleep is None or not isinstance(deep_sleep, int) or deep_sleep <= 0: - raise ValueError("deep_sleep must be a positive integer") - - if "light_sleep" in idle_timeout: - light_sleep = idle_timeout.get("light_sleep") - if ( - light_sleep is None - or not isinstance(light_sleep, int) - or light_sleep <= 0 - ): - raise ValueError("light_sleep must be a positive integer") - - if deep_sleep < light_sleep: - raise ValueError( - "deep_sleep must be >= light_sleep when both are provided" - ) - - -def _is_light_sleep_enabled( - instance_type: str, - catalog_instances_api: Optional[CatalogInstancesApi] = None, -) -> bool: - """ - Check if light sleep is enabled for the instance type using API or fallback. - - Args: - instance_type: Instance type string - catalog_instances_api: Optional CatalogInstancesApi client (if None, will try to create one) - - Returns: - True if light sleep is enabled, False otherwise (defaults to True if API call fails) - """ - try: - if catalog_instances_api is None: - _, _, _, catalog_instances_api = get_api_client(None) - response = catalog_instances_api.get_catalog_instance(id=instance_type) - if response and response.instance: - return response.instance.light_sleep_enabled or False - except (ApiException, NotFoundException): - # If API call fails, default to True (assume light sleep is enabled) - pass - except Exception: - # Any other error, default to True (assume light sleep is enabled) - pass - # Default to True if we can't determine from API - return True - - -def _process_idle_timeout( - idle_timeout: Optional[IdleTimeout], - light_sleep_enabled: bool = True, -) -> Optional[DeploymentScalingTargetSleepIdleDelay]: - """ - Process idle_timeout parameter and convert to DeploymentScalingTargetSleepIdleDelay. - - According to spec: - - If unsupported instance type: idle_timeout is silently ignored (returns None) - - None (default): Auto-enable light_sleep=300s, deep_sleep=600s - - 0: Explicitly disable scale-to-zero (returns None) - - int > 0: Deep sleep only - - dict: Explicit configuration - - If light_sleep_enabled is False for the instance type, light_sleep is ignored - - Args: - idle_timeout: Idle timeout configuration - light_sleep_enabled: Whether light sleep is enabled for the instance type (default: True) - - Returns: - DeploymentScalingTargetSleepIdleDelay or None if disabled/ignored - """ - # Validate the parameter - _validate_idle_timeout(idle_timeout) - - # Process according to spec - if idle_timeout is None: - # Default: Auto-enable light_sleep=300s, deep_sleep=600s - # If light sleep is not enabled, only use deep_sleep - if not light_sleep_enabled: - return DeploymentScalingTargetSleepIdleDelay( - deep_sleep_value=600, - ) - return DeploymentScalingTargetSleepIdleDelay( - light_sleep_value=300, - deep_sleep_value=600, - ) - - if isinstance(idle_timeout, int): - if idle_timeout == 0: - # Explicitly disable scale-to-zero - return None - # Deep sleep only - return DeploymentScalingTargetSleepIdleDelay( - deep_sleep_value=idle_timeout, - ) - - if isinstance(idle_timeout, dict): - deep_sleep = idle_timeout.get("deep_sleep") - light_sleep = idle_timeout.get("light_sleep") - - # If light sleep is not enabled, ignore light_sleep if provided - if not light_sleep_enabled: - return DeploymentScalingTargetSleepIdleDelay( - deep_sleep_value=deep_sleep, - ) - - if light_sleep is not None: - # Both light_sleep and deep_sleep provided - return DeploymentScalingTargetSleepIdleDelay( - light_sleep_value=light_sleep, - deep_sleep_value=deep_sleep, - ) - else: - # Deep sleep only - return DeploymentScalingTargetSleepIdleDelay( - deep_sleep_value=deep_sleep, - ) - - def create_deployment_definition( name: str, docker_source: DockerSource, @@ -388,9 +244,9 @@ def create_deployment_definition( exposed_port_protocol: Optional[str] = None, region: Optional[str] = None, routes: Optional[List[DeploymentRoute]] = None, - idle_timeout: Optional[IdleTimeout] = None, - light_sleep_enabled: bool = True, + idle_timeout: Optional[IdleTimeout] = 300, enable_tcp_proxy: bool = False, + _experimental_enable_light_sleep: bool = False, ) -> DeploymentDefinition: """ Create deployment definition for a sandbox service. @@ -405,9 +261,10 @@ def create_deployment_definition( If provided, must be one of "http" or "http2". region: Region to deploy to (defaults to "na") routes: List of routes for public access - idle_timeout: Idle timeout configuration (see IdleTimeout type) - light_sleep_enabled: Whether light sleep is enabled for the instance type (default: True) + idle_timeout: Number of seconds to wait before sleeping the instance if it receives no traffic enable_tcp_proxy: If True, enables TCP proxy for direct TCP access to port 3031 + _experimental_enable_light_sleep: If True, uses light sleep when reaching idle_timeout. + Light Sleep reduces cold starts to ~200ms. After scaling to zero, the service stays in Light Sleep for 3600s before going into Deep Sleep. Returns: DeploymentDefinition object @@ -433,11 +290,23 @@ def create_deployment_definition( deployment_type = DeploymentDefinitionType.SANDBOX # Process idle_timeout - sleep_idle_delay = _process_idle_timeout(idle_timeout, light_sleep_enabled) + if idle_timeout is None or idle_timeout == 0: + sleep_idle_delay = None + elif _experimental_enable_light_sleep: + # Experimental mode: idle_timeout sets light_sleep value, deep_sleep is always 3900 + sleep_idle_delay = DeploymentScalingTargetSleepIdleDelay( + light_sleep_value=idle_timeout, + deep_sleep_value=3900, + ) + else: + # Normal mode: only use deep_sleep + sleep_idle_delay = DeploymentScalingTargetSleepIdleDelay( + deep_sleep_value=idle_timeout, + ) # Create scaling configuration # If idle_timeout is 0, explicitly disable scale-to-zero (min=1, always-on) - # Otherwise (None, int > 0, or dict), enable scale-to-zero (min=0) + # Otherwise (None or int > 0), enable scale-to-zero (min=0) min_scale = 1 if idle_timeout == 0 else 0 targets = None if sleep_idle_delay is not None: