|
|
@@ -0,0 +1,155 @@
|
|
|
+package com.zsjz.ai.common.datasource;
|
|
|
+
|
|
|
+import org.junit.jupiter.api.DisplayName;
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+import org.junit.jupiter.api.io.TempDir;
|
|
|
+
|
|
|
+import java.nio.file.Files;
|
|
|
+import java.nio.file.Path;
|
|
|
+import java.sql.Connection;
|
|
|
+import java.sql.DriverManager;
|
|
|
+import java.sql.ResultSet;
|
|
|
+import java.sql.Statement;
|
|
|
+import java.util.ArrayList;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Properties;
|
|
|
+import java.util.concurrent.ExecutorService;
|
|
|
+import java.util.concurrent.Executors;
|
|
|
+import java.util.concurrent.Future;
|
|
|
+
|
|
|
+import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertFalse;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
|
+
|
|
|
+/**
|
|
|
+ * DuckDB 快照导出的契约测试(真实驱动,不用 mock)。
|
|
|
+ *
|
|
|
+ * <p>守两件事:
|
|
|
+ * <ol>
|
|
|
+ * <li><b>表与视图都随快照带过去</b>:Python 侧的查询口径与直连原库一致,不用改写法;</li>
|
|
|
+ * <li><b>快照能被另一个连接只读打开</b>:这正是「Java 持写锁时 Python 连原库必失败」的解药
|
|
|
+ * (跨进程的锁语义已用独立 JVM 进程实测,这里用同进程的独立连接做回归)。</li>
|
|
|
+ * </ol>
|
|
|
+ */
|
|
|
+class DuckdbSnapshotTest {
|
|
|
+
|
|
|
+ private static final String SRC = "jdbc:duckdb:";
|
|
|
+
|
|
|
+ @TempDir
|
|
|
+ Path dir;
|
|
|
+
|
|
|
+ private static Connection open(Path file) throws Exception {
|
|
|
+ return DriverManager.getConnection(SRC + file);
|
|
|
+ }
|
|
|
+
|
|
|
+ private static Connection openReadOnly(Path file) throws Exception {
|
|
|
+ Properties props = new Properties();
|
|
|
+ props.setProperty("access_mode", "read_only");
|
|
|
+ return DriverManager.getConnection(SRC + file, props);
|
|
|
+ }
|
|
|
+
|
|
|
+ private static int scalar(Connection con, String sql) throws Exception {
|
|
|
+ try (Statement st = con.createStatement();
|
|
|
+ ResultSet rs = st.executeQuery(sql)) {
|
|
|
+ rs.next();
|
|
|
+ return rs.getInt(1);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("导出的快照包含表与视图,可被独立连接只读打开")
|
|
|
+ void exportCopiesTablesAndViews() throws Exception {
|
|
|
+ Path source = dir.resolve("case1.db");
|
|
|
+ Path snapshot = dir.resolve("py-snapshot").resolve("case-123456789.db");
|
|
|
+
|
|
|
+ try (Connection con = open(source); Statement st = con.createStatement()) {
|
|
|
+ st.execute("create table t as select * from range(7) tbl(i)");
|
|
|
+ st.execute("create view v as select i * 2 as j from t");
|
|
|
+
|
|
|
+ assertTrue(DuckdbSnapshot.export(con, snapshot), "导出应成功");
|
|
|
+
|
|
|
+ // 快照存在于另一个文件,且源库仍可继续用(导出不影响原连接)
|
|
|
+ assertTrue(Files.isRegularFile(snapshot));
|
|
|
+ assertEquals(7, scalar(con, "select count(*) from t"));
|
|
|
+ }
|
|
|
+
|
|
|
+ try (Connection snap = openReadOnly(snapshot)) {
|
|
|
+ assertEquals(7, scalar(snap, "select count(*) from t"), "表数据应完整");
|
|
|
+ assertEquals(7, scalar(snap, "select count(*) from v"), "视图应随快照复制");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("重复导出覆盖旧快照(数据是导出时刻的状态)")
|
|
|
+ void exportOverwritesExistingSnapshot() throws Exception {
|
|
|
+ Path source = dir.resolve("case2.db");
|
|
|
+ Path snapshot = dir.resolve("py-snapshot").resolve("case-123456789.db");
|
|
|
+
|
|
|
+ try (Connection con = open(source); Statement st = con.createStatement()) {
|
|
|
+ st.execute("create table t as select * from range(3) tbl(i)");
|
|
|
+ assertTrue(DuckdbSnapshot.export(con, snapshot));
|
|
|
+
|
|
|
+ st.execute("insert into t select * from range(10) tbl(i)");
|
|
|
+ assertTrue(DuckdbSnapshot.export(con, snapshot), "第二次导出应覆盖而不是报错");
|
|
|
+ }
|
|
|
+
|
|
|
+ try (Connection snap = openReadOnly(snapshot)) {
|
|
|
+ assertEquals(13, scalar(snap, "select count(*) from t"), "快照应是最近一次导出的内容");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("并发导出同一路径:别名不撞车、结果仍是可用快照、临时文件不残留")
|
|
|
+ void concurrentExportToSameTarget() throws Exception {
|
|
|
+ Path source = dir.resolve("case9.db");
|
|
|
+ Path snapshotDir = dir.resolve("py-snapshot");
|
|
|
+ Path snapshot = snapshotDir.resolve("case-9.db");
|
|
|
+ try (Connection con = open(source); Statement st = con.createStatement()) {
|
|
|
+ st.execute("create table t as select * from range(5) tbl(i)");
|
|
|
+ }
|
|
|
+
|
|
|
+ int threads = 4;
|
|
|
+ ExecutorService pool = Executors.newFixedThreadPool(threads);
|
|
|
+ try {
|
|
|
+ List<Future<Boolean>> futures = new ArrayList<>();
|
|
|
+ for (int i = 0; i < threads; i++) {
|
|
|
+ futures.add(pool.submit(() -> {
|
|
|
+ try (Connection c = open(source)) {
|
|
|
+ return DuckdbSnapshot.export(c, snapshot);
|
|
|
+ }
|
|
|
+ }));
|
|
|
+ }
|
|
|
+ int ok = 0;
|
|
|
+ for (Future<Boolean> future : futures) {
|
|
|
+ if (Boolean.TRUE.equals(future.get())) {
|
|
|
+ ok++;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ assertTrue(ok >= 1, "并发导出一份都没成功");
|
|
|
+ } finally {
|
|
|
+ pool.shutdownNow();
|
|
|
+ }
|
|
|
+
|
|
|
+ // 结果仍是可用快照(最后一次替换生效),且没有 .tmp- 残留
|
|
|
+ try (Connection snap = openReadOnly(snapshot)) {
|
|
|
+ assertEquals(5, scalar(snap, "select count(*) from t"));
|
|
|
+ }
|
|
|
+ try (var files = Files.list(snapshotDir)) {
|
|
|
+ assertTrue(files.noneMatch(p -> p.getFileName().toString().contains(".tmp-")),
|
|
|
+ "导出结束后不应留下临时文件");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("入参非法/连接不可用时返回 false,不抛异常(调用方回退原库)")
|
|
|
+ void exportFailsQuietly() throws Exception {
|
|
|
+ Path source = dir.resolve("case3.db");
|
|
|
+ Path snapshot = dir.resolve("py-snapshot").resolve("case-123456789.db");
|
|
|
+ Connection con = open(source);
|
|
|
+ con.close();
|
|
|
+
|
|
|
+ assertFalse(DuckdbSnapshot.export(null, snapshot), "空连接应返回 false");
|
|
|
+ assertFalse(DuckdbSnapshot.export(con, null), "空目标应返回 false");
|
|
|
+ assertFalse(DuckdbSnapshot.export(con, snapshot), "连接已关闭应返回 false 而不是抛异常");
|
|
|
+ }
|
|
|
+}
|