feat(iotdb): replace JDBC agent with native Go driver

This commit is contained in:
t8y2
2026-08-10 13:53:43 +08:00
parent 195c880097
commit 27f3ea7287
40 changed files with 4750 additions and 111 deletions
+2 -1
View File
@@ -53,12 +53,13 @@ const nativeDriverDirectories = {
duckdb: "duckdb",
oracle: "oracle-go",
kingbase: "kingbase-go",
iotdb: "iotdb",
neo4j: "neo4j-go",
vastbase: "vastbase-go",
rabbitmq: "rabbitmq",
tdengine: "tdengine",
};
const nativeDriverModules = new Set(["cassandra", "duckdb", "oracle", "xugu", "kingbase", "neo4j", "vastbase", "rabbitmq", "tdengine"]);
const nativeDriverModules = new Set(["cassandra", "duckdb", "oracle", "xugu", "kingbase", "iotdb", "neo4j", "vastbase", "rabbitmq", "tdengine"]);
function resolveAgentModule(moduleName, { legacyStandaloneModules, moduleExists, readModuleFile }) {
let checkDir = null;
@@ -96,6 +96,19 @@ test("bumps Neo4j from its native Go source directory", () => {
assert.deepEqual(result.nativeModules, ["neo4j"]);
});
test("bumps IoTDB from its native Go source directory", () => {
const result = evaluateAgentVersionBump({
versions: { iotdb: "0.1.30" },
changedFiles: ["agents/drivers/iotdb/main.go"],
moduleExists: (path) => path === "agents/drivers/iotdb",
readModuleFile: () => "",
});
assert.equal(result.versions.iotdb, "0.1.31");
assert.deepEqual(result.javaModules, []);
assert.deepEqual(result.nativeModules, ["iotdb"]);
});
test("rebuilds native modules when shared native packaging changes", () => {
const existing = new Set([
"agents/drivers/access",
@@ -15,7 +15,7 @@ import { basename, join } from "node:path";
import { tmpdir } from "node:os";
const REGISTRY_ASSET = "agent-registry.json";
const NATIVE_MODULES = new Set(["duckdb", "oracle", "xugu", "kingbase", "neo4j", "vastbase", "rabbitmq", "tdengine"]);
const NATIVE_MODULES = new Set(["duckdb", "oracle", "xugu", "kingbase", "iotdb", "neo4j", "vastbase", "rabbitmq", "tdengine"]);
const PLATFORMS = [
"macos-aarch64",
"macos-x64",
@@ -96,6 +96,24 @@ test("requires all Neo4j native platforms when reusing a release", () => {
);
});
test("requires all IoTDB native platforms when reusing a release", () => {
const native = Object.fromEntries(
platforms.slice(0, -1).map((platform, index) => [platform, artifact(`dbx-agent-iotdb-0.1.30-${platform}.tar.zst`, String(index + 1))]),
);
const registry = { drivers: { iotdb: { version: "0.1.30", native } }, jres: {} };
assert.throws(
() => collectReusableAssetPlan({
registry,
release: releaseFor(Object.values(native)),
versions: { iotdb: "0.1.30" },
modules: ["iotdb"],
reuseJre: false,
}),
/missing=windows-x64/,
);
});
test("ignores zero-size legacy JAR placeholders for native-only modules", () => {
const native = Object.fromEntries(
platforms.map((platform, index) => [platform, artifact(`dbx-agent-duckdb-0.1.2-${platform}.tar.zst`, String(index + 1))]),
+46 -2
View File
@@ -449,6 +449,46 @@ jobs:
name: neo4j-native
path: "release-native/dbx-agent-neo4j-*"
build-iotdb-native:
needs: [bump-versions]
if: ${{ contains(fromJSON(needs.bump-versions.outputs.native_modules), 'iotdb') }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.25.x"
- name: Test IoTDB native agent
working-directory: agents/drivers/iotdb
run: go test ./...
- name: Cross-compile IoTDB native agent
shell: bash
run: |
mkdir -p release-native
cd agents/drivers/iotdb
declare -A TARGETS=(
["macos-aarch64"]="darwin/arm64"
["macos-x64"]="darwin/amd64"
["linux-aarch64"]="linux/arm64"
["linux-x64"]="linux/amd64"
["windows-aarch64"]="windows/arm64"
["windows-x64"]="windows/amd64"
)
for platform in "${!TARGETS[@]}"; do
IFS=/ read -r goos goarch <<< "${TARGETS[$platform]}"
output="../../../release-native/dbx-agent-iotdb-${platform}"
if [[ "$goos" == "windows" ]]; then
output="${output}.exe"
fi
echo "Building $platform ($goos/$goarch)"
CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" go build -trimpath -ldflags="-s -w" -o "$output" .
done
ls -lh ../../../release-native
- uses: actions/upload-artifact@v4
with:
name: iotdb-native
path: "release-native/dbx-agent-iotdb-*"
build-duckdb-native:
needs: [bump-versions]
if: ${{ contains(fromJSON(needs.bump-versions.outputs.native_modules), 'duckdb') }}
@@ -788,7 +828,7 @@ jobs:
retention-days: 1
release:
needs: [bump-versions, commit-versions, build-agents, build-oracle-native, build-xugu-native, build-rabbitmq-native, build-cassandra-native, build-kingbase-native, build-vastbase-native, build-neo4j-native, build-duckdb-native, build-tdengine-native, build-jre, reuse-previous-assets]
needs: [bump-versions, commit-versions, build-agents, build-oracle-native, build-xugu-native, build-rabbitmq-native, build-cassandra-native, build-kingbase-native, build-vastbase-native, build-neo4j-native, build-iotdb-native, build-duckdb-native, build-tdengine-native, build-jre, reuse-previous-assets]
if: ${{ always() && !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') }}
runs-on: ubuntu-latest
steps:
@@ -912,6 +952,7 @@ jobs:
rabbitmq) echo "RabbitMQ" ;;
cassandra) echo "Apache Cassandra" ;;
neo4j) echo "Neo4j" ;;
iotdb) echo "Apache IoTDB" ;;
tdengine) echo "TDengine" ;;
*) echo "$name" ;;
esac
@@ -976,7 +1017,7 @@ jobs:
[ -n "$DRIVERS" ] && DRIVERS="${DRIVERS},"$'\n'
DRIVERS="${DRIVERS}$(generate_jar_entry "$name" "$label" "$f" "$jre_key" "$version" "$external_driver" "$native_json")"
done
for name in oracle xugu kingbase vastbase neo4j duckdb rabbitmq cassandra tdengine; do
for name in oracle xugu kingbase vastbase neo4j iotdb duckdb rabbitmq cassandra tdengine; do
version=$(get_module_version "$name")
[ -f "release/dbx-agent-${name}-${version}.jar" ] && continue
native_json=$(generate_native_platforms "$name" "$version")
@@ -1048,6 +1089,7 @@ jobs:
rabbitmq) echo "RabbitMQ" ;;
cassandra) echo "Apache Cassandra" ;;
neo4j) echo "Neo4j" ;;
iotdb) echo "Apache IoTDB" ;;
tdengine) echo "TDengine" ;;
*) echo "$name" ;;
esac
@@ -1077,6 +1119,8 @@ jobs:
LOG_PATH="agents/drivers/cassandra-go/"
elif [ "$name" = "neo4j" ]; then
LOG_PATH="agents/drivers/neo4j-go/"
elif [ "$name" = "iotdb" ]; then
LOG_PATH="agents/drivers/iotdb/"
elif [ -d "agents/drivers/$name" ]; then
LOG_PATH="agents/drivers/$name/"
else
+9 -1
View File
@@ -701,7 +701,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: "1.24.x"
go-version: "1.25.x"
- name: Setup Rust
uses: dtolnay/rust-toolchain@1.97.1
@@ -752,6 +752,10 @@ jobs:
run: go test ./...
working-directory: agents/drivers/neo4j-go
- name: IoTDB native agent tests
run: go test ./...
working-directory: agents/drivers/iotdb
- name: Oracle native agent build
run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /tmp/dbx-agent-oracle-linux-x64 .
working-directory: agents/drivers/oracle-go
@@ -776,6 +780,10 @@ jobs:
run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /tmp/dbx-agent-neo4j-linux-x64 .
working-directory: agents/drivers/neo4j-go
- name: IoTDB native agent build
run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /tmp/dbx-agent-iotdb-linux-x64 .
working-directory: agents/drivers/iotdb
- name: TDengine native agent build
run: cargo build --manifest-path drivers/tdengine/Cargo.toml --locked --release --bin dbx-tdengine-driver
+2 -2
View File
@@ -90,7 +90,7 @@
### 70+ Databases, One Tool
MySQL, PostgreSQL, SQLite, Cloudflare D1, Redis, MongoDB, DuckDB, ClickHouse, SQL Server, Oracle, Elasticsearch, Easysearch, Qdrant, Milvus, Weaviate, MariaDB, TiDB, OceanBase, openGauss, GaussDB, KWDB, KingBase, Vastbase, GoldenDB, Doris, SelectDB, StarRocks, Manticore Search, Redshift, DM, TDengine, XuguDB, CockroachDB, Access, HighGo, UXDB, Dolt, and more. Agent/JDBC-oriented profiles extend DBX to H2, Snowflake, Trino, PrestoSQL, Hive, DB2, Informix, Neo4j, Cassandra, BigQuery, Kylin, SunDB, JDBCX, and custom JDBC connections. New native and agent-driven drivers also cover Databricks, SAP HANA, Teradata, Vertica, Firebird, Exasol, YashanDB, GBase 8a/8s, Databend, RQLite, Turso, InfluxDB, QuestDB, IoTDB, etcd, ZooKeeper, Nacos, IRIS, and more. Message queue admin is also available for Pulsar, Kafka, and RocketMQ. All in a single ~20 MB app. No bundled Chromium.
MySQL, PostgreSQL, SQLite, Cloudflare D1, Redis, MongoDB, DuckDB, ClickHouse, SQL Server, Oracle, Elasticsearch, Easysearch, Qdrant, Milvus, Weaviate, MariaDB, TiDB, OceanBase, openGauss, GaussDB, KWDB, KingBase, Vastbase, GoldenDB, Doris, SelectDB, StarRocks, Manticore Search, Redshift, DM, TDengine, XuguDB, CockroachDB, Access, HighGo, UXDB, Dolt, and more. Agent-based profiles extend DBX to H2, Snowflake, Trino, PrestoSQL, Hive, DB2, Informix, Neo4j, Cassandra, BigQuery, Kylin, SunDB, JDBCX, and custom JDBC connections. New native and agent-driven drivers also cover Databricks, SAP HANA, Teradata, Vertica, Firebird, Exasol, YashanDB, GBase 8a/8s, Databend, RQLite, Turso, InfluxDB, QuestDB, IoTDB, etcd, ZooKeeper, Nacos, IRIS, and more. Message queue admin is also available for Pulsar, Kafka, and RocketMQ. All in a single ~20 MB app. No bundled Chromium.
### Query Editor
@@ -483,7 +483,7 @@ DBX is 20 MB with no runtime dependencies for its native database features (no s
<details>
<summary><strong>What databases are supported?</strong></summary>
MySQL, PostgreSQL, SQLite, Cloudflare D1, Redis, MongoDB, DuckDB, ClickHouse, SQL Server, Oracle, Elasticsearch, Easysearch, Qdrant, Milvus, Weaviate, MariaDB, TiDB, OceanBase, openGauss, GaussDB, KWDB, KingBase, Vastbase, GoldenDB, Doris, SelectDB, StarRocks, Manticore Search, Redshift, DM, TDengine, XuguDB, CockroachDB, Access, HighGo, UXDB, and more. Agent/JDBC-oriented profiles extend support to H2, Snowflake, Trino, PrestoSQL, Hive, DB2, Informix, Neo4j, Cassandra, BigQuery, Kylin, SunDB, JDBCX, Databricks, SAP HANA, Teradata, Vertica, Firebird, Exasol, YashanDB, GBase 8a/8s, Databend, RQLite, Turso, InfluxDB, QuestDB, IoTDB, etcd, ZooKeeper, Nacos, IRIS, and custom JDBC connections. Message queue admin (Pulsar, Kafka, RocketMQ) is also supported.
MySQL, PostgreSQL, SQLite, Cloudflare D1, Redis, MongoDB, DuckDB, ClickHouse, SQL Server, Oracle, Elasticsearch, Easysearch, Qdrant, Milvus, Weaviate, MariaDB, TiDB, OceanBase, openGauss, GaussDB, KWDB, KingBase, Vastbase, GoldenDB, Doris, SelectDB, StarRocks, Manticore Search, Redshift, DM, TDengine, XuguDB, CockroachDB, Access, HighGo, UXDB, and more. Agent-based profiles extend support to H2, Snowflake, Trino, PrestoSQL, Hive, DB2, Informix, Neo4j, Cassandra, BigQuery, Kylin, SunDB, JDBCX, Databricks, SAP HANA, Teradata, Vertica, Firebird, Exasol, YashanDB, GBase 8a/8s, Databend, RQLite, Turso, InfluxDB, QuestDB, IoTDB, etcd, ZooKeeper, Nacos, IRIS, and custom JDBC connections. Message queue admin (Pulsar, Kafka, RocketMQ) is also supported.
</details>
<details>
+2 -2
View File
@@ -90,7 +90,7 @@
### 70+ 种数据库,一个工具搞定
MySQL、PostgreSQL、SQLite、Cloudflare D1、Redis、MongoDB、DuckDB、ClickHouse、SQL Server、Oracle、Elasticsearch、Easysearch、MariaDB、TiDB、OceanBase、openGauss、GaussDB、KWDB、KingBase、Vastbase、GoldenDB、Doris、SelectDB、StarRocks、Manticore Search、Redshift、DM、TDengine、虚谷 XuguDB、CockroachDB、Access、HighGo、UXDB、Dolt 等数据库都能直接连接。Agent/JDBC 方向的配置还可扩展到 H2、Snowflake、Trino、Hive、DB2、Informix、Neo4j、Cassandra、BigQuery、Kylin、SunDB、JDBCX 和自定义 JDBC。新增的原生与 Agent 驱动还覆盖了 Databricks、SAP HANA、Teradata、Vertica、Firebird、Exasol、崖山 YashanDB、GBase、Databend、RQLite、Turso、InfluxDB、QuestDB、IoTDB、etcd、IRIS 等。全部装进约 20 MB 的应用里,不内嵌 Chromium。
MySQL、PostgreSQL、SQLite、Cloudflare D1、Redis、MongoDB、DuckDB、ClickHouse、SQL Server、Oracle、Elasticsearch、Easysearch、MariaDB、TiDB、OceanBase、openGauss、GaussDB、KWDB、KingBase、Vastbase、GoldenDB、Doris、SelectDB、StarRocks、Manticore Search、Redshift、DM、TDengine、虚谷 XuguDB、CockroachDB、Access、HighGo、UXDB、Dolt 等数据库都能直接连接。Agent 配置还可扩展到 H2、Snowflake、Trino、Hive、DB2、Informix、Neo4j、Cassandra、BigQuery、Kylin、SunDB、JDBCX 和自定义 JDBC。新增的原生与 Agent 驱动还覆盖了 Databricks、SAP HANA、Teradata、Vertica、Firebird、Exasol、崖山 YashanDB、GBase、Databend、RQLite、Turso、InfluxDB、QuestDB、IoTDB、etcd、IRIS 等。全部装进约 20 MB 的应用里,不内嵌 Chromium。
### 查询编辑器
@@ -486,7 +486,7 @@ DBX 仅 20 MB,无需运行时依赖(无需 Java、无需 Python)。AI 和
<details>
<summary><strong>支持哪些数据库?</strong></summary>
MySQL、PostgreSQL、SQLite、Cloudflare D1、Redis、MongoDB、DuckDB、ClickHouse、SQL Server、Oracle、Elasticsearch、Easysearch、Qdrant、Milvus、Weaviate、MariaDB、TiDB、OceanBase、openGauss、GaussDB、KWDB、KingBase、Vastbase、GoldenDB、Doris、SelectDB、StarRocks、Manticore Search、Redshift、DM、TDengine、虚谷 XuguDB、CockroachDB、Access、HighGo、UXDB 等。JDBC 方向配置可扩展到 H2、Snowflake、Trino、PrestoSQL、Hive、DB2、Informix、Neo4j、Cassandra、BigQuery、Kylin、SunDB、JDBCX、Databricks、SAP HANA、Teradata、Vertica、Firebird、Exasol、崖山 YashanDB、GBase 8a/8s、Databend、RQLite、Turso、InfluxDB、QuestDB、IoTDB、etcd、ZooKeeper、Nacos、IRIS 及自定义 JDBC 连接,并支持消息队列管理(Pulsar、Kafka、RocketMQ)。
MySQL、PostgreSQL、SQLite、Cloudflare D1、Redis、MongoDB、DuckDB、ClickHouse、SQL Server、Oracle、Elasticsearch、Easysearch、Qdrant、Milvus、Weaviate、MariaDB、TiDB、OceanBase、openGauss、GaussDB、KWDB、KingBase、Vastbase、GoldenDB、Doris、SelectDB、StarRocks、Manticore Search、Redshift、DM、TDengine、虚谷 XuguDB、CockroachDB、Access、HighGo、UXDB 等。Agent 配置可扩展到 H2、Snowflake、Trino、PrestoSQL、Hive、DB2、Informix、Neo4j、Cassandra、BigQuery、Kylin、SunDB、JDBCX、Databricks、SAP HANA、Teradata、Vertica、Firebird、Exasol、崖山 YashanDB、GBase 8a/8s、Databend、RQLite、Turso、InfluxDB、QuestDB、IoTDB、etcd、ZooKeeper、Nacos、IRIS 及自定义 JDBC 连接,并支持消息队列管理(Pulsar、Kafka、RocketMQ)。
</details>
<details>
+6 -5
View File
@@ -41,7 +41,7 @@ Each agent runs as a standalone process and communicates with DBX via stdin/stdo
| tdengine | TDengine 2.4+ | taos-connector-rust native WebSocket agent |
| yashandb | 崖山 YashanDB | YashanDB JDBC |
| xugu | 虚谷 XuguDB | XuguDB Go native agent |
| iotdb | Apache IoTDB | IoTDB JDBC |
| iotdb | Apache IoTDB | Apache IoTDB Go Client native agent |
| etcd | etcd | jetcd |
| zookeeper | Apache ZooKeeper | Apache Curator |
| rabbitmq | RabbitMQ | amqp091-go native agent |
@@ -49,7 +49,7 @@ Each agent runs as a standalone process and communicates with DBX via stdin/stdo
## Multi-JRE Support
Most Java agents target JRE 21. Native agents, such as `cassandra`, `duckdb`, `oracle`, `kingbase`, `tdengine`, `xugu`, and `rabbitmq`, do not require a JRE. DBX downloads and manages the JRE 21 installation automatically for Java agents.
Most Java agents target JRE 21. Native agents, such as `cassandra`, `duckdb`, `iotdb`, `oracle`, `kingbase`, `tdengine`, `xugu`, and `rabbitmq`, do not require a JRE. DBX downloads and manages the JRE 21 installation automatically for Java agents.
## JDBC Connection Pooling
@@ -75,7 +75,7 @@ Set `DBX_AGENT_JDBC_POOL_ENABLED=false` for a runtime-level compatibility fallba
For new agents, prefer a **native (Go or Rust) driver** over a Java/JDBC agent whenever a mature, license-compatible native driver is available. Native agents ship as a single self-contained executable with no JRE, which significantly reduces memory footprint and startup time — the JVM baseline that every Java agent pays even when idle is avoided entirely.
- **Native (C++/Go/Rust)** — preferred when a usable native driver exists. See `drivers/cassandra-go` (Apache cassandra-gocql-driver), `drivers/duckdb`, `drivers/oracle-go` (go-ora), `drivers/kingbase-go` (gokb), `drivers/vastbase-go` (openGauss connector), `drivers/tdengine` (taos-connector-rust), `drivers/xugu`, and `drivers/rabbitmq` (amqp091-go) as reference implementations. No JRE download or management is needed.
- **Native (C++/Go/Rust)** — preferred when a usable native driver exists. See `drivers/cassandra-go` (Apache cassandra-gocql-driver), `drivers/duckdb`, `drivers/iotdb` (Apache IoTDB Go Client), `drivers/oracle-go` (go-ora), `drivers/kingbase-go` (gokb), `drivers/vastbase-go` (openGauss connector), `drivers/tdengine` (taos-connector-rust), `drivers/xugu`, and `drivers/rabbitmq` (amqp091-go) as reference implementations. No JRE download or management is needed.
- **Java/JDBC** — the default fallback when only a JDBC driver exists for the database, or when the native driver is immature or unmaintained. Most agents still fall in this category.
Native agents implement the same JSON-RPC contract and `versions.json` registration as Java agents; they ship an `agent` executable instead of `agent.jar`. If both native and Java source implementations exist for the same database, publish only the native artifact unless the Java variant has a separately registered compatibility profile, such as `oracle-legacy` / `oracle-10g`.
@@ -88,6 +88,7 @@ Requires JDK 21 (Gradle toolchain auto-downloads if needed).
./gradlew shadowJar
(cd drivers/oracle-go && go build -o agent .)
(cd drivers/cassandra-go && go build -o agent .)
(cd drivers/iotdb && go build -o agent .)
(cd drivers/kingbase-go && go build -o agent .)
(cd drivers/vastbase-go && go build -o agent .)
(cargo build --manifest-path drivers/tdengine/Cargo.toml --release --locked)
@@ -95,7 +96,7 @@ Requires JDK 21 (Gradle toolchain auto-downloads if needed).
(cd drivers/rabbitmq && go build -o agent .)
```
Output JARs are in `drivers/{module}/build/libs/`. Native agents build from `drivers/cassandra-go`, `drivers/oracle-go`, `drivers/kingbase-go`, `drivers/vastbase-go`, `drivers/tdengine`, `drivers/xugu`, and `drivers/rabbitmq`.
Output JARs are in `drivers/{module}/build/libs/`. Native agents build from `drivers/cassandra-go`, `drivers/duckdb`, `drivers/iotdb`, `drivers/oracle-go`, `drivers/kingbase-go`, `drivers/vastbase-go`, `drivers/tdengine`, `drivers/xugu`, and `drivers/rabbitmq`.
### Local DBX Runtime Test
@@ -109,7 +110,7 @@ cp agents/drivers/<db_type>/build/libs/*-all.jar ~/.dbx/agents/drivers/<db_type>
Restart DBX or disconnect and reconnect the database so the new agent process loads the replacement JAR.
Native agents such as `cassandra`, `oracle`, `kingbase`, `tdengine`, `xugu`, and `rabbitmq` use an `agent` executable instead of `agent.jar`. TDengine builds `target/release/dbx-tdengine-driver` from `drivers/tdengine/Cargo.toml`.
Native agents such as `cassandra`, `iotdb`, `oracle`, `kingbase`, `tdengine`, `xugu`, and `rabbitmq` use an `agent` executable instead of `agent.jar`. TDengine builds `target/release/dbx-tdengine-driver` from `drivers/tdengine/Cargo.toml`.
## Versioning
+6 -5
View File
@@ -41,7 +41,7 @@ DBX 的 Agent 驱动 —— 通过 JDBC 和原生数据库驱动支持各种数
| tdengine | TDengine 2.4+ | taos-connector-rust 原生 WebSocket agent |
| yashandb | 崖山 YashanDB | YashanDB JDBC |
| xugu | 虚谷 XuguDB | XuguDB Go 原生 agent |
| iotdb | Apache IoTDB | IoTDB JDBC |
| iotdb | Apache IoTDB | Apache IoTDB Go Client 原生 Agent |
| etcd | etcd | jetcd |
| zookeeper | Apache ZooKeeper | Apache Curator |
| rabbitmq | RabbitMQ | amqp091-go 原生 agent |
@@ -49,7 +49,7 @@ DBX 的 Agent 驱动 —— 通过 JDBC 和原生数据库驱动支持各种数
## 多 JRE 支持
多数 Java agent 以 JRE 21 为目标。原生 agent(如 `cassandra`、`duckdb`、`oracle`、`kingbase`、`tdengine`、`xugu` 和 `rabbitmq`)不需要 JRE。对 Java agent,DBX 会自动下载并管理 JRE 21 安装。
多数 Java agent 以 JRE 21 为目标。原生 agent(如 `cassandra`、`duckdb`、`iotdb`、`oracle`、`kingbase`、`tdengine`、`xugu` 和 `rabbitmq`)不需要 JRE。对 Java agent,DBX 会自动下载并管理 JRE 21 安装。
## JDBC 连接池
@@ -75,7 +75,7 @@ HikariCP 会直接打进启用连接池的 Agent JAR。已经使用 DBX 托管 J
对于新 agent,只要存在成熟、许可证兼容的原生驱动,优先选择**原生(Go 或 Rust)驱动**而非 Java/JDBC agent。原生 agent 以单一自包含可执行文件发布,无需 JRE,可显著降低内存占用和启动时间 —— 完全避开 Java agent 即便空闲也要付出的 JVM 基线开销。
- **原生(Go/Rust)** —— 存在可用原生驱动时首选。参考 `drivers/cassandra-go`(Apache cassandra-gocql-driver)、`drivers/duckdb`、`drivers/oracle-go`(go-ora)、`drivers/kingbase-go`(gokb)、`drivers/vastbase-go`(openGauss connector)、`drivers/tdengine`(taos-connector-rust)、`drivers/xugu` 和 `drivers/rabbitmq`(amqp091-go)。无需 JRE 下载与管理。
- **原生(Go/Rust)** —— 存在可用原生驱动时首选。参考 `drivers/cassandra-go`(Apache cassandra-gocql-driver)、`drivers/duckdb`、`drivers/iotdb`(Apache IoTDB Go Client)、`drivers/oracle-go`(go-ora)、`drivers/kingbase-go`(gokb)、`drivers/vastbase-go`(openGauss connector)、`drivers/tdengine`(taos-connector-rust)、`drivers/xugu` 和 `drivers/rabbitmq`(amqp091-go)。无需 JRE 下载与管理。
- **Java/JDBC** —— 当某数据库只有 JDBC 驱动,或原生驱动不成熟、缺乏维护时的默认兜底方案。多数 agent 仍属此类。
原生 agent 实现与 Java agent 相同的 JSON-RPC 契约和 `versions.json` 登记;它发布的是 `agent` 可执行文件而非 `agent.jar`。若同一数据库同时保留原生和 Java 源码实现,默认只发布原生产物;只有 Java 变体以独立兼容配置登记时才同时发布,例如 `oracle-legacy` / `oracle-10g`。
@@ -88,6 +88,7 @@ HikariCP 会直接打进启用连接池的 Agent JAR。已经使用 DBX 托管 J
./gradlew shadowJar
(cd drivers/oracle-go && go build -o agent .)
(cd drivers/cassandra-go && go build -o agent .)
(cd drivers/iotdb && go build -o agent .)
(cd drivers/kingbase-go && go build -o agent .)
(cd drivers/vastbase-go && go build -o agent .)
(cargo build --manifest-path drivers/tdengine/Cargo.toml --release --locked)
@@ -95,7 +96,7 @@ HikariCP 会直接打进启用连接池的 Agent JAR。已经使用 DBX 托管 J
(cd drivers/rabbitmq && go build -o agent .)
```
产物 JAR 在 `drivers/{module}/build/libs/`。原生 agent 从 `drivers/cassandra-go`、`drivers/duckdb`、`drivers/oracle-go`、`drivers/kingbase-go`、`drivers/vastbase-go`、`drivers/tdengine`、`drivers/xugu` 和 `drivers/rabbitmq` 构建。
产物 JAR 在 `drivers/{module}/build/libs/`。原生 agent 从 `drivers/cassandra-go`、`drivers/duckdb`、`drivers/iotdb`、`drivers/oracle-go`、`drivers/kingbase-go`、`drivers/vastbase-go`、`drivers/tdengine`、`drivers/xugu` 和 `drivers/rabbitmq` 构建。
### 本地 DBX 运行时测试
@@ -109,7 +110,7 @@ cp agents/drivers/<db_type>/build/libs/*-all.jar ~/.dbx/agents/drivers/<db_type>
重启 DBX 或断开重连数据库,使新 agent 进程加载替换后的 JAR。
`cassandra`、`oracle`、`kingbase`、`tdengine`、`xugu` 和 `rabbitmq` 等原生 agent 使用可执行文件而非 `agent.jar`。TDengine 从 `drivers/tdengine/Cargo.toml` 构建 `target/release/dbx-tdengine-driver`。
`cassandra`、`iotdb`、`oracle`、`kingbase`、`tdengine`、`xugu` 和 `rabbitmq` 等原生 agent 使用可执行文件而非 `agent.jar`。TDengine 从 `drivers/tdengine/Cargo.toml` 构建 `target/release/dbx-tdengine-driver`。
## 版本管理
+1 -1
View File
@@ -7,7 +7,7 @@ def legacyStandaloneProjects = ['mongodb', 'kafka', 'rocketmq'] as Set
def pooledJdbcProjects = [
'access', 'bigquery', 'dameng', 'databend', 'databricks', 'db2', 'exasol',
'firebird', 'gbase8a', 'gbase8s', 'goldendb', 'h2', 'h2-legacy', 'highgo', 'hive',
'informix', 'iotdb', 'iris', 'kylin', 'oceanbase-oracle', 'oscar', 'saphana',
'informix', 'iris', 'kylin', 'oceanbase-oracle', 'oscar', 'saphana',
'snowflake', 'spark', 'sqlserver-legacy', 'sundb', 'teradata', 'trino', 'uxdb',
'vertica', 'yashandb'
] as Set
+55
View File
@@ -0,0 +1,55 @@
# Apache IoTDB native Agent
This module implements the DBX Agent protocol with the official Apache IoTDB
Go client. Tree SQL is the default; set `sql_dialect=table` for the Table model.
## Build and test
Go 1.25 or newer is required by the pinned upstream client revision.
```bash
go test ./...
go test -race ./...
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o agent .
```
Run the live Tree/Table, paging, multi-session, and cancellation tests against
an IoTDB server with:
```bash
DBX_IOTDB_LIVE=1 go test -race ./... -count=1
```
The live tests default to `127.0.0.1:6667` with `root/root`. Override them with
`DBX_IOTDB_HOST`, `DBX_IOTDB_PORT`, `DBX_IOTDB_USER`, and
`DBX_IOTDB_PASSWORD`. Tests create and remove isolated Tree and Table databases.
## Connection options
DBX connection fields and `jdbc:iotdb://...` connection strings are accepted.
The following URL parameters are supported:
- `sql_dialect=tree|table`
- `database` or `db`
- `fetch_size`
- `time_zone`
- `connect_retry_max`
- `connect_timeout_ms`
- `enable_compression=true`
- `node_urls=host1:6667,host2:6667` for cluster sessions
- `ssl=true` and `insecure_skip_verify=true`
The standard DBX certificate fields provide CA and client certificate paths
for TLS or mTLS connections.
## Compatibility
- Tree sessions expose databases, devices, timeseries columns, queries, DDL,
paging, and sequential batch/transaction execution.
- Table sessions expose databases, tables, TIME/TAG/FIELD column categories,
comments, queries, DDL, paging, and database switching.
- The Agent keeps one physical IoTDB session per logical DBX session and
invalidates that session after cancellation, timeout, or connection failure.
The historical JDBC-versus-Go driver benchmark and raw result summary are kept
under [`bench`](bench/README.md).
+59
View File
@@ -0,0 +1,59 @@
# IoTDB JDBC vs Go driver benchmark
This standalone benchmark preserves the JDBC-versus-Go comparison used before
the production IoTDB Agent migrated to Go. It compares the released Apache
IoTDB `2.0.8` JDBC and Go clients against the same Tree-model server and
fixture, measuring cold process plus connection startup and warm query latency
while fully decoding every returned cell.
The default fixture contains 10,000 rows in `root.dbx_bench.d1`. Workloads are:
- `SHOW DATABASES`
- one timestamp point query
- a 100-row range query
- a full scan of all fixture rows
## Start IoTDB
For example, run the matching standalone Docker image:
```bash
docker run --rm --name dbx-iotdb-bench -p 6667:6667 apache/iotdb:2.0.8-standalone
```
Wait until port `6667` is ready before starting the benchmark.
## Run
From the repository root:
```bash
python3 agents/drivers/iotdb/bench/run.py \
> /tmp/dbx-iotdb-driver-benchmark.json
```
The runner builds both candidates from this benchmark directory, recreates the
fixture, alternates cold-start and connected-RSS samples, then runs three warm
workload rounds with alternating candidate order. It does not build or modify
the production IoTDB Agent.
## Configuration
- `IOTDB_HOST`, `IOTDB_PORT`, `IOTDB_USERNAME`, `IOTDB_PASSWORD`
- `BENCH_ROWS`, default `10000`
- `BENCH_FETCH_SIZE`, default `1024`
- `BENCH_STARTUPS`, default `5`
- `BENCH_RSS_SAMPLES`, default `3`
- `BENCH_ROUNDS`, default `3`
- `BENCH_ORDER`, default `jdbc,go`; use `go,jdbc` to check order effects
- `BENCH_WARMUPS`, default `3`
- `BENCH_METADATA_ITERATIONS`, default `20`
- `BENCH_POINT_ITERATIONS`, default `100`
- `BENCH_RANGE_ITERATIONS`, default `30`
- `BENCH_SCAN_ITERATIONS`, default `5`
- `BENCH_PREPARE=false` to preserve an existing fixture
- `BENCH_SKIP_BUILD=true` to reuse existing benchmark artifacts
Run both candidates against the same server and do not change the fixture or
fetch size between candidates. This is a client-side comparison, not an IoTDB
server throughput benchmark.
+31
View File
@@ -0,0 +1,31 @@
plugins {
id 'java'
id 'com.gradleup.shadow' version '9.0.0-beta12'
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.apache.iotdb:iotdb-jdbc:2.0.8'
implementation 'org.slf4j:slf4j-nop:2.0.16'
}
sourceSets {
main {
java.srcDir 'java'
}
}
tasks.named('shadowJar') {
archiveBaseName = 'dbx-iotdb-jdbc-benchmark'
archiveClassifier = ''
manifest {
attributes('Main-Class': 'com.dbx.agent.iotdb.bench.JdbcDriverBenchmark')
}
}
tasks.register('benchmarkJar') {
dependsOn tasks.named('shadowJar')
}
+7
View File
@@ -0,0 +1,7 @@
module github.com/t8y2/dbx/agents/drivers/iotdb/bench
go 1.23
require github.com/apache/iotdb-client-go/v2 v2.0.8
require github.com/apache/thrift v0.15.0 // indirect
+29
View File
@@ -0,0 +1,29 @@
github.com/apache/iotdb-client-go/v2 v2.0.8 h1:4Z2bmnoeH9gPNz0cDSfqbvwfyY0Ocbo18vtPfjKo34k=
github.com/apache/iotdb-client-go/v2 v2.0.8/go.mod h1:keIG+1Xfw8n2NukUbrEcYnnyN9VxP8OJLYvnHsg1k2s=
github.com/apache/thrift v0.15.0 h1:aGvdaR0v1t9XLgjtBYwxcBvBOTMqClzwE26CHOgjW1Y=
github.com/apache/thrift v0.15.0/go.mod h1:PHK3hniurgQaNMZYaCLEqXKsYK8upmhPbmdP2FXSqgU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+284
View File
@@ -0,0 +1,284 @@
package main
import (
"encoding/json"
"fmt"
"os"
"sort"
"strconv"
"time"
"github.com/apache/iotdb-client-go/v2/client"
)
const (
clientVersion = "2.0.8"
database = "root.dbx_bench"
device = database + ".d1"
)
type config struct {
Mode string
Host string
Port string
Username string
Password string
Rows int
FetchSize int32
ConnectTimeoutMS int
QueryTimeoutMS int64
Warmups int
MetadataIterations int
PointIterations int
RangeIterations int
ScanIterations int
}
type workload struct {
Name string
SQL string
Iterations int
}
type workloadResult struct {
Name string `json:"name"`
Iterations int `json:"iterations"`
Rows int64 `json:"rows"`
DecodedCells int64 `json:"decoded_cells"`
MeanMS float64 `json:"mean_ms"`
P50MS float64 `json:"p50_ms"`
P95MS float64 `json:"p95_ms"`
MinMS float64 `json:"min_ms"`
MaxMS float64 `json:"max_ms"`
}
type benchmarkResult struct {
Driver string `json:"driver"`
ClientVersion string `json:"client_version"`
ConnectMS float64 `json:"connect_ms"`
FetchSize int32 `json:"fetch_size"`
Workloads []workloadResult `json:"workloads"`
}
type queryObservation struct {
Rows int64
DecodedCells int64
}
func main() {
conf := loadConfig()
session := client.NewSession(&client.Config{
Host: conf.Host,
Port: conf.Port,
UserName: conf.Username,
Password: conf.Password,
FetchSize: conf.FetchSize,
TimeZone: client.DefaultTimeZone,
})
connectStarted := time.Now()
if err := session.Open(false, conf.ConnectTimeoutMS); err != nil {
fatal(err)
}
connectMS := elapsedMS(connectStarted)
defer session.Close()
if conf.Mode == "probe" {
observation, err := executeQuery(&session, conf, "SHOW DATABASES")
if err != nil {
fatal(err)
}
writeJSON(map[string]any{
"driver": "go",
"client_version": clientVersion,
"connect_ms": roundMillis(connectMS),
"rows": observation.Rows,
})
return
}
if conf.Mode == "hold" {
observation, err := executeQuery(&session, conf, "SHOW DATABASES")
if err != nil {
fatal(err)
}
writeJSON(map[string]any{
"driver": "go",
"client_version": clientVersion,
"ready": true,
"rows": observation.Rows,
})
time.Sleep(time.Duration(envInt("BENCH_HOLD_MS", 30_000)) * time.Millisecond)
return
}
if conf.Mode != "benchmark" {
fatal(fmt.Errorf("unsupported IOTDB_BENCH_MODE: %s", conf.Mode))
}
workloads := []workload{
{Name: "show_databases", SQL: "SHOW DATABASES", Iterations: conf.MetadataIterations},
{
Name: "point_query",
SQL: fmt.Sprintf("SELECT s1,s2,s3 FROM %s WHERE time = %d", device, max(1, conf.Rows/2)),
Iterations: conf.PointIterations,
},
{Name: "range_100", SQL: "SELECT s1,s2,s3 FROM " + device + " LIMIT 100", Iterations: conf.RangeIterations},
{Name: "scan_all", SQL: "SELECT s1,s2,s3 FROM " + device, Iterations: conf.ScanIterations},
}
results := make([]workloadResult, 0, len(workloads))
for _, item := range workloads {
warmups := conf.Warmups
if item.Name == "scan_all" && warmups > 1 {
warmups = 1
}
for index := 0; index < warmups; index++ {
if _, err := executeQuery(&session, conf, item.SQL); err != nil {
fatal(err)
}
}
result, err := runWorkload(&session, conf, item)
if err != nil {
fatal(err)
}
results = append(results, result)
}
writeJSON(benchmarkResult{
Driver: "go",
ClientVersion: clientVersion,
ConnectMS: roundMillis(connectMS),
FetchSize: conf.FetchSize,
Workloads: results,
})
}
func runWorkload(session *client.Session, conf config, item workload) (workloadResult, error) {
samples := make([]float64, 0, item.Iterations)
var expected queryObservation
for index := 0; index < item.Iterations; index++ {
started := time.Now()
observation, err := executeQuery(session, conf, item.SQL)
if err != nil {
return workloadResult{}, err
}
samples = append(samples, elapsedMS(started))
if index == 0 {
expected = observation
} else if expected != observation {
return workloadResult{}, fmt.Errorf("unstable result shape for %s", item.Name)
}
}
ordered := append([]float64(nil), samples...)
sort.Float64s(ordered)
var total float64
for _, sample := range samples {
total += sample
}
return workloadResult{
Name: item.Name,
Iterations: item.Iterations,
Rows: expected.Rows,
DecodedCells: expected.DecodedCells,
MeanMS: roundMillis(total / float64(len(samples))),
P50MS: roundMillis(percentile(ordered, 0.50)),
P95MS: roundMillis(percentile(ordered, 0.95)),
MinMS: roundMillis(ordered[0]),
MaxMS: roundMillis(ordered[len(ordered)-1]),
}, nil
}
func executeQuery(session *client.Session, conf config, sql string) (queryObservation, error) {
timeout := conf.QueryTimeoutMS
dataset, err := session.ExecuteQueryStatement(sql, &timeout)
if err != nil {
return queryObservation{}, err
}
defer dataset.Close()
columns := len(dataset.GetColumnNames())
var observation queryObservation
for {
hasNext, nextErr := dataset.Next()
if nextErr != nil {
return queryObservation{}, nextErr
}
if !hasNext {
break
}
observation.Rows++
for column := 1; column <= columns; column++ {
if _, valueErr := dataset.GetObjectByIndex(int32(column)); valueErr != nil {
return queryObservation{}, valueErr
}
observation.DecodedCells++
}
}
return observation, nil
}
func loadConfig() config {
return config{
Mode: env("IOTDB_BENCH_MODE", "benchmark"),
Host: env("IOTDB_HOST", "127.0.0.1"),
Port: env("IOTDB_PORT", "6667"),
Username: env("IOTDB_USERNAME", "root"),
Password: env("IOTDB_PASSWORD", "root"),
Rows: envInt("BENCH_ROWS", 10_000),
FetchSize: int32(envInt("BENCH_FETCH_SIZE", 1_024)),
ConnectTimeoutMS: envInt("BENCH_CONNECT_TIMEOUT_MS", 5_000),
QueryTimeoutMS: int64(envInt("BENCH_QUERY_TIMEOUT_MS", 30_000)),
Warmups: envInt("BENCH_WARMUPS", 3),
MetadataIterations: envInt("BENCH_METADATA_ITERATIONS", 20),
PointIterations: envInt("BENCH_POINT_ITERATIONS", 100),
RangeIterations: envInt("BENCH_RANGE_ITERATIONS", 30),
ScanIterations: envInt("BENCH_SCAN_ITERATIONS", 5),
}
}
func percentile(values []float64, fraction float64) float64 {
index := int(float64(len(values))*fraction+0.999999) - 1
if index < 0 {
index = 0
}
if index >= len(values) {
index = len(values) - 1
}
return values[index]
}
func elapsedMS(started time.Time) float64 {
return float64(time.Since(started).Microseconds()) / 1_000
}
func roundMillis(value float64) float64 {
return float64(int64(value*1_000+0.5)) / 1_000
}
func env(name, fallback string) string {
if value := os.Getenv(name); value != "" {
return value
}
return fallback
}
func envInt(name string, fallback int) int {
value, err := strconv.Atoi(env(name, strconv.Itoa(fallback)))
if err != nil || value <= 0 {
fatal(fmt.Errorf("%s must be a positive integer", name))
}
return value
}
func writeJSON(value any) {
encoder := json.NewEncoder(os.Stdout)
encoder.SetEscapeHTML(false)
if err := encoder.Encode(value); err != nil {
fatal(err)
}
}
func fatal(err error) {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
@@ -0,0 +1,287 @@
package com.dbx.agent.iotdb.bench;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.ResultSetMetaData;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
public final class JdbcDriverBenchmark {
private static final String CLIENT_VERSION = "2.0.8";
private static final String DATABASE = "root.dbx_bench";
private static final String DEVICE = DATABASE + ".d1";
private JdbcDriverBenchmark() {
}
public static void main(String[] args) throws Exception {
Class.forName("org.apache.iotdb.jdbc.IoTDBDriver");
Config config = Config.fromEnvironment();
switch (config.mode()) {
case "prepare" -> prepare(config);
case "probe" -> probe(config);
case "hold" -> hold(config);
case "benchmark" -> benchmark(config);
default -> throw new IllegalArgumentException("Unsupported IOTDB_BENCH_MODE: " + config.mode());
}
}
private static void prepare(Config config) throws SQLException {
try (Connection connection = openConnection(config);
Statement statement = connection.createStatement()) {
try {
statement.execute("DROP DATABASE " + DATABASE);
} catch (SQLException ignored) {
}
statement.execute("CREATE DATABASE " + DATABASE);
statement.execute("CREATE TIMESERIES " + DEVICE + ".s1 WITH DATATYPE=INT64, ENCODING=RLE");
statement.execute("CREATE TIMESERIES " + DEVICE + ".s2 WITH DATATYPE=DOUBLE, ENCODING=GORILLA");
statement.execute("CREATE TIMESERIES " + DEVICE + ".s3 WITH DATATYPE=TEXT, ENCODING=PLAIN");
String insertSql = "INSERT INTO " + DEVICE + "(time,s1,s2,s3) VALUES(?,?,?,?)";
try (PreparedStatement prepared = connection.prepareStatement(insertSql)) {
for (int row = 1; row <= config.rows(); row++) {
prepared.setLong(1, row);
prepared.setLong(2, row * 10L);
prepared.setDouble(3, row / 10.0);
prepared.setString(4, "row-" + row);
prepared.addBatch();
if (row % config.insertBatchSize() == 0) {
prepared.executeBatch();
}
}
if (config.rows() % config.insertBatchSize() != 0) {
prepared.executeBatch();
}
}
}
System.out.printf(Locale.ROOT, "{\"prepared_rows\":%d,\"database\":\"%s\"}%n", config.rows(), DATABASE);
}
private static void probe(Config config) throws SQLException {
long started = System.nanoTime();
try (Connection connection = openConnection(config)) {
double connectMs = elapsedMillis(started);
QueryObservation observation = executeQuery(connection, config.fetchSize(), "SHOW DATABASES");
System.out.printf(
Locale.ROOT,
"{\"driver\":\"jdbc\",\"client_version\":\"%s\",\"connect_ms\":%.3f,\"rows\":%d}%n",
CLIENT_VERSION,
connectMs,
observation.rows()
);
}
}
private static void hold(Config config) throws Exception {
try (Connection connection = openConnection(config)) {
QueryObservation observation = executeQuery(connection, config.fetchSize(), "SHOW DATABASES");
System.out.printf(
Locale.ROOT,
"{\"driver\":\"jdbc\",\"client_version\":\"%s\",\"ready\":true,\"rows\":%d}%n",
CLIENT_VERSION,
observation.rows()
);
System.out.flush();
Thread.sleep(envInt("BENCH_HOLD_MS", 30_000));
}
}
private static void benchmark(Config config) throws SQLException {
long connectStarted = System.nanoTime();
try (Connection connection = openConnection(config)) {
double connectMs = elapsedMillis(connectStarted);
List<Workload> workloads = List.of(
new Workload("show_databases", "SHOW DATABASES", config.metadataIterations()),
new Workload(
"point_query",
"SELECT s1,s2,s3 FROM " + DEVICE + " WHERE time = " + Math.max(1, config.rows() / 2),
config.pointIterations()
),
new Workload("range_100", "SELECT s1,s2,s3 FROM " + DEVICE + " LIMIT 100", config.rangeIterations()),
new Workload("scan_all", "SELECT s1,s2,s3 FROM " + DEVICE, config.scanIterations())
);
List<WorkloadResult> results = new ArrayList<>();
for (Workload workload : workloads) {
int warmups = workload.name().equals("scan_all") ? Math.min(1, config.warmups()) : config.warmups();
for (int index = 0; index < warmups; index++) {
executeQuery(connection, config.fetchSize(), workload.sql());
}
results.add(runWorkload(connection, config.fetchSize(), workload));
}
System.out.println(resultJson(config, connectMs, results));
}
}
private static WorkloadResult runWorkload(Connection connection, int fetchSize, Workload workload) throws SQLException {
List<Double> samples = new ArrayList<>(workload.iterations());
long expectedRows = -1;
long expectedCells = -1;
for (int index = 0; index < workload.iterations(); index++) {
long started = System.nanoTime();
QueryObservation observation = executeQuery(connection, fetchSize, workload.sql());
samples.add(elapsedMillis(started));
if (expectedRows < 0) {
expectedRows = observation.rows();
expectedCells = observation.decodedCells();
} else if (expectedRows != observation.rows() || expectedCells != observation.decodedCells()) {
throw new IllegalStateException("Unstable result shape for " + workload.name());
}
}
return WorkloadResult.from(workload.name(), samples, expectedRows, expectedCells);
}
private static QueryObservation executeQuery(Connection connection, int fetchSize, String sql) throws SQLException {
long rows = 0;
long decodedCells = 0;
try (Statement statement = connection.createStatement()) {
statement.setFetchSize(fetchSize);
try (ResultSet resultSet = statement.executeQuery(sql)) {
ResultSetMetaData metadata = resultSet.getMetaData();
int columns = metadata.getColumnCount();
while (resultSet.next()) {
rows++;
for (int column = 1; column <= columns; column++) {
resultSet.getObject(column);
decodedCells++;
}
}
}
}
return new QueryObservation(rows, decodedCells);
}
private static Connection openConnection(Config config) throws SQLException {
String url = "jdbc:iotdb://" + config.host() + ":" + config.port() + "/";
return DriverManager.getConnection(url, config.username(), config.password());
}
private static String resultJson(Config config, double connectMs, List<WorkloadResult> results) {
StringBuilder json = new StringBuilder(512);
json.append('{')
.append("\"driver\":\"jdbc\",")
.append("\"client_version\":\"").append(CLIENT_VERSION).append("\",")
.append("\"connect_ms\":").append(decimal(connectMs)).append(',')
.append("\"fetch_size\":").append(config.fetchSize()).append(',')
.append("\"workloads\":[");
for (int index = 0; index < results.size(); index++) {
if (index > 0) {
json.append(',');
}
json.append(results.get(index).toJson());
}
return json.append("]}").toString();
}
private static double elapsedMillis(long started) {
return (System.nanoTime() - started) / 1_000_000.0;
}
private static String decimal(double value) {
return String.format(Locale.ROOT, "%.3f", value);
}
private static String env(String name, String fallback) {
String value = System.getenv(name);
return value == null || value.isBlank() ? fallback : value;
}
private static int envInt(String name, int fallback) {
return Integer.parseInt(env(name, Integer.toString(fallback)));
}
private record Config(
String mode,
String host,
int port,
String username,
String password,
int rows,
int fetchSize,
int insertBatchSize,
int warmups,
int metadataIterations,
int pointIterations,
int rangeIterations,
int scanIterations
) {
private static Config fromEnvironment() {
return new Config(
env("IOTDB_BENCH_MODE", "benchmark"),
env("IOTDB_HOST", "127.0.0.1"),
envInt("IOTDB_PORT", 6667),
env("IOTDB_USERNAME", "root"),
env("IOTDB_PASSWORD", "root"),
envInt("BENCH_ROWS", 10_000),
envInt("BENCH_FETCH_SIZE", 1_024),
envInt("BENCH_INSERT_BATCH_SIZE", 500),
envInt("BENCH_WARMUPS", 3),
envInt("BENCH_METADATA_ITERATIONS", 20),
envInt("BENCH_POINT_ITERATIONS", 100),
envInt("BENCH_RANGE_ITERATIONS", 30),
envInt("BENCH_SCAN_ITERATIONS", 5)
);
}
}
private record Workload(String name, String sql, int iterations) {
}
private record QueryObservation(long rows, long decodedCells) {
}
private record WorkloadResult(
String name,
int iterations,
long rows,
long decodedCells,
double meanMs,
double p50Ms,
double p95Ms,
double minMs,
double maxMs
) {
private static WorkloadResult from(String name, List<Double> samples, long rows, long decodedCells) {
List<Double> ordered = new ArrayList<>(samples);
Collections.sort(ordered);
double total = samples.stream().mapToDouble(Double::doubleValue).sum();
return new WorkloadResult(
name,
samples.size(),
rows,
decodedCells,
total / samples.size(),
percentile(ordered, 0.50),
percentile(ordered, 0.95),
ordered.get(0),
ordered.get(ordered.size() - 1)
);
}
private static double percentile(List<Double> values, double fraction) {
int index = Math.max(0, (int) Math.ceil(values.size() * fraction) - 1);
return values.get(Math.min(values.size() - 1, index));
}
private String toJson() {
return "{\"name\":\"" + name + "\""
+ ",\"iterations\":" + iterations
+ ",\"rows\":" + rows
+ ",\"decoded_cells\":" + decodedCells
+ ",\"mean_ms\":" + decimal(meanMs)
+ ",\"p50_ms\":" + decimal(p50Ms)
+ ",\"p95_ms\":" + decimal(p95Ms)
+ ",\"min_ms\":" + decimal(minMs)
+ ",\"max_ms\":" + decimal(maxMs)
+ '}';
}
}
}
@@ -0,0 +1,32 @@
# IoTDB 2.0.8 local driver comparison
Date: 2026-08-10
Environment:
- Apple M2 Pro, macOS arm64
- Java 21.0.7
- Go 1.26.5
- Apache IoTDB 2.0.8 standalone
- JDBC and Go clients both pinned to 2.0.8
- 10,000 Tree-model rows, fetch size 1,024
- 15 alternating cold starts, 5 connected-RSS samples, 5 alternating workload rounds
| Metric | JDBC | Go | Go reduction |
| --- | ---: | ---: | ---: |
| Cold process + connection p50 | 172.612 ms | 9.315 ms | 94.6% |
| Connected RSS p50 | 62,368 KB | 7,008 KB | 88.8% |
| Artifact size | 25,864,492 B | 6,475,202 B | 75.0% |
| In-process connection mean | 66.061 ms | 1.932 ms | 97.1% |
| `SHOW DATABASES` mean | 0.775 ms | 0.428 ms | 44.8% |
| Point query mean | 0.813 ms | 0.595 ms | 26.8% |
| 100-row query mean | 1.437 ms | 1.010 ms | 29.7% |
| 10,000-row full decode mean | 5.455 ms | 4.941 ms | 9.4% |
The result supports a Go Agent primarily for startup, connection, memory, and
distribution-size improvements. Warm query differences are much smaller and
shrink as server work and result decoding increase. This benchmark compares
drivers directly; it does not include the DBX JSON-RPC Agent layer or prove
feature and version compatibility parity.
See `iotdb-2.0.8-macos-arm64.json` for all raw rounds.
@@ -0,0 +1,839 @@
{
"server": {
"host": "127.0.0.1",
"port": 6667
},
"config": {
"rows": 10000,
"fetch_size": 1024,
"warmups": 3
},
"artifact_bytes": {
"jdbc": 25864492,
"go": 6475202
},
"startup_ms": {
"jdbc": {
"count": 15,
"mean": 181.087,
"p50": 172.612,
"p95": 268.332,
"min": 160.077,
"max": 268.332
},
"go": {
"count": 15,
"mean": 35.459,
"p50": 9.315,
"p95": 406.199,
"min": 6.986,
"max": 406.199
}
},
"connected_rss_kb": {
"jdbc": {
"count": 5,
"mean": 62323.2,
"p50": 62368.0,
"p95": 62528.0,
"min": 62112.0,
"max": 62528.0
},
"go": {
"count": 5,
"mean": 7062.4,
"p50": 7008.0,
"p95": 7248.0,
"min": 6992.0,
"max": 7248.0
}
},
"results": {
"jdbc": {
"driver": "jdbc",
"client_version": "2.0.8",
"connect_ms": {
"count": 5,
"mean": 66.061,
"p50": 66.0,
"p95": 69.002,
"min": 63.535,
"max": 69.002
},
"workloads": {
"show_databases": {
"rows": 2,
"decoded_cells": 10,
"round_mean_ms": {
"count": 5,
"mean": 0.775,
"p50": 0.771,
"p95": 0.816,
"min": 0.733,
"max": 0.816
},
"round_p50_ms": {
"count": 5,
"mean": 0.78,
"p50": 0.773,
"p95": 0.833,
"min": 0.726,
"max": 0.833
},
"round_p95_ms": {
"count": 5,
"mean": 0.89,
"p50": 0.885,
"p95": 0.979,
"min": 0.796,
"max": 0.979
}
},
"point_query": {
"rows": 1,
"decoded_cells": 4,
"round_mean_ms": {
"count": 5,
"mean": 0.813,
"p50": 0.846,
"p95": 0.885,
"min": 0.722,
"max": 0.885
},
"round_p50_ms": {
"count": 5,
"mean": 0.77,
"p50": 0.784,
"p95": 0.847,
"min": 0.688,
"max": 0.847
},
"round_p95_ms": {
"count": 5,
"mean": 1.113,
"p50": 1.175,
"p95": 1.254,
"min": 0.94,
"max": 1.254
}
},
"range_100": {
"rows": 100,
"decoded_cells": 400,
"round_mean_ms": {
"count": 5,
"mean": 1.437,
"p50": 1.495,
"p95": 1.72,
"min": 1.205,
"max": 1.72
},
"round_p50_ms": {
"count": 5,
"mean": 1.35,
"p50": 1.365,
"p95": 1.644,
"min": 1.143,
"max": 1.644
},
"round_p95_ms": {
"count": 5,
"mean": 2.193,
"p50": 2.119,
"p95": 2.825,
"min": 1.639,
"max": 2.825
}
},
"scan_all": {
"rows": 10000,
"decoded_cells": 40000,
"round_mean_ms": {
"count": 5,
"mean": 5.455,
"p50": 5.476,
"p95": 5.715,
"min": 5.102,
"max": 5.715
},
"round_p50_ms": {
"count": 5,
"mean": 5.146,
"p50": 5.254,
"p95": 5.351,
"min": 4.701,
"max": 5.351
},
"round_p95_ms": {
"count": 5,
"mean": 6.962,
"p50": 7.04,
"p95": 7.809,
"min": 5.891,
"max": 7.809
}
}
},
"rounds": [
{
"driver": "jdbc",
"client_version": "2.0.8",
"connect_ms": 69.002,
"fetch_size": 1024,
"workloads": [
{
"name": "show_databases",
"iterations": 20,
"rows": 2,
"decoded_cells": 10,
"mean_ms": 0.755,
"p50_ms": 0.753,
"p95_ms": 0.885,
"min_ms": 0.6,
"max_ms": 0.988
},
{
"name": "point_query",
"iterations": 100,
"rows": 1,
"decoded_cells": 4,
"mean_ms": 0.885,
"p50_ms": 0.847,
"p95_ms": 1.254,
"min_ms": 0.54,
"max_ms": 1.625
},
{
"name": "range_100",
"iterations": 30,
"rows": 100,
"decoded_cells": 400,
"mean_ms": 1.72,
"p50_ms": 1.644,
"p95_ms": 2.641,
"min_ms": 1.173,
"max_ms": 2.729
},
{
"name": "scan_all",
"iterations": 5,
"rows": 10000,
"decoded_cells": 40000,
"mean_ms": 5.295,
"p50_ms": 5.254,
"p95_ms": 5.891,
"min_ms": 4.579,
"max_ms": 5.891
}
]
},
{
"driver": "jdbc",
"client_version": "2.0.8",
"connect_ms": 67.177,
"fetch_size": 1024,
"workloads": [
{
"name": "show_databases",
"iterations": 20,
"rows": 2,
"decoded_cells": 10,
"mean_ms": 0.816,
"p50_ms": 0.833,
"p95_ms": 0.979,
"min_ms": 0.62,
"max_ms": 1.151
},
{
"name": "point_query",
"iterations": 100,
"rows": 1,
"decoded_cells": 4,
"mean_ms": 0.865,
"p50_ms": 0.806,
"p95_ms": 1.223,
"min_ms": 0.573,
"max_ms": 2.011
},
{
"name": "range_100",
"iterations": 30,
"rows": 100,
"decoded_cells": 400,
"mean_ms": 1.495,
"p50_ms": 1.43,
"p95_ms": 2.825,
"min_ms": 0.972,
"max_ms": 3.059
},
{
"name": "scan_all",
"iterations": 5,
"rows": 10000,
"decoded_cells": 40000,
"mean_ms": 5.689,
"p50_ms": 5.132,
"p95_ms": 6.746,
"min_ms": 5.001,
"max_ms": 6.746
}
]
},
{
"driver": "jdbc",
"client_version": "2.0.8",
"connect_ms": 66.0,
"fetch_size": 1024,
"workloads": [
{
"name": "show_databases",
"iterations": 20,
"rows": 2,
"decoded_cells": 10,
"mean_ms": 0.771,
"p50_ms": 0.773,
"p95_ms": 0.862,
"min_ms": 0.6,
"max_ms": 0.982
},
{
"name": "point_query",
"iterations": 100,
"rows": 1,
"decoded_cells": 4,
"mean_ms": 0.846,
"p50_ms": 0.784,
"p95_ms": 1.175,
"min_ms": 0.589,
"max_ms": 2.059
},
{
"name": "range_100",
"iterations": 30,
"rows": 100,
"decoded_cells": 400,
"mean_ms": 1.265,
"p50_ms": 1.166,
"p95_ms": 1.739,
"min_ms": 0.974,
"max_ms": 2.395
},
{
"name": "scan_all",
"iterations": 5,
"rows": 10000,
"decoded_cells": 40000,
"mean_ms": 5.715,
"p50_ms": 5.351,
"p95_ms": 7.809,
"min_ms": 4.569,
"max_ms": 7.809
}
]
},
{
"driver": "jdbc",
"client_version": "2.0.8",
"connect_ms": 63.535,
"fetch_size": 1024,
"workloads": [
{
"name": "show_databases",
"iterations": 20,
"rows": 2,
"decoded_cells": 10,
"mean_ms": 0.798,
"p50_ms": 0.813,
"p95_ms": 0.928,
"min_ms": 0.618,
"max_ms": 0.963
},
{
"name": "point_query",
"iterations": 100,
"rows": 1,
"decoded_cells": 4,
"mean_ms": 0.722,
"p50_ms": 0.688,
"p95_ms": 0.94,
"min_ms": 0.567,
"max_ms": 1.719
},
{
"name": "range_100",
"iterations": 30,
"rows": 100,
"decoded_cells": 400,
"mean_ms": 1.205,
"p50_ms": 1.143,
"p95_ms": 1.639,
"min_ms": 0.94,
"max_ms": 2.42
},
{
"name": "scan_all",
"iterations": 5,
"rows": 10000,
"decoded_cells": 40000,
"mean_ms": 5.102,
"p50_ms": 4.701,
"p95_ms": 7.04,
"min_ms": 4.094,
"max_ms": 7.04
}
]
},
{
"driver": "jdbc",
"client_version": "2.0.8",
"connect_ms": 64.589,
"fetch_size": 1024,
"workloads": [
{
"name": "show_databases",
"iterations": 20,
"rows": 2,
"decoded_cells": 10,
"mean_ms": 0.733,
"p50_ms": 0.726,
"p95_ms": 0.796,
"min_ms": 0.641,
"max_ms": 0.914
},
{
"name": "point_query",
"iterations": 100,
"rows": 1,
"decoded_cells": 4,
"mean_ms": 0.749,
"p50_ms": 0.723,
"p95_ms": 0.973,
"min_ms": 0.549,
"max_ms": 1.207
},
{
"name": "range_100",
"iterations": 30,
"rows": 100,
"decoded_cells": 400,
"mean_ms": 1.499,
"p50_ms": 1.365,
"p95_ms": 2.119,
"min_ms": 1.009,
"max_ms": 2.608
},
{
"name": "scan_all",
"iterations": 5,
"rows": 10000,
"decoded_cells": 40000,
"mean_ms": 5.476,
"p50_ms": 5.292,
"p95_ms": 7.324,
"min_ms": 4.345,
"max_ms": 7.324
}
]
}
]
},
"go": {
"driver": "go",
"client_version": "2.0.8",
"connect_ms": {
"count": 5,
"mean": 1.932,
"p50": 1.9,
"p95": 2.455,
"min": 1.575,
"max": 2.455
},
"workloads": {
"show_databases": {
"rows": 2,
"decoded_cells": 10,
"round_mean_ms": {
"count": 5,
"mean": 0.428,
"p50": 0.417,
"p95": 0.478,
"min": 0.388,
"max": 0.478
},
"round_p50_ms": {
"count": 5,
"mean": 0.408,
"p50": 0.398,
"p95": 0.437,
"min": 0.386,
"max": 0.437
},
"round_p95_ms": {
"count": 5,
"mean": 0.543,
"p50": 0.552,
"p95": 0.619,
"min": 0.43,
"max": 0.619
}
},
"point_query": {
"rows": 1,
"decoded_cells": 4,
"round_mean_ms": {
"count": 5,
"mean": 0.595,
"p50": 0.565,
"p95": 0.723,
"min": 0.51,
"max": 0.723
},
"round_p50_ms": {
"count": 5,
"mean": 0.567,
"p50": 0.538,
"p95": 0.702,
"min": 0.488,
"max": 0.702
},
"round_p95_ms": {
"count": 5,
"mean": 0.785,
"p50": 0.788,
"p95": 0.929,
"min": 0.639,
"max": 0.929
}
},
"range_100": {
"rows": 100,
"decoded_cells": 400,
"round_mean_ms": {
"count": 5,
"mean": 1.01,
"p50": 1.009,
"p95": 1.061,
"min": 0.94,
"max": 1.061
},
"round_p50_ms": {
"count": 5,
"mean": 0.977,
"p50": 0.995,
"p95": 1.023,
"min": 0.913,
"max": 1.023
},
"round_p95_ms": {
"count": 5,
"mean": 1.319,
"p50": 1.304,
"p95": 1.749,
"min": 1.094,
"max": 1.749
}
},
"scan_all": {
"rows": 10000,
"decoded_cells": 40000,
"round_mean_ms": {
"count": 5,
"mean": 4.941,
"p50": 4.804,
"p95": 5.474,
"min": 4.624,
"max": 5.474
},
"round_p50_ms": {
"count": 5,
"mean": 4.858,
"p50": 4.838,
"p95": 5.15,
"min": 4.567,
"max": 5.15
},
"round_p95_ms": {
"count": 5,
"mean": 5.466,
"p50": 5.076,
"p95": 7.106,
"min": 4.858,
"max": 7.106
}
}
},
"rounds": [
{
"driver": "go",
"client_version": "2.0.8",
"connect_ms": 2.455,
"fetch_size": 1024,
"workloads": [
{
"name": "show_databases",
"iterations": 20,
"rows": 2,
"decoded_cells": 10,
"mean_ms": 0.478,
"p50_ms": 0.434,
"p95_ms": 0.619,
"min_ms": 0.382,
"max_ms": 0.638
},
{
"name": "point_query",
"iterations": 100,
"rows": 1,
"decoded_cells": 4,
"mean_ms": 0.723,
"p50_ms": 0.702,
"p95_ms": 0.929,
"min_ms": 0.498,
"max_ms": 1.183
},
{
"name": "range_100",
"iterations": 30,
"rows": 100,
"decoded_cells": 400,
"mean_ms": 1.061,
"p50_ms": 1.021,
"p95_ms": 1.323,
"min_ms": 0.901,
"max_ms": 1.38
},
{
"name": "scan_all",
"iterations": 5,
"rows": 10000,
"decoded_cells": 40000,
"mean_ms": 5.474,
"p50_ms": 5.129,
"p95_ms": 7.106,
"min_ms": 4.77,
"max_ms": 7.106
}
]
},
{
"driver": "go",
"client_version": "2.0.8",
"connect_ms": 1.575,
"fetch_size": 1024,
"workloads": [
{
"name": "show_databases",
"iterations": 20,
"rows": 2,
"decoded_cells": 10,
"mean_ms": 0.449,
"p50_ms": 0.437,
"p95_ms": 0.546,
"min_ms": 0.364,
"max_ms": 0.561
},
{
"name": "point_query",
"iterations": 100,
"rows": 1,
"decoded_cells": 4,
"mean_ms": 0.651,
"p50_ms": 0.616,
"p95_ms": 0.884,
"min_ms": 0.481,
"max_ms": 1.035
},
{
"name": "range_100",
"iterations": 30,
"rows": 100,
"decoded_cells": 400,
"mean_ms": 1.046,
"p50_ms": 1.023,
"p95_ms": 1.304,
"min_ms": 0.882,
"max_ms": 1.401
},
{
"name": "scan_all",
"iterations": 5,
"rows": 10000,
"decoded_cells": 40000,
"mean_ms": 5.157,
"p50_ms": 5.15,
"p95_ms": 5.39,
"min_ms": 4.891,
"max_ms": 5.39
}
]
},
{
"driver": "go",
"client_version": "2.0.8",
"connect_ms": 1.9,
"fetch_size": 1024,
"workloads": [
{
"name": "show_databases",
"iterations": 20,
"rows": 2,
"decoded_cells": 10,
"mean_ms": 0.417,
"p50_ms": 0.398,
"p95_ms": 0.569,
"min_ms": 0.343,
"max_ms": 0.585
},
{
"name": "point_query",
"iterations": 100,
"rows": 1,
"decoded_cells": 4,
"mean_ms": 0.565,
"p50_ms": 0.538,
"p95_ms": 0.788,
"min_ms": 0.404,
"max_ms": 0.86
},
{
"name": "range_100",
"iterations": 30,
"rows": 100,
"decoded_cells": 400,
"mean_ms": 0.94,
"p50_ms": 0.932,
"p95_ms": 1.094,
"min_ms": 0.822,
"max_ms": 1.166
},
{
"name": "scan_all",
"iterations": 5,
"rows": 10000,
"decoded_cells": 40000,
"mean_ms": 4.647,
"p50_ms": 4.606,
"p95_ms": 4.858,
"min_ms": 4.48,
"max_ms": 4.858
}
]
},
{
"driver": "go",
"client_version": "2.0.8",
"connect_ms": 1.707,
"fetch_size": 1024,
"workloads": [
{
"name": "show_databases",
"iterations": 20,
"rows": 2,
"decoded_cells": 10,
"mean_ms": 0.388,
"p50_ms": 0.387,
"p95_ms": 0.43,
"min_ms": 0.328,
"max_ms": 0.433
},
{
"name": "point_query",
"iterations": 100,
"rows": 1,
"decoded_cells": 4,
"mean_ms": 0.525,
"p50_ms": 0.493,
"p95_ms": 0.685,
"min_ms": 0.423,
"max_ms": 1.744
},
{
"name": "range_100",
"iterations": 30,
"rows": 100,
"decoded_cells": 400,
"mean_ms": 1.009,
"p50_ms": 0.913,
"p95_ms": 1.749,
"min_ms": 0.829,
"max_ms": 2.153
},
{
"name": "scan_all",
"iterations": 5,
"rows": 10000,
"decoded_cells": 40000,
"mean_ms": 4.624,
"p50_ms": 4.567,
"p95_ms": 4.901,
"min_ms": 4.413,
"max_ms": 4.901
}
]
},
{
"driver": "go",
"client_version": "2.0.8",
"connect_ms": 2.024,
"fetch_size": 1024,
"workloads": [
{
"name": "show_databases",
"iterations": 20,
"rows": 2,
"decoded_cells": 10,
"mean_ms": 0.409,
"p50_ms": 0.386,
"p95_ms": 0.552,
"min_ms": 0.321,
"max_ms": 0.554
},
{
"name": "point_query",
"iterations": 100,
"rows": 1,
"decoded_cells": 4,
"mean_ms": 0.51,
"p50_ms": 0.488,
"p95_ms": 0.639,
"min_ms": 0.355,
"max_ms": 1.366
},
{
"name": "range_100",
"iterations": 30,
"rows": 100,
"decoded_cells": 400,
"mean_ms": 0.994,
"p50_ms": 0.995,
"p95_ms": 1.125,
"min_ms": 0.891,
"max_ms": 1.197
},
{
"name": "scan_all",
"iterations": 5,
"rows": 10000,
"decoded_cells": 40000,
"mean_ms": 4.804,
"p50_ms": 4.838,
"p95_ms": 5.076,
"min_ms": 4.501,
"max_ms": 5.076
}
]
}
]
}
},
"environment": {
"date": "2026-08-10",
"machine": "Apple M2 Pro",
"os": "macOS Darwin 24.5.0 arm64",
"java": "21.0.7",
"go": "1.26.5",
"iotdb_server": "2.0.8 standalone",
"jdbc_client": "2.0.8",
"go_client": "2.0.8"
}
}
+253
View File
@@ -0,0 +1,253 @@
#!/usr/bin/env python3
import json
import os
import socket
import statistics
import subprocess
import sys
import threading
import time
from pathlib import Path
BENCH_DIR = Path(__file__).resolve().parent
IOTDB_PROJECT = BENCH_DIR.parent
AGENTS_DIR = IOTDB_PROJECT.parents[1]
JDBC_JAR = BENCH_DIR / "build" / "libs" / "dbx-iotdb-jdbc-benchmark.jar"
GO_BINARY = BENCH_DIR / "build" / "iotdb-go-benchmark"
def main() -> None:
environment = os.environ.copy()
wait_for_server(environment)
if not env_bool("BENCH_SKIP_BUILD", False):
build_candidates()
ensure_artifacts()
if env_bool("BENCH_PREPARE", True):
run_json(["java", "-jar", str(JDBC_JAR)], environment | {"IOTDB_BENCH_MODE": "prepare"})
commands = {
"jdbc": ["java", "-jar", str(JDBC_JAR)],
"go": [str(GO_BINARY)],
}
names = benchmark_order(commands)
startup_samples = {name: [] for name in commands}
startup_iterations = env_int("BENCH_STARTUPS", 5)
for iteration in range(startup_iterations):
order = names if iteration % 2 == 0 else list(reversed(names))
for name in order:
started = time.perf_counter()
run_json(commands[name], environment | {"IOTDB_BENCH_MODE": "probe"})
startup_samples[name].append((time.perf_counter() - started) * 1_000)
rss_samples = {name: [] for name in commands}
for iteration in range(env_int("BENCH_RSS_SAMPLES", 3)):
order = names if iteration % 2 == 0 else list(reversed(names))
for name in order:
rss_samples[name].append(measure_rss(commands[name], environment))
rounds = {name: [] for name in commands}
for iteration in range(env_int("BENCH_ROUNDS", 3)):
order = names if iteration % 2 == 0 else list(reversed(names))
for name in order:
rounds[name].append(run_json(commands[name], environment | {"IOTDB_BENCH_MODE": "benchmark"}))
output = {
"server": {
"host": environment.get("IOTDB_HOST", "127.0.0.1"),
"port": int(environment.get("IOTDB_PORT", "6667")),
},
"config": {
"rows": env_int("BENCH_ROWS", 10_000),
"fetch_size": env_int("BENCH_FETCH_SIZE", 1_024),
"warmups": env_int("BENCH_WARMUPS", 3),
},
"artifact_bytes": {
"jdbc": JDBC_JAR.stat().st_size,
"go": GO_BINARY.stat().st_size,
},
"startup_ms": {name: summarize(samples) for name, samples in startup_samples.items()},
"connected_rss_kb": {name: summarize(samples) for name, samples in rss_samples.items()},
"results": {name: summarize_rounds(values) for name, values in rounds.items()},
}
print(json.dumps(output, ensure_ascii=False, indent=2))
def build_candidates() -> None:
subprocess.run(
[str(AGENTS_DIR / "gradlew"), "-p", str(BENCH_DIR), "benchmarkJar", "--console=plain"],
check=True,
stdout=sys.stderr,
stderr=sys.stderr,
)
GO_BINARY.parent.mkdir(parents=True, exist_ok=True)
subprocess.run(
["go", "build", "-o", str(GO_BINARY), "."],
cwd=BENCH_DIR / "go",
check=True,
stdout=sys.stderr,
stderr=sys.stderr,
)
def wait_for_server(environment: dict[str, str]) -> None:
host = environment.get("IOTDB_HOST", "127.0.0.1")
port = int(environment.get("IOTDB_PORT", "6667"))
deadline = time.monotonic() + env_float("BENCH_SERVER_TIMEOUT", 60.0)
while time.monotonic() < deadline:
try:
with socket.create_connection((host, port), timeout=1):
return
except OSError:
time.sleep(0.5)
raise TimeoutError(f"IoTDB is not reachable at {host}:{port}")
def run_json(command: list[str], environment: dict[str, str]) -> dict:
completed = subprocess.run(
command,
env=environment,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
timeout=env_float("BENCH_COMMAND_TIMEOUT", 180.0),
)
if completed.returncode != 0:
raise RuntimeError(
f"command failed ({' '.join(command)}):\nstdout:\n{completed.stdout}\nstderr:\n{completed.stderr}"
)
for line in reversed(completed.stdout.splitlines()):
try:
value = json.loads(line)
except json.JSONDecodeError:
continue
if isinstance(value, dict):
return value
raise RuntimeError(f"command returned no JSON ({' '.join(command)}):\n{completed.stdout}")
def measure_rss(command: list[str], environment: dict[str, str]) -> float:
process = subprocess.Popen(
command,
env=environment | {"IOTDB_BENCH_MODE": "hold"},
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
stderr_lines: list[str] = []
def drain_stderr() -> None:
assert process.stderr is not None
stderr_lines.extend(line.rstrip() for line in process.stderr)
threading.Thread(target=drain_stderr, daemon=True).start()
try:
assert process.stdout is not None
deadline = time.monotonic() + env_float("BENCH_READY_TIMEOUT", 30.0)
while time.monotonic() < deadline:
line = process.stdout.readline()
if line == "" and process.poll() is not None:
raise RuntimeError(f"RSS probe exited early: {'; '.join(stderr_lines)}")
try:
payload = json.loads(line)
except json.JSONDecodeError:
continue
if payload.get("ready") is True:
break
else:
raise TimeoutError(f"RSS probe timed out: {'; '.join(stderr_lines)}")
time.sleep(0.2)
completed = subprocess.run(
["ps", "-o", "rss=", "-p", str(process.pid)],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=True,
)
return float(completed.stdout.strip())
finally:
process.terminate()
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=5)
def summarize(samples: list[float]) -> dict:
ordered = sorted(samples)
return {
"count": len(samples),
"mean": round(statistics.mean(samples), 3),
"p50": round(percentile(ordered, 0.50), 3),
"p95": round(percentile(ordered, 0.95), 3),
"min": round(ordered[0], 3),
"max": round(ordered[-1], 3),
}
def summarize_rounds(rounds: list[dict]) -> dict:
workloads: dict[str, list[dict]] = {}
for result in rounds:
for workload in result["workloads"]:
workloads.setdefault(workload["name"], []).append(workload)
return {
"driver": rounds[0]["driver"],
"client_version": rounds[0]["client_version"],
"connect_ms": summarize([result["connect_ms"] for result in rounds]),
"workloads": {
name: {
"rows": values[0]["rows"],
"decoded_cells": values[0]["decoded_cells"],
"round_mean_ms": summarize([value["mean_ms"] for value in values]),
"round_p50_ms": summarize([value["p50_ms"] for value in values]),
"round_p95_ms": summarize([value["p95_ms"] for value in values]),
}
for name, values in workloads.items()
},
"rounds": rounds,
}
def percentile(values: list[float], fraction: float) -> float:
index = max(0, min(len(values) - 1, int(len(values) * fraction + 0.999999) - 1))
return values[index]
def ensure_artifacts() -> None:
for path in (JDBC_JAR, GO_BINARY):
if not path.is_file():
raise FileNotFoundError(path)
def benchmark_order(commands: dict[str, list[str]]) -> list[str]:
names = [name.strip() for name in os.getenv("BENCH_ORDER", "jdbc,go").split(",") if name.strip()]
if len(names) != len(commands) or set(names) != set(commands):
raise ValueError(f"BENCH_ORDER must contain exactly: {','.join(commands)}")
return names
def env_int(name: str, fallback: int) -> int:
value = int(os.getenv(name, str(fallback)))
if value <= 0:
raise ValueError(f"{name} must be positive")
return value
def env_float(name: str, fallback: float) -> float:
value = float(os.getenv(name, str(fallback)))
if value <= 0:
raise ValueError(f"{name} must be positive")
return value
def env_bool(name: str, fallback: bool) -> bool:
raw = os.getenv(name)
if raw is None or raw == "":
return fallback
return raw.strip().lower() in {"1", "true", "yes", "on"}
if __name__ == "__main__":
main()
@@ -0,0 +1 @@
rootProject.name = 'dbx-iotdb-driver-benchmark'
-10
View File
@@ -1,10 +0,0 @@
dependencies {
implementation 'org.apache.iotdb:iotdb-jdbc:2.0.8'
implementation 'org.slf4j:slf4j-nop:2.0.16'
}
tasks.named('jar') {
manifest {
attributes('Agent-Label': 'Apache IoTDB', 'Main-Class': 'com.dbx.agent.iotdb.IoTDBAgent')
}
}
+333
View File
@@ -0,0 +1,333 @@
package main
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net"
"net/url"
"strconv"
"strings"
"time"
"github.com/apache/iotdb-client-go/v2/client"
)
const iotdbGoClientVersion = "2.0.9-0.20260807074554-e59fc7f55df1"
type iotdbSession interface {
ExecuteQueryStatement(context.Context, string, *int64) (*client.SessionDataSet, error)
ExecuteNonQueryStatement(context.Context, string) error
Close() error
}
type nativeIoTDBSession struct {
session *client.Session
}
func (s *nativeIoTDBSession) ExecuteQueryStatement(ctx context.Context, sql string, _ *int64) (*client.SessionDataSet, error) {
return executeIoTDBStatement(ctx, s.session, sql)
}
func (s *nativeIoTDBSession) ExecuteNonQueryStatement(ctx context.Context, sql string) error {
dataset, err := executeIoTDBStatement(ctx, s.session, sql)
if err != nil {
return err
}
if dataset != nil {
return dataset.Close()
}
return nil
}
func (s *nativeIoTDBSession) Close() error {
return s.session.Close()
}
func executeIoTDBStatement(ctx context.Context, session *client.Session, sql string) (dataset *client.SessionDataSet, err error) {
defer func() {
if recovered := recover(); recovered != nil {
if ctxErr := ctx.Err(); ctxErr != nil {
dataset = nil
err = ctxErr
return
}
dataset = nil
err = fmt.Errorf("IoTDB Go client panicked while executing statement: %v", recovered)
}
}()
return session.ExecuteStatementWithContext(ctx, sql)
}
type connectionConfig struct {
Host string
Port int
NodeURLs []string
Username string
Password string
Database string
Dialect string
FetchSize int32
TimeZone string
ConnectRetryMax int
ConnectTimeoutMS int
EnableCompression bool
TLSConfig *client.TLSConfig
TLSInsecureSkipVerify bool
}
type sessionClient struct {
session iotdbSession
dialect string
}
func parseConnectionConfig(params connectParams) (connectionConfig, error) {
config := connectionConfig{
Host: strings.TrimSpace(params.Host),
Port: params.Port,
Username: params.Username,
Password: params.Password,
Database: strings.TrimSpace(params.Database),
Dialect: client.TreeSqlDialect,
FetchSize: defaultFetchSize,
TimeZone: client.DefaultTimeZone,
ConnectRetryMax: client.DefaultConnectRetryMax,
ConnectTimeoutMS: int(defaultConnectTimeout / time.Millisecond),
}
if config.Host == "" {
config.Host = "127.0.0.1"
}
if config.Port <= 0 {
config.Port = defaultIoTDBPort
}
if config.Username == "" {
config.Username = "root"
}
if config.Password == "" {
config.Password = "root"
}
query := url.Values{}
if raw := strings.TrimSpace(params.ConnectionString); raw != "" {
normalized := strings.TrimPrefix(raw, "jdbc:")
parsed, err := url.Parse(normalized)
if err != nil {
return connectionConfig{}, fmt.Errorf("parse IoTDB connection string: %w", err)
}
if parsed.Scheme != "" && !strings.EqualFold(parsed.Scheme, "iotdb") {
return connectionConfig{}, fmt.Errorf("unsupported IoTDB connection scheme: %s", parsed.Scheme)
}
if parsed.Hostname() != "" {
config.Host = parsed.Hostname()
}
if parsed.Port() != "" {
port, err := strconv.Atoi(parsed.Port())
if err != nil || port <= 0 {
return connectionConfig{}, fmt.Errorf("invalid IoTDB port: %s", parsed.Port())
}
config.Port = port
}
if parsed.User != nil {
if username := parsed.User.Username(); username != "" {
config.Username = username
}
if password, ok := parsed.User.Password(); ok {
config.Password = password
}
}
if database := strings.Trim(strings.TrimSpace(parsed.Path), "/"); database != "" {
config.Database = database
}
query = parsed.Query()
}
if raw := strings.TrimSpace(params.URLParams); raw != "" {
values, err := url.ParseQuery(strings.TrimPrefix(raw, "?"))
if err != nil {
return connectionConfig{}, fmt.Errorf("parse IoTDB URL parameters: %w", err)
}
for key, entries := range values {
query[key] = entries
}
}
if dialect := strings.ToLower(strings.TrimSpace(firstQueryValue(query, "sql_dialect", "dialect"))); dialect != "" {
switch dialect {
case client.TreeSqlDialect, client.TableSqlDialect:
config.Dialect = dialect
default:
return connectionConfig{}, fmt.Errorf("unsupported IoTDB SQL dialect: %s", dialect)
}
}
if database := strings.TrimSpace(firstQueryValue(query, "database", "db")); database != "" {
config.Database = database
}
if value := firstQueryValue(query, "fetch_size", "fetchSize"); value != "" {
parsed, err := positiveInt(value, "fetch_size")
if err != nil {
return connectionConfig{}, err
}
config.FetchSize = int32(parsed)
}
if value := firstQueryValue(query, "time_zone", "timezone", "zone_id"); value != "" {
config.TimeZone = value
}
if value := firstQueryValue(query, "connect_retry_max", "connectRetryMax"); value != "" {
parsed, err := positiveInt(value, "connect_retry_max")
if err != nil {
return connectionConfig{}, err
}
config.ConnectRetryMax = parsed
}
if value := firstQueryValue(query, "connect_timeout_ms", "connection_timeout_ms"); value != "" {
parsed, err := positiveInt(value, "connect_timeout_ms")
if err != nil {
return connectionConfig{}, err
}
config.ConnectTimeoutMS = parsed
}
config.EnableCompression = queryBool(query, "enable_compression", "rpc_compression")
config.NodeURLs = parseNodeURLs(firstQueryValue(query, "node_urls", "nodes"))
if len(config.NodeURLs) == 0 {
config.NodeURLs = []string{net.JoinHostPort(config.Host, strconv.Itoa(config.Port))}
}
tlsEnabled := params.SSL || queryBool(query, "ssl", "useSSL", "use_ssl", "tls")
if tlsEnabled {
config.TLSInsecureSkipVerify = queryBool(query, "insecure_skip_verify", "tls_insecure_skip_verify")
config.TLSConfig = &client.TLSConfig{
Config: &tls.Config{
ServerName: config.Host,
MinVersion: tls.VersionTLS12,
InsecureSkipVerify: config.TLSInsecureSkipVerify,
},
CAFile: strings.TrimSpace(params.CACertPath),
CertFile: strings.TrimSpace(params.ClientCertPath),
KeyFile: strings.TrimSpace(params.ClientKeyPath),
}
if (config.TLSConfig.CertFile == "") != (config.TLSConfig.KeyFile == "") {
return connectionConfig{}, errors.New("both client_cert_path and client_key_path are required for IoTDB mTLS")
}
}
return config, nil
}
func newSessionClient(config connectionConfig) (*sessionClient, error) {
var session client.Session
var err error
database := config.Database
if config.Dialect == client.TableSqlDialect {
database = ""
}
if len(config.NodeURLs) > 1 {
session, err = client.NewClusterSession(&client.ClusterConfig{
NodeUrls: config.NodeURLs,
UserName: config.Username,
Password: config.Password,
FetchSize: config.FetchSize,
TimeZone: config.TimeZone,
ConnectRetryMax: config.ConnectRetryMax,
Database: database,
TLSConfig: config.TLSConfig,
})
if err == nil {
err = session.OpenCluster(config.EnableCompression)
}
} else {
session = client.NewSession(&client.Config{
Host: config.Host,
Port: strconv.Itoa(config.Port),
UserName: config.Username,
Password: config.Password,
FetchSize: config.FetchSize,
TimeZone: config.TimeZone,
ConnectRetryMax: config.ConnectRetryMax,
Database: database,
TLSConfig: config.TLSConfig,
})
err = session.Open(config.EnableCompression, config.ConnectTimeoutMS)
}
if err != nil {
return nil, err
}
wrapped := &nativeIoTDBSession{session: &session}
connected := &sessionClient{session: wrapped, dialect: config.Dialect}
if config.Dialect == client.TableSqlDialect {
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(config.ConnectTimeoutMS)*time.Millisecond)
defer cancel()
if err := wrapped.ExecuteNonQueryStatement(ctx, "SET SQL_DIALECT=TABLE"); err != nil {
_ = wrapped.Close()
return nil, err
}
if strings.TrimSpace(config.Database) != "" {
if err := wrapped.ExecuteNonQueryStatement(ctx, "USE "+quoteTableIdentifier(config.Database)); err != nil {
_ = wrapped.Close()
return nil, err
}
}
}
return connected, nil
}
func (s *sessionClient) Close() error {
if s == nil || s.session == nil {
return nil
}
return s.session.Close()
}
func (s *server) ensureClient() (*sessionClient, error) {
s.clientMu.Lock()
defer s.clientMu.Unlock()
if s.client != nil {
return s.client, nil
}
connected, err := newSessionClient(s.config)
if err != nil {
return nil, err
}
s.client = connected
return connected, nil
}
func (s *server) invalidateClient(target *sessionClient) {
s.clientMu.Lock()
if s.client == target {
s.client = nil
}
s.clientMu.Unlock()
_ = target.Close()
}
func firstQueryValue(values url.Values, keys ...string) string {
for _, key := range keys {
if value := strings.TrimSpace(values.Get(key)); value != "" {
return value
}
}
return ""
}
func queryBool(values url.Values, keys ...string) bool {
value := strings.ToLower(firstQueryValue(values, keys...))
return value == "1" || value == "true" || value == "yes" || value == "on"
}
func positiveInt(value, name string) (int, error) {
parsed, err := strconv.Atoi(value)
if err != nil || parsed <= 0 {
return 0, fmt.Errorf("%s must be a positive integer", name)
}
return parsed, nil
}
func parseNodeURLs(value string) []string {
parts := strings.FieldsFunc(value, func(char rune) bool { return char == ',' || char == ';' })
result := make([]string, 0, len(parts))
for _, part := range parts {
if normalized := strings.TrimSpace(part); normalized != "" {
result = append(result, normalized)
}
}
return result
}
+7
View File
@@ -0,0 +1,7 @@
module github.com/t8y2/dbx/agents/drivers/iotdb
go 1.25
require github.com/apache/iotdb-client-go/v2 v2.0.9-0.20260807074554-e59fc7f55df1
require github.com/apache/thrift v0.24.0 // indirect
+4
View File
@@ -0,0 +1,4 @@
github.com/apache/iotdb-client-go/v2 v2.0.9-0.20260807074554-e59fc7f55df1 h1:UAfvet8VA2ZUCsIBusSqNDDecFGot8uDshegeDdLBRo=
github.com/apache/iotdb-client-go/v2 v2.0.9-0.20260807074554-e59fc7f55df1/go.mod h1:Ef61z8w2tRnuAcQKESFfiH03kDvfQniVPUgOXCxWnNw=
github.com/apache/thrift v0.24.0 h1:zy31L1a49QTNB2bG1BBfMXol3yJrTH975G3pPubQVLQ=
github.com/apache/thrift v0.24.0/go.mod h1:zPt6WxgvTOM6hF92y8C+MkEM5LMxZuk4JcQOiU4Esvs=
+331
View File
@@ -0,0 +1,331 @@
package main
import (
"bufio"
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"strconv"
"strings"
"sync"
"testing"
"time"
)
func TestLiveIoTDBAgentTreeAndTable(t *testing.T) {
if os.Getenv("DBX_IOTDB_LIVE") != "1" {
t.Skip("set DBX_IOTDB_LIVE=1 to run against a real IoTDB server")
}
suffix := strconv.Itoa(os.Getpid())
treeDatabase := "root.dbx_go_agent_" + suffix
treeDevice := treeDatabase + ".d1"
treeServer := liveIoTDBServer(t, connectParams{Database: treeDatabase})
defer treeServer.disconnect()
defer func() { _ = treeServer.executeNonQuery("DELETE DATABASE "+treeDatabase, "", 0) }()
mustExecuteNonQuery(t, treeServer, "CREATE DATABASE "+treeDatabase, "")
mustExecuteNonQuery(t, treeServer, "CREATE TIMESERIES "+treeDevice+".s1 WITH DATATYPE=INT64, ENCODING=RLE", "")
for index := 1; index <= 4; index++ {
mustExecuteNonQuery(t, treeServer, fmt.Sprintf("INSERT INTO %s(time,s1) VALUES(%d,%d)", treeDevice, index, index*10), "")
}
tables, err := treeServer.listTables(treeDatabase, metadataListConstraints{})
if err != nil || len(tables) != 1 || tables[0].Name != "d1" {
t.Fatalf("tree listTables() = %#v, %v", tables, err)
}
columns, err := treeServer.getColumns(treeDatabase, "d1")
if err != nil || len(columns) != 1 || columns[0].Name != "s1" || columns[0].DataType != "INT64" {
t.Fatalf("tree getColumns() = %#v, %v", columns, err)
}
page, err := treeServer.executeQueryPage(queryOptions{SQL: "SELECT * FROM " + treeDevice, MaxRows: 3}, 2)
if err != nil || len(page.Rows) != 2 || !page.HasMore || page.SessionID == nil {
t.Fatalf("tree first page = %#v, %v", page, err)
}
lastPage, err := treeServer.fetchQueryPage(*page.SessionID, 2)
if err != nil || len(lastPage.Rows) != 1 || lastPage.HasMore || !lastPage.Truncated {
t.Fatalf("tree final page = %#v, %v", lastPage, err)
}
ddl, err := treeServer.getTableDDL(treeDatabase, "d1")
if err != nil || !strings.Contains(ddl, "CREATE TIMESERIES "+treeDevice+".s1") {
t.Fatalf("tree DDL = %q, %v", ddl, err)
}
tableDatabase := "dbx_go_agent_" + suffix
tableParams := liveIoTDBParams()
tableParams.URLParams = "sql_dialect=table&time_zone=Asia%2FShanghai"
tableServer := liveIoTDBServer(t, tableParams)
defer tableServer.disconnect()
defer func() { _ = tableServer.executeNonQuery("DROP DATABASE "+quoteTableIdentifier(tableDatabase), "", 0) }()
mustExecuteNonQuery(t, tableServer, "CREATE DATABASE "+quoteTableIdentifier(tableDatabase), "")
mustExecuteNonQuery(t, tableServer,
"CREATE TABLE "+quoteTableIdentifier(tableDatabase)+"."+quoteTableIdentifier("d1")+
" (time TIMESTAMP TIME, device STRING TAG, s1 INT64 FIELD COMMENT 'value') COMMENT 'DBX live test' WITH (TTL='INF')",
tableDatabase,
)
mustExecuteNonQuery(t, tableServer,
"INSERT INTO "+quoteTableIdentifier("d1")+"(time,device,s1) VALUES(1,'a',10),(2,'a',20)",
tableDatabase,
)
tableColumns, err := tableServer.getColumns(tableDatabase, "d1")
if err != nil || len(tableColumns) != 3 || !tableColumns[0].IsPrimaryKey || !tableColumns[1].IsPrimaryKey {
t.Fatalf("table getColumns() = %#v, %v", tableColumns, err)
}
comment, err := tableServer.getTableComment(tableDatabase, "d1")
if err != nil || comment == nil || *comment != "DBX live test" {
t.Fatalf("table comment = %#v, %v", comment, err)
}
result, err := tableServer.executeQuery(queryOptions{SQL: "SELECT * FROM d1 ORDER BY time", Database: tableDatabase, MaxRows: 10})
if err != nil || len(result.Rows) != 2 || result.Rows[0][0] != "1970-01-01T08:00:00.001+08:00" {
t.Fatalf("table query = %#v, %v", result, err)
}
tableDDL, err := tableServer.getTableDDL(tableDatabase, "d1")
if err != nil || !strings.Contains(tableDDL, "COMMENT 'DBX live test'") || !strings.Contains(tableDDL, `"device" STRING TAG`) {
t.Fatalf("table DDL = %q, %v", tableDDL, err)
}
}
func TestLiveIoTDBAgentProcessMultiSessionAndCancellation(t *testing.T) {
if os.Getenv("DBX_IOTDB_LIVE") != "1" {
t.Skip("set DBX_IOTDB_LIVE=1 to run against a real IoTDB server")
}
process := startIoTDBAgentProcess(t)
defer process.close()
treeDatabase := "root.dbx_go_process_" + strconv.Itoa(os.Getpid())
treeDevice := treeDatabase + ".d1"
treeParams := liveIoTDBParamsMap()
treeParams["agentSessionId"] = "tree"
treeParams["database"] = treeDatabase
process.call(t, "open_session", treeParams)
tableParams := liveIoTDBParamsMap()
tableParams["agentSessionId"] = "table"
tableParams["url_params"] = "sql_dialect=table"
process.call(t, "open_session", tableParams)
process.call(t, "execute_query", map[string]any{"agentSessionId": "tree", "sql": "CREATE DATABASE " + treeDatabase})
process.call(t, "execute_query", map[string]any{
"agentSessionId": "tree", "sql": "CREATE TIMESERIES " + treeDevice + ".s1 WITH DATATYPE=INT64, ENCODING=RLE",
})
process.call(t, "execute_query", map[string]any{
"agentSessionId": "tree", "sql": "INSERT INTO " + treeDevice + "(time,s1) VALUES(1,1)",
})
treeInfo := process.call(t, "connection_info", map[string]any{"agentSessionId": "tree"})
tableInfo := process.call(t, "connection_info", map[string]any{"agentSessionId": "table"})
if treeInfo.(map[string]any)["sqlDialect"] != "TREE" || tableInfo.(map[string]any)["sqlDialect"] != "TABLE" {
t.Fatalf("unexpected process dialects: tree=%#v table=%#v", treeInfo, tableInfo)
}
queryID := process.send(t, "execute_query", map[string]any{
"agentSessionId": "tree",
"sql": "SELECT COUNT(s1) FROM " + treeDevice + " GROUP BY ([0, 1000000000), 1ms)",
"maxRows": 100000000,
})
time.Sleep(250 * time.Millisecond)
process.call(t, "cancel_session", map[string]any{"agentSessionId": "tree"})
response := process.waitFor(t, queryID, 10*time.Second)
if response.Error == nil || response.Error.Data == nil || response.Error.Data.Category != "canceled" {
t.Fatalf("unexpected cancellation response: %#v", response)
}
process.call(t, "validate_session", map[string]any{"agentSessionId": "tree"})
process.call(t, "execute_query", map[string]any{"agentSessionId": "tree", "sql": "DELETE DATABASE " + treeDatabase})
process.call(t, "shutdown", nil)
}
func TestIoTDBAgentHelperProcess(t *testing.T) {
if os.Getenv("DBX_IOTDB_AGENT_HELPER") != "1" {
return
}
main()
os.Exit(0)
}
func liveIoTDBServer(t *testing.T, params connectParams) *server {
t.Helper()
base := liveIoTDBParams()
if params.Database != "" {
base.Database = params.Database
}
if params.URLParams != "" {
base.URLParams = params.URLParams
}
server, err := newServer(base)
if err != nil {
t.Fatal(err)
}
if err := server.validateConnection(); err != nil {
server.disconnect()
t.Fatal(err)
}
return server
}
func liveIoTDBParams() connectParams {
return connectParams{
Host: envOr("DBX_IOTDB_HOST", "127.0.0.1"),
Port: envIntOr("DBX_IOTDB_PORT", defaultIoTDBPort),
Username: envOr("DBX_IOTDB_USER", "root"),
Password: envOr("DBX_IOTDB_PASSWORD", "root"),
}
}
func liveIoTDBParamsMap() map[string]any {
params := liveIoTDBParams()
return map[string]any{
"host": params.Host, "port": params.Port, "username": params.Username, "password": params.Password,
}
}
func mustExecuteNonQuery(t *testing.T, server *server, sql, database string) {
t.Helper()
if err := server.executeNonQuery(sql, database, 0); err != nil {
t.Fatalf("execute %q: %v", sql, err)
}
}
func envOr(name, fallback string) string {
if value := os.Getenv(name); value != "" {
return value
}
return fallback
}
func envIntOr(name string, fallback int) int {
if value, err := strconv.Atoi(os.Getenv(name)); err == nil && value > 0 {
return value
}
return fallback
}
type agentProcessResponse struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
Result any `json:"result"`
Error *rpcError `json:"error"`
}
type iotdbAgentProcess struct {
command *exec.Cmd
stdin *json.Encoder
responses chan agentProcessResponse
stderr *synchronizedBuffer
nextID int64
pendingMu sync.Mutex
pending map[int64]agentProcessResponse
}
func startIoTDBAgentProcess(t *testing.T) *iotdbAgentProcess {
t.Helper()
command := exec.Command(os.Args[0], "-test.run=^TestIoTDBAgentHelperProcess$")
command.Env = append(os.Environ(), "DBX_IOTDB_AGENT_HELPER=1")
stdout, err := command.StdoutPipe()
if err != nil {
t.Fatal(err)
}
stdin, err := command.StdinPipe()
if err != nil {
t.Fatal(err)
}
process := &iotdbAgentProcess{
command: command, stdin: json.NewEncoder(stdin), responses: make(chan agentProcessResponse, 16),
stderr: &synchronizedBuffer{}, pending: map[int64]agentProcessResponse{},
}
command.Stderr = process.stderr
if err := command.Start(); err != nil {
t.Fatal(err)
}
scanner := bufio.NewScanner(stdout)
if !scanner.Scan() || scanner.Text() != `{"ready":true}` {
process.close()
t.Fatalf("unexpected ready line %q: %s", scanner.Text(), process.stderr.String())
}
go func() {
for scanner.Scan() {
var response agentProcessResponse
if json.Unmarshal(scanner.Bytes(), &response) == nil {
process.responses <- response
}
}
close(process.responses)
}()
return process
}
func (p *iotdbAgentProcess) send(t *testing.T, method string, params map[string]any) int64 {
t.Helper()
p.nextID++
request := map[string]any{"jsonrpc": "2.0", "id": p.nextID, "method": method, "params": params}
if err := p.stdin.Encode(request); err != nil {
t.Fatal(err)
}
return p.nextID
}
func (p *iotdbAgentProcess) call(t *testing.T, method string, params map[string]any) any {
t.Helper()
id := p.send(t, method, params)
response := p.waitFor(t, id, 10*time.Second)
if response.Error != nil {
t.Fatalf("%s failed: %#v", method, response.Error)
}
return response.Result
}
func (p *iotdbAgentProcess) waitFor(t *testing.T, id int64, timeout time.Duration) agentProcessResponse {
t.Helper()
p.pendingMu.Lock()
if response, ok := p.pending[id]; ok {
delete(p.pending, id)
p.pendingMu.Unlock()
return response
}
p.pendingMu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
for {
select {
case response, ok := <-p.responses:
if !ok {
t.Fatalf("agent exited while waiting for %d: %s", id, p.stderr.String())
}
var responseID int64
if err := json.Unmarshal(response.ID, &responseID); err != nil {
t.Fatalf("invalid response id %s", response.ID)
}
if responseID == id {
return response
}
p.pendingMu.Lock()
p.pending[responseID] = response
p.pendingMu.Unlock()
case <-ctx.Done():
t.Fatalf("timed out waiting for response %d: %s", id, p.stderr.String())
}
}
}
func (p *iotdbAgentProcess) close() {
if p.command.Process == nil || p.command.ProcessState != nil {
return
}
_ = p.command.Process.Kill()
_ = p.command.Wait()
}
type synchronizedBuffer struct {
mu sync.Mutex
builder strings.Builder
}
func (b *synchronizedBuffer) Write(value []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.builder.Write(value)
}
func (b *synchronizedBuffer) String() string {
b.mu.Lock()
defer b.mu.Unlock()
return b.builder.String()
}
+515
View File
@@ -0,0 +1,515 @@
package main
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"runtime"
"strconv"
"strings"
"sync"
"time"
)
const (
protocolVersion = 2
defaultIoTDBPort = 6667
defaultMaxRows = 10000
defaultPageSize = 1000
defaultFetchSize = 1024
defaultConnectTimeout = 15 * time.Second
legacyAgentSessionID = "__legacy__"
maxAgentSessions = 256
)
type request struct {
ID json.RawMessage `json:"id"`
Method string `json:"method"`
Params map[string]json.RawMessage `json:"params"`
}
type response struct {
JSONRPC string `json:"jsonrpc,omitempty"`
ID json.RawMessage `json:"id,omitempty"`
Result any `json:"result,omitempty"`
Error *rpcError `json:"error,omitempty"`
}
type connectParams struct {
Host string `json:"host"`
Port int `json:"port"`
Database string `json:"database"`
Username string `json:"username"`
Password string `json:"password"`
URLParams string `json:"url_params"`
ConnectionString string `json:"connection_string"`
SSL bool `json:"ssl"`
CACertPath string `json:"ca_cert_path"`
ClientCertPath string `json:"client_cert_path"`
ClientKeyPath string `json:"client_key_path"`
SessionRole string `json:"sessionRole"`
}
type queryOptions struct {
SQL string `json:"sql"`
Database string `json:"database"`
Schema string `json:"schema"`
MaxRows int `json:"maxRows"`
FetchSize int `json:"fetchSize"`
TimeoutSecs int `json:"timeoutSecs"`
}
type queryResult struct {
Columns []string `json:"columns"`
ColumnTypes []string `json:"column_types"`
Rows [][]any `json:"rows"`
AffectedRows int64 `json:"affected_rows"`
ExecutionTimeMS int64 `json:"execution_time_ms"`
Truncated bool `json:"truncated"`
}
type queryPageResult struct {
Columns []string `json:"columns"`
ColumnTypes []string `json:"column_types"`
Rows [][]any `json:"rows"`
AffectedRows int64 `json:"affected_rows"`
ExecutionTimeMS int64 `json:"execution_time_ms"`
Truncated bool `json:"truncated"`
SessionID *string `json:"session_id"`
HasMore bool `json:"has_more"`
}
type server struct {
params connectParams
config connectionConfig
clientMu sync.Mutex
client *sessionClient
querySessions map[string]*querySession
nextSessionID uint64
activeMu sync.Mutex
activeCancel context.CancelFunc
}
type agentSession struct {
server *server
mu sync.Mutex
}
type runtimeServer struct {
mu sync.RWMutex
sessions map[string]*agentSession
}
func main() {
configureRuntimeParallelism()
runtimeServer := newRuntimeServer()
encoder := json.NewEncoder(os.Stdout)
var encoderMu sync.Mutex
var requests sync.WaitGroup
fmt.Fprintln(os.Stdout, `{"ready":true}`)
scanner := bufio.NewScanner(os.Stdin)
scanner.Buffer(make([]byte, 0, 64*1024), 512*1024*1024)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
var envelope request
if json.Unmarshal([]byte(line), &envelope) == nil && envelope.Method == "shutdown" {
requests.Wait()
result, _ := runtimeServer.handleLine(line)
encoderMu.Lock()
_ = encoder.Encode(result)
encoderMu.Unlock()
return
}
requests.Add(1)
go func(line string) {
defer requests.Done()
result, _ := runtimeServer.handleLine(line)
encoderMu.Lock()
defer encoderMu.Unlock()
if err := encoder.Encode(result); err != nil {
fmt.Fprintf(os.Stderr, "failed to write response: %v\n", err)
}
}(line)
}
requests.Wait()
}
func configureRuntimeParallelism() {
if raw := strings.TrimSpace(os.Getenv("DBX_AGENT_IOTDB_GOMAXPROCS")); raw != "" {
if configured, err := strconv.Atoi(raw); err == nil && configured > 0 {
runtime.GOMAXPROCS(configured)
return
}
}
if strings.TrimSpace(os.Getenv("GOMAXPROCS")) == "" {
runtime.GOMAXPROCS(min(runtime.NumCPU(), 4))
}
}
func newRuntimeServer() *runtimeServer {
return &runtimeServer{sessions: map[string]*agentSession{}}
}
func (r *runtimeServer) handleLine(line string) (response, bool) {
var req request
if err := json.Unmarshal([]byte(line), &req); err != nil {
return errorResponse(nil, "", "", err), false
}
if len(req.ID) == 0 {
req.ID = json.RawMessage("1")
}
result, shutdown, err := r.dispatch(req.Method, req.Params)
if err != nil {
return errorResponse(req.ID, req.Method, stringParam(req.Params, "agentSessionId"), err), false
}
return response{JSONRPC: "2.0", ID: req.ID, Result: result}, shutdown
}
func (r *runtimeServer) dispatch(method string, params map[string]json.RawMessage) (any, bool, error) {
switch method {
case "handshake":
return handshakeResult(true), false, nil
case "open_session":
id := stringParam(params, "agentSessionId")
if id == "" {
return nil, false, errors.New("agentSessionId is required")
}
var connection connectParams
if err := decodeParams(params, &connection); err != nil {
return nil, false, err
}
return map[string]bool{"ok": true}, false, r.openSession(id, connection)
case "close_session":
return map[string]bool{"ok": true}, false, r.closeSession(stringParam(params, "agentSessionId"))
case "validate_session":
session, err := r.session(stringParam(params, "agentSessionId"))
if err != nil {
return nil, false, err
}
session.mu.Lock()
defer session.mu.Unlock()
return map[string]bool{"ok": true}, false, session.server.validateConnection()
case "cancel_session":
session, err := r.session(stringParam(params, "agentSessionId"))
if err != nil {
return nil, false, err
}
session.server.cancelActiveQuery()
return map[string]bool{"ok": true}, false, nil
case "test_connection":
var connection connectParams
if err := decodeParams(params, &connection); err != nil {
return nil, false, err
}
result, err := testConnection(connection)
return result, false, err
case "connect":
var connection connectParams
if err := decodeParams(params, &connection); err != nil {
return nil, false, err
}
_ = r.closeSession(legacyAgentSessionID)
return map[string]bool{"ok": true}, false, r.openSession(legacyAgentSessionID, connection)
case "disconnect":
return map[string]bool{"ok": true}, false, r.closeSession(legacyAgentSessionID)
case "shutdown":
return map[string]bool{"ok": true}, true, r.closeAllSessions()
default:
id := stringParam(params, "agentSessionId")
if id == "" {
id = legacyAgentSessionID
}
session, err := r.session(id)
if err != nil {
return nil, false, err
}
session.mu.Lock()
defer session.mu.Unlock()
return session.server.dispatch(method, params)
}
}
func (r *runtimeServer) openSession(id string, params connectParams) error {
r.mu.Lock()
if _, exists := r.sessions[id]; exists {
r.mu.Unlock()
return fmt.Errorf("agent session already exists: %s", id)
}
if len(r.sessions) >= maxAgentSessions {
r.mu.Unlock()
return fmt.Errorf("agent session limit reached: %d", maxAgentSessions)
}
r.mu.Unlock()
server, err := newServer(params)
if err != nil {
return err
}
if err := server.validateConnection(); err != nil {
server.disconnect()
return err
}
r.mu.Lock()
defer r.mu.Unlock()
if _, exists := r.sessions[id]; exists {
server.disconnect()
return fmt.Errorf("agent session already exists: %s", id)
}
r.sessions[id] = &agentSession{server: server}
return nil
}
func (r *runtimeServer) session(id string) (*agentSession, error) {
r.mu.RLock()
session := r.sessions[id]
r.mu.RUnlock()
if session == nil {
return nil, fmt.Errorf("agent session not found: %s", id)
}
return session, nil
}
func (r *runtimeServer) closeSession(id string) error {
r.mu.Lock()
session := r.sessions[id]
delete(r.sessions, id)
r.mu.Unlock()
if session == nil {
return nil
}
session.server.cancelActiveQuery()
session.mu.Lock()
defer session.mu.Unlock()
return session.server.disconnect()
}
func (r *runtimeServer) closeAllSessions() error {
r.mu.RLock()
ids := make([]string, 0, len(r.sessions))
for id := range r.sessions {
ids = append(ids, id)
}
r.mu.RUnlock()
var firstErr error
for _, id := range ids {
if err := r.closeSession(id); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
func newServer(params connectParams) (*server, error) {
config, err := parseConnectionConfig(params)
if err != nil {
return nil, err
}
return &server{
params: params,
config: config,
querySessions: map[string]*querySession{},
}, nil
}
func (s *server) dispatch(method string, params map[string]json.RawMessage) (any, bool, error) {
switch method {
case "handshake":
return handshakeResult(false), false, nil
case "validate_connection":
return map[string]bool{"ok": true}, false, s.validateConnection()
case "connection_info":
result, err := s.connectionInfo()
return result, false, err
case "list_databases":
result, err := s.listDatabases()
return result, false, err
case "list_schemas":
result, err := s.listSchemas()
return result, false, err
case "list_tables":
result, err := s.listTables(stringParam(params, "schema"), metadataListConstraintsFromParams(params))
return result, false, err
case "get_table_comment":
result, err := s.getTableComment(stringParam(params, "schema"), stringParam(params, "table"))
return result, false, err
case "list_objects":
result, err := s.listObjects(stringParam(params, "schema"), metadataListConstraintsFromParams(params))
return result, false, err
case "list_data_types":
return iotdbDataTypes(), false, nil
case "completion_assistant_search_v1":
var input completionAssistantRequest
if err := decodeParams(params, &input); err != nil {
return nil, false, err
}
result, err := s.completionAssistantSearch(input)
return result, false, err
case "get_columns":
result, err := s.getColumns(stringParam(params, "schema"), stringParam(params, "table"))
return result, false, err
case "list_indexes":
result, err := s.listIndexes(stringParam(params, "schema"), stringParam(params, "table"))
return result, false, err
case "list_foreign_keys", "list_triggers", "list_constraints", "list_partitions", "list_subpartitions":
return []any{}, false, nil
case "get_object_source", "get_table_ddl":
result, err := s.getTableDDL(stringParam(params, "schema"), stringParam(params, "table"))
return result, false, err
case "get_explain_info":
return map[string]any{"plan": "", "has_actual_stats": false}, false, nil
case "execute_query":
result, err := s.executeQuery(queryOptionsFromParams(params))
return result, false, err
case "execute_query_page", "start_table_read":
result, err := s.executeQueryPage(queryOptionsFromParams(params), intParam(params, "pageSize"))
return result, false, err
case "fetch_query_page", "fetch_table_read_page":
result, err := s.fetchQueryPage(stringParam(params, "sessionId"), intParam(params, "pageSize"))
return result, false, err
case "close_query_session", "close_table_read_session":
return s.closeQuerySession(stringParam(params, "sessionId")), false, nil
case "execute_transaction":
result, err := s.executeStatements(params, true)
return result, false, err
case "execute_batch":
result, err := s.executeStatements(params, false)
return result, false, err
case "disconnect":
return map[string]bool{"ok": true}, false, s.disconnect()
case "shutdown":
return map[string]bool{"ok": true}, true, s.disconnect()
default:
return nil, false, fmt.Errorf("unknown method: %s", method)
}
}
func handshakeResult(multiSession bool) map[string]any {
capabilities := []string{
"connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "structured_error_v1",
}
if multiSession {
capabilities = append(capabilities, "multi_session")
}
return map[string]any{
"protocolVersion": protocolVersion,
"agentProtocolVersion": protocolVersion,
"capabilities": capabilities,
}
}
func testConnection(params connectParams) (map[string]any, error) {
server, err := newServer(params)
if err != nil {
return nil, err
}
defer server.disconnect()
if err := server.validateConnection(); err != nil {
return nil, err
}
info, err := server.connectionInfo()
if err != nil {
return nil, err
}
return map[string]any{"ok": true, "info": info}, nil
}
func (s *server) disconnect() error {
s.cancelActiveQuery()
if err := s.closeAllQuerySessions(); err != nil {
return err
}
s.clientMu.Lock()
client := s.client
s.client = nil
s.clientMu.Unlock()
if client != nil {
return client.Close()
}
return nil
}
func (s *server) setActiveOperation(cancel context.CancelFunc) {
s.activeMu.Lock()
s.activeCancel = cancel
s.activeMu.Unlock()
}
func (s *server) clearActiveOperation(cancel context.CancelFunc) {
cancel()
s.activeMu.Lock()
s.activeCancel = nil
s.activeMu.Unlock()
}
func (s *server) cancelActiveQuery() {
s.activeMu.Lock()
cancel := s.activeCancel
s.activeMu.Unlock()
if cancel != nil {
cancel()
}
}
func queryOptionsFromParams(params map[string]json.RawMessage) queryOptions {
return queryOptions{
SQL: stringParam(params, "sql"),
Database: stringParam(params, "database"),
Schema: stringParam(params, "schema"),
MaxRows: intParam(params, "maxRows"),
FetchSize: intParam(params, "fetchSize"),
TimeoutSecs: intParam(params, "timeoutSecs"),
}
}
func decodeParams(params map[string]json.RawMessage, target any) error {
data, err := json.Marshal(params)
if err != nil {
return err
}
return json.Unmarshal(data, target)
}
func stringParam(params map[string]json.RawMessage, key string) string {
if raw, ok := params[key]; ok {
var value string
if json.Unmarshal(raw, &value) == nil {
return value
}
}
return ""
}
func intParam(params map[string]json.RawMessage, key string) int {
if raw, ok := params[key]; ok {
var value int
if json.Unmarshal(raw, &value) == nil {
return value
}
}
return 0
}
func stringSliceParam(params map[string]json.RawMessage, key string) []string {
if raw, ok := params[key]; ok {
var value []string
if json.Unmarshal(raw, &value) == nil && value != nil {
return value
}
}
return []string{}
}
func errorResponse(id json.RawMessage, method, sessionID string, err error) response {
return response{JSONRPC: "2.0", ID: id, Error: classifyRPCError(method, sessionID, err)}
}
+199
View File
@@ -0,0 +1,199 @@
package main
import (
"context"
"encoding/json"
"errors"
"reflect"
"testing"
"time"
"github.com/apache/iotdb-client-go/v2/client"
)
func TestHandshakeAdvertisesNativeCapabilities(t *testing.T) {
result, shutdown, err := newRuntimeServer().dispatch("handshake", nil)
if err != nil || shutdown {
t.Fatalf("unexpected handshake: shutdown=%t err=%v", shutdown, err)
}
capabilities := result.(map[string]any)["capabilities"].([]string)
want := []string{
"connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl",
"structured_error_v1", "multi_session",
}
if !reflect.DeepEqual(capabilities, want) {
t.Fatalf("unexpected capabilities: %#v", capabilities)
}
}
func TestHandleLineClassifiesMissingSession(t *testing.T) {
response, _ := newRuntimeServer().handleLine(
`{"jsonrpc":"2.0","id":7,"method":"validate_session","params":{"agentSessionId":"missing"}}`,
)
if response.Error == nil || response.Error.Data == nil {
t.Fatalf("expected structured error: %#v", response)
}
if response.Error.Data.Stage != "validate" || response.Error.Data.Category != "protocol" {
t.Fatalf("unexpected error classification: %#v", response.Error.Data)
}
}
func TestParseConnectionConfig(t *testing.T) {
config, err := parseConnectionConfig(connectParams{
ConnectionString: "jdbc:iotdb://alice:secret@[::1]:7777/dbx_table?sql_dialect=table&fetch_size=2048&connect_retry_max=4",
URLParams: "enable_compression=true&node_urls=node1:6667,node2:6667&time_zone=UTC",
})
if err != nil {
t.Fatal(err)
}
if config.Host != "::1" || config.Port != 7777 || config.Username != "alice" || config.Password != "secret" {
t.Fatalf("unexpected endpoint config: %#v", config)
}
if config.Database != "dbx_table" || config.Dialect != client.TableSqlDialect || config.FetchSize != 2048 {
t.Fatalf("unexpected session config: %#v", config)
}
if !config.EnableCompression || config.ConnectRetryMax != 4 || config.TimeZone != "UTC" {
t.Fatalf("unexpected optional config: %#v", config)
}
if !reflect.DeepEqual(config.NodeURLs, []string{"node1:6667", "node2:6667"}) {
t.Fatalf("unexpected cluster nodes: %#v", config.NodeURLs)
}
}
func TestParseConnectionConfigTLS(t *testing.T) {
config, err := parseConnectionConfig(connectParams{
Host: "iotdb.example.com", SSL: true, CACertPath: "/tmp/ca.pem",
ClientCertPath: "/tmp/client.pem", ClientKeyPath: "/tmp/client.key",
URLParams: "insecure_skip_verify=true",
})
if err != nil {
t.Fatal(err)
}
if config.TLSConfig == nil || config.TLSConfig.CAFile != "/tmp/ca.pem" || !config.TLSInsecureSkipVerify {
t.Fatalf("unexpected TLS config: %#v", config.TLSConfig)
}
if _, err := parseConnectionConfig(connectParams{SSL: true, ClientCertPath: "/tmp/client.pem"}); err == nil {
t.Fatal("expected incomplete mTLS configuration to fail")
}
if _, err := parseConnectionConfig(connectParams{URLParams: "sql_dialect=relational"}); err == nil {
t.Fatal("expected unsupported SQL dialect to fail")
}
}
func TestMetadataHelpers(t *testing.T) {
values := []string{"alpha", "beta", "gamma"}
if got := applyMetadataWindow(values, 1, 1); !reflect.DeepEqual(got, []string{"beta"}) {
t.Fatalf("unexpected metadata window: %#v", got)
}
if !metadataNameMatches("DeviceOne", "vice") || metadataNameMatches("DeviceOne", "table") {
t.Fatal("unexpected metadata name matching")
}
if got := treeDevicePath("root.db", "root.db.d1"); got != "root.db.d1" {
t.Fatalf("treeDevicePath() = %q", got)
}
if got := quoteTreePath("root.db.with-dash"); got != "root.db.`with-dash`" {
t.Fatalf("quoteTreePath() = %q", got)
}
if got := quoteTableIdentifier(`a"b`); got != `"a""b"` {
t.Fatalf("quoteTableIdentifier() = %q", got)
}
}
func TestNormalizeIoTDBValues(t *testing.T) {
when := time.Date(2026, time.August, 10, 12, 34, 56, 123, time.FixedZone("CST", 8*60*60))
if got := normalizeIoTDBValue(when, "TIMESTAMP"); got != "2026-08-10T12:34:56.000000123+08:00" {
t.Fatalf("unexpected timestamp: %#v", got)
}
if got := normalizeIoTDBValue(when, "DATE"); got != "2026-08-10" {
t.Fatalf("unexpected date: %#v", got)
}
if got := normalizeIoTDBValue([]byte{0xde, 0xad}, "BLOB"); got != "dead" {
t.Fatalf("unexpected blob: %#v", got)
}
}
func TestClassifyIoTDBErrors(t *testing.T) {
canceled := classifyRPCError("execute_query", "session-1", context.Canceled)
if canceled.Data.Category != "canceled" || canceled.Data.SessionDisposition != "quarantine" {
t.Fatalf("unexpected cancellation classification: %#v", canceled)
}
timedOut := classifyRPCError("execute_query", "session-1", context.DeadlineExceeded)
if timedOut.Data.Category != "timeout" || timedOut.Data.SessionDisposition != "quarantine" {
t.Fatalf("unexpected timeout classification: %#v", timedOut)
}
}
func TestDecodeQueryOptions(t *testing.T) {
params := map[string]json.RawMessage{
"sql": json.RawMessage(`"SHOW DATABASES"`),
"maxRows": json.RawMessage(`100`),
"fetchSize": json.RawMessage(`25`),
"timeoutSecs": json.RawMessage(`5`),
}
options := queryOptionsFromParams(params)
if options.SQL != "SHOW DATABASES" || options.MaxRows != 100 || options.FetchSize != 25 || options.TimeoutSecs != 5 {
t.Fatalf("unexpected query options: %#v", options)
}
}
func TestCancelActiveOperationInvalidatesClient(t *testing.T) {
blocking := &blockingIoTDBSession{closed: make(chan struct{})}
connected := &sessionClient{session: blocking, dialect: client.TreeSqlDialect}
server := &server{client: connected}
done := make(chan error, 1)
go func() {
_, err := runCancelable(server, 0, connected, func(ctx context.Context) (struct{}, error) {
<-ctx.Done()
return struct{}{}, ctx.Err()
})
done <- err
}()
deadline := time.Now().Add(time.Second)
for {
server.activeMu.Lock()
active := server.activeCancel != nil
server.activeMu.Unlock()
if active {
break
}
if time.Now().After(deadline) {
t.Fatal("operation never became active")
}
time.Sleep(time.Millisecond)
}
server.cancelActiveQuery()
select {
case err := <-done:
if !errors.Is(err, context.Canceled) {
t.Fatalf("runCancelable() error = %v", err)
}
case <-time.After(time.Second):
t.Fatal("canceled operation did not return")
}
server.clientMu.Lock()
defer server.clientMu.Unlock()
if server.client != nil {
t.Fatal("canceled client must be invalidated")
}
}
type blockingIoTDBSession struct {
closed chan struct{}
}
func (s *blockingIoTDBSession) ExecuteQueryStatement(context.Context, string, *int64) (*client.SessionDataSet, error) {
return nil, errors.New("not implemented")
}
func (s *blockingIoTDBSession) ExecuteNonQueryStatement(context.Context, string) error {
return errors.New("not implemented")
}
func (s *blockingIoTDBSession) Close() error {
select {
case <-s.closed:
default:
close(s.closed)
}
return nil
}
+703
View File
@@ -0,0 +1,703 @@
package main
import (
"encoding/json"
"fmt"
"regexp"
"sort"
"strconv"
"strings"
"github.com/apache/iotdb-client-go/v2/client"
)
const metadataQueryLimit = 100000
var (
iotdbTypes = []string{
"BOOLEAN", "INT32", "INT64", "FLOAT", "DOUBLE", "TEXT", "STRING", "BLOB", "TIMESTAMP", "DATE",
}
simpleTreeNodePattern = regexp.MustCompile(`^[A-Za-z0-9_]+$`)
)
type databaseInfo struct {
Name string `json:"name"`
}
type tableInfo struct {
Name string `json:"name"`
TableType string `json:"table_type"`
Comment *string `json:"comment"`
ParentSchema *string `json:"parent_schema,omitempty"`
ParentName *string `json:"parent_name,omitempty"`
}
type objectInfo struct {
Name string `json:"name"`
ObjectType string `json:"object_type"`
Schema string `json:"schema"`
Comment *string `json:"comment"`
Valid *bool `json:"valid,omitempty"`
}
type columnInfo struct {
Name string `json:"name"`
DataType string `json:"data_type"`
IsNullable bool `json:"is_nullable"`
ColumnDefault *string `json:"column_default"`
IsPrimaryKey bool `json:"is_primary_key"`
Extra *string `json:"extra"`
Comment *string `json:"comment"`
NumericPrecision *int `json:"numeric_precision"`
NumericScale *int `json:"numeric_scale"`
CharacterMaximumLength *int `json:"character_maximum_length"`
}
type indexInfo struct {
Name string `json:"name"`
Columns []string `json:"columns"`
IsUnique bool `json:"is_unique"`
IsPrimary bool `json:"is_primary"`
Filter *string `json:"filter"`
IndexType *string `json:"index_type"`
IncludedColumns []string `json:"included_columns"`
Comment *string `json:"comment"`
}
func (i indexInfo) MarshalJSON() ([]byte, error) {
type alias indexInfo
value := alias(i)
if value.Columns == nil {
value.Columns = []string{}
}
if value.IncludedColumns == nil {
value.IncludedColumns = []string{}
}
return json.Marshal(value)
}
type metadataListConstraints struct {
Filter string
Limit int
Offset int
ObjectTypes []string
}
type completionAssistantRequest struct {
ConnectionID string `json:"connection_id"`
Database string `json:"database"`
Schema string `json:"schema"`
ObjectKinds []string `json:"object_kinds"`
Mask string `json:"mask"`
CaseSensitive bool `json:"case_sensitive"`
GlobalSearch bool `json:"global_search"`
MaxResults int `json:"max_results"`
ParentSchema string `json:"parent_schema"`
ParentName string `json:"parent_name"`
MatchMode string `json:"match_mode"`
}
type completionAssistantCandidate struct {
Name string `json:"name"`
Kind string `json:"kind"`
Database *string `json:"database"`
Schema *string `json:"schema"`
ParentSchema *string `json:"parent_schema"`
ParentName *string `json:"parent_name"`
Comment *string `json:"comment"`
DataType *string `json:"data_type"`
}
type completionAssistantResponse struct {
Candidates []completionAssistantCandidate `json:"candidates"`
Incomplete bool `json:"incomplete"`
FallbackUsed bool `json:"fallback_used"`
}
func iotdbDataTypes() []string {
return append([]string(nil), iotdbTypes...)
}
func (s *server) connectionInfo() (map[string]any, error) {
version := ""
if values, err := s.queryValues("SHOW VERSION", "", 1, 5); err == nil && len(values.Rows) > 0 {
version = metadataRowString(values, values.Rows[0], "Version")
} else if err != nil {
return nil, err
}
username := s.params.Username
if values, err := s.queryValues("SHOW CURRENT_USER", "", 1, 5); err == nil && len(values.Rows) > 0 {
if current := metadataRowString(values, values.Rows[0], "CurrentUser"); current != "" {
username = current
}
}
dialect := strings.ToUpper(s.config.Dialect)
if values, err := s.queryValues("SHOW CURRENT_SQL_DIALECT", "", 1, 5); err == nil && len(values.Rows) > 0 {
if current := metadataRowString(values, values.Rows[0], "CurrentSqlDialect"); current != "" {
dialect = strings.ToUpper(current)
}
}
quote := "`"
identifierCase := "mixed"
if s.config.Dialect == client.TableSqlDialect {
quote = `"`
identifierCase = "lower"
}
return map[string]any{
"database": s.config.Database,
"schema": s.config.Database,
"username": username,
"version": version,
"sqlDialect": dialect,
"identifierQuote": quote,
"compatibilityMode": "iotdb-" + strings.ToLower(dialect),
"databaseInfo": map[string]string{
"productName": "Apache IoTDB",
"productVersion": version,
"unquotedIdentifierCase": identifierCase,
"quotedIdentifierCase": "mixed",
"driverName": "Apache IoTDB Go Client",
"driverVersion": "v" + iotdbGoClientVersion,
},
}, nil
}
func (s *server) listDatabases() ([]databaseInfo, error) {
values, err := s.queryValues("SHOW DATABASES", "", metadataQueryLimit, 0)
if err != nil {
return nil, err
}
result := make([]databaseInfo, 0, len(values.Rows))
seen := map[string]bool{}
for _, row := range values.Rows {
name := metadataRowString(values, row, "Database")
if name == "" || seen[name] {
continue
}
seen[name] = true
result = append(result, databaseInfo{Name: name})
}
sort.Slice(result, func(left, right int) bool { return result[left].Name < result[right].Name })
return result, nil
}
func (s *server) listSchemas() ([]string, error) {
databases, err := s.listDatabases()
if err != nil {
return nil, err
}
result := make([]string, len(databases))
for index, database := range databases {
result[index] = database.Name
}
return result, nil
}
func (s *server) listTables(schema string, constraints metadataListConstraints) ([]tableInfo, error) {
if !acceptsIoTDBTable(constraints.ObjectTypes) {
return []tableInfo{}, nil
}
if s.config.Dialect == client.TableSqlDialect {
return s.listTableDialectTables(schema, constraints)
}
return s.listTreeDialectDevices(schema, constraints)
}
func (s *server) listTreeDialectDevices(schema string, constraints metadataListConstraints) ([]tableInfo, error) {
schema = s.effectiveMetadataSchema(schema)
pattern := "root.**"
if schema != "" {
pattern = quoteTreePath(schema) + ".**"
}
values, err := s.queryValues("SHOW DEVICES "+pattern, "", metadataQueryLimit, 0)
if err != nil {
return nil, err
}
prefix := strings.TrimSuffix(schema, ".")
if prefix != "" {
prefix += "."
}
result := make([]tableInfo, 0, len(values.Rows))
for _, row := range values.Rows {
device := metadataRowString(values, row, "Device")
name := strings.TrimPrefix(device, prefix)
if name == "" || name == device && prefix != "" || !metadataNameMatches(name, constraints.Filter) {
continue
}
result = append(result, tableInfo{Name: name, TableType: "TABLE"})
}
sort.Slice(result, func(left, right int) bool { return result[left].Name < result[right].Name })
return applyMetadataWindow(result, constraints.Offset, constraints.Limit), nil
}
func (s *server) listTableDialectTables(schema string, constraints metadataListConstraints) ([]tableInfo, error) {
schema = s.effectiveMetadataSchema(schema)
if schema == "" {
return []tableInfo{}, nil
}
values, err := s.queryValues("SHOW TABLES DETAILS FROM "+quoteTableIdentifier(schema), schema, metadataQueryLimit, 0)
if err != nil {
values, err = s.queryValues("SHOW TABLES FROM "+quoteTableIdentifier(schema), schema, metadataQueryLimit, 0)
if err != nil {
return nil, err
}
}
result := make([]tableInfo, 0, len(values.Rows))
for _, row := range values.Rows {
name := firstMetadataRowString(values, row, "TableName", "table_name")
if name == "" || !metadataNameMatches(name, constraints.Filter) {
continue
}
tableType := firstMetadataRowString(values, row, "TableType", "table_type")
if tableType == "" || strings.EqualFold(tableType, "BASE TABLE") {
tableType = "TABLE"
}
result = append(result, tableInfo{
Name: name,
TableType: tableType,
Comment: optionalMetadataString(firstMetadataRowString(values, row, "Comment", "comment")),
})
}
sort.Slice(result, func(left, right int) bool { return result[left].Name < result[right].Name })
return applyMetadataWindow(result, constraints.Offset, constraints.Limit), nil
}
func (s *server) getTableComment(schema, table string) (*string, error) {
if s.config.Dialect != client.TableSqlDialect {
return nil, nil
}
tables, err := s.listTableDialectTables(schema, metadataListConstraints{})
if err != nil {
return nil, err
}
for _, item := range tables {
if item.Name == table {
return item.Comment, nil
}
}
return nil, nil
}
func (s *server) listObjects(schema string, constraints metadataListConstraints) ([]objectInfo, error) {
tables, err := s.listTables(schema, constraints)
if err != nil {
return nil, err
}
result := make([]objectInfo, 0, len(tables))
for _, table := range tables {
result = append(result, objectInfo{
Name: table.Name,
ObjectType: "TABLE",
Schema: schema,
Comment: table.Comment,
})
}
return result, nil
}
func (s *server) getColumns(schema, table string) ([]columnInfo, error) {
if strings.TrimSpace(table) == "" {
return []columnInfo{}, nil
}
if s.config.Dialect == client.TableSqlDialect {
return s.getTableDialectColumns(schema, table)
}
return s.getTreeDialectColumns(schema, table)
}
func (s *server) getTreeDialectColumns(schema, table string) ([]columnInfo, error) {
device := treeDevicePath(s.effectiveMetadataSchema(schema), table)
if device == "" {
return []columnInfo{}, nil
}
values, err := s.queryValues("SHOW TIMESERIES "+quoteTreePath(device)+".**", "", metadataQueryLimit, 0)
if err != nil {
return nil, err
}
prefix := device + "."
result := make([]columnInfo, 0, len(values.Rows))
for _, row := range values.Rows {
path := metadataRowString(values, row, "Timeseries")
name := strings.TrimPrefix(path, prefix)
if name == "" || name == path || strings.Contains(name, ".") {
continue
}
encoding := metadataRowString(values, row, "Encoding")
compression := metadataRowString(values, row, "Compression")
extra := make([]string, 0, 2)
if encoding != "" {
extra = append(extra, "encoding="+encoding)
}
if compression != "" {
extra = append(extra, "compressor="+compression)
}
result = append(result, columnInfo{
Name: name,
DataType: metadataRowString(values, row, "DataType"),
IsNullable: true,
Extra: optionalMetadataString(strings.Join(extra, ", ")),
})
}
return result, nil
}
func (s *server) getTableDialectColumns(schema, table string) ([]columnInfo, error) {
schema = s.effectiveMetadataSchema(schema)
qualified := quoteTableIdentifier(schema) + "." + quoteTableIdentifier(table)
values, err := s.queryValues("DESC "+qualified+" DETAILS", schema, metadataQueryLimit, 0)
if err != nil {
values, err = s.queryValues("DESC "+qualified, schema, metadataQueryLimit, 0)
if err != nil {
return nil, err
}
}
result := make([]columnInfo, 0, len(values.Rows))
for _, row := range values.Rows {
name := firstMetadataRowString(values, row, "ColumnName", "column_name")
if name == "" {
continue
}
category := strings.ToUpper(firstMetadataRowString(values, row, "Category", "category"))
isPrimary := category == "TIME" || category == "TAG"
result = append(result, columnInfo{
Name: name,
DataType: strings.ToUpper(firstMetadataRowString(values, row, "DataType", "datatype")),
IsNullable: !isPrimary,
IsPrimaryKey: isPrimary,
Extra: optionalMetadataString(category),
Comment: optionalMetadataString(firstMetadataRowString(values, row, "Comment", "comment")),
})
}
return result, nil
}
func (s *server) listIndexes(_, _ string) ([]indexInfo, error) {
return []indexInfo{}, nil
}
func (s *server) getTableDDL(schema, table string) (string, error) {
if strings.TrimSpace(table) == "" {
return "", nil
}
if s.config.Dialect == client.TableSqlDialect {
return s.getTableDialectDDL(schema, table)
}
return s.getTreeDialectDDL(schema, table)
}
func (s *server) getTreeDialectDDL(schema, table string) (string, error) {
device := treeDevicePath(s.effectiveMetadataSchema(schema), table)
columns, err := s.getTreeDialectColumns(schema, table)
if err != nil {
return "", err
}
if len(columns) == 0 {
return "", nil
}
aligned := false
if values, queryErr := s.queryValues("SHOW DEVICES "+quoteTreePath(device), "", 1, 0); queryErr == nil && len(values.Rows) > 0 {
aligned = metadataRowBool(values, values.Rows[0], "IsAligned")
}
var ddl strings.Builder
for _, column := range columns {
ddl.WriteString("DELETE TIMESERIES ")
ddl.WriteString(quoteTreePath(device + "." + column.Name))
ddl.WriteString(";\n")
}
ddl.WriteString("\n")
if aligned {
definitions := make([]string, 0, len(columns))
for _, column := range columns {
definition := quoteTreeNode(column.Name) + " " + column.DataType
if column.Extra != nil && *column.Extra != "" {
definition += " " + strings.ReplaceAll(*column.Extra, ", ", " ")
}
definitions = append(definitions, definition)
}
ddl.WriteString("CREATE ALIGNED TIMESERIES ")
ddl.WriteString(quoteTreePath(device))
ddl.WriteString("(")
ddl.WriteString(strings.Join(definitions, ", "))
ddl.WriteString(");")
return ddl.String(), nil
}
for _, column := range columns {
ddl.WriteString("CREATE TIMESERIES ")
ddl.WriteString(quoteTreePath(device + "." + column.Name))
ddl.WriteString(" WITH DATATYPE=")
ddl.WriteString(column.DataType)
if column.Extra != nil && *column.Extra != "" {
ddl.WriteString(", ")
ddl.WriteString(*column.Extra)
}
ddl.WriteString(";\n")
}
return strings.TrimSuffix(ddl.String(), "\n"), nil
}
func (s *server) getTableDialectDDL(schema, table string) (string, error) {
schema = s.effectiveMetadataSchema(schema)
columns, err := s.getTableDialectColumns(schema, table)
if err != nil {
return "", err
}
if len(columns) == 0 {
return "", nil
}
comment, err := s.getTableComment(schema, table)
if err != nil {
return "", err
}
ttl := "INF"
if values, queryErr := s.queryValues("SHOW TABLES DETAILS FROM "+quoteTableIdentifier(schema), schema, metadataQueryLimit, 0); queryErr == nil {
for _, row := range values.Rows {
if firstMetadataRowString(values, row, "TableName", "table_name") == table {
if value := firstMetadataRowString(values, row, "TTL(ms)", "ttl(ms)"); value != "" {
ttl = value
}
break
}
}
}
definitions := make([]string, 0, len(columns))
for _, column := range columns {
definition := " " + quoteTableIdentifier(column.Name) + " " + column.DataType
if column.Extra != nil && *column.Extra != "" {
definition += " " + *column.Extra
}
if column.Comment != nil && *column.Comment != "" {
definition += " COMMENT " + quoteSQLString(*column.Comment)
}
definitions = append(definitions, definition)
}
qualified := quoteTableIdentifier(schema) + "." + quoteTableIdentifier(table)
ddl := "DROP TABLE IF EXISTS " + qualified + ";\n\nCREATE TABLE " + qualified + " (\n" + strings.Join(definitions, ",\n") + "\n)"
if comment != nil && *comment != "" {
ddl += " COMMENT " + quoteSQLString(*comment)
}
if strings.EqualFold(ttl, "INF") {
ddl += " WITH (TTL='INF')"
} else if _, parseErr := strconv.ParseInt(ttl, 10, 64); parseErr == nil {
ddl += " WITH (TTL=" + ttl + ")"
}
return ddl + ";", nil
}
func (s *server) completionAssistantSearch(input completionAssistantRequest) (completionAssistantResponse, error) {
limit := input.MaxResults
if limit <= 0 || limit > 1000 {
limit = 100
}
candidates := make([]completionAssistantCandidate, 0, limit+1)
kinds := metadataStringSet(input.ObjectKinds)
if (kinds["column"] || kinds["property"] || len(kinds) == 0 && input.ParentName != "") && input.ParentName != "" {
schema := input.ParentSchema
if schema == "" {
schema = input.Schema
}
columns, err := s.getColumns(schema, input.ParentName)
if err != nil {
return completionAssistantResponse{}, err
}
for _, column := range columns {
if !completionNameMatches(column.Name, input) {
continue
}
dataType := column.DataType
candidates = append(candidates, completionAssistantCandidate{
Name: column.Name, Kind: "COLUMN", Database: stringPtr(input.Database), Schema: stringPtr(schema),
ParentSchema: stringPtr(schema), ParentName: stringPtr(input.ParentName), DataType: &dataType,
})
if len(candidates) > limit {
return completionAssistantResponse{Candidates: candidates[:limit], Incomplete: true}, nil
}
}
return completionAssistantResponse{Candidates: candidates}, nil
}
schemas := []string{input.Schema}
if input.GlobalSearch || input.Schema == "" {
var err error
schemas, err = s.listSchemas()
if err != nil {
return completionAssistantResponse{}, err
}
}
for _, schema := range schemas {
objects, err := s.listObjects(schema, metadataListConstraints{ObjectTypes: input.ObjectKinds})
if err != nil {
return completionAssistantResponse{}, err
}
for _, object := range objects {
if !completionNameMatches(object.Name, input) {
continue
}
candidates = append(candidates, completionAssistantCandidate{
Name: object.Name, Kind: object.ObjectType, Database: stringPtr(input.Database),
Schema: stringPtr(schema), Comment: object.Comment,
})
if len(candidates) > limit {
return completionAssistantResponse{Candidates: candidates[:limit], Incomplete: true}, nil
}
}
}
return completionAssistantResponse{Candidates: candidates}, nil
}
func (s *server) effectiveMetadataSchema(schema string) string {
if value := strings.TrimSpace(schema); value != "" {
return value
}
return strings.TrimSpace(s.config.Database)
}
func metadataListConstraintsFromParams(params map[string]json.RawMessage) metadataListConstraints {
return metadataListConstraints{
Filter: stringParam(params, "filter"),
Limit: intParam(params, "limit"),
Offset: intParam(params, "offset"),
ObjectTypes: stringSliceParam(params, "object_types"),
}
}
func metadataNameMatches(name, filter string) bool {
return filter == "" || strings.Contains(strings.ToLower(name), strings.ToLower(filter))
}
func acceptsIoTDBTable(objectTypes []string) bool {
if len(objectTypes) == 0 {
return true
}
for _, objectType := range objectTypes {
switch strings.ToLower(strings.TrimSpace(objectType)) {
case "table", "base table", "device", "timeseries":
return true
}
}
return false
}
func applyMetadataWindow[T any](values []T, offset, limit int) []T {
if offset < 0 {
offset = 0
}
if offset >= len(values) {
return []T{}
}
values = values[offset:]
if limit > 0 && limit < len(values) {
values = values[:limit]
}
return values
}
func completionNameMatches(name string, input completionAssistantRequest) bool {
mask := input.Mask
if mask == "" {
return true
}
if !input.CaseSensitive {
name = strings.ToLower(name)
mask = strings.ToLower(mask)
}
switch strings.ToLower(input.MatchMode) {
case "exact":
return name == mask
case "contains":
return strings.Contains(name, mask)
default:
return strings.HasPrefix(name, mask)
}
}
func metadataStringSet(values []string) map[string]bool {
result := make(map[string]bool, len(values))
for _, value := range values {
result[strings.ToLower(strings.TrimSpace(value))] = true
}
return result
}
func metadataRowString(values queryResult, row []any, column string) string {
index := metadataColumnIndex(values.Columns, column)
if index < 0 || index >= len(row) || row[index] == nil {
return ""
}
return strings.TrimSpace(fmt.Sprint(row[index]))
}
func firstMetadataRowString(values queryResult, row []any, columns ...string) string {
for _, column := range columns {
if value := metadataRowString(values, row, column); value != "" {
return value
}
}
return ""
}
func metadataRowBool(values queryResult, row []any, column string) bool {
index := metadataColumnIndex(values.Columns, column)
if index < 0 || index >= len(row) || row[index] == nil {
return false
}
switch value := row[index].(type) {
case bool:
return value
case string:
parsed, _ := strconv.ParseBool(value)
return parsed
default:
parsed, _ := strconv.ParseBool(fmt.Sprint(value))
return parsed
}
}
func metadataColumnIndex(columns []string, target string) int {
for index, column := range columns {
if strings.EqualFold(strings.TrimSpace(column), strings.TrimSpace(target)) {
return index
}
}
return -1
}
func treeDevicePath(schema, table string) string {
schema = strings.TrimSuffix(strings.TrimSpace(schema), ".")
table = strings.TrimPrefix(strings.TrimSpace(table), ".")
if table == "" {
return ""
}
if schema == "" || table == schema || strings.HasPrefix(table, schema+".") {
return table
}
return schema + "." + table
}
func quoteTreePath(value string) string {
nodes := strings.Split(strings.TrimSpace(value), ".")
for index, node := range nodes {
nodes[index] = quoteTreeNode(node)
}
return strings.Join(nodes, ".")
}
func quoteTreeNode(value string) string {
if simpleTreeNodePattern.MatchString(value) {
return value
}
return "`" + strings.ReplaceAll(value, "`", "``") + "`"
}
func quoteSQLString(value string) string {
return "'" + strings.ReplaceAll(value, "'", "''") + "'"
}
func optionalMetadataString(value string) *string {
if strings.TrimSpace(value) == "" {
return nil
}
return &value
}
func stringPtr(value string) *string {
return &value
}
+138
View File
@@ -0,0 +1,138 @@
package main
import (
"context"
"errors"
"fmt"
"io"
"net"
"strings"
"github.com/apache/iotdb-client-go/v2/client"
)
type rpcError struct {
Code int `json:"code"`
Message string `json:"message"`
Data *rpcErrorData `json:"data,omitempty"`
}
type rpcErrorData struct {
Category string `json:"category"`
Retryable bool `json:"retryable"`
SessionDisposition string `json:"sessionDisposition"`
Stage string `json:"stage"`
ContractVersion int `json:"contractVersion"`
OperationOutcome string `json:"operationOutcome"`
SQLState string `json:"sqlState,omitempty"`
VendorCode int32 `json:"vendorCode,omitempty"`
ExceptionClass string `json:"exceptionClass,omitempty"`
AgentSessionID string `json:"agentSessionId,omitempty"`
}
func classifyRPCError(method, agentSessionID string, err error) *rpcError {
stage := rpcErrorStage(method)
data := &rpcErrorData{
Category: "protocol",
Retryable: false,
SessionDisposition: "keep",
Stage: stage,
ContractVersion: 1,
OperationOutcome: rpcOperationOutcome(stage),
ExceptionClass: safeRPCDiagnostic(fmt.Sprintf("%T", err), 160),
AgentSessionID: strings.TrimSpace(agentSessionID),
}
var executionError *client.ExecutionError
if errors.As(err, &executionError) {
data.Category = "sql"
data.VendorCode = executionError.Code
data.SQLState = fmt.Sprintf("IOTDB-%d", executionError.Code)
lower := strings.ToLower(executionError.Message)
if strings.Contains(lower, "timeout") {
data.Category = "timeout"
data.Retryable = true
}
} else if errors.Is(err, context.Canceled) {
data.Category = "canceled"
data.SessionDisposition = "quarantine"
} else if errors.Is(err, context.DeadlineExceeded) || isTimeoutError(err) {
data.Category = "timeout"
data.SessionDisposition = "quarantine"
} else if isConnectionError(err) {
data.Category = "connection"
data.Retryable = stage == "connect" || stage == "validate"
if stage != "connect" {
data.SessionDisposition = "quarantine"
}
}
return &rpcError{Code: -1, Message: err.Error(), Data: data}
}
func rpcErrorStage(method string) string {
switch method {
case "connect", "open_session", "test_connection":
return "connect"
case "validate_connection", "validate_session":
return "validate"
case "cancel_session":
return "cancel"
case "close_session", "disconnect", "close_query_session", "close_table_read_session", "shutdown":
return "close"
case "fetch_query_page", "fetch_table_read_page":
return "fetch"
case "handshake", "":
return "request"
default:
return "execute"
}
}
func rpcOperationOutcome(stage string) string {
if stage == "request" || stage == "connect" || stage == "validate" {
return "not_started"
}
return "unknown"
}
func isTimeoutError(err error) bool {
var timeout interface{ Timeout() bool }
return errors.As(err, &timeout) && timeout.Timeout()
}
func isConnectionError(err error) bool {
if errors.Is(err, io.EOF) || errors.Is(err, net.ErrClosed) {
return true
}
var networkError *net.OpError
if errors.As(err, &networkError) {
return true
}
var executionError *client.ExecutionError
if errors.As(err, &executionError) {
return false
}
lower := strings.ToLower(err.Error())
for _, marker := range []string{
"connection refused", "connection reset", "broken pipe", "connection closed", "connection lost",
"unexpected eof", "no route to host", "no server can connect", "transport is not open",
} {
if strings.Contains(lower, marker) {
return true
}
}
return false
}
func safeRPCDiagnostic(value string, maxLength int) string {
var result strings.Builder
for _, char := range value {
if result.Len() >= maxLength {
break
}
if char >= 0x21 && char <= 0x7e {
result.WriteRune(char)
}
}
return result.String()
}
+515
View File
@@ -0,0 +1,515 @@
package main
import (
"context"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"math"
"strings"
"time"
"github.com/apache/iotdb-client-go/v2/client"
)
type querySession struct {
dataset *client.SessionDataSet
client *sessionClient
columns []string
columnTypes []string
prefetched []any
remaining int
}
type queryPageRead struct {
state *querySession
rows [][]any
hasMore bool
truncated bool
}
func (s *server) validateConnection() error {
values, err := s.queryValues("SHOW VERSION", "", 1, 0)
if err != nil {
return err
}
if len(values.Rows) == 0 {
return errors.New("IoTDB SHOW VERSION returned no rows")
}
return nil
}
func (s *server) executeQuery(options queryOptions) (queryResult, error) {
started := time.Now()
sql := trimStatementSQL(options.SQL)
if sql == "" {
return queryResult{}, errors.New("sql is required")
}
if !isQueryStatement(sql) {
if err := s.executeNonQuery(sql, effectiveDatabase(options), options.TimeoutSecs); err != nil {
return queryResult{}, err
}
return queryResult{
Columns: []string{},
ColumnTypes: []string{},
Rows: [][]any{},
AffectedRows: 0,
ExecutionTimeMS: time.Since(started).Milliseconds(),
}, nil
}
limit := options.MaxRows
if limit <= 0 {
limit = defaultMaxRows
}
values, err := s.queryValues(sql, effectiveDatabase(options), limit, options.TimeoutSecs)
if err != nil {
return queryResult{}, err
}
values.ExecutionTimeMS = time.Since(started).Milliseconds()
return values, nil
}
func (s *server) queryValues(sql, database string, limit, timeoutSecs int) (queryResult, error) {
connected, err := s.ensureClient()
if err != nil {
return queryResult{}, err
}
timeout := timeoutMilliseconds(timeoutSecs)
return runCancelable(s, timeoutSecs, connected, func(ctx context.Context) (queryResult, error) {
if err := s.applyDatabaseContext(ctx, connected, database); err != nil {
return queryResult{}, err
}
dataset, err := connected.session.ExecuteQueryStatement(ctx, sql, timeout)
if err != nil {
return queryResult{}, err
}
defer dataset.Close()
columns := append([]string(nil), dataset.GetColumnNames()...)
columnTypes := normalizedColumnTypes(dataset.GetColumnTypes(), len(columns))
rows, truncated, err := readDataset(ctx, dataset, connected.dialect, columns, columnTypes, limit)
if err != nil {
return queryResult{}, err
}
return queryResult{
Columns: columns,
ColumnTypes: columnTypes,
Rows: rows,
AffectedRows: 0,
Truncated: truncated,
}, nil
})
}
func (s *server) executeNonQuery(sql, database string, timeoutSecs int) error {
connected, err := s.ensureClient()
if err != nil {
return err
}
_, err = runCancelable(s, timeoutSecs, connected, func(ctx context.Context) (struct{}, error) {
if err := s.applyDatabaseContext(ctx, connected, database); err != nil {
return struct{}{}, err
}
return struct{}{}, connected.session.ExecuteNonQueryStatement(ctx, sql)
})
return err
}
func (s *server) executeQueryPage(options queryOptions, requestedPageSize int) (queryPageResult, error) {
started := time.Now()
sql := trimStatementSQL(options.SQL)
if sql == "" {
return queryPageResult{}, errors.New("sql is required")
}
if !isQueryStatement(sql) {
if err := s.executeNonQuery(sql, effectiveDatabase(options), options.TimeoutSecs); err != nil {
return queryPageResult{}, err
}
return queryPageResult{
Columns: []string{},
ColumnTypes: []string{},
Rows: [][]any{},
ExecutionTimeMS: time.Since(started).Milliseconds(),
}, nil
}
connected, err := s.ensureClient()
if err != nil {
return queryPageResult{}, err
}
timeout := timeoutMilliseconds(options.TimeoutSecs)
limit := options.MaxRows
if limit <= 0 {
limit = defaultMaxRows
}
pageSize := requestedPageSize
if pageSize <= 0 {
pageSize = options.FetchSize
}
if pageSize <= 0 {
pageSize = defaultPageSize
}
var dataset *client.SessionDataSet
page, err := runCancelable(s, options.TimeoutSecs, connected, func(ctx context.Context) (queryPageRead, error) {
if err := s.applyDatabaseContext(ctx, connected, effectiveDatabase(options)); err != nil {
return queryPageRead{}, err
}
dataset, err = connected.session.ExecuteQueryStatement(ctx, sql, timeout)
if err != nil {
return queryPageRead{}, err
}
queryState := &querySession{
dataset: dataset,
client: connected,
columns: append([]string(nil), dataset.GetColumnNames()...),
columnTypes: normalizedColumnTypes(dataset.GetColumnTypes(), len(dataset.GetColumnNames())),
remaining: limit,
}
rows, hasMore, truncated, err := s.readQuerySessionPage(ctx, queryState, pageSize)
return queryPageRead{state: queryState, rows: rows, hasMore: hasMore, truncated: truncated}, err
})
if err != nil {
if dataset != nil {
_ = dataset.Close()
}
return queryPageResult{}, err
}
result := queryPageResult{
Columns: page.state.columns,
ColumnTypes: page.state.columnTypes,
Rows: page.rows,
ExecutionTimeMS: time.Since(started).Milliseconds(),
Truncated: page.truncated,
HasMore: page.hasMore,
}
if page.hasMore {
s.nextSessionID++
sessionID := fmt.Sprintf("iotdb-go-%d", s.nextSessionID)
s.querySessions[sessionID] = page.state
result.SessionID = &sessionID
} else {
_ = dataset.Close()
}
return result, nil
}
func (s *server) fetchQueryPage(sessionID string, requestedPageSize int) (queryPageResult, error) {
started := time.Now()
queryState := s.querySessions[sessionID]
if queryState == nil {
return queryPageResult{}, fmt.Errorf("query session not found: %s", sessionID)
}
pageSize := requestedPageSize
if pageSize <= 0 {
pageSize = defaultPageSize
}
page, err := runCancelable(s, 0, queryState.client, func(ctx context.Context) (queryPageRead, error) {
rows, hasMore, truncated, err := s.readQuerySessionPage(ctx, queryState, pageSize)
return queryPageRead{state: queryState, rows: rows, hasMore: hasMore, truncated: truncated}, err
})
if err != nil {
_ = s.closeQuerySession(sessionID)
return queryPageResult{}, err
}
result := queryPageResult{
Columns: queryState.columns,
ColumnTypes: queryState.columnTypes,
Rows: page.rows,
ExecutionTimeMS: time.Since(started).Milliseconds(),
Truncated: page.truncated,
HasMore: page.hasMore,
}
if page.hasMore {
result.SessionID = &sessionID
} else {
_ = s.closeQuerySession(sessionID)
}
return result, nil
}
func (s *server) readQuerySessionPage(ctx context.Context, state *querySession, pageSize int) ([][]any, bool, bool, error) {
if pageSize <= 0 {
pageSize = defaultPageSize
}
target := min(pageSize, state.remaining)
rows := make([][]any, 0, target)
if state.prefetched != nil && target > 0 {
rows = append(rows, state.prefetched)
state.prefetched = nil
state.remaining--
}
for len(rows) < target {
row, hasNext, err := s.nextDatasetRow(ctx, state)
if err != nil {
return nil, false, false, err
}
if !hasNext {
return rows, false, false, nil
}
rows = append(rows, row)
state.remaining--
}
if state.remaining <= 0 {
_, hasNext, err := s.nextDatasetRow(ctx, state)
if err != nil {
return nil, false, false, err
}
return rows, false, hasNext, nil
}
next, hasNext, err := s.nextDatasetRow(ctx, state)
if err != nil {
return nil, false, false, err
}
if !hasNext {
return rows, false, false, nil
}
state.prefetched = next
return rows, true, false, nil
}
func (s *server) nextDatasetRow(ctx context.Context, state *querySession) ([]any, bool, error) {
if err := ctx.Err(); err != nil {
return nil, false, err
}
hasNext, err := state.dataset.Next()
if err != nil || !hasNext {
return nil, false, err
}
if err := ctx.Err(); err != nil {
return nil, false, err
}
row, err := datasetRow(state.dataset, state.client.dialect, state.columns, state.columnTypes)
return row, true, err
}
func (s *server) closeQuerySession(sessionID string) bool {
state := s.querySessions[sessionID]
delete(s.querySessions, sessionID)
if state == nil {
return false
}
_ = state.dataset.Close()
return true
}
func (s *server) closeAllQuerySessions() error {
var firstErr error
for sessionID, state := range s.querySessions {
if err := state.dataset.Close(); err != nil && firstErr == nil {
firstErr = err
}
delete(s.querySessions, sessionID)
}
return firstErr
}
func (s *server) executeStatements(params map[string]json.RawMessage, transaction bool) (queryResult, error) {
started := time.Now()
statements := stringSliceParam(params, "statements")
if len(statements) == 0 {
return queryResult{}, errors.New("statements are required")
}
database := strings.TrimSpace(stringParam(params, "schema"))
for _, statement := range statements {
sql := trimStatementSQL(statement)
if sql == "" {
continue
}
if isQueryStatement(sql) {
values, err := s.queryValues(sql, database, defaultMaxRows, 0)
if err != nil {
return queryResult{}, err
}
_ = values
} else if err := s.executeNonQuery(sql, database, 0); err != nil {
return queryResult{}, err
}
}
return queryResult{
Columns: []string{},
ColumnTypes: []string{},
Rows: [][]any{},
AffectedRows: 0,
ExecutionTimeMS: time.Since(started).Milliseconds(),
Truncated: false,
}, nil
}
func (s *server) applyDatabaseContext(ctx context.Context, connected *sessionClient, database string) error {
if connected.dialect != client.TableSqlDialect {
return nil
}
database = strings.TrimSpace(database)
if database == "" {
database = strings.TrimSpace(s.config.Database)
}
if database == "" || strings.EqualFold(database, "information_schema") {
return nil
}
return connected.session.ExecuteNonQueryStatement(ctx, "USE "+quoteTableIdentifier(database))
}
func runCancelable[T any](s *server, timeoutSecs int, connected *sessionClient, operation func(context.Context) (T, error)) (T, error) {
var zero T
ctx := context.Background()
var cancel context.CancelFunc
if timeoutSecs > 0 {
ctx, cancel = context.WithTimeout(ctx, time.Duration(timeoutSecs)*time.Second)
} else {
ctx, cancel = context.WithCancel(ctx)
}
s.setActiveOperation(cancel)
defer s.clearActiveOperation(cancel)
value, err := operation(ctx)
if ctxErr := ctx.Err(); ctxErr != nil {
s.invalidateClient(connected)
return zero, ctxErr
}
if err != nil && isConnectionError(err) {
s.invalidateClient(connected)
}
return value, err
}
func readDataset(ctx context.Context, dataset *client.SessionDataSet, dialect string, columns, columnTypes []string, limit int) ([][]any, bool, error) {
if limit <= 0 {
limit = defaultMaxRows
}
rows := make([][]any, 0, min(limit, defaultPageSize))
for len(rows) < limit {
if err := ctx.Err(); err != nil {
return nil, false, err
}
hasNext, err := dataset.Next()
if err != nil {
return nil, false, err
}
if !hasNext {
return rows, false, nil
}
if err := ctx.Err(); err != nil {
return nil, false, err
}
row, err := datasetRow(dataset, dialect, columns, columnTypes)
if err != nil {
return nil, false, err
}
rows = append(rows, row)
}
if err := ctx.Err(); err != nil {
return nil, false, err
}
hasNext, err := dataset.Next()
return rows, hasNext, err
}
func datasetRow(dataset *client.SessionDataSet, dialect string, columns, columnTypes []string) ([]any, error) {
row := make([]any, len(columns))
for index := range columns {
columnIndex := int32(index + 1)
columnType := ""
if index < len(columnTypes) {
columnType = strings.ToUpper(columnTypes[index])
}
if dialect == client.TreeSqlDialect && index == 0 && strings.EqualFold(columns[index], "Time") {
value, err := dataset.GetStringByIndex(columnIndex)
if err != nil {
return nil, err
}
row[index] = value
continue
}
value, err := dataset.GetObjectByIndex(columnIndex)
if err != nil {
return nil, err
}
row[index] = normalizeIoTDBValue(value, columnType)
}
return row, nil
}
func normalizeIoTDBValue(value any, columnType string) any {
switch typed := value.(type) {
case nil:
return nil
case time.Time:
if columnType == "DATE" {
return typed.Format("2006-01-02")
}
return typed.Format(time.RFC3339Nano)
case []byte:
return hex.EncodeToString(typed)
case float32:
if math.IsNaN(float64(typed)) || math.IsInf(float64(typed), 0) {
return fmt.Sprint(typed)
}
return typed
case float64:
if math.IsNaN(typed) || math.IsInf(typed, 0) {
return fmt.Sprint(typed)
}
return typed
default:
return typed
}
}
func normalizedColumnTypes(values []string, columnCount int) []string {
result := make([]string, columnCount)
for index := range result {
if index < len(values) {
result[index] = strings.ToUpper(values[index])
}
}
return result
}
func timeoutMilliseconds(timeoutSecs int) *int64 {
if timeoutSecs <= 0 {
return nil
}
value := int64(timeoutSecs) * 1000
return &value
}
func effectiveDatabase(options queryOptions) string {
if value := strings.TrimSpace(options.Schema); value != "" {
return value
}
return strings.TrimSpace(options.Database)
}
func trimStatementSQL(sql string) string {
trimmed := strings.TrimSpace(sql)
for strings.HasSuffix(trimmed, ";") {
trimmed = strings.TrimSpace(strings.TrimSuffix(trimmed, ";"))
}
return trimmed
}
func isQueryStatement(sql string) bool {
fields := strings.Fields(strings.TrimSpace(sql))
if len(fields) == 0 {
return false
}
switch strings.ToUpper(fields[0]) {
case "SELECT", "SHOW", "DESC", "DESCRIBE", "EXPLAIN", "WITH":
return true
default:
return false
}
}
func quoteTableIdentifier(value string) string {
return `"` + strings.ReplaceAll(value, `"`, `""`) + `"`
}
func rawParams(values map[string]any) map[string]json.RawMessage {
result := make(map[string]json.RawMessage, len(values))
for key, value := range values {
data, _ := json.Marshal(value)
result[key] = data
}
return result
}
@@ -1,46 +0,0 @@
package com.dbx.agent.iotdb;
import com.dbx.agent.ConfiguredJdbcAgent;
import com.dbx.agent.DatabaseInfo;
import com.dbx.agent.JdbcAgentProfile;
import com.dbx.agent.MultiSessionJsonRpcServer;
import java.sql.ResultSet;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
public final class IoTDBAgent extends ConfiguredJdbcAgent {
public static final JdbcAgentProfile IOTDB_PROFILE = new JdbcAgentProfile(
"org.apache.iotdb.jdbc.IoTDBDriver",
"jdbc:iotdb://{host}:{port}/",
6667,
true
);
public IoTDBAgent() {
super(IOTDB_PROFILE);
}
// IoTDB 的 database 是 `root.xxx` 路径前缀;JDBC metadata 会把同一路径同时投影成 catalog/schema。
// 这里使用 IoTDB 原生语句作为 database 列表来源,避免依赖驱动的关系型兼容层。
@Override
public List<DatabaseInfo> listDatabases() {
return unchecked(() -> {
List<DatabaseInfo> result = new ArrayList<>();
try (Statement stmt = requireConnection().createStatement();
ResultSet rs = stmt.executeQuery("SHOW DATABASES")) {
while (rs.next()) {
String name = rs.getString(1);
if (name != null && !name.trim().isEmpty()) {
result.add(new DatabaseInfo(name.trim()));
}
}
}
return result;
});
}
public static void main(String[] args) {
new MultiSessionJsonRpcServer(IoTDBAgent::new).run();
}
}
@@ -1,32 +0,0 @@
package com.dbx.agent.iotdb;
import com.dbx.agent.ConnectParams;
import com.dbx.agent.DatabaseAgent;
import com.dbx.agent.test.JdbcFakeExecutionBehaviorTest;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
class IoTDBAgentTest extends JdbcFakeExecutionBehaviorTest {
@Override
protected DatabaseAgent createAgent() {
return new IoTDBAgent();
}
@Override
protected String resultSetSql() {
return "SELECT * FROM sample_table";
}
@Test
void buildsOfficialJdbcUrl() {
ConnectParams params = new ConnectParams();
params.setHost("iotdb.example.com");
params.setPort(6667);
params.setUrl_params("sql_dialect=table&useSSL=false");
Assertions.assertEquals(
"jdbc:iotdb://iotdb.example.com:6667/?sql_dialect=table&useSSL=false",
IoTDBAgent.IOTDB_PROFILE.buildUrl(params)
);
}
}
+1
View File
@@ -11,6 +11,7 @@ goldendb native-pushdown java-sql Uses information_schema with type, filter, sta
h2 native-pushdown java-sql Uses INFORMATION_SCHEMA with type, filter, stable order, and LIMIT/OFFSET.
hive intentional-fallback java-sql Uses Hive metadata paths where stable server-side fuzzy paging is not portable, common constraints filter locally.
informix native-pushdown java-sql Uses systables/sysprocedures with type, filter, stable order, and SKIP/FIRST.
iotdb shared-fallback native-go Uses SHOW DEVICES or SHOW TABLES DETAILS for Tree/Table metadata, then applies stable filtering and paging in the native agent.
kingbase-go shared-fallback native-go Uses sys_catalog/information_schema for compatibility-aware metadata, then applies stable filtering and paging in the native agent.
vastbase-go shared-fallback native-go Uses pg_catalog/information_schema for Vastbase metadata, then applies stable filtering and paging in the native agent.
kylin intentional-fallback java-jdbc-metadata Uses JDBC metadata from Kylin driver; no portable server-side paging API, common constraints filter locally.
1 driver strategy scope reason
11 h2 native-pushdown java-sql Uses INFORMATION_SCHEMA with type, filter, stable order, and LIMIT/OFFSET.
12 hive intentional-fallback java-sql Uses Hive metadata paths where stable server-side fuzzy paging is not portable, common constraints filter locally.
13 informix native-pushdown java-sql Uses systables/sysprocedures with type, filter, stable order, and SKIP/FIRST.
14 iotdb shared-fallback native-go Uses SHOW DEVICES or SHOW TABLES DETAILS for Tree/Table metadata, then applies stable filtering and paging in the native agent.
15 kingbase-go shared-fallback native-go Uses sys_catalog/information_schema for compatibility-aware metadata, then applies stable filtering and paging in the native agent.
16 vastbase-go shared-fallback native-go Uses pg_catalog/information_schema for Vastbase metadata, then applies stable filtering and paging in the native agent.
17 kylin intentional-fallback java-jdbc-metadata Uses JDBC metadata from Kylin driver; no portable server-side paging API, common constraints filter locally.
@@ -35,6 +35,7 @@ class DriverReleasePackagesTest(unittest.TestCase):
"oracle": "0.1.10",
"xugu": "0.1.20",
"kingbase": "0.1.34",
"iotdb": "0.1.30",
"neo4j": "0.1.40",
"vastbase": "0.1.37",
"duckdb": "0.1.0",
@@ -245,6 +246,21 @@ class DriverReleasePackagesTest(unittest.TestCase):
self.assertFalse(source.exists())
self.assertEqual(versioned.read_bytes(), b"\xcf\xfa\xed\xfetest-neo4j-agent")
def test_versions_iotdb_native_artifacts(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
release_dir = Path(temp_dir)
source = release_dir / "dbx-agent-iotdb-linux-x64"
source.write_bytes(b"\x7fELFtest-iotdb-agent")
versions = {driver: "0.1.0" for driver in NATIVE_DRIVERS}
versions["iotdb"] = "0.1.30"
renamed = version_agent_artifacts(release_dir, versions)
versioned = release_dir / "dbx-agent-iotdb-0.1.30-linux-x64"
self.assertEqual(renamed, [versioned])
self.assertFalse(source.exists())
self.assertEqual(versioned.read_bytes(), b"\x7fELFtest-iotdb-agent")
def test_full_offline_bundle_includes_supported_windows_artifacts(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
release_dir = Path(temp_dir)
+1
View File
@@ -17,6 +17,7 @@ NATIVE_ONLY_AGENT_MODULES = {
"duckdb": "drivers/duckdb",
"oracle": "drivers/oracle-go",
"kingbase": "drivers/kingbase-go",
"iotdb": "drivers/iotdb",
"neo4j": "drivers/neo4j-go",
"vastbase": "drivers/vastbase-go",
"tdengine": "drivers/tdengine",
+2 -1
View File
@@ -140,6 +140,7 @@ class ValidateAgentsTest(unittest.TestCase):
"cassandra-go",
"oracle-go",
"kingbase-go",
"iotdb",
"neo4j-go",
"vastbase-go",
"xugu",
@@ -149,7 +150,7 @@ class ValidateAgentsTest(unittest.TestCase):
):
(root / "drivers" / driver).mkdir(parents=True)
(root / "versions.json").write_text(
json.dumps({"h2": "0.1.0", "cassandra": "0.1.0", "oracle": "0.1.0", "kingbase": "0.1.0", "neo4j": "0.1.0", "vastbase": "0.1.0", "xugu": "0.1.0", "rabbitmq": "0.1.0", "tdengine": "0.1.0"}),
json.dumps({"h2": "0.1.0", "cassandra": "0.1.0", "oracle": "0.1.0", "kingbase": "0.1.0", "iotdb": "0.1.0", "neo4j": "0.1.0", "vastbase": "0.1.0", "xugu": "0.1.0", "rabbitmq": "0.1.0", "tdengine": "0.1.0"}),
encoding="utf-8",
)
+1 -1
View File
@@ -4,7 +4,7 @@ import json
from pathlib import Path
NATIVE_DRIVERS = ("cassandra", "oracle", "xugu", "kingbase", "neo4j", "vastbase", "duckdb", "rabbitmq", "tdengine")
NATIVE_DRIVERS = ("cassandra", "oracle", "xugu", "kingbase", "iotdb", "neo4j", "vastbase", "duckdb", "rabbitmq", "tdengine")
PLATFORMS = (
"macos-aarch64",
"macos-x64",
+1 -1
View File
@@ -6,7 +6,7 @@ def driverModules = [
'teradata', 'vertica', 'firebird', 'exasol', 'oceanbase-oracle', 'gbase8a', 'gbase8s',
'bigquery', 'kylin', 'sundb', 'h2', 'h2-legacy', 'snowflake', 'trino', 'hive', 'spark',
'db2', 'informix', 'mongodb', 'highgo', 'uxdb', 'yashandb', 'oscar',
'iris', 'iotdb', 'etcd', 'zookeeper', 'kafka', 'rocketmq', 'sqlserver-legacy'
'iris', 'etcd', 'zookeeper', 'kafka', 'rocketmq', 'sqlserver-legacy'
]
include(*(infrastructureModules + driverModules))