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 2a830b0bc706ab426d74418b5d41cbc57774efd7 Author: CritasWang <[email protected]> AuthorDate: Mon Jul 13 12:36:29 2026 +0800 V4: insert_tablets batch (tree only, per-tablet redirect harvest) + pool lazy idle eviction (max_idle_time 60s, sweep on activity, min_size floor) --- README.md | 2 + README_ZH.md | 2 + src/client/pool.rs | 247 +++++++++++++++++++++++++++++++++++++--- src/client/session.rs | 307 +++++++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 539 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index e35aa09..746f805 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,8 @@ fn main() -> Result<()> { tablet.add_row(1_720_000_000_000, vec![Some(Value::Double(21.5))])?; tablet.add_row(1_720_000_001_000, vec![None])?; // null cell session.insert_tablet(&tablet)?; + // Multiple tablets in one RPC: insert_tablets(&[t1, t2], false) + // (tree model only; insert_aligned_tablets for aligned devices). // Or write a single row via insertRecord (row-oriented; aligned variants // and multi-row insert_records / insert_records_of_one_device also exist). diff --git a/README_ZH.md b/README_ZH.md index f1f9db3..d743d31 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -60,6 +60,8 @@ fn main() -> Result<()> { tablet.add_row(1_720_000_000_000, vec![Some(Value::Double(21.5))])?; tablet.add_row(1_720_000_001_000, vec![None])?; // null 单元格 session.insert_tablet(&tablet)?; + // 一次 RPC 批量写入多个 tablet:insert_tablets(&[t1, t2], false) + //(仅树模型;aligned 设备用 insert_aligned_tablets)。 // 也可以通过 insertRecord 写入单行(行式编码;另有 aligned 变体 // 以及多行的 insert_records / insert_records_of_one_device)。 diff --git a/src/client/pool.rs b/src/client/pool.rs index 887aff5..64b3564 100644 --- a/src/client/pool.rs +++ b/src/client/pool.rs @@ -45,6 +45,16 @@ pub struct SessionPoolConfig { /// How long [`SessionPool::acquire`] waits for an idle session once the /// pool is at `max_size`. Default 60 s (Node.js `waitTimeout`). pub acquire_timeout: Duration, + /// Idle sessions unused for longer than this are closed by the lazy + /// sweep (never shrinking the pool below `min_size`). Default 60 s + /// (Node.js `maxIdleTime`). + pub max_idle_time: Duration, + /// Minimum time between idle sweeps. There is **no** timer thread — + /// the sweep runs lazily on pool activity (acquire/release) once this + /// much time has passed since the previous sweep, so a completely idle + /// pool keeps its sessions until the next acquire. Default 30 s + /// (Node.js sweep interval). + pub idle_sweep_interval: Duration, } impl Default for SessionPoolConfig { @@ -54,6 +64,8 @@ impl Default for SessionPoolConfig { max_size: 8, min_size: 0, acquire_timeout: Duration::from_secs(60), + max_idle_time: Duration::from_secs(60), + idle_sweep_interval: Duration::from_secs(30), } } } @@ -66,11 +78,29 @@ impl SessionPoolConfig { } } +/// An idle session together with the moment it went idle, so the sweep +/// can measure how long it has been unused. +struct IdleEntry { + session: Session, + since: Instant, +} + +impl IdleEntry { + fn new(session: Session) -> Self { + Self { + session, + since: Instant::now(), + } + } +} + /// Idle sessions plus the pool lifecycle flag, guarded by one mutex so -/// [`Condvar`] waiters observe both consistently. +/// [`Condvar`] waiters observe both consistently. `last_sweep` gates the +/// lazy idle sweep (see [`SessionPoolConfig::idle_sweep_interval`]). struct PoolState { - idle: VecDeque<Session>, + idle: VecDeque<IdleEntry>, closed: bool, + last_sweep: Instant, } /// A pool of open tree-model [`Session`]s. @@ -78,7 +108,10 @@ struct PoolState { /// Sessions are created lazily up to `max_size` (after the eager /// `min_size`); [`SessionPool::acquire`] blocks up to `acquire_timeout` /// when the pool is exhausted. Dead sessions are discarded on acquire and -/// on release. The pool tracks the most recent `USE <db>` seen on any +/// on release; sessions idle longer than `max_idle_time` are closed by a +/// lazy sweep that runs on pool activity (no timer thread — see +/// [`SessionPoolConfig::idle_sweep_interval`]), never shrinking the pool +/// below `min_size`. The pool tracks the most recent `USE <db>` seen on any /// released session and replays it on acquire so every handed-out session /// is in the pool's current database (spec §6.2). pub struct SessionPool { @@ -107,6 +140,7 @@ impl SessionPool { state: Mutex::new(PoolState { idle: VecDeque::new(), closed: false, + last_sweep: Instant::now(), }), available: Condvar::new(), live: AtomicUsize::new(0), @@ -115,7 +149,7 @@ impl SessionPool { for _ in 0..pool.config.min_size { let session = pool.open_session()?; let mut state = pool.state.lock().expect("pool lock poisoned"); - state.idle.push_back(session); + state.idle.push_back(IdleEntry::new(session)); pool.live.fetch_add(1, Ordering::Relaxed); } Ok(pool) @@ -126,6 +160,45 @@ impl SessionPool { self.live.load(Ordering::Relaxed) } + /// Sessions sitting idle in the pool right now. + pub fn idle_count(&self) -> usize { + self.state.lock().expect("pool lock poisoned").idle.len() + } + + /// Lazy idle sweep, run at acquire/release time (there is no timer + /// thread — a completely inactive pool is only swept on its next use). + /// No-op until `idle_sweep_interval` has elapsed since the last sweep; + /// then removes sessions idle longer than `max_idle_time`, oldest + /// first, never shrinking the pool (idle + handed out) below + /// `min_size`. Returns the expired sessions — the caller closes them + /// **after** dropping the state lock, so a slow `closeSession` RPC + /// cannot stall other pool users. + #[must_use] + fn sweep_idle(&self, state: &mut PoolState) -> Vec<Session> { + let now = Instant::now(); + if now.duration_since(state.last_sweep) < self.config.idle_sweep_interval { + return Vec::new(); + } + state.last_sweep = now; + let mut expired = Vec::new(); + let mut i = 0; + while i < state.idle.len() { + if self.live.load(Ordering::Relaxed) - expired.len() <= self.config.min_size { + break; + } + if now.duration_since(state.idle[i].since) > self.config.max_idle_time { + expired.push(state.idle.remove(i).expect("index in bounds").session); + } else { + i += 1; + } + } + if !expired.is_empty() { + self.live.fetch_sub(expired.len(), Ordering::Relaxed); + log::debug!("idle sweep evicted {} session(s)", expired.len()); + } + expired + } + /// Acquire a session, blocking up to `acquire_timeout` when the pool is /// at capacity with nothing idle. Dead idle sessions are discarded and /// the acquire retried; a fresh session is opened while under @@ -137,11 +210,20 @@ impl SessionPool { if state.closed { return Err(Error::Client("session pool is closed".into())); } + let expired = self.sweep_idle(&mut state); + if !expired.is_empty() { + drop(state); + for mut session in expired { + let _ = session.close(); + } + state = self.state.lock().expect("pool lock poisoned"); + continue; // re-check closed/idle after re-locking + } // Idle session available → validate liveness, evict the dead. - while let Some(session) = state.idle.pop_front() { - if session.is_open() { + while let Some(entry) = state.idle.pop_front() { + if entry.session.is_open() { drop(state); - return self.hand_out(session); + return self.hand_out(entry.session); } self.live.fetch_sub(1, Ordering::Relaxed); } @@ -197,16 +279,15 @@ impl SessionPool { let hint = state .idle .iter_mut() - .find_map(|s| s.redirect_hint(device_id)); + .find_map(|e| e.session.redirect_hint(device_id)); if let Some(endpoint) = hint { - let matching = state - .idle - .iter() - .position(|s| s.is_open() && s.current_endpoint() == Some(&endpoint)); + let matching = state.idle.iter().position(|e| { + e.session.is_open() && e.session.current_endpoint() == Some(&endpoint) + }); if let Some(pos) = matching { - let session = state.idle.remove(pos).expect("index in bounds"); + let entry = state.idle.remove(pos).expect("index in bounds"); drop(state); - return self.hand_out(session); + return self.hand_out(entry.session); } } } @@ -230,8 +311,8 @@ impl SessionPool { std::mem::take(&mut state.idle) }; self.live.fetch_sub(drained.len(), Ordering::Relaxed); - for mut session in drained { - let _ = session.close(); + for mut entry in drained { + let _ = entry.session.close(); } self.available.notify_all(); } @@ -288,8 +369,12 @@ impl SessionPool { let mut session = session; let _ = session.close(); } else { - state.idle.push_back(session); + state.idle.push_back(IdleEntry::new(session)); + let expired = self.sweep_idle(&mut state); drop(state); + for mut session in expired { + let _ = session.close(); + } } self.available.notify_one(); } @@ -299,7 +384,7 @@ impl SessionPool { #[cfg(test)] fn inject_idle(&self, session: Session) { let mut state = self.state.lock().expect("pool lock poisoned"); - state.idle.push_back(session); + state.idle.push_back(IdleEntry::new(session)); self.live.fetch_add(1, Ordering::Relaxed); } } @@ -378,6 +463,10 @@ impl TableSessionPool { self.pool.live_count() } + pub fn idle_count(&self) -> usize { + self.pool.idle_count() + } + pub fn close(&self) { self.pool.close() } @@ -412,6 +501,8 @@ mod tests { assert_eq!(cfg.max_size, 8); assert_eq!(cfg.min_size, 0); assert_eq!(cfg.acquire_timeout, Duration::from_secs(60)); + assert_eq!(cfg.max_idle_time, Duration::from_secs(60)); + assert_eq!(cfg.idle_sweep_interval, Duration::from_secs(30)); } #[test] @@ -591,6 +682,126 @@ mod tests { assert_eq!(guard.current_endpoint(), Some(&ep_a)); } + /// Backdate an idle entry and the sweep clock so eviction tests are + /// deterministic without long sleeps. + fn backdate(pool: &SessionPool, entry_ages: Duration, sweep_age: Duration) { + let mut state = pool.state.lock().unwrap(); + state.last_sweep = Instant::now() - sweep_age; + for entry in &mut state.idle { + entry.since = Instant::now() - entry_ages; + } + } + + /// Sessions idle past `max_idle_time` are closed by the sweep on the + /// next acquire; the freed capacity is then used to (try to) grow. + #[test] + fn idle_sessions_are_evicted_on_acquire() { + let ep = fake_listener(); + let cfg = SessionPoolConfig { + max_size: 4, + acquire_timeout: Duration::from_millis(50), + max_idle_time: Duration::from_millis(5), + idle_sweep_interval: Duration::from_millis(1), + session: dead_endpoint_config(), + ..Default::default() + }; + let pool = SessionPool::new(cfg).unwrap(); + pool.inject_idle(injected_session(&ep)); + pool.inject_idle(injected_session(&ep)); + assert_eq!(pool.idle_count(), 2); + + // Exceed max_idle_time, then acquire: the sweep evicts both idle + // sessions and the acquire grows — which fails against the dead + // endpoint (a connect error, proving the idle queue really was + // emptied rather than handed out). + std::thread::sleep(Duration::from_millis(10)); + match pool.acquire() { + Err(Error::Thrift(_)) => {} + other => panic!("expected thrift connect error, got {other:?}"), + } + assert_eq!(pool.idle_count(), 0); + assert_eq!(pool.live_count(), 0); + } + + /// The sweep never shrinks the pool (idle + handed out) below + /// `min_size`, evicting oldest-first. + #[test] + fn idle_sweep_respects_min_size_floor() { + let ep = fake_listener(); + let cfg = SessionPoolConfig { + max_size: 4, + max_idle_time: Duration::from_millis(5), + idle_sweep_interval: Duration::from_millis(1), + session: dead_endpoint_config(), + ..Default::default() + }; + let mut pool = SessionPool::new(cfg).unwrap(); + pool.config.min_size = 2; // set post-new: eager open would need a server + for _ in 0..3 { + pool.inject_idle(injected_session(&ep)); + } + backdate(&pool, Duration::from_millis(10), Duration::from_millis(10)); + + let mut state = pool.state.lock().unwrap(); + let expired = pool.sweep_idle(&mut state); + // All 3 exceed max_idle_time, but only 1 may go: 3 live - 1 = floor. + assert_eq!(expired.len(), 1); + assert_eq!(state.idle.len(), 2); + drop(state); + assert_eq!(pool.live_count(), 2); + } + + /// The sweep is a no-op until `idle_sweep_interval` has passed since the + /// previous sweep — even when idle sessions have already expired. + #[test] + fn idle_sweep_is_gated_by_interval() { + let ep = fake_listener(); + let cfg = SessionPoolConfig { + max_size: 4, + max_idle_time: Duration::from_millis(1), + idle_sweep_interval: Duration::from_secs(3600), + session: dead_endpoint_config(), + ..Default::default() + }; + let pool = SessionPool::new(cfg).unwrap(); + pool.inject_idle(injected_session(&ep)); + // Entry long expired, but the last sweep (pool creation) is recent + // relative to the huge interval → gated. + backdate(&pool, Duration::from_secs(10), Duration::ZERO); + let mut state = pool.state.lock().unwrap(); + assert!(pool.sweep_idle(&mut state).is_empty()); + assert_eq!(state.idle.len(), 1); + + // Once the interval has elapsed the same entry is evicted… + state.last_sweep = Instant::now() - Duration::from_secs(3601); + assert_eq!(pool.sweep_idle(&mut state).len(), 1); + // …and last_sweep was refreshed, so an immediate re-sweep is gated. + assert!(pool.sweep_idle(&mut state).is_empty()); + } + + /// Releasing a session also drives the sweep: the stale idle session is + /// evicted while the just-released (fresh) one stays. + #[test] + fn release_triggers_sweep_but_keeps_fresh_session() { + let ep = fake_listener(); + let cfg = SessionPoolConfig { + max_size: 4, + max_idle_time: Duration::from_millis(5), + idle_sweep_interval: Duration::from_millis(1), + session: dead_endpoint_config(), + ..Default::default() + }; + let pool = SessionPool::new(cfg).unwrap(); + pool.inject_idle(injected_session(&ep)); + backdate(&pool, Duration::from_millis(10), Duration::from_millis(10)); + + // Simulate returning a handed-out session (live already counted). + pool.live.fetch_add(1, Ordering::Relaxed); + pool.release(injected_session(&ep)); + assert_eq!(pool.idle_count(), 1, "stale evicted, fresh kept"); + assert_eq!(pool.live_count(), 1); + } + /// Live-server tests: acquire/release round-trip, reuse, and blocking /// hand-off. Skipped when no IoTDB server is reachable. #[test] diff --git a/src/client/session.rs b/src/client/session.rs index 96199df..f98a308 100644 --- a/src/client/session.rs +++ b/src/client/session.rs @@ -30,7 +30,7 @@ use crate::error::{Error, Result}; use crate::protocol::client::{ TIClientRPCServiceSyncClient, TSCloseOperationReq, TSCloseSessionReq, TSExecuteStatementReq, TSFetchResultsReq, TSInsertRecordReq, TSInsertRecordsOfOneDeviceReq, TSInsertRecordsReq, - TSInsertTabletReq, TSOpenSessionReq, TSProtocolVersion, + TSInsertTabletReq, TSInsertTabletsReq, TSOpenSessionReq, TSProtocolVersion, }; use crate::protocol::common::TSStatus; @@ -515,6 +515,34 @@ impl Session { self.check_insert_status(&[prefix_path], &status) } + /// Insert a batch of [`Tablet`]s in one `insertTablets` RPC (spec §3.6). + /// Each tablet is serialized exactly like [`Session::insert_tablet`] — + /// column-major values with trailing null bitmaps, i64-BE timestamps, + /// rows sorted by timestamp. `is_aligned` applies to the **whole batch** + /// (the RPC carries a single flag); the per-tablet aligned flag is + /// ignored, matching the Java client's `insertTablets` / + /// `insertAlignedTablets` split. + /// + /// Tree model only: the batch request has no `writeToTable` / + /// `columnCategories` fields, so table-model tablets are rejected — + /// send those one at a time via [`Session::insert_tablet`]. + pub fn insert_tablets(&mut self, tablets: &[Tablet], is_aligned: bool) -> Result<()> { + let req = build_insert_tablets_req(self.session_id, tablets, is_aligned)?; + let status = self.with_retry(|session| { + // Clone per attempt: a reconnect refreshes the session id. + let mut req = req.clone(); + req.session_id = session.session_id; + Ok(session.connection_mut()?.client_mut().insert_tablets(req)?) + })?; + let devices: Vec<&str> = req.prefix_paths.iter().map(String::as_str).collect(); + self.check_insert_status(&devices, &status) + } + + /// [`Session::insert_tablets`] against aligned devices. + pub fn insert_aligned_tablets(&mut self, tablets: &[Tablet]) -> Result<()> { + self.insert_tablets(tablets, true) + } + /// Insert one row for one device via `insertRecord`. `values[i]` pairs /// with `measurements[i]`. The value buffer is row-oriented (per-cell /// type marker + big-endian payload), unlike the tablet's column-major @@ -774,6 +802,58 @@ impl Session { } } +/// Assembles a `TSInsertTabletsReq` (spec §3.6): one entry per tablet in +/// each parallel list, every buffer serialized exactly like the +/// single-tablet path. Kept as a free function so request assembly is +/// testable without a connection. Rejects empty batches and table-model +/// tablets (the batch RPC has no table-model fields). +fn build_insert_tablets_req( + session_id: i64, + tablets: &[Tablet], + is_aligned: bool, +) -> Result<TSInsertTabletsReq> { + if tablets.is_empty() { + return Err(Error::Client( + "insert_tablets called with no tablets".into(), + )); + } + let n = tablets.len(); + let mut prefix_paths = Vec::with_capacity(n); + let mut measurements_list = Vec::with_capacity(n); + let mut values_list = Vec::with_capacity(n); + let mut timestamps_list = Vec::with_capacity(n); + let mut types_list = Vec::with_capacity(n); + let mut size_list = Vec::with_capacity(n); + for tablet in tablets { + if tablet.is_table_model() { + return Err(Error::Client(format!( + "insert_tablets is tree-model only; tablet for table {:?} \ + must go through insert_tablet", + tablet.target() + ))); + } + // Serialization sorts in place; clone so the caller's tablet order + // is untouched (the clone is cheap relative to the RPC). + let mut tablet = tablet.clone(); + values_list.push(tablet.serialize_values()); + timestamps_list.push(tablet.serialize_timestamps()); + prefix_paths.push(tablet.target().to_string()); + measurements_list.push(tablet.measurements().to_vec()); + types_list.push(tablet.types().iter().map(|t| t.code()).collect()); + size_list.push(tablet.row_count() as i32); + } + Ok(TSInsertTabletsReq::new( + session_id, + prefix_paths, + measurements_list, + values_list, + timestamps_list, + types_list, + size_list, + is_aligned, + )) +} + /// Stably sorts one-device record rows by timestamp — reordering the /// measurement lists in step and serializing each row's value buffer in the /// sorted order (Java `genTSInsertRecordsOfOneDeviceReq`; the server @@ -1041,6 +1121,95 @@ mod tests { assert_eq!(ms, [vec!["first".to_string()], vec!["second".into()]]); } + /// insert_tablets request assembly: parallel lists pair 1:1 with the + /// input tablets, and every buffer matches what the single-tablet path + /// (`serialize_values`/`serialize_timestamps`) produces for the same + /// tablet — including the timestamp sort and null bitmaps. + #[test] + fn insert_tablets_request_assembly() { + use crate::data::{tablet::Tablet, TSDataType, Value}; + + let mut t1 = Tablet::new( + "root.sg.d1", + vec!["i".into(), "s".into()], + vec![TSDataType::Int32, TSDataType::Text], + ) + .unwrap(); + // Unsorted rows + a null: serialization must sort and bitmap. + t1.add_row(2, vec![Some(Value::Int32(20)), None]).unwrap(); + t1.add_row(1, vec![None, Some(Value::Text("a".into()))]) + .unwrap(); + let mut t2 = Tablet::new("root.sg.d2", vec!["b".into()], vec![TSDataType::Boolean]) // + .unwrap(); + t2.add_row(5, vec![Some(Value::Boolean(true))]).unwrap(); + + let req = build_insert_tablets_req(7, &[t1.clone(), t2.clone()], false).unwrap(); + assert_eq!(req.session_id, 7); + assert_eq!(req.prefix_paths, ["root.sg.d1", "root.sg.d2"]); + assert_eq!( + req.measurements_list, + [vec!["i".to_string(), "s".into()], vec!["b".into()]] + ); + assert_eq!(req.types_list, [vec![1, 5], vec![0]]); // Int32+Text, Boolean + assert_eq!(req.size_list, [2, 1]); + assert_eq!(req.is_aligned, Some(false)); + // Byte-identical to the single-tablet serialization helpers. + assert_eq!( + req.values_list, + [t1.serialize_values(), t2.serialize_values()] + ); + assert_eq!( + req.timestamps_list, + [t1.serialize_timestamps(), t2.serialize_timestamps()] + ); + // Known bytes for t1 after sorting (ts 1 first): int col null@row0, + // text col null@row1. + assert_eq!( + req.values_list[0], + [ + 0x80, 0x00, 0x00, 0x00, // i row0: null placeholder (i32::MIN) + 0x00, 0x00, 0x00, 0x14, // i row1: 20 + 0x00, 0x00, 0x00, 0x01, b'a', // s row0: "a" + 0x00, 0x00, 0x00, 0x00, // s row1: null placeholder (empty) + 0x01, 0x01, // i bitmap: flag + row 0 null (LSB-first) + 0x01, 0x02, // s bitmap: flag + row 1 null + ] + ); + assert_eq!( + req.timestamps_list[0], + [0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 2] + ); + + // The batch flag is a single RPC-level field. + let req = build_insert_tablets_req(7, &[t2], true).unwrap(); + assert_eq!(req.is_aligned, Some(true)); + } + + #[test] + fn insert_tablets_rejects_empty_and_table_model() { + use crate::data::{tablet::Tablet, ColumnCategory, TSDataType}; + + let err = build_insert_tablets_req(1, &[], false).unwrap_err(); + assert!(matches!(err, Error::Client(m) if m.contains("no tablets"))); + + let tree = Tablet::new("root.sg.d1", vec!["s".into()], vec![TSDataType::Int32]).unwrap(); + let table = Tablet::new_table( + "sensors", + vec!["f1".into()], + vec![TSDataType::Double], + vec![ColumnCategory::Field], + ) + .unwrap(); + // A table-model tablet anywhere in the batch fails the whole call. + let err = build_insert_tablets_req(1, &[tree, table], false).unwrap_err(); + assert!(matches!(err, Error::Client(m) if m.contains("tree-model only"))); + + // The errors fire before any connection use. + let mut session = Session::new(SessionConfig::default()); + let err = session.insert_tablets(&[], false).unwrap_err(); + assert!(matches!(err, Error::Client(m) if m.contains("no tablets"))); + } + #[test] fn insert_records_length_mismatch_is_client_error() { use crate::data::Value; @@ -1659,4 +1828,140 @@ mod tests { .expect("cleanup"); session.close().expect("close session"); } + + /// Live roundtrip for `insertTablets`: one batch of three tablets across + /// two devices (with nulls and unsorted rows), plus an aligned batch on + /// an aligned device. Every written cell is read back and asserted. + /// Skipped when no IoTDB instance is reachable on localhost:6667. + #[test] + fn live_insert_tablets_readback() { + use crate::data::{tablet::Tablet, TSDataType, Value}; + use std::net::TcpStream; + if TcpStream::connect_timeout( + &"127.0.0.1:6667".parse().unwrap(), + Duration::from_millis(300), + ) + .is_err() + { + eprintln!("skipping live_insert_tablets_readback: no IoTDB server on 127.0.0.1:6667"); + return; + } + + const DB: &str = "root.rusttest_tablets"; + let _guard = LIVE_DB_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + + let read_rows = |session: &mut Session, sql: &str| -> Vec<(i64, Vec<Value>)> { + let mut rows = Vec::new(); + let mut dataset = session.execute_query(sql).expect("query"); + while let Some(row) = dataset.next_row().expect("next_row") { + rows.push((row.timestamp.expect("timestamp"), row.values.clone())); + } + rows + }; + + let mut session = Session::new(SessionConfig::default()); + session.open().expect("open session"); + let _ = session.execute_non_query(&format!("DELETE DATABASE {DB}")); + session + .execute_non_query(&format!("CREATE DATABASE {DB}")) + .expect("create database"); + + // Three tablets, two devices, one aligned=false batch. t1 and t3 + // both target d1 over disjoint time ranges; t1 has a null and + // unsorted rows (the client must sort per tablet). + let mut t1 = Tablet::new( + format!("{DB}.d1"), + vec!["i".into(), "s".into()], + vec![TSDataType::Int32, TSDataType::Text], + ) + .expect("tablet t1"); + t1.add_row(2, vec![Some(Value::Int32(20)), None]) + .expect("add_row"); + t1.add_row(1, vec![None, Some(Value::Text("one".into()))]) + .expect("add_row"); + let mut t2 = Tablet::new( + format!("{DB}.d2"), + vec!["d".into()], + vec![TSDataType::Double], + ) + .expect("tablet t2"); + t2.add_row(10, vec![Some(Value::Double(1.5))]) + .expect("add_row"); + t2.add_row(11, vec![Some(Value::Double(-2.5))]) + .expect("add_row"); + let mut t3 = Tablet::new( + format!("{DB}.d1"), + vec!["i".into(), "s".into()], + vec![TSDataType::Int32, TSDataType::Text], + ) + .expect("tablet t3"); + t3.add_row( + 3, + vec![Some(Value::Int32(30)), Some(Value::Text("three".into()))], + ) + .expect("add_row"); + session + .insert_tablets(&[t1, t2, t3], false) + .expect("insert_tablets"); + + let rows = read_rows( + &mut session, + &format!("SELECT i, s FROM {DB}.d1 ORDER BY time"), + ); + assert_eq!( + rows, + [ + (1, vec![Value::Null, Value::Text("one".into())]), + (2, vec![Value::Int32(20), Value::Null]), + (3, vec![Value::Int32(30), Value::Text("three".into())]), + ] + ); + let rows = read_rows( + &mut session, + &format!("SELECT d FROM {DB}.d2 ORDER BY time"), + ); + assert_eq!( + rows, + [ + (10, vec![Value::Double(1.5)]), + (11, vec![Value::Double(-2.5)]), + ] + ); + + // Aligned batch on an aligned device. + session + .execute_non_query(&format!( + "CREATE ALIGNED TIMESERIES {DB}.a1(s1 INT32, s2 DOUBLE)" + )) + .expect("create aligned timeseries"); + let mut a1 = Tablet::new_aligned( + format!("{DB}.a1"), + vec!["s1".into(), "s2".into()], + vec![TSDataType::Int32, TSDataType::Double], + ) + .expect("aligned tablet"); + a1.add_row(40, vec![Some(Value::Int32(400)), None]) + .expect("add_row"); + a1.add_row(41, vec![Some(Value::Int32(410)), Some(Value::Double(4.1))]) + .expect("add_row"); + session + .insert_aligned_tablets(&[a1]) + .expect("insert_aligned_tablets"); + let rows = read_rows( + &mut session, + &format!("SELECT s1, s2 FROM {DB}.a1 ORDER BY time"), + ); + assert_eq!( + rows, + [ + (40, vec![Value::Int32(400), Value::Null]), + (41, vec![Value::Int32(410), Value::Double(4.1)]), + ] + ); + + session + .execute_non_query(&format!("DELETE DATABASE {DB}")) + .expect("cleanup"); + session.close().expect("close session"); + } }
