This is an automated email from the ASF dual-hosted git repository. CritasWang pushed a commit to branch main in repository https://gitbox.apache.org/repos/asf/iotdb-client-rust.git
commit bb1a1d0ac6f6b387cee589a1024bed1f60cd02d5 Author: CritasWang <[email protected]> AuthorDate: Mon Jul 13 13:53:34 2026 +0800 V6: write benchmark suite (examples/benchmark.rs) mirroring Node.js/iot-benchmark methodology; tree 9.7M pts/s @ 10M points, 8 clients, 1000-row tablets --- README.md | 25 ++ README_ZH.md | 25 ++ examples/benchmark.rs | 726 ++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 776 insertions(+) diff --git a/README.md b/README.md index 746f805..a6bc084 100644 --- a/README.md +++ b/README.md @@ -208,6 +208,31 @@ docker compose up -d # standalone IoTDB (see docker-compose-1c1d.yml for a 1C1 cargo test # now includes the live-server tests ``` +## Benchmark + +`examples/benchmark.rs` is a write-performance benchmark modeled on the Node.js client's `benchmark/` suite (which follows [thulab/iot-benchmark](https://github.com/thulab/iot-benchmark)); metric definitions match, so results are comparable across the SDKs. Tablets are pre-generated outside the timed section; N worker threads each own a pooled session and insert `insert_tablet` batches round-robin over their devices. Timestamps are sequential per device from a fixed base, so runs are det [...] + +```sh +# tree model, defaults: 100 devices × 10 sensors × 20 batches × 1000 rows = 20M points, 8 clients +cargo run --release --example benchmark -- --mode tree + +# table model at a custom scale, dropping the database afterwards +cargo run --release --example benchmark -- --mode table \ + --devices 20 --sensors 10 --batches 100 --batch-size 100 --clients 8 --cleanup +``` + +Knobs: `--mode tree|table`, `--devices`, `--sensors`, `--batches` (per device), `--batch-size` (rows per tablet), `--clients` (worker threads = pool size), `--host/--port/--user/--password` (also via `IOTDB_HOST/PORT/USER/PASSWORD`), `--base-ts`, `--point-step`, `--cleanup`. Sensor types follow the Node.js default distribution (30% FLOAT, 20% DOUBLE, 20% INT32, 10% INT64, 10% TEXT, 10% BOOLEAN). The report includes total points, wall time, points/sec, per-batch latency p50/p90/p95/p99/ma [...] + +Measured on an Apple M2 Pro (10 cores), IoTDB 2.0.6 standalone in Docker on the same machine, release build, 8 clients: + +| Mode | Devices × Sensors × Batches × Rows | Points | Throughput | p50 / p99 latency | +| --- | --- | --- | --- | --- | +| tree | 20 × 10 × 100 × 100 | 2M | ~1.98M pts/s | 2.46 ms / 8.38 ms | +| table | 20 × 10 × 100 × 100 | 2M | ~1.97M pts/s | 2.13 ms / 9.97 ms | +| tree | 100 × 10 × 10 × 1000 | 10M | ~9.73M pts/s | 4.36 ms / 72.18 ms | + +Throughput scales with tablet size (rows × sensors per RPC); 1000-row tablets give ~5× the throughput of 100-row tablets at the same client count. Numbers are client+server on one machine — treat them as an upper bound on client overhead, not a server capacity measurement. + ## Project layout | Path | Contents | diff --git a/README_ZH.md b/README_ZH.md index d743d31..2a5cb36 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -208,6 +208,31 @@ docker compose up -d # 单机版 IoTDB(1C1D 集群拓扑见 docker-compose-1 cargo test # 此时包含在线测试 ``` +## 性能基准测试 + +`examples/benchmark.rs` 是写入性能基准,参考 Node.js 客户端的 `benchmark/` 套件(后者遵循 [thulab/iot-benchmark](https://github.com/thulab/iot-benchmark));指标定义一致,因此各语言 SDK 的结果可直接对比。Tablet 在计时区间之外预先生成;N 个工作线程各持有一个池化会话,按批次轮询各自的设备执行 `insert_tablet`。时间戳从固定基准按设备顺序递增,运行结果可复现。 + +```sh +# 树模型,默认规模:100 设备 × 10 传感器 × 20 批 × 1000 行 = 2000 万点,8 客户端 +cargo run --release --example benchmark -- --mode tree + +# 表模型,自定义规模,结束后删除数据库 +cargo run --release --example benchmark -- --mode table \ + --devices 20 --sensors 10 --batches 100 --batch-size 100 --clients 8 --cleanup +``` + +参数:`--mode tree|table`、`--devices`、`--sensors`、`--batches`(每设备批数)、`--batch-size`(每 tablet 行数)、`--clients`(工作线程数 = 池大小)、`--host/--port/--user/--password`(亦支持 `IOTDB_HOST/PORT/USER/PASSWORD` 环境变量)、`--base-ts`、`--point-step`、`--cleanup`。传感器类型分布沿用 Node.js 默认比例(30% FLOAT、20% DOUBLE、20% INT32、10% INT64、10% TEXT、10% BOOLEAN)。报告包含总点数、耗时、points/sec、单批延迟 p50/p90/p95/p99/max、错误数,以及读回行数校验。 + +实测环境:Apple M2 Pro(10 核),IoTDB 2.0.6 standalone(Docker,与客户端同机),release 构建,8 客户端: + +| 模式 | 设备 × 传感器 × 批数 × 行数 | 总点数 | 吞吐量 | p50 / p99 延迟 | +| --- | --- | --- | --- | --- | +| tree | 20 × 10 × 100 × 100 | 200 万 | ~198 万 pts/s | 2.46 ms / 8.38 ms | +| table | 20 × 10 × 100 × 100 | 200 万 | ~197 万 pts/s | 2.13 ms / 9.97 ms | +| tree | 100 × 10 × 10 × 1000 | 1000 万 | ~973 万 pts/s | 4.36 ms / 72.18 ms | + +吞吐量随 tablet 大小(每 RPC 的行 × 传感器数)增长;同等客户端数下 1000 行 tablet 的吞吐约为 100 行的 5 倍。数据为客户端与服务器同机测得——应视为客户端开销的上界,而非服务器容量。 + ## 项目结构 | 路径 | 内容 | diff --git a/examples/benchmark.rs b/examples/benchmark.rs new file mode 100644 index 0000000..9c1fe3b --- /dev/null +++ b/examples/benchmark.rs @@ -0,0 +1,726 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Write-performance benchmark for the tree and table models, modeled on the +//! Node.js client's `benchmark/` suite (which in turn follows +//! [thulab/iot-benchmark](https://github.com/thulab/iot-benchmark)). Metric +//! definitions match the Node.js `MetricsCollector` so results are directly +//! comparable: per-`insert_tablet` latency percentiles (p = ceil(p% × n) − 1 +//! over the sorted samples), points/sec over the wall time of the timed +//! insert phase only. Schema setup and data generation happen **outside** +//! the timed section ("Pre-generated Test Data"); timestamps are sequential +//! per device from a fixed base, so runs are deterministic. +//! +//! Sensor data types follow the Node.js default distribution: +//! 30% FLOAT, 20% DOUBLE, 20% INT32, 10% INT64, 10% TEXT, 10% BOOLEAN. +//! +//! Run against a live IoTDB (release mode, or the client dominates): +//! +//! ```sh +//! cargo run --release --example benchmark -- --mode tree \ +//! --devices 20 --sensors 10 --batches 100 --batch-size 100 --clients 8 +//! cargo run --release --example benchmark -- --mode table --cleanup +//! ``` +//! +//! Connection defaults honor `IOTDB_HOST` / `IOTDB_PORT` / `IOTDB_USER` / +//! `IOTDB_PASSWORD` like the e2e tests; CLI flags override env vars. + +use std::env; +use std::process::exit; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::thread; +use std::time::{Duration, Instant}; + +use iotdb_client::{ + ColumnCategory, PooledSession, Result, SessionPool, SessionPoolConfig, TSDataType, + TableSessionPool, Tablet, Value, +}; + +const TREE_DB: &str = "root.benchmark"; +const TABLE_DB: &str = "benchmark_db"; +const TABLE_NAME: &str = "benchmark_table"; +/// Node.js `STRING_LENGTH` default. +const TEXT_LEN: usize = 16; +/// Progress report interval (Node.js `REPORT_INTERVAL` default 5000 ms). +const REPORT_INTERVAL: Duration = Duration::from_secs(5); + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Mode { + Tree, + Table, +} + +struct BenchConfig { + mode: Mode, + devices: usize, + sensors: usize, + batches: usize, + batch_size: usize, + clients: usize, + host: String, + port: u16, + user: String, + password: String, + /// Fixed base timestamp (epoch ms) — deterministic, no wall clock in + /// the data path. + base_ts: i64, + /// Time interval between consecutive points per device (ms), Node.js + /// `POINT_STEP`. + point_step: i64, + cleanup: bool, +} + +impl BenchConfig { + fn total_points(&self) -> u64 { + (self.devices * self.sensors * self.batches * self.batch_size) as u64 + } +} + +const USAGE: &str = "IoTDB Rust client write benchmark + +USAGE: + cargo run --release --example benchmark -- [OPTIONS] + +OPTIONS: + --mode <tree|table> Data model to benchmark (default: tree) + --devices <N> Number of devices (default: 100) + --sensors <N> Sensors (FIELD columns) per device (default: 10) + --batches <N> Batches (tablets) per device (default: 20) + --batch-size <N> Rows per tablet (default: 1000) + --clients <N> Worker threads = session pool size (default: 8) + --host <HOST> Server host (default: $IOTDB_HOST or 127.0.0.1) + --port <PORT> Server port (default: $IOTDB_PORT or 6667) + --user <USER> Username (default: $IOTDB_USER or root) + --password <PASS> Password (default: $IOTDB_PASSWORD or root) + --base-ts <MS> Base epoch-ms timestamp (default: 1720000000000) + --point-step <MS> Interval between points per device (default: 1000) + --cleanup Drop the benchmark database after the run + --help Print this help + +Total points = devices x sensors x batches x batch-size +(defaults: 100 x 10 x 20 x 1000 = 20,000,000)."; + +fn env_or(key: &str, default: &str) -> String { + env::var(key).unwrap_or_else(|_| default.to_string()) +} + +fn parse_args() -> BenchConfig { + let mut config = BenchConfig { + mode: Mode::Tree, + devices: 100, + sensors: 10, + batches: 20, + batch_size: 1000, + clients: 8, + host: env_or("IOTDB_HOST", "127.0.0.1"), + port: env_or("IOTDB_PORT", "6667").parse().unwrap_or(6667), + user: env_or("IOTDB_USER", "root"), + password: env_or("IOTDB_PASSWORD", "root"), + base_ts: 1_720_000_000_000, + point_step: 1000, + cleanup: false, + }; + + let args: Vec<String> = env::args().skip(1).collect(); + let mut i = 0; + let value = |i: &mut usize, flag: &str| -> String { + *i += 1; + args.get(*i).cloned().unwrap_or_else(|| { + eprintln!("missing value for {flag}\n\n{USAGE}"); + exit(2); + }) + }; + while i < args.len() { + let flag = args[i].as_str(); + match flag { + "--mode" => { + config.mode = match value(&mut i, flag).as_str() { + "tree" => Mode::Tree, + "table" => Mode::Table, + other => { + eprintln!("invalid --mode `{other}` (expected tree|table)"); + exit(2); + } + } + } + "--devices" => config.devices = parse_num(&value(&mut i, flag), flag), + "--sensors" => config.sensors = parse_num(&value(&mut i, flag), flag), + "--batches" => config.batches = parse_num(&value(&mut i, flag), flag), + "--batch-size" => config.batch_size = parse_num(&value(&mut i, flag), flag), + "--clients" => config.clients = parse_num(&value(&mut i, flag), flag), + "--host" => config.host = value(&mut i, flag), + "--port" => config.port = parse_num(&value(&mut i, flag), flag) as u16, + "--user" => config.user = value(&mut i, flag), + "--password" => config.password = value(&mut i, flag), + "--base-ts" => config.base_ts = parse_num(&value(&mut i, flag), flag) as i64, + "--point-step" => config.point_step = parse_num(&value(&mut i, flag), flag) as i64, + "--cleanup" => config.cleanup = true, + "--help" | "-h" => { + println!("{USAGE}"); + exit(0); + } + other => { + eprintln!("unknown flag `{other}`\n\n{USAGE}"); + exit(2); + } + } + i += 1; + } + + for (name, v) in [ + ("--devices", config.devices), + ("--sensors", config.sensors), + ("--batches", config.batches), + ("--batch-size", config.batch_size), + ("--clients", config.clients), + ] { + if v == 0 { + eprintln!("{name} must be positive"); + exit(2); + } + } + config +} + +fn parse_num(s: &str, flag: &str) -> usize { + s.parse().unwrap_or_else(|_| { + eprintln!("invalid numeric value `{s}` for {flag}"); + exit(2); + }) +} + +// --------------------------------------------------------------------------- +// Deterministic data generation (Node.js data-generator.js distribution) +// --------------------------------------------------------------------------- + +/// Sensor `i` of `n` gets its type from the Node.js default +/// `INSERT_DATATYPE_PROPORTION`: FLOAT 0.3, DOUBLE 0.2, INT32 0.2, +/// INT64 0.1, TEXT 0.1, BOOLEAN 0.1. +fn sensor_type(i: usize, n: usize) -> TSDataType { + let f = i as f64 / n as f64; + if f < 0.3 { + TSDataType::Float + } else if f < 0.5 { + TSDataType::Double + } else if f < 0.7 { + TSDataType::Int32 + } else if f < 0.8 { + TSDataType::Int64 + } else if f < 0.9 { + TSDataType::Text + } else { + TSDataType::Boolean + } +} + +fn type_name(ty: TSDataType) -> &'static str { + match ty { + TSDataType::Float => "FLOAT", + TSDataType::Double => "DOUBLE", + TSDataType::Int32 => "INT32", + TSDataType::Int64 => "INT64", + TSDataType::Text => "TEXT", + TSDataType::Boolean => "BOOLEAN", + other => unreachable!("benchmark does not generate {other:?}"), + } +} + +/// Deterministic cell value from (device, sensor, row) — no RNG dependency. +fn cell_value(ty: TSDataType, device: usize, sensor: usize, row: usize) -> Value { + let seed = (row as u64) + .wrapping_mul(31) + .wrapping_add((sensor as u64).wrapping_mul(7)) + .wrapping_add(device as u64); + match ty { + TSDataType::Float => Value::Float((seed % 1000) as f32 * 0.1), + TSDataType::Double => Value::Double((seed % 10_000) as f64 * 0.01), + TSDataType::Int32 => Value::Int32((seed % 100_000) as i32), + TSDataType::Int64 => Value::Int64(seed as i64), + TSDataType::Text => Value::Text(format!( + "v{:0width$}", + seed % 1_000_000_000, + width = TEXT_LEN - 1 + )), + TSDataType::Boolean => Value::Boolean(seed % 2 == 0), + other => unreachable!("benchmark does not generate {other:?}"), + } +} + +/// One tablet = one batch for one device. Timestamps are sequential per +/// device: `base_ts + (batch*batch_size + row) * point_step` — already +/// sorted, so the client's sort pass is a no-op. +fn build_tablet( + config: &BenchConfig, + device: usize, + batch: usize, + sensor_names: &[String], + sensor_types: &[TSDataType], +) -> Result<Tablet> { + let mut tablet = match config.mode { + Mode::Tree => Tablet::new( + format!("{TREE_DB}.d{device}"), + sensor_names.to_vec(), + sensor_types.to_vec(), + )?, + Mode::Table => { + let mut names = Vec::with_capacity(sensor_names.len() + 1); + names.push("device_id".to_string()); + names.extend_from_slice(sensor_names); + let mut types = Vec::with_capacity(sensor_types.len() + 1); + types.push(TSDataType::String); + types.extend_from_slice(sensor_types); + let mut categories = vec![ColumnCategory::Tag]; + categories.extend(vec![ColumnCategory::Field; sensor_types.len()]); + Tablet::new_table(TABLE_NAME, names, types, categories)? + } + }; + + for r in 0..config.batch_size { + let row_index = batch * config.batch_size + r; + let ts = config.base_ts + row_index as i64 * config.point_step; + let mut row: Vec<Option<Value>> = Vec::with_capacity(sensor_types.len() + 1); + if config.mode == Mode::Table { + row.push(Some(Value::String(format!("d{device}")))); + } + for (s, &ty) in sensor_types.iter().enumerate() { + row.push(Some(cell_value(ty, device, s, row_index))); + } + tablet.add_row(ts, row)?; + } + Ok(tablet) +} + +// --------------------------------------------------------------------------- +// Pool abstraction (tree vs table) — both hand out the same PooledSession +// --------------------------------------------------------------------------- + +enum Pool { + Tree(SessionPool), + Table(TableSessionPool), +} + +impl Pool { + fn acquire(&self) -> Result<PooledSession<'_>> { + match self { + Pool::Tree(p) => p.acquire(), + Pool::Table(p) => p.acquire(), + } + } + + fn execute_non_query(&self, sql: &str) -> Result<()> { + match self { + Pool::Tree(p) => p.execute_non_query(sql), + Pool::Table(p) => p.execute_non_query(sql), + } + } + + fn close(&self) { + match self { + Pool::Tree(p) => p.close(), + Pool::Table(p) => p.close(), + } + } +} + +fn create_pool(config: &BenchConfig) -> Result<Pool> { + let mut pool_config = SessionPoolConfig { + // One dedicated session per worker; open all of them eagerly so + // connection setup stays outside the timed section. + max_size: config.clients, + min_size: config.clients, + ..SessionPoolConfig::default() + } + .with_node_urls(&[format!("{}:{}", config.host, config.port)])?; + pool_config.session.username = config.user.clone(); + pool_config.session.password = config.password.clone(); + Ok(match config.mode { + Mode::Tree => Pool::Tree(SessionPool::new(pool_config)?), + Mode::Table => Pool::Table(TableSessionPool::new(pool_config)?), + }) +} + +// --------------------------------------------------------------------------- +// Schema setup / cleanup (outside the timed section) +// --------------------------------------------------------------------------- + +fn setup_schema( + pool: &Pool, + config: &BenchConfig, + sensor_names: &[String], + sensor_types: &[TSDataType], +) -> Result<()> { + match config.mode { + Mode::Tree => { + // Fresh database each run; ignore "does not exist" on the drop. + let _ = pool.execute_non_query(&format!("DELETE DATABASE {TREE_DB}")); + pool.execute_non_query(&format!("CREATE DATABASE {TREE_DB}"))?; + // Pre-register every timeseries so metadata creation cost stays + // out of the write path (Node.js schema-manager behavior). + let mut session = pool.acquire()?; + for device in 0..config.devices { + for (name, &ty) in sensor_names.iter().zip(sensor_types) { + session.execute_non_query(&format!( + "CREATE TIMESERIES {TREE_DB}.d{device}.{name} WITH DATATYPE={}, ENCODING=PLAIN", + type_name(ty) + ))?; + } + } + } + Mode::Table => { + let _ = pool.execute_non_query(&format!("DROP DATABASE IF EXISTS {TABLE_DB}")); + pool.execute_non_query(&format!("CREATE DATABASE {TABLE_DB}"))?; + // The pool replays the last USE on every acquire, so all worker + // sessions land in the right database. + pool.execute_non_query(&format!("USE {TABLE_DB}"))?; + let columns: Vec<String> = std::iter::once("device_id STRING TAG".to_string()) + .chain( + sensor_names + .iter() + .zip(sensor_types) + .map(|(name, &ty)| format!("{name} {} FIELD", type_name(ty))), + ) + .collect(); + pool.execute_non_query(&format!( + "CREATE TABLE IF NOT EXISTS {TABLE_NAME} ({})", + columns.join(", ") + ))?; + } + } + Ok(()) +} + +fn cleanup_schema(pool: &Pool, mode: Mode) -> Result<()> { + match mode { + Mode::Tree => pool.execute_non_query(&format!("DELETE DATABASE {TREE_DB}")), + Mode::Table => pool.execute_non_query(&format!("DROP DATABASE {TABLE_DB}")), + } +} + +/// Post-run sanity check: read back the row count for the whole run. +fn verify_row_count(pool: &Pool, config: &BenchConfig) -> Result<()> { + let expected_rows = (config.devices * config.batches * config.batch_size) as i64; + let sql = match config.mode { + // COUNT over one representative sensor across all devices. + Mode::Tree => format!("SELECT COUNT(s_0) FROM {TREE_DB}.*"), + Mode::Table => format!("SELECT COUNT(*) FROM {TABLE_NAME}"), + }; + let mut session = pool.acquire()?; + let mut dataset = session.execute_query(&sql)?; + let mut total: i64 = 0; + while let Some(row) = dataset.next_row()? { + for v in &row.values { + if let Value::Int64(n) = v { + total += n; + } + } + } + let status = if total == expected_rows { + "OK" + } else { + "MISMATCH" + }; + println!("[Verify] rows on server: {total} (expected {expected_rows}) — {status}"); + Ok(()) +} + +// --------------------------------------------------------------------------- +// Metrics (definitions match Node.js benchmark-core.js MetricsCollector) +// --------------------------------------------------------------------------- + +#[derive(Default)] +struct WorkerStats { + /// One entry per insert_tablet call (ms), success or failure. + latencies_ms: Vec<f64>, + ops: u64, + failures: u64, + /// Points from successful operations only. + points: u64, + error_samples: Vec<String>, +} + +/// Node.js `getPercentile`: index = ceil(p/100 × n) − 1 over ascending samples. +fn percentile(sorted: &[f64], p: f64) -> f64 { + if sorted.is_empty() { + return 0.0; + } + let index = ((p / 100.0 * sorted.len() as f64).ceil() as usize).saturating_sub(1); + sorted[index.min(sorted.len() - 1)] +} + +fn print_summary(config: &BenchConfig, wall: Duration, workers: &[WorkerStats]) { + let mut latencies: Vec<f64> = workers + .iter() + .flat_map(|w| w.latencies_ms.iter().copied()) + .collect(); + latencies.sort_by(|a, b| a.partial_cmp(b).expect("latency is never NaN")); + let ops: u64 = workers.iter().map(|w| w.ops).sum(); + let failures: u64 = workers.iter().map(|w| w.failures).sum(); + let points: u64 = workers.iter().map(|w| w.points).sum(); + let secs = wall.as_secs_f64(); + let avg = if latencies.is_empty() { + 0.0 + } else { + latencies.iter().sum::<f64>() / latencies.len() as f64 + }; + + let sep = "=".repeat(80); + println!("\n{sep}\nBENCHMARK RESULTS\n{sep}"); + println!("\n[Execution Time]"); + println!( + " Duration: {:.2}s ({:.0}ms)", + secs, + secs * 1000.0 + ); + println!("\n[Operations]"); + println!(" Total Operations: {ops}"); + println!(" Successful: {}", ops - failures); + println!(" Failed: {failures}"); + println!( + " Success Rate: {:.2}%", + if ops == 0 { + 0.0 + } else { + (ops - failures) as f64 / ops as f64 * 100.0 + } + ); + println!("\n[Data Points]"); + println!(" Total Points Written: {points}"); + println!("\n[Throughput]"); + println!(" Operations/sec: {:.2}", ops as f64 / secs); + println!(" Points/sec: {:.0}", points as f64 / secs); + println!("\n[Latency (ms)]"); + println!( + " Min: {:.2}ms", + latencies.first().copied().unwrap_or(0.0) + ); + println!( + " Max: {:.2}ms", + latencies.last().copied().unwrap_or(0.0) + ); + println!(" Average: {avg:.2}ms"); + println!( + " P50 (Median): {:.2}ms", + percentile(&latencies, 50.0) + ); + println!( + " P90: {:.2}ms", + percentile(&latencies, 90.0) + ); + println!( + " P95: {:.2}ms", + percentile(&latencies, 95.0) + ); + println!( + " P99: {:.2}ms", + percentile(&latencies, 99.0) + ); + + let samples: Vec<&String> = workers.iter().flat_map(|w| &w.error_samples).collect(); + if !samples.is_empty() { + println!("\n[Error Samples]"); + for (i, err) in samples.iter().take(5).enumerate() { + println!(" {}. {err}", i + 1); + } + } + println!("\n{sep}"); + println!( + "Config: mode={} devices={} sensors={} batches={} batch-size={} clients={} → {} points", + if config.mode == Mode::Tree { + "tree" + } else { + "table" + }, + config.devices, + config.sensors, + config.batches, + config.batch_size, + config.clients, + config.total_points(), + ); +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +fn main() -> Result<()> { + env_logger::init(); + let config = parse_args(); + + let sensor_names: Vec<String> = (0..config.sensors).map(|i| format!("s_{i}")).collect(); + let sensor_types: Vec<TSDataType> = (0..config.sensors) + .map(|i| sensor_type(i, config.sensors)) + .collect(); + + let sep = "=".repeat(80); + println!("{sep}\nIoTDB Rust Client Write Benchmark\n{sep}"); + println!( + " Mode: {}", + if config.mode == Mode::Tree { + "tree" + } else { + "table" + } + ); + println!(" Server: {}:{}", config.host, config.port); + println!(" Devices: {}", config.devices); + println!( + " Sensors: {} ({})", + config.sensors, + sensor_types + .iter() + .map(|&t| type_name(t)) + .collect::<Vec<_>>() + .join(", ") + ); + println!(" Batches: {} per device", config.batches); + println!(" Batch size: {} rows", config.batch_size); + println!(" Clients: {} (pool size)", config.clients); + println!(" Total: {} points", config.total_points()); + println!("{sep}\n"); + + // --- Pool + schema (untimed) ----------------------------------------- + println!( + "[Setup] opening session pool ({} eager sessions)...", + config.clients + ); + let pool = create_pool(&config)?; + println!("[Setup] creating schema..."); + let t0 = Instant::now(); + setup_schema(&pool, &config, &sensor_names, &sensor_types)?; + println!("[Setup] schema ready in {:.2}s", t0.elapsed().as_secs_f64()); + + // --- Pre-generate all tablets (untimed) ------------------------------- + // Worker w owns devices where device % clients == w and walks them + // batch-major, i.e. round-robin over its devices. + println!("[Setup] pre-generating test data..."); + let t0 = Instant::now(); + let mut worker_tablets: Vec<Vec<Tablet>> = Vec::with_capacity(config.clients); + for w in 0..config.clients { + let devices: Vec<usize> = (0..config.devices) + .filter(|d| d % config.clients == w) + .collect(); + let mut tablets = Vec::with_capacity(devices.len() * config.batches); + for batch in 0..config.batches { + for &device in &devices { + tablets.push(build_tablet( + &config, + device, + batch, + &sensor_names, + &sensor_types, + )?); + } + } + worker_tablets.push(tablets); + } + println!( + "[Setup] {} tablets generated in {:.2}s", + worker_tablets.iter().map(Vec::len).sum::<usize>(), + t0.elapsed().as_secs_f64() + ); + + // --- Timed insert phase ------------------------------------------------ + println!( + "\n[Test Phase] running with {} concurrent clients...\n", + config.clients + ); + let ops_done = AtomicU64::new(0); + let points_done = AtomicU64::new(0); + let stop_reporter = AtomicBool::new(false); + + let started = Instant::now(); + let worker_stats: Vec<WorkerStats> = thread::scope(|scope| { + // Progress reporter (Node.js ProgressReporter equivalent). + let reporter = scope.spawn(|| { + let mut last_ops = 0u64; + let mut last_tick = Instant::now(); + while !stop_reporter.load(Ordering::Relaxed) { + thread::sleep(Duration::from_millis(200)); + if last_tick.elapsed() >= REPORT_INTERVAL { + let ops = ops_done.load(Ordering::Relaxed); + let rate = (ops - last_ops) as f64 / last_tick.elapsed().as_secs_f64(); + println!( + "[Progress] Operations: {ops}, Rate: {rate:.2} ops/s, Total Points: {}", + points_done.load(Ordering::Relaxed) + ); + last_ops = ops; + last_tick = Instant::now(); + } + } + }); + + let handles: Vec<_> = worker_tablets + .iter() + .map(|tablets| { + let pool = &pool; + let ops_done = &ops_done; + let points_done = &points_done; + scope.spawn(move || -> Result<WorkerStats> { + let mut session = pool.acquire()?; + let mut stats = WorkerStats { + latencies_ms: Vec::with_capacity(tablets.len()), + ..WorkerStats::default() + }; + for tablet in tablets { + let points = (tablet.row_count() * config.sensors) as u64; + let start = Instant::now(); + let outcome = session.insert_tablet(tablet); + stats + .latencies_ms + .push(start.elapsed().as_secs_f64() * 1000.0); + stats.ops += 1; + ops_done.fetch_add(1, Ordering::Relaxed); + match outcome { + Ok(()) => { + stats.points += points; + points_done.fetch_add(points, Ordering::Relaxed); + } + Err(e) => { + stats.failures += 1; + if stats.error_samples.len() < 5 { + stats.error_samples.push(e.to_string()); + } + } + } + } + Ok(stats) + }) + }) + .collect(); + + let stats = handles + .into_iter() + .map(|h| h.join().expect("worker thread panicked")) + .collect::<Result<Vec<_>>>(); + stop_reporter.store(true, Ordering::Relaxed); + reporter.join().expect("reporter thread panicked"); + stats + })?; + let wall = started.elapsed(); + + // --- Results + verification + cleanup --------------------------------- + print_summary(&config, wall, &worker_stats); + verify_row_count(&pool, &config)?; + + if config.cleanup { + println!("[Cleanup] dropping benchmark database..."); + cleanup_schema(&pool, config.mode)?; + } + pool.close(); + Ok(()) +}
