diff --git a/cookbook/en/sandbox/best_practice.md b/cookbook/en/sandbox/best_practice.md new file mode 100644 index 0000000..7dba838 --- /dev/null +++ b/cookbook/en/sandbox/best_practice.md @@ -0,0 +1,73 @@ +# Sandbox Usage Best Practices + +> **Prerequisite Reading**: This document assumes you are familiar with the basic concepts and usage of sandboxes. Before diving into the content below, it is strongly recommended to complete the previous tutorial on [ Sandbox Basics ](sandbox.md) to better understand the advanced deployment strategies discussed in this section. + +In production environments, the deployment and management of sandboxes must be designed according to system scale, concurrency requirements, and resource isolation needs. Different Runtime deployment architectures present distinct challenges regarding sandbox lifecycle management, resource reuse mechanisms, and backend storage. The following sections introduce sandbox usage best practices from single-machine to distributed scenarios, categorized by hierarchy. + +## Single Machine, Single Runtime Scenario + +### Applicable Scenario + +Suitable for development and debugging, lightweight services, or single-instance applications where only one Runtime process exists in the system, without the need to share sandboxes across processes. + +### Recommended Architecture + +* **Sandbox Management**: Use an in-memory `SandboxMap` implementation for managing sandbox state. +* **Container Backend**: Directly interface with the local Docker Daemon, using the basic Docker driver as the container runtime backend. + +### Practice Recommendations + +In this scenario, since there is no concurrent access or reuse of the same sandbox by multiple Runtime instances, there is no need to introduce external state storage. In-memory level sandbox mapping is sufficient to meet performance and consistency requirements. It is simple to deploy and starts quickly, making it ideal for rapid iteration and local verification. + +> **Note**: This mode lacks scalability across processes or nodes and is not suitable for multi-instance deployment environments. + +## Single Machine, Multiple Runtime Scenario + +### Applicable Scenario + +Suitable for high-concurrency, multi-tenant, or modular architecture deployments on a single machine, where multiple Runtime instances are launched to process tasks in parallel, sharing the resources of the same host. + +### Core Challenge + +Multiple Runtime instances may simultaneously attempt to access, reuse, or destroy the same sandbox instance. If local memory management is still used, it will lead to state inconsistency, resource contention, or duplicate creation issues. + +### Recommended Architecture + +* **State Management**: **Redis must be introduced** as a globally shared sandbox metadata storage center to ensure all Runtime instances can consistently read and update sandbox states. +* **Container Backend**: All Runtime instances access the backend through the same `containerClient` to achieve unified scheduling and reuse of sandbox instances. + +### Practice Recommendations + +Utilize the RedisSandboxMap provided by AgentScope Runtime Java to manage sandbox reference counting and lifecycle, avoiding race conditions. + +## Multiple Machines, Multiple Runtime Scenario + +### Applicable Scenario + +Suitable for distributed systems, elastic scaling clusters, or microservice architectures where multiple Runtime instances are distributed across different hosts and need to collaboratively manage sandbox resources. + +### Core Challenge + +In addition to state consistency, the reachability of the container backend and network isolation issues must be addressed. Different nodes may not be able to directly access each other's container runtimes, leading to sandboxes that cannot be reused or managed effectively. + +### Recommended Solutions + +Based on whether all nodes can access a unified container backend, the following two deployment strategies are divided: + +#### 1. All Nodes Can Access the Same Container Backend (Centralized Container Management) + +* **Architecture Description**: All Runtime nodes access the same remote container runtime (e.g., remote Docker Daemon or Kubernetes cluster) through the network (e.g., Docker TCP API or Kubernetes CRI). +* **Management Method**: Consistent with the "Single Machine, Multiple Runtime" scenario, **use the RedisSandboxMap provided by AgentScope Runtime Java to centrally manage sandbox states**. All nodes operate containers through the shared backend. +* **Advantages**: Unified architecture, simple management, and sandboxes can be reused by any node. +* **Note**: Ensure the high availability and network stability of the container backend to avoid single points of failure. + +#### 2. Nodes Cannot Access the Same Container Backend (Distributed Container Environment) + +* **Architecture Description**: Each node has its own independent container runtime (e.g., running Docker locally) and cannot directly operate containers on other nodes. +* **Recommended Solution**: Adopt a **Remote Runtime architecture**. +* **Implementation Method**: + * Deploy a dedicated **Runtime agent process** (Sandbox Manager) on a machine that can access the container backend. + * Configure the remaining nodes as **Remote Mode**. Connect to the agent through the built-in remote connection mode in **SandboxService**, delegating it to create, manage, and reuse sandboxes. + * All sandbox operation requests are forwarded to a unified entry point, achieving a management architecture that is logically centralized but physically distributed. + +By reasonably selecting the above schemes, system complexity, performance, and scalability can be effectively balanced, providing a secure, efficient, and reusable sandbox runtime environment for applications of different scales. \ No newline at end of file diff --git a/cookbook/zh/sandbox/best_practice.md b/cookbook/zh/sandbox/best_practice.md new file mode 100644 index 0000000..9c9771e --- /dev/null +++ b/cookbook/zh/sandbox/best_practice.md @@ -0,0 +1,83 @@ +# 沙箱使用最佳实践 + +> **建议阅读前提**:本文档假设你已熟悉沙箱的基本概念与基础用法。在深入以下内容前,强烈建议先完成上一节关于[ 沙箱基础 ](sandbox.md)的教程,以便更好地理解本节所讨论的高级部署策略。 + +在实际生产环境中,沙箱的部署与管理方式需根据系统规模、并发需求以及资源隔离要求进行合理设计。不同的运行时(Runtime)部署架构对沙箱生命周期管理、资源复用机制及后端存储提出了不同挑战。以下将从单机到分布式场景,分层次介绍沙箱使用的最佳实践。 + +## 单机单 Runtime 场景 + +### 适用场景 + +适用于开发调试、轻量级服务或单实例应用,系统中仅存在一个 Runtime 进程,且无跨进程共享沙箱的需求。 + +### 推荐架构 + +* **沙箱管理**:使用基于内存的 `SandboxMap` 实现沙箱状态管理。 + +* **容器后端**:直接对接本地 Docker Daemon,采用基础 Docker 驱动作为容器运行时后端。 + +### 实践建议 + +在此场景下,由于不存在多个 Runtime 实例对同一沙箱的并发访问与复用问题,无需引入外部状态存储。内存级别的沙箱映射足以满足性能与一致性要求,部署简单、启动快速,适合快速迭代与本地验证。 + +> **注意**:该模式不具备跨进程或跨节点的可扩展性,不适用于多实例部署环境。 + +## 单机多 Runtime 场景 + +### 适用场景 + +适用于高并发、多租户或模块化架构的单机部署,系统中启动多个 Runtime 实例以并行处理任务,但所有实例共享同一台主机资源。 + +### 核心挑战 + +多个 Runtime 实例可能同时尝试访问、复用或销毁同一个沙箱实例,若仍使用本地内存管理,将导致状态不一致、资源竞争或重复创建等问题。 + +### 推荐架构 + +* **状态管理**:**必须引入 Redis** 作为全局共享的沙箱元数据存储中心,确保所有 Runtime 实例能一致地读取和更新沙箱状态。 + +* **容器后端**:所有 Runtime 实例通过同一个 `containerClient` 共享访问后端,实现沙箱实例的统一调度与复用。 + +### 实践建议 + +通过 AgentScope Runtime Java 提供的 RedisSandboxMap 实现沙箱的引用计数与生命周期管理,避免竞态条件。 + +## 多机多 Runtime 场景 + +### 适用场景 + +适用于分布式系统、弹性伸缩集群或微服务架构,多个 Runtime 实例分布在不同主机上,需协同管理沙箱资源。 + +### 核心挑战 + +除了状态一致性外,还需解决容器后端的可达性与网络隔离问题。不同节点可能无法直接访问彼此的容器运行时,导致沙箱无法复用或管理失效。 + +### 推荐方案 + +根据各节点是否能访问到统一的容器后端,分为以下两种部署策略: + +#### 1. 所有节点可访问同一容器后端(集中式容器管理) + +* **架构描述**:所有 Runtime 节点通过网络(如 Docker TCP API 或 Kubernetes CRI)访问同一个远程容器运行时(如远程 Docker Daemon 或 Kubernetes 集群)。 + +* **管理方式**:与“单机多 Runtime”一致,**使用 AgentScope Runtime Java 提供的 RedisSandboxMap 统一管理沙箱状态**,所有节点通过共享后端操作容器。 + +* **优势**:架构统一,管理简单,沙箱可被任意节点复用。 + +* **注意**:需保障容器后端的高可用与网络稳定性,避免单点故障。 + +#### 2. 节点无法访问同一容器后端(分布式容器环境) + +* **架构描述**:各节点拥有独立的容器运行时(如各自运行 Docker),无法直接操作其他节点的容器。 + +* **推荐方案**:采用 **远程Runtime(Remote Runtime)架构**。 + +* **实施方式**: + + * 在能够访问容器后端的机器上部署一个专用的 **Runtime 代理进程**(Sandbox Manager)。 + + * 其余节点配置为 **远程模式(Remote Mode)**,通过 **SandboxService** 中内置的远程连接模式连接该代理,委托其创建、管理与复用沙箱。 + + * 所有沙箱操作请求被转发至统一入口,实现逻辑集中、物理分布的管理架构。 + +通过合理选择上述方案,可有效平衡系统复杂性、性能与可扩展性,为不同规模的应用提供安全、高效、可复用的沙箱运行环境。 \ No newline at end of file diff --git a/engine-core/src/main/java/io/agentscope/runtime/adapters/agentscope/AgentScopeAgentHandler.java b/engine-core/src/main/java/io/agentscope/runtime/adapters/agentscope/AgentScopeAgentHandler.java index 8173f01..2e40dd2 100644 --- a/engine-core/src/main/java/io/agentscope/runtime/adapters/agentscope/AgentScopeAgentHandler.java +++ b/engine-core/src/main/java/io/agentscope/runtime/adapters/agentscope/AgentScopeAgentHandler.java @@ -142,6 +142,9 @@ public void stop() { if (sessionHistoryService != null) { sessionHistoryService.stop(); } + if(sandboxService != null){ + sandboxService.stop(); + } } /** diff --git a/examples/simple_agent_use_examples/agentscope_use_example/src/main/java/io/agentscope/MyAgentScopeAgentHandler.java b/examples/simple_agent_use_examples/agentscope_use_example/src/main/java/io/agentscope/MyAgentScopeAgentHandler.java index 8c32f50..4672b8b 100755 --- a/examples/simple_agent_use_examples/agentscope_use_example/src/main/java/io/agentscope/MyAgentScopeAgentHandler.java +++ b/examples/simple_agent_use_examples/agentscope_use_example/src/main/java/io/agentscope/MyAgentScopeAgentHandler.java @@ -51,7 +51,6 @@ * */ public class MyAgentScopeAgentHandler extends AgentScopeAgentHandler { - private SandboxService sandboxService; private static final Logger logger = LoggerFactory.getLogger(MyAgentScopeAgentHandler.class); private final String apiKey; diff --git a/sandbox-core/src/main/java/io/agentscope/runtime/sandbox/box/BrowserSandbox.java b/sandbox-core/src/main/java/io/agentscope/runtime/sandbox/box/BrowserSandbox.java index 99d91e5..3cc122b 100644 --- a/sandbox-core/src/main/java/io/agentscope/runtime/sandbox/box/BrowserSandbox.java +++ b/sandbox-core/src/main/java/io/agentscope/runtime/sandbox/box/BrowserSandbox.java @@ -81,7 +81,7 @@ public BrowserSandbox( * @throws RuntimeException if sandbox is not healthy */ public String getDesktopUrl() { - return GuiMixin.getDesktopUrl(managerApi, sandboxId, baseUrl); + return GuiMixin.getDesktopUrl(managerApi, this, baseUrl); } public String navigate(String url) { diff --git a/sandbox-core/src/main/java/io/agentscope/runtime/sandbox/box/FilesystemSandbox.java b/sandbox-core/src/main/java/io/agentscope/runtime/sandbox/box/FilesystemSandbox.java index 335f23d..dfb709e 100644 --- a/sandbox-core/src/main/java/io/agentscope/runtime/sandbox/box/FilesystemSandbox.java +++ b/sandbox-core/src/main/java/io/agentscope/runtime/sandbox/box/FilesystemSandbox.java @@ -82,7 +82,7 @@ public FilesystemSandbox( * @throws RuntimeException if sandbox is not healthy */ public String getDesktopUrl() { - return GuiMixin.getDesktopUrl(managerApi, sandboxId, baseUrl); + return GuiMixin.getDesktopUrl(managerApi, this, baseUrl); } public String readFile(String path) { diff --git a/sandbox-core/src/main/java/io/agentscope/runtime/sandbox/box/GuiMixin.java b/sandbox-core/src/main/java/io/agentscope/runtime/sandbox/box/GuiMixin.java index d2841d7..d7921d9 100644 --- a/sandbox-core/src/main/java/io/agentscope/runtime/sandbox/box/GuiMixin.java +++ b/sandbox-core/src/main/java/io/agentscope/runtime/sandbox/box/GuiMixin.java @@ -35,27 +35,27 @@ public class GuiMixin { * SandboxService and sandboxId. * * @param managerApi The SandboxService instance - * @param sandboxId The sandbox ID + * @param sandbox The sandbox instance * @param baseUrl Optional base URL (can be null) * @return The desktop URL for VNC access * @throws RuntimeException if sandbox is not healthy or info cannot be retrieved */ - public static String getDesktopUrl(SandboxService managerApi, String sandboxId, String baseUrl) { + public static String getDesktopUrl(SandboxService managerApi, Sandbox sandbox, String baseUrl) { // Check if sandbox is healthy by attempting to get info ContainerModel info; try { - info = managerApi.getInfo(sandboxId); + info = managerApi.getInfo(sandbox); } catch (Exception e) { - throw new RuntimeException("Sandbox " + sandboxId + " is not healthy: " + e.getMessage(), e); + throw new RuntimeException("Sandbox " + sandbox.getSandboxId() + " is not healthy: " + e.getMessage(), e); } if (info == null) { - throw new RuntimeException("Sandbox " + sandboxId + " is not healthy: cannot retrieve info"); + throw new RuntimeException("Sandbox " + sandbox.getSandboxId() + " is not healthy: cannot retrieve info"); } String runtimeToken = info.getRuntimeToken(); if (runtimeToken == null || runtimeToken.isEmpty()) { - throw new RuntimeException("Sandbox " + sandboxId + " does not have a runtime token"); + throw new RuntimeException("Sandbox " + sandbox.getSandboxId() + " does not have a runtime token"); } String path = "/vnc/vnc_lite.html"; @@ -68,7 +68,7 @@ public static String getDesktopUrl(SandboxService managerApi, String sandboxId, // Use direct URL from container info String containerUrl = info.getBaseUrl(); if (containerUrl == null || containerUrl.isEmpty()) { - throw new RuntimeException("Sandbox " + sandboxId + " does not have a base URL"); + throw new RuntimeException("Sandbox " + sandbox.getSandboxId() + " does not have a base URL"); } // Ensure URL ends with / if not present if (!containerUrl.endsWith("/")) { @@ -81,7 +81,7 @@ public static String getDesktopUrl(SandboxService managerApi, String sandboxId, } else { // Use base_url with sandbox ID String base = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl; - return base + "/desktop/" + sandboxId + remotePath + "?" + params; + return base + "/desktop/" + sandbox.getSandboxId() + remotePath + "?" + params; } } } diff --git a/sandbox-core/src/main/java/io/agentscope/runtime/sandbox/box/GuiSandbox.java b/sandbox-core/src/main/java/io/agentscope/runtime/sandbox/box/GuiSandbox.java index bcd172b..61db197 100644 --- a/sandbox-core/src/main/java/io/agentscope/runtime/sandbox/box/GuiSandbox.java +++ b/sandbox-core/src/main/java/io/agentscope/runtime/sandbox/box/GuiSandbox.java @@ -86,7 +86,7 @@ public GuiSandbox( * @throws RuntimeException if sandbox is not healthy */ public String getDesktopUrl() { - return GuiMixin.getDesktopUrl(managerApi, sandboxId, baseUrl); + return GuiMixin.getDesktopUrl(managerApi, this, baseUrl); } /** diff --git a/sandbox-core/src/main/java/io/agentscope/runtime/sandbox/box/Sandbox.java b/sandbox-core/src/main/java/io/agentscope/runtime/sandbox/box/Sandbox.java index 94967b6..d26ef81 100644 --- a/sandbox-core/src/main/java/io/agentscope/runtime/sandbox/box/Sandbox.java +++ b/sandbox-core/src/main/java/io/agentscope/runtime/sandbox/box/Sandbox.java @@ -104,6 +104,10 @@ public Sandbox( this.environment = new HashMap<>(environment); } + public void setSandboxId(String sandboxId) { + this.sandboxId = sandboxId; + } + public String getSandboxId() { return sandboxId; } @@ -124,7 +128,7 @@ public Map getEnvironment() { return environment; } - public FileSystemConfig getFileSystemStarter() { + public FileSystemConfig getFileSystemConfig() { return fileSystemConfig; } @@ -149,7 +153,13 @@ private void initializeSandbox(){ @JsonIgnore public ContainerModel getInfo() { initializeSandbox(); - return managerApi.getInfo(sandboxId); + try { + return managerApi.getInfo(this); + } + catch (Exception e) { + logger.error("Failed to get sandbox info: {}", e.getMessage()); + throw new RuntimeException("Failed to get sandbox info", e); + } } public Map listTools() { @@ -158,12 +168,24 @@ public Map listTools() { public Map listTools(String toolType) { initializeSandbox(); - return managerApi.listTools(sandboxId, toolType); + try{ + return managerApi.listTools(this, toolType); + } + catch (Exception e) { + logger.error("Failed to list tools: {}", e.getMessage()); + throw new RuntimeException("Failed to list tools", e); + } } public String callTool(String name, Map arguments) { initializeSandbox(); - return managerApi.callTool(sandboxId, name, arguments); + try{ + return managerApi.callTool(this, name, arguments); + } + catch (Exception e) { + logger.error("Failed to call tool {}: {}", name, e.getMessage()); + throw new RuntimeException("Failed to call tool " + name, e); + } } public Map addMcpServers(Map serverConfigs) { @@ -172,7 +194,13 @@ public Map addMcpServers(Map serverConfigs) { public Map addMcpServers(Map serverConfigs, boolean overwrite) { initializeSandbox(); - return managerApi.addMcpServers(sandboxId, serverConfigs, overwrite); + try{ + return managerApi.addMcpServers(this, serverConfigs, overwrite); + } + catch (Exception e) { + logger.error("Failed to add MCP servers: {}", e.getMessage()); + throw new RuntimeException("Failed to add MCP servers", e); + } } @Override diff --git a/sandbox-core/src/main/java/io/agentscope/runtime/sandbox/manager/SandboxService.java b/sandbox-core/src/main/java/io/agentscope/runtime/sandbox/manager/SandboxService.java index eb8d6ca..7ef3dec 100644 --- a/sandbox-core/src/main/java/io/agentscope/runtime/sandbox/manager/SandboxService.java +++ b/sandbox-core/src/main/java/io/agentscope/runtime/sandbox/manager/SandboxService.java @@ -44,8 +44,13 @@ import org.slf4j.LoggerFactory; import java.io.File; +import java.io.IOException; import java.nio.file.Paths; import java.util.*; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; public class SandboxService implements AutoCloseable { private static final Logger logger = LoggerFactory.getLogger(SandboxService.class); @@ -55,6 +60,8 @@ public class SandboxService implements AutoCloseable { private static final String BROWSER_SESSION_ID = "123e4567-e89b-12d3-a456-426614174000"; private final RemoteHttpClient remoteHttpClient; private AgentBayClient agentBayClient; + private ScheduledExecutorService cleanupExecutor; + private ScheduledFuture cleanupFuture; public SandboxService(ManagerConfig managerConfig) { this.managerConfig = managerConfig; @@ -74,9 +81,36 @@ public void start() { if (managerConfig.getAgentBayApiKey() != null && !managerConfig.getAgentBayApiKey().isEmpty()) { agentBayClient = new AgentBayClient(managerConfig.getAgentBayApiKey()); } + startCleanupTask(); logger.info("SandboxService started."); } + private void startCleanupTask() { + this.cleanupExecutor = Executors.newSingleThreadScheduledExecutor(r -> { + Thread thread = new Thread(r, "sandbox-cleanup-task"); + thread.setDaemon(true); + return thread; + }); + this.cleanupFuture = this.cleanupExecutor.scheduleAtFixedRate(this::cleanupExpiredSandboxes, 5, 5, TimeUnit.SECONDS); + logger.info("Scheduled cleanup task every 5 seconds"); + } + + private void cleanupExpiredSandboxes() { + try { + Map allSandboxes = sandboxMap.getAllSandboxes(); + for (Map.Entry entry : allSandboxes.entrySet()) { + String containerId = entry.getKey(); + long ttl = sandboxMap.getTTL(containerId); + if (ttl > 0 && ttl < 10) { + logger.info("Sandbox {} is expiring (TTL: {}s), removing...", containerId, ttl); + removeSandbox(containerId); + } + } + } catch (Exception e) { + logger.error("Error during scheduled cleanup task: {}", e.getMessage()); + } + } + public String createAgentBayContainer(AgentBaySandbox sandbox) { if (agentBayClient == null) { throw new RuntimeException("AgentBay client is not initialized."); @@ -88,6 +122,29 @@ public String createAgentBayContainer(AgentBaySandbox sandbox) { return createResult.getContainerId(); } + private boolean checkSandboxStatus(String userId, String sessionId, String sandboxType){ + String status = getSandboxStatus(userId, sessionId, sandboxType); + return checkStatusValid(status); + } + + private boolean checkSandboxStatus(ContainerModel containerModel){ + String status = getSandboxStatus(containerModel); + return checkStatusValid(status); + } + + private boolean checkSandboxStatus(String sandboxId){ + String status = getSandboxStatus(sandboxId); + return checkStatusValid(status); + } + + private boolean checkStatusValid(String status){ + return status.equalsIgnoreCase("running") || + status.equalsIgnoreCase("created") || + status.equalsIgnoreCase("partiallyReady") || + status.equalsIgnoreCase("pending") || + status.equalsIgnoreCase("starting"); + } + @RemoteWrapper public ContainerModel createContainer(Sandbox sandbox) throws JsonProcessingException { if (this.remoteHttpClient != null) { @@ -112,11 +169,18 @@ public ContainerModel createContainer(Sandbox sandbox) throws JsonProcessingExce } if (sandboxMap.containSandbox(new SandboxKey(sandbox.getUserId(), sandbox.getSessionId(), sandbox.getSandboxType()))) { - return sandboxMap.getSandbox(new SandboxKey(sandbox.getUserId(), sandbox.getSessionId(), sandbox.getSandboxType())); + if(checkSandboxStatus(sandbox.getUserId(), sandbox.getSessionId(), sandbox.getSandboxType())){ + ContainerModel existingModel = sandboxMap.getSandbox(new SandboxKey(sandbox.getUserId(), sandbox.getSessionId(), sandbox.getSandboxType())); + if (existingModel != null) { + sandboxMap.incrementRefCount(existingModel.getContainerId()); + } + return existingModel; + } } + removeSandbox(sandbox.getUserId(), sandbox.getSessionId(), sandbox.getSandboxType()); Map environment = sandbox.getEnvironment(); - FileSystemConfig fileSystemConfig = sandbox.getFileSystemStarter(); + FileSystemConfig fileSystemConfig = sandbox.getFileSystemConfig(); String sandboxType = sandbox.getSandboxType(); ContainerClientType containerClientType = managerConfig.getClientStarter().getContainerClientType(); StorageManager storageManager = fileSystemConfig.createStorageManager(); @@ -180,7 +244,7 @@ public ContainerModel createContainer(Sandbox sandbox) throws JsonProcessingExce if (!file.exists()) { boolean ignored = file.mkdirs(); } - String storagePath = sandbox.getFileSystemStarter().getStorageFolderPath(); + String storagePath = sandbox.getFileSystemConfig().getStorageFolderPath(); // Todo:Currently using global storage path if not provided, still need to wait for next movement of python version if (!mountDir.isEmpty() && !storagePath.isEmpty() && containerClientType != ContainerClientType.AGENTRUN && containerClientType != ContainerClientType.FC) { logger.info("Downloading from storage path: {} to mount dir: {}", storagePath, mountDir); @@ -228,8 +292,32 @@ public ContainerModel createContainer(Sandbox sandbox) throws JsonProcessingExce } File hostFile = new File(hostPath); if (!hostFile.exists()) { - logger.warn("NonCopy mount host path does not exist: {}, skipping", hostPath); - continue; + logger.warn("Host path does not exist: {}, attempting to create", hostPath); + try { + if (hostPath.endsWith(File.separator) || hostPath.endsWith("/") || + containerPath.endsWith("/") || containerPath.endsWith(File.separator)) { + if (hostFile.mkdirs()) { + logger.info("Successfully created directory: {}", hostPath); + } else { + logger.warn("Failed to create directory: {}", hostPath); + continue; + } + } else { + File parentDir = hostFile.getParentFile(); + if (parentDir != null && !parentDir.exists()) { + parentDir.mkdirs(); + } + if (hostFile.createNewFile()) { + logger.info("Successfully created file: {}", hostPath); + } else { + logger.warn("Failed to create file: {}", hostPath); + continue; + } + } + } catch (IOException e) { + logger.warn("Exception while creating path {}: {}", hostPath, e.getMessage()); + continue; + } } volumeBindings.add(new VolumeBinding(hostPath, containerPath, "rw")); logger.info("Added non Copy mount: {} -> {}", hostPath, containerPath); @@ -280,6 +368,8 @@ public ContainerModel createContainer(Sandbox sandbox) throws JsonProcessingExce containerClient.startContainer(containerId); sandboxMap.addSandbox(new SandboxKey(sandbox.getUserId(), sandbox.getSessionId(), sandbox.getSandboxType()), containerModel); + sandboxMap.incrementRefCount(containerModel.getContainerId()); + sandbox.setSandboxId(containerModel.getContainerId()); return containerModel; } @@ -360,6 +450,17 @@ public boolean removeSandbox(String containerId) { @RemoteWrapper public boolean removeSandbox(ContainerModel containerModel) { + if(containerModel == null){ + return false; + } + + sandboxMap.decrementRefCount(containerModel.getContainerId()); + long refCount = sandboxMap.getRefCount(containerModel.getContainerId()); + if (refCount > 0) { + logger.info("Sandbox {} has active references ({}), skip removing", containerModel.getContainerId(), refCount); + return true; + } + if(containerModel.getContainerName().startsWith("agentbay_")) { logger.warn("AgentBay sandbox can only be stopped, not removed via AgentBayClient"); return true; @@ -392,6 +493,7 @@ public boolean stopAndRemoveSandbox(String containerId) { } public boolean release(String containerId) { + if (containerId == null || containerId.isEmpty()) return false; return stopAndRemoveSandbox(containerId); } @@ -430,10 +532,12 @@ public Map getAllSandboxes() { } @RemoteWrapper - public ContainerModel getInfo(String containerId) { + public ContainerModel getInfo(Sandbox sandbox) throws JsonProcessingException { if (this.remoteHttpClient != null) { logger.info("Getting sandbox info in remote mode via RemoteHttpClient"); - Map request = Map.of("containerId", containerId); + ObjectMapper mapper = new ObjectMapper(); + String sandboxJson = mapper.writeValueAsString(sandbox); + Map request = Map.of("sandbox", sandboxJson); Object result = remoteHttpClient.makeRequest( RequestMethod.POST, "/sandbox/getInfo", @@ -447,7 +551,7 @@ public ContainerModel getInfo(String containerId) { } return null; } - return sandboxMap.getSandbox(containerId); + return sandboxMap.getSandbox(sandbox.getSandboxId()); } public void cleanupAllSandboxes() { @@ -460,12 +564,26 @@ public void cleanupAllSandboxes() { @Override public void close() { + if (cleanupExecutor != null) { + cleanupExecutor.shutdown(); + try { + if (!cleanupExecutor.awaitTermination(2, TimeUnit.SECONDS)) { + cleanupExecutor.shutdownNow(); + } + } catch (InterruptedException e) { + cleanupExecutor.shutdownNow(); + Thread.currentThread().interrupt(); + } + } cleanupAllSandboxes(); } - private SandboxClient establishConnection(String sandboxId) { + private SandboxClient establishConnection(Sandbox sandbox) { try { - ContainerModel containerInfo = getInfo(sandboxId); + if(!checkSandboxStatus(sandbox.getSandboxId())){ + createContainer(sandbox); + } + ContainerModel containerInfo = getInfo(sandbox); if (containerInfo.getVersion().contains("sandbox-appworld") || containerInfo.getVersion().contains("sandbox-bfcl")) { return new TrainingSandboxClient(containerInfo, 60); } @@ -477,11 +595,13 @@ private SandboxClient establishConnection(String sandboxId) { } @RemoteWrapper - public Map listTools(String sandboxId, String toolType) { + public Map listTools(Sandbox sandbox, String toolType) throws JsonProcessingException { if (this.remoteHttpClient != null) { logger.info("Listing tools in remote mode via RemoteHttpClient"); + ObjectMapper mapper = new ObjectMapper(); + String sandboxJson = mapper.writeValueAsString(sandbox); Map request = Map.of( - "sandboxId", sandboxId, + "sandbox", sandboxJson, "toolType", toolType ); Object result = remoteHttpClient.makeRequest( @@ -497,7 +617,7 @@ public Map listTools(String sandboxId, String toolType) { } return new HashMap<>(); } - try (SandboxClient client = establishConnection(sandboxId)) { + try (SandboxClient client = establishConnection(sandbox)) { return client.listTools(toolType, Map.of()); } catch (Exception e) { logger.error("Error listing tools: {}", e.getMessage()); @@ -506,11 +626,13 @@ public Map listTools(String sandboxId, String toolType) { } @RemoteWrapper - public String callTool(String sandboxId, String toolName, Map arguments) { + public String callTool(Sandbox sandbox, String toolName, Map arguments) throws JsonProcessingException { if (this.remoteHttpClient != null) { logger.info("Calling tool in remote mode via RemoteHttpClient"); + ObjectMapper mapper = new ObjectMapper(); + String sandboxJson = mapper.writeValueAsString(sandbox); Map request = Map.of( - "sandboxId", sandboxId, + "sandbox", sandboxJson, "toolName", toolName, "arguments", arguments ); @@ -525,7 +647,7 @@ public String callTool(String sandboxId, String toolName, Map ar } return "{\"isError\":true,\"content\":[{\"type\":\"text\",\"text\":\"Invalid response from remote callTool\"}]}"; } - try (SandboxClient client = establishConnection(sandboxId)) { + try (SandboxClient client = establishConnection(sandbox)) { return client.callTool(toolName, arguments); } catch (Exception e) { logger.error("Error calling tool {}: {}", toolName, e.getMessage()); @@ -534,11 +656,13 @@ public String callTool(String sandboxId, String toolName, Map ar } @RemoteWrapper - public Map addMcpServers(String sandboxId, Map serverConfigs, boolean overwrite) { + public Map addMcpServers(Sandbox sandbox, Map serverConfigs, boolean overwrite) throws JsonProcessingException { if (this.remoteHttpClient != null) { logger.info("Adding MCP servers in remote mode via RemoteHttpClient"); + ObjectMapper mapper = new ObjectMapper(); + String sandboxJson = mapper.writeValueAsString(sandbox); Map request = Map.of( - "sandboxId", sandboxId, + "sandbox", sandboxJson, "serverConfigs", serverConfigs, "overwrite", overwrite ); @@ -555,7 +679,7 @@ public Map addMcpServers(String sandboxId, Map s } return new HashMap<>(); } - try (SandboxClient client = establishConnection(sandboxId)) { + try (SandboxClient client = establishConnection(sandbox)) { return client.addMcpServers(serverConfigs, overwrite); } catch (Exception e) { logger.error("Error adding MCP servers: {}", e.getMessage()); @@ -566,4 +690,11 @@ public Map addMcpServers(String sandboxId, Map s public AgentBayClient getAgentBayClient() { return agentBayClient; } + + public void stop(){ + cleanupAllSandboxes(); + if (this.cleanupFuture != null) { + this.cleanupFuture.cancel(true); + } + } } diff --git a/sandbox-core/src/main/java/io/agentscope/runtime/sandbox/manager/client/container/agentbay/AgentBayClient.java b/sandbox-core/src/main/java/io/agentscope/runtime/sandbox/manager/client/container/agentbay/AgentBayClient.java index 5c0046f..f066ecb 100644 --- a/sandbox-core/src/main/java/io/agentscope/runtime/sandbox/manager/client/container/agentbay/AgentBayClient.java +++ b/sandbox-core/src/main/java/io/agentscope/runtime/sandbox/manager/client/container/agentbay/AgentBayClient.java @@ -153,7 +153,7 @@ public Session getSession(String sessionId){ @Override public String getContainerStatus(String containerId) { - return ""; + return "running"; } @Override diff --git a/sandbox-core/src/main/java/io/agentscope/runtime/sandbox/manager/utils/InMemorySandboxMap.java b/sandbox-core/src/main/java/io/agentscope/runtime/sandbox/manager/utils/InMemorySandboxMap.java index 76738ad..a2c29b9 100644 --- a/sandbox-core/src/main/java/io/agentscope/runtime/sandbox/manager/utils/InMemorySandboxMap.java +++ b/sandbox-core/src/main/java/io/agentscope/runtime/sandbox/manager/utils/InMemorySandboxMap.java @@ -25,6 +25,7 @@ public class InMemorySandboxMap implements SandboxMap{ private final Map idContainerMap = new ConcurrentHashMap<>(); private final Map keyIdMap = new ConcurrentHashMap<>(); + private final Map refCountMap = new ConcurrentHashMap<>(); @Override public void addSandbox(SandboxKey sandboxKey, ContainerModel containerModel) { @@ -97,4 +98,30 @@ public boolean containSandbox(String containerId) { } return idContainerMap.containsKey(containerId); } + + @Override + public long getTTL(String containerId) { + return -1; + } + + @Override + public long incrementRefCount(String containerId) { + if (containerId == null || containerId.isEmpty()) return 0; + return refCountMap.merge(containerId, 1L, Long::sum); + } + + @Override + public long decrementRefCount(String containerId) { + if (containerId == null || containerId.isEmpty()) return 0; + return refCountMap.compute(containerId, (k, v) -> { + if (v == null || v <= 0) return 0L; + return v - 1; + }); + } + + @Override + public long getRefCount(String containerId) { + if (containerId == null || containerId.isEmpty()) return 0; + return refCountMap.getOrDefault(containerId, 0L); + } } diff --git a/sandbox-core/src/main/java/io/agentscope/runtime/sandbox/manager/utils/SandboxMap.java b/sandbox-core/src/main/java/io/agentscope/runtime/sandbox/manager/utils/SandboxMap.java index 13a88eb..6136de4 100644 --- a/sandbox-core/src/main/java/io/agentscope/runtime/sandbox/manager/utils/SandboxMap.java +++ b/sandbox-core/src/main/java/io/agentscope/runtime/sandbox/manager/utils/SandboxMap.java @@ -37,4 +37,12 @@ public interface SandboxMap { boolean containSandbox(SandboxKey sandboxKey); boolean containSandbox(String containerId); + + long getTTL(String containerId); + + long incrementRefCount(String containerId); + + long decrementRefCount(String containerId); + + long getRefCount(String containerId); } diff --git a/sandbox-core/src/test/java/io/agentscope/runtime/sandbox/manager/LocalFileSystemTest.java b/sandbox-core/src/test/java/io/agentscope/runtime/sandbox/manager/LocalFileSystemTest.java index 7a5be5a..c0fed06 100644 --- a/sandbox-core/src/test/java/io/agentscope/runtime/sandbox/manager/LocalFileSystemTest.java +++ b/sandbox-core/src/test/java/io/agentscope/runtime/sandbox/manager/LocalFileSystemTest.java @@ -107,7 +107,7 @@ void testLocalFileSystemIntegration() { createInitialTestFiles(testFolder.getAbsolutePath()); // 2. Configure local file system storage - LocalFileSystemConfig localFileSystemStarter = LocalFileSystemConfig.builder() + LocalFileSystemConfig localFileSystemConfig = LocalFileSystemConfig.builder() .storageFolderPath(localStoragePath) .build(); @@ -128,7 +128,7 @@ void testLocalFileSystemIntegration() { // Use the just created test folder System.out.println("Copying from local path: " + storagePath); - Sandbox sandbox = new BaseSandbox(sandboxService, "test-user", "test-session", localFileSystemStarter); + Sandbox sandbox = new BaseSandbox(sandboxService, "test-user", "test-session", localFileSystemConfig); // Verify container created successfully assertNotNull(sandbox, "Container creation should succeed"); @@ -136,7 +136,7 @@ void testLocalFileSystemIntegration() { // 6. Verify copied files System.out.println("\n--- Verifying files copied from local storage ---"); - File mountDirectory = new File(localFileSystemStarter.getMountDir()); + File mountDirectory = new File(localFileSystemConfig.getMountDir()); assertTrue(mountDirectory.exists(), "Mount directory should exist"); System.out.println("Files in mount directory:"); @@ -144,7 +144,7 @@ void testLocalFileSystemIntegration() { // 7. Create new files in container (for testing upload functionality) System.out.println("\n--- Creating new files in container ---"); - createTestFilesInContainer(localFileSystemStarter.getMountDir()); + createTestFilesInContainer(localFileSystemConfig.getMountDir()); // 8. Display file list again System.out.println("\nUpdated file list:"); @@ -154,7 +154,7 @@ void testLocalFileSystemIntegration() { System.out.println("\n--- Destroying container and copying data back to local storage ---"); boolean released = sandboxService.stopAndRemoveSandbox(sandbox.getSandboxId()); assertTrue(released, "Container should be released successfully"); - System.out.println("Container destroyed, data copied back to local storage: " + localFileSystemStarter.getStorageFolderPath()); + System.out.println("Container destroyed, data copied back to local storage: " + localFileSystemConfig.getStorageFolderPath()); // 10. Verify data has been copied back to local storage System.out.println("\n--- Verifying files in local storage ---"); @@ -176,18 +176,18 @@ void testLocalFileSystemConfigValidation() { System.out.println("\n--- Testing Local File System Config Validation ---"); // Create local file system configuration - LocalFileSystemConfig localFileSystemStarter = LocalFileSystemConfig.builder() + LocalFileSystemConfig localFileSystemConfig = LocalFileSystemConfig.builder() .storageFolderPath(localStoragePath) .mountDir("custom_mount_dir") .build(); // Validate configuration - assertEquals(localStoragePath, localFileSystemStarter.getStorageFolderPath(), "Storage path should match"); - assertEquals("custom_mount_dir", localFileSystemStarter.getMountDir(), "Mount directory should match"); + assertEquals(localStoragePath, localFileSystemConfig.getStorageFolderPath(), "Storage path should match"); + assertEquals("custom_mount_dir", localFileSystemConfig.getMountDir(), "Mount directory should match"); System.out.println("Local file system configuration validated successfully"); - System.out.println(" - Storage Path: " + localFileSystemStarter.getStorageFolderPath()); - System.out.println(" - Mount Dir: " + localFileSystemStarter.getMountDir()); + System.out.println(" - Storage Path: " + localFileSystemConfig.getStorageFolderPath()); + System.out.println(" - Mount Dir: " + localFileSystemConfig.getMountDir()); } /** diff --git a/sandbox-extensions/redis-extension/src/main/java/io/agentscope/runtime/sandbox/map/RedisClientWrapper.java b/sandbox-extensions/redis-extension/src/main/java/io/agentscope/runtime/sandbox/map/RedisClientWrapper.java index b9b6c74..81f6968 100644 --- a/sandbox-extensions/redis-extension/src/main/java/io/agentscope/runtime/sandbox/map/RedisClientWrapper.java +++ b/sandbox-extensions/redis-extension/src/main/java/io/agentscope/runtime/sandbox/map/RedisClientWrapper.java @@ -15,13 +15,16 @@ */ package io.agentscope.runtime.sandbox.map; +import io.lettuce.core.KeyScanCursor; import io.lettuce.core.RedisClient; import io.lettuce.core.RedisURI; +import io.lettuce.core.ScanArgs; import io.lettuce.core.api.StatefulRedisConnection; import io.lettuce.core.api.sync.RedisCommands; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.util.HashSet; import java.util.List; import java.util.Set; @@ -118,17 +121,47 @@ public List keys(String pattern) { } /** - * Scan keys matching a pattern + * Scan keys matching a pattern using Redis SCAN command * * @param pattern the pattern to match * @return set of matching keys */ public Set scan(String pattern) { - return Set.copyOf(syncCommands.keys(pattern)); + Set keys = new HashSet<>(); + ScanArgs scanArgs = ScanArgs.Builder.limit(100).match(pattern); + KeyScanCursor cursor = syncCommands.scan(scanArgs); + + while (cursor != null) { + keys.addAll(cursor.getKeys()); + if (cursor.isFinished()) { + break; + } + cursor = syncCommands.scan(cursor, scanArgs); + } + return keys; } - // List operations + /** + * Set expiration on a key + * + * @param key the key + * @param seconds expiration time in seconds + * @return true if timeout was set, false otherwise + */ + public boolean expire(String key, long seconds) { + return syncCommands.expire(key, seconds); + } + /** + * Get the time to live for a key + * + * @param key the key + * @return TTL in seconds, or -2 if key doesn't exist, -1 if no timeout + */ + public Long ttl(String key) { + return syncCommands.ttl(key); + } + /** * Push value to the right end of a list * @@ -247,6 +280,26 @@ public Long scard(String key) { return syncCommands.scard(key); } + /** + * Increment the integer value of a key by one + * + * @param key the key + * @return the value of key after the increment + */ + public Long incr(String key) { + return syncCommands.incr(key); + } + + /** + * Decrement the integer value of a key by one + * + * @param key the key + * @return the value of key after the decrement + */ + public Long decr(String key) { + return syncCommands.decr(key); + } + /** * Ping the Redis server * diff --git a/sandbox-extensions/redis-extension/src/main/java/io/agentscope/runtime/sandbox/map/RedisManagerConfig.java b/sandbox-extensions/redis-extension/src/main/java/io/agentscope/runtime/sandbox/map/RedisManagerConfig.java index 2599092..6491558 100644 --- a/sandbox-extensions/redis-extension/src/main/java/io/agentscope/runtime/sandbox/map/RedisManagerConfig.java +++ b/sandbox-extensions/redis-extension/src/main/java/io/agentscope/runtime/sandbox/map/RedisManagerConfig.java @@ -24,6 +24,7 @@ public class RedisManagerConfig { private final Integer redisDb; private final String redisUser; private final String redisPassword; + private final Integer expirationSeconds; private RedisManagerConfig(Builder builder) { this.redisServer = builder.redisServer; @@ -31,6 +32,7 @@ private RedisManagerConfig(Builder builder) { this.redisDb = builder.redisDb; this.redisUser = builder.redisUser; this.redisPassword = builder.redisPassword; + this.expirationSeconds = builder.expirationSeconds; } public String getRedisServer() { @@ -57,12 +59,17 @@ public static Builder builder() { return new Builder(); } + public Integer getExpirationSeconds() { + return expirationSeconds; + } + public static class Builder { private String redisServer = "localhost"; private Integer redisPort = 6379; private Integer redisDb = 0; private String redisUser; private String redisPassword; + private Integer expirationSeconds = -1; public Builder redisServer(String redisServer) { this.redisServer = redisServer; @@ -89,6 +96,11 @@ public Builder redisPassword(String redisPassword) { return this; } + public Builder expirationSeconds(Integer expirationSeconds) { + this.expirationSeconds = expirationSeconds; + return this; + } + public RedisManagerConfig build() { return new RedisManagerConfig(this); } diff --git a/sandbox-extensions/redis-extension/src/main/java/io/agentscope/runtime/sandbox/map/RedisSandboxMap.java b/sandbox-extensions/redis-extension/src/main/java/io/agentscope/runtime/sandbox/map/RedisSandboxMap.java index b61204d..77a57fa 100644 --- a/sandbox-extensions/redis-extension/src/main/java/io/agentscope/runtime/sandbox/map/RedisSandboxMap.java +++ b/sandbox-extensions/redis-extension/src/main/java/io/agentscope/runtime/sandbox/map/RedisSandboxMap.java @@ -37,21 +37,34 @@ public class RedisSandboxMap implements SandboxMap { private static final String ID_TO_MODEL_PREFIX = "id_to_model:"; private static final String KEY_TO_ID_PREFIX = "key_to_id:"; private static final String ID_TO_KEY_PREFIX = "id_to_key:"; + private static final String REF_COUNT_PREFIX = "ref_count:"; private static final String MAIN_DATA_PREFIX = "sandbox:"; + private final int expirationSeconds; + public RedisSandboxMap(RedisManagerConfig redisManagerConfig) { try { this.redisClient = new RedisClientWrapper(redisManagerConfig); String pong = this.redisClient.ping(); logger.info("Redis connection test: {}", pong); this.objectMapper = new ObjectMapper(); + this.expirationSeconds = redisManagerConfig.getExpirationSeconds(); } catch (Exception e) { logger.error("Failed to initialize Redis client: {}", e.getMessage()); throw new RuntimeException("Failed to initialize Redis", e); } } + private void refreshExpiration(String... keys) { + if (expirationSeconds > 0) { + long ttl = (long) expirationSeconds + 10; + for (String key : keys) { + redisClient.expire(key, ttl); + } + } + } + private String getKeyToIdKey(SandboxKey key) { return MAIN_DATA_PREFIX + KEY_TO_ID_PREFIX + key.userID() + ":" + key.sessionID() + ":" + key.sandboxType(); } @@ -64,6 +77,10 @@ private String getIdToModelKey(String containerId) { return MAIN_DATA_PREFIX + ID_TO_MODEL_PREFIX + containerId; } + private String getRefCountKey(String containerId) { + return MAIN_DATA_PREFIX + REF_COUNT_PREFIX + containerId; + } + @Override public void addSandbox(SandboxKey sandboxKey, ContainerModel containerModel) { if (sandboxKey == null || containerModel == null) { @@ -88,6 +105,8 @@ public void addSandbox(SandboxKey sandboxKey, ContainerModel containerModel) { redisClient.set(idToKeyKey, sandboxKeyJson); redisClient.set(idToModelKey, containerJson); + refreshExpiration(keyToIdKey, idToKeyKey, idToModelKey); + logger.info("Added container {} with key {} and id {}", containerModel.getContainerName(), keyToIdKey, containerId); } catch (JsonProcessingException e) { @@ -109,6 +128,9 @@ public ContainerModel getSandbox(SandboxKey sandboxKey) { if (json == null || json.isEmpty()) { return null; } + + refreshExpiration(keyToIdKey, getIdToKeyKey(containerId), idToModelKey); + try { ContainerModel model = objectMapper.readValue(json, ContainerModel.class); logger.debug("Retrieved container {} from Redis", model.getContainerName()); @@ -127,6 +149,22 @@ public ContainerModel getSandbox(String containerId) { if (json == null || json.isEmpty()) { return null; } + + String idToKeyKey = getIdToKeyKey(containerId); + String sandboxKeyJson = redisClient.get(idToKeyKey); + if (sandboxKeyJson != null && !sandboxKeyJson.isEmpty()) { + try { + SandboxKey key = objectMapper.readValue(sandboxKeyJson, SandboxKey.class); + String keyToIdKey = getKeyToIdKey(key); + refreshExpiration(keyToIdKey, idToKeyKey, idToModelKey); + } catch (JsonProcessingException e) { + logger.warn("Failed to deserialize SandboxKey when refreshing expiration for containerId: {}", e.getMessage()); + refreshExpiration(idToKeyKey, idToModelKey); + } + } else { + refreshExpiration(idToKeyKey, idToModelKey); + } + try { ContainerModel model = objectMapper.readValue(json, ContainerModel.class); logger.debug("Retrieved container {} from Redis", model.getContainerName()); @@ -218,4 +256,48 @@ public Map getAllSandboxes() { logger.debug("Retrieved {} sandboxes from Redis", result.size()); return result; } + + @Override + public long getTTL(String containerId) { + if (containerId == null || containerId.isEmpty()) return -1; + String idToModelKey = getIdToModelKey(containerId); + return redisClient.ttl(idToModelKey); + } + + @Override + public long incrementRefCount(String containerId) { + if (containerId == null || containerId.isEmpty()) return 0; + String refCountKey = getRefCountKey(containerId); + long count = redisClient.incr(refCountKey); + refreshExpiration(refCountKey); + logger.debug("Incremented ref count for container {}: {}", containerId, count); + return count; + } + + @Override + public long decrementRefCount(String containerId) { + if (containerId == null || containerId.isEmpty()) return 0; + String refCountKey = getRefCountKey(containerId); + long count = redisClient.decr(refCountKey); + if (count < 0) { + redisClient.set(refCountKey, "0"); + count = 0; + } + refreshExpiration(refCountKey); + logger.debug("Decremented ref count for container {}: {}", containerId, count); + return count; + } + + @Override + public long getRefCount(String containerId) { + if (containerId == null || containerId.isEmpty()) return 0; + String refCountKey = getRefCountKey(containerId); + String val = redisClient.get(refCountKey); + if (val == null || val.isEmpty()) return 0; + try { + return Long.parseLong(val); + } catch (NumberFormatException e) { + return 0; + } + } } \ No newline at end of file