|
@@ -2,6 +2,7 @@ package com.zsjz.ai.module.agent.python;
|
|
|
|
|
|
|
|
import com.zsjz.ai.common.constants.PathConst;
|
|
import com.zsjz.ai.common.constants.PathConst;
|
|
|
import com.zsjz.ai.common.context.CaseContextHolder;
|
|
import com.zsjz.ai.common.context.CaseContextHolder;
|
|
|
|
|
+import com.zsjz.ai.common.datasource.CaseDataSourceRegistry;
|
|
|
import com.zsjz.ai.common.model.plat.entity.CaseInfo;
|
|
import com.zsjz.ai.common.model.plat.entity.CaseInfo;
|
|
|
import com.zsjz.ai.module.agent.config.EtlProperties;
|
|
import com.zsjz.ai.module.agent.config.EtlProperties;
|
|
|
|
|
|
|
@@ -23,9 +24,19 @@ import java.util.stream.Stream;
|
|
|
* Python 子进程沙箱执行器
|
|
* Python 子进程沙箱执行器
|
|
|
*
|
|
*
|
|
|
* <p>每次执行使用独立临时目录({@code data/workspace/{wsId}/tmp/py-{execId}}),
|
|
* <p>每次执行使用独立临时目录({@code data/workspace/{wsId}/tmp/py-{execId}}),
|
|
|
- * 通过环境变量 {@code ETL_DB_PATH} 注入工作空间 DuckDB 文件路径(Python 内只读连接),
|
|
|
|
|
* 超时强制终止、输出截断;执行后将沙箱内生成的图片移动到
|
|
* 超时强制终止、输出截断;执行后将沙箱内生成的图片移动到
|
|
|
* {@code data/workspace/{wsId}/py-out/{execId}} 供文件服务接口输出。
|
|
* {@code data/workspace/{wsId}/py-out/{execId}} 供文件服务接口输出。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <h3>ETL_DB_PATH 指向的是「快照副本」而不是原库</h3>
|
|
|
|
|
+ * DuckDB 单文件只允许一个进程读写打开,而案件库在本进程(Java)里是常驻打开的 ——
|
|
|
|
|
+ * 子进程直接连原库必然失败(Windows 报「另一个程序正在使用此文件」)。
|
|
|
|
|
+ * 因此执行前由本进程用 {@code ATTACH + COPY FROM DATABASE} 导出一份单文件快照
|
|
|
|
|
+ * ({@code data/workspace/{wsId}/py-snapshot/case-{wsId}.db},见 {@link CaseDataSourceRegistry#exportSnapshot}),
|
|
|
|
|
+ * 把 {@code ETL_DB_PATH} 指向它。快照里表与视图都在,Python 侧用法不变(仍可 read_only 打开)。
|
|
|
|
|
+ * 目录按工作空间隔离、文件名再带上工作空间 ID,多用户/多空间并发时既不会重名也好辨认。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>快照按<b>源库指纹</b>(主文件与 WAL 的大小/修改时间)缓存:源库自上次导出后没变就直接复用,
|
|
|
|
|
+ * 避免模型连着跑几段 Python 时反复导出整库。
|
|
|
*/
|
|
*/
|
|
|
@Slf4j
|
|
@Slf4j
|
|
|
@Service
|
|
@Service
|
|
@@ -37,7 +48,9 @@ public class PythonExecutor {
|
|
|
private static final String CHECK_IMPORTS = "import pandas, duckdb, matplotlib";
|
|
private static final String CHECK_IMPORTS = "import pandas, duckdb, matplotlib";
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
- * 环境注入的 DuckDB 文件路径变量名
|
|
|
|
|
|
|
+ * 环境注入的 DuckDB 文件路径变量名。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>值是<b>快照副本</b>路径(不是案件原库):原库被本进程写锁持有,子进程连不上。
|
|
|
*/
|
|
*/
|
|
|
public static final String ENV_DB_PATH = "ETL_DB_PATH";
|
|
public static final String ENV_DB_PATH = "ETL_DB_PATH";
|
|
|
|
|
|
|
@@ -51,12 +64,27 @@ public class PythonExecutor {
|
|
|
*/
|
|
*/
|
|
|
public static final String IMAGE_URL_PREFIX = "/js/a/py/files/";
|
|
public static final String IMAGE_URL_PREFIX = "/js/a/py/files/";
|
|
|
|
|
|
|
|
|
|
+ /** 快照目录名(每个工作空间一份,覆盖写) */
|
|
|
|
|
+ private static final String SNAPSHOT_DIR = "py-snapshot";
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 快照文件名前缀。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>文件名里带<b>工作空间 ID</b>({@code case-{workspaceId}.db}):本系统是多用户、多工作空间并存的,
|
|
|
|
|
+ * 通用文件名一旦被挪到同一目录就会互相覆盖,也不利于排障时辨认归属。
|
|
|
|
|
+ */
|
|
|
|
|
+ private static final String SNAPSHOT_PREFIX = "case-";
|
|
|
|
|
+
|
|
|
private final EtlProperties etlProperties;
|
|
private final EtlProperties etlProperties;
|
|
|
private final CaseInfoMapper caseInfoMapper;
|
|
private final CaseInfoMapper caseInfoMapper;
|
|
|
|
|
+ private final CaseDataSourceRegistry caseDataSourceRegistry;
|
|
|
|
|
|
|
|
- public PythonExecutor(EtlProperties etlProperties, CaseInfoMapper caseInfoMapper) {
|
|
|
|
|
|
|
+ public PythonExecutor(EtlProperties etlProperties,
|
|
|
|
|
+ CaseInfoMapper caseInfoMapper,
|
|
|
|
|
+ CaseDataSourceRegistry caseDataSourceRegistry) {
|
|
|
this.etlProperties = etlProperties;
|
|
this.etlProperties = etlProperties;
|
|
|
this.caseInfoMapper = caseInfoMapper;
|
|
this.caseInfoMapper = caseInfoMapper;
|
|
|
|
|
+ this.caseDataSourceRegistry = caseDataSourceRegistry;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
@@ -119,7 +147,9 @@ public class PythonExecutor {
|
|
|
|
|
|
|
|
ProcessBuilder pb = new ProcessBuilder(etlProperties.getPython().getCommand(), "main.py")
|
|
ProcessBuilder pb = new ProcessBuilder(etlProperties.getPython().getCommand(), "main.py")
|
|
|
.directory(sandbox.toFile());
|
|
.directory(sandbox.toFile());
|
|
|
- pb.environment().put(ENV_DB_PATH, dbFile);
|
|
|
|
|
|
|
+ // 指向快照副本:原库被本进程写锁持有,子进程连只读都打不开(见类注释)
|
|
|
|
|
+ String pythonDbFile = resolvePythonDbFile(workspaceId, dbFile);
|
|
|
|
|
+ pb.environment().put(ENV_DB_PATH, pythonDbFile);
|
|
|
pb.environment().put("MPLBACKEND", "Agg"); // matplotlib 无头渲染
|
|
pb.environment().put("MPLBACKEND", "Agg"); // matplotlib 无头渲染
|
|
|
|
|
|
|
|
Process process = pb.start();
|
|
Process process = pb.start();
|
|
@@ -196,6 +226,58 @@ public class PythonExecutor {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 解析注入给 Python 的库文件路径:优先「当前案件的快照副本」,失败时回退原库。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>导出时机用源库指纹判断:主文件与 WAL 的大小/修改时间都没变 → 复用已有快照。
|
|
|
|
|
+ * 任何写入都会改 WAL,因此数据新鲜度与「每次重新导出」等价,代价只在真正变更时付一次。
|
|
|
|
|
+ */
|
|
|
|
|
+ private String resolvePythonDbFile(Long workspaceId, String dbFile) {
|
|
|
|
|
+ if (!etlProperties.getPython().isSnapshotEnabled()) {
|
|
|
|
|
+ return dbFile;
|
|
|
|
|
+ }
|
|
|
|
|
+ Path db = Path.of(dbFile);
|
|
|
|
|
+ Path snapshot = PathConst.WORKSPACE.resolve(String.valueOf(workspaceId))
|
|
|
|
|
+ .resolve(SNAPSHOT_DIR).resolve(SNAPSHOT_PREFIX + workspaceId + ".db");
|
|
|
|
|
+ Path meta = snapshot.resolveSibling(SNAPSHOT_PREFIX + workspaceId + ".meta");
|
|
|
|
|
+ try {
|
|
|
|
|
+ String fingerprint = sourceFingerprint(db);
|
|
|
|
|
+ if (Files.isRegularFile(snapshot) && Files.isRegularFile(meta)
|
|
|
|
|
+ && fingerprint.equals(Files.readString(meta).trim())) {
|
|
|
|
|
+ return snapshot.toAbsolutePath().toString();
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!caseDataSourceRegistry.exportSnapshot(workspaceId, snapshot)) {
|
|
|
|
|
+ return dbFile;
|
|
|
|
|
+ }
|
|
|
|
|
+ // 指纹在导出之后再取一次:避免把导出过程自身对源库的副作用算进来
|
|
|
|
|
+ Files.writeString(meta, sourceFingerprint(db));
|
|
|
|
|
+ return snapshot.toAbsolutePath().toString();
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("准备 Python 数据库快照失败,回退原库: caseId={}, error={}", workspaceId, e.getMessage());
|
|
|
|
|
+ return dbFile;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 源库指纹:主文件 + WAL 的大小与修改时间。任一写入都会动 WAL,因此指纹变化 = 数据变了。
|
|
|
|
|
+ */
|
|
|
|
|
+ private static String sourceFingerprint(Path dbFile) {
|
|
|
|
|
+ StringBuilder sb = new StringBuilder();
|
|
|
|
|
+ sb.append(statOf(dbFile)).append('|').append(statOf(Path.of(dbFile + ".wal")));
|
|
|
|
|
+ return sb.toString();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static String statOf(Path file) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ if (!Files.exists(file)) {
|
|
|
|
|
+ return "-";
|
|
|
|
|
+ }
|
|
|
|
|
+ return Files.size(file) + "@" + Files.getLastModifiedTime(file).toMillis();
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ return "?";
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
/**
|
|
/**
|
|
|
* 捕获沙箱内图片:移动到 py-out/{execId}/ 并生成访问 URL,LRU 清理超额执行目录
|
|
* 捕获沙箱内图片:移动到 py-out/{execId}/ 并生成访问 URL,LRU 清理超额执行目录
|
|
|
*/
|
|
*/
|