| /* |
| * 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. |
| */ |
| |
| #[cfg(test)] |
| mod table_test { |
| use crate::integration::utils::{create_partitions, create_table, get_shared_cluster}; |
| use arrow::array::record_batch; |
| use fluss::client::{EARLIEST_OFFSET, FlussTable, TableScan}; |
| use fluss::metadata::{DataTypes, Schema, TableDescriptor, TablePath}; |
| use fluss::record::ScanRecord; |
| use fluss::row::InternalRow; |
| use fluss::rpc::message::OffsetSpec; |
| use std::collections::HashMap; |
| use std::time::Duration; |
| |
| #[tokio::test] |
| async fn append_record_batch_and_scan() { |
| let cluster = get_shared_cluster(); |
| let connection = cluster.get_fluss_connection().await; |
| |
| let admin = connection.get_admin().await.expect("Failed to get admin"); |
| |
| let table_path = TablePath::new("fluss", "test_append_record_batch_and_scan"); |
| |
| let table_descriptor = TableDescriptor::builder() |
| .schema( |
| Schema::builder() |
| .column("c1", DataTypes::int()) |
| .column("c2", DataTypes::string()) |
| .build() |
| .expect("Failed to build schema"), |
| ) |
| .distributed_by(Some(3), vec!["c1".to_string()]) |
| .build() |
| .expect("Failed to build table"); |
| |
| create_table(&admin, &table_path, &table_descriptor).await; |
| |
| let table = connection |
| .get_table(&table_path) |
| .await |
| .expect("Failed to get table"); |
| |
| let append_writer = table |
| .new_append() |
| .expect("Failed to create append") |
| .create_writer() |
| .expect("Failed to create writer"); |
| |
| let batch1 = |
| record_batch!(("c1", Int32, [1, 2, 3]), ("c2", Utf8, ["a1", "a2", "a3"])).unwrap(); |
| append_writer |
| .append_arrow_batch(batch1) |
| .expect("Failed to append batch"); |
| |
| let batch2 = |
| record_batch!(("c1", Int32, [4, 5, 6]), ("c2", Utf8, ["a4", "a5", "a6"])).unwrap(); |
| append_writer |
| .append_arrow_batch(batch2) |
| .expect("Failed to append batch"); |
| |
| // Flush to ensure all writes are acknowledged |
| append_writer.flush().await.expect("Failed to flush"); |
| |
| // Create scanner to verify appended records |
| let table = connection |
| .get_table(&table_path) |
| .await |
| .expect("Failed to get table"); |
| let num_buckets = table.get_table_info().get_num_buckets(); |
| let log_scanner = table |
| .new_scan() |
| .create_log_scanner() |
| .expect("Failed to create log scanner"); |
| for bucket_id in 0..num_buckets { |
| log_scanner |
| .subscribe(bucket_id, EARLIEST_OFFSET) |
| .await |
| .expect("Failed to subscribe with EARLIEST_OFFSET"); |
| } |
| |
| // Poll for records across all buckets |
| let mut collected: Vec<(i32, String)> = Vec::new(); |
| let start_time = std::time::Instant::now(); |
| while collected.len() < 6 && start_time.elapsed() < Duration::from_secs(10) { |
| let scan_records = log_scanner |
| .poll(Duration::from_millis(500)) |
| .await |
| .expect("Failed to poll records"); |
| for rec in scan_records { |
| let row = rec.row(); |
| collected.push(( |
| row.get_int(0).unwrap(), |
| row.get_string(1).unwrap().to_string(), |
| )); |
| } |
| } |
| |
| assert_eq!(collected.len(), 6, "Expected 6 records"); |
| |
| // Sort and verify record contents |
| collected.sort(); |
| let expected: Vec<(i32, String)> = vec![ |
| (1, "a1".to_string()), |
| (2, "a2".to_string()), |
| (3, "a3".to_string()), |
| (4, "a4".to_string()), |
| (5, "a5".to_string()), |
| (6, "a6".to_string()), |
| ]; |
| assert_eq!(collected, expected); |
| |
| // Test unsubscribe: unsubscribe from bucket 0, verify no error |
| log_scanner |
| .unsubscribe(0) |
| .await |
| .expect("Failed to unsubscribe from bucket 0"); |
| |
| // Verify unsubscribe_partition fails on a non-partitioned table |
| assert!( |
| log_scanner.unsubscribe_partition(0, 0).await.is_err(), |
| "unsubscribe_partition should fail on a non-partitioned table" |
| ); |
| } |
| |
| #[tokio::test] |
| async fn list_offsets() { |
| let cluster = get_shared_cluster(); |
| let connection = cluster.get_fluss_connection().await; |
| |
| let admin = connection.get_admin().await.expect("Failed to get admin"); |
| |
| let table_path = TablePath::new("fluss", "test_list_offsets"); |
| |
| let table_descriptor = TableDescriptor::builder() |
| .schema( |
| Schema::builder() |
| .column("id", DataTypes::int()) |
| .column("name", DataTypes::string()) |
| .build() |
| .expect("Failed to build schema"), |
| ) |
| .build() |
| .expect("Failed to build table"); |
| |
| create_table(&admin, &table_path, &table_descriptor).await; |
| |
| // Wait for table to be fully initialized |
| tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; |
| |
| // Test earliest offset (should be 0 for empty table) |
| let earliest_offsets = admin |
| .list_offsets(&table_path, &[0], OffsetSpec::Earliest) |
| .await |
| .expect("Failed to list earliest offsets"); |
| |
| assert_eq!( |
| earliest_offsets.get(&0), |
| Some(&0), |
| "Earliest offset should be 0 for bucket 0" |
| ); |
| |
| // Test latest offset (should be 0 for empty table) |
| let latest_offsets = admin |
| .list_offsets(&table_path, &[0], OffsetSpec::Latest) |
| .await |
| .expect("Failed to list latest offsets"); |
| |
| assert_eq!( |
| latest_offsets.get(&0), |
| Some(&0), |
| "Latest offset should be 0 for empty table" |
| ); |
| |
| // Append some records |
| let append_writer = connection |
| .get_table(&table_path) |
| .await |
| .expect("Failed to get table") |
| .new_append() |
| .expect("Failed to create append") |
| .create_writer() |
| .expect("Failed to create writer"); |
| |
| let batch = record_batch!( |
| ("id", Int32, [1, 2, 3]), |
| ("name", Utf8, ["alice", "bob", "charlie"]) |
| ) |
| .unwrap(); |
| append_writer |
| .append_arrow_batch(batch) |
| .expect("Failed to append batch"); |
| |
| // Flush to ensure all writes are acknowledged |
| append_writer.flush().await.expect("Failed to flush"); |
| |
| tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; |
| |
| // Test latest offset after appending (should be 3) |
| let latest_offsets_after = admin |
| .list_offsets(&table_path, &[0], OffsetSpec::Latest) |
| .await |
| .expect("Failed to list latest offsets after append"); |
| |
| assert_eq!( |
| latest_offsets_after.get(&0), |
| Some(&3), |
| "Latest offset should be 3 after appending 3 records" |
| ); |
| |
| // Test earliest offset after appending (should still be 0) |
| let earliest_offsets_after = admin |
| .list_offsets(&table_path, &[0], OffsetSpec::Earliest) |
| .await |
| .expect("Failed to list earliest offsets after append"); |
| |
| assert_eq!( |
| earliest_offsets_after.get(&0), |
| Some(&0), |
| "Earliest offset should still be 0" |
| ); |
| |
| // Scan records back to get server-assigned timestamps (avoids host/container |
| // clock skew issues that make host-based timestamps unreliable). |
| let table = connection |
| .get_table(&table_path) |
| .await |
| .expect("Failed to get table"); |
| let log_scanner = table |
| .new_scan() |
| .create_log_scanner() |
| .expect("Failed to create log scanner"); |
| log_scanner |
| .subscribe(0, EARLIEST_OFFSET) |
| .await |
| .expect("Failed to subscribe"); |
| |
| let mut record_timestamps: Vec<i64> = Vec::new(); |
| let scan_start = std::time::Instant::now(); |
| while record_timestamps.len() < 3 && scan_start.elapsed() < Duration::from_secs(10) { |
| let scan_records = log_scanner |
| .poll(Duration::from_millis(500)) |
| .await |
| .expect("Failed to poll records"); |
| for rec in scan_records { |
| record_timestamps.push(rec.timestamp()); |
| } |
| } |
| assert_eq!(record_timestamps.len(), 3, "Expected 3 record timestamps"); |
| |
| let min_ts = *record_timestamps.iter().min().unwrap(); |
| let max_ts = *record_timestamps.iter().max().unwrap(); |
| |
| // Timestamp before all records should resolve to offset 0 |
| let before_offsets = admin |
| .list_offsets(&table_path, &[0], OffsetSpec::Timestamp(min_ts - 1)) |
| .await |
| .expect("Failed to list offsets by timestamp (before)"); |
| |
| assert_eq!( |
| before_offsets.get(&0), |
| Some(&0), |
| "Timestamp before first record should resolve to offset 0" |
| ); |
| |
| // Timestamp after all records should resolve to offset 3 |
| let after_offsets = admin |
| .list_offsets(&table_path, &[0], OffsetSpec::Timestamp(max_ts + 1)) |
| .await |
| .expect("Failed to list offsets by timestamp (after)"); |
| |
| assert_eq!( |
| after_offsets.get(&0), |
| Some(&3), |
| "Timestamp after last record should resolve to offset 3" |
| ); |
| } |
| |
| #[tokio::test] |
| async fn test_project() { |
| let cluster = get_shared_cluster(); |
| let connection = cluster.get_fluss_connection().await; |
| |
| let admin = connection.get_admin().await.expect("Failed to get admin"); |
| |
| let table_path = TablePath::new("fluss", "test_project"); |
| |
| let table_descriptor = TableDescriptor::builder() |
| .schema( |
| Schema::builder() |
| .column("col_a", DataTypes::int()) |
| .column("col_b", DataTypes::string()) |
| .column("col_c", DataTypes::int()) |
| .build() |
| .expect("Failed to build schema"), |
| ) |
| .build() |
| .expect("Failed to build table"); |
| |
| create_table(&admin, &table_path, &table_descriptor).await; |
| |
| let table = connection |
| .get_table(&table_path) |
| .await |
| .expect("Failed to get table"); |
| |
| // Append 3 records |
| let append_writer = table |
| .new_append() |
| .expect("Failed to create append") |
| .create_writer() |
| .expect("Failed to create writer"); |
| |
| let batch = record_batch!( |
| ("col_a", Int32, [1, 2, 3]), |
| ("col_b", Utf8, ["x", "y", "z"]), |
| ("col_c", Int32, [10, 20, 30]) |
| ) |
| .unwrap(); |
| append_writer |
| .append_arrow_batch(batch) |
| .expect("Failed to append batch"); |
| append_writer.flush().await.expect("Failed to flush"); |
| |
| // Test project_by_name: select col_b and col_c only |
| let records = scan_table(&table, |scan| { |
| scan.project_by_name(&["col_b", "col_c"]) |
| .expect("Failed to project by name") |
| }) |
| .await; |
| |
| assert_eq!( |
| records.len(), |
| 3, |
| "Should have 3 records with project_by_name" |
| ); |
| |
| // Verify projected columns are in the correct order (col_b, col_c) |
| let expected_col_b = ["x", "y", "z"]; |
| let expected_col_c = [10, 20, 30]; |
| |
| for (i, record) in records.iter().enumerate() { |
| let row = record.row(); |
| // col_b is now at index 0, col_c is at index 1 |
| assert_eq!( |
| row.get_string(0).unwrap(), |
| expected_col_b[i], |
| "col_b mismatch at index {}", |
| i |
| ); |
| assert_eq!( |
| row.get_int(1).unwrap(), |
| expected_col_c[i], |
| "col_c mismatch at index {}", |
| i |
| ); |
| } |
| |
| // test project by column indices |
| let records = scan_table(&table, |scan| { |
| scan.project(&[1, 0]).expect("Failed to project by indices") |
| }) |
| .await; |
| |
| assert_eq!( |
| records.len(), |
| 3, |
| "Should have 3 records with project_by_name" |
| ); |
| // Verify projected columns are in the correct order (col_b, col_a) |
| let expected_col_b = ["x", "y", "z"]; |
| let expected_col_a = [1, 2, 3]; |
| |
| for (i, record) in records.iter().enumerate() { |
| let row = record.row(); |
| // col_b is now at index 0, col_c is at index 1 |
| assert_eq!( |
| row.get_string(0).unwrap(), |
| expected_col_b[i], |
| "col_b mismatch at index {}", |
| i |
| ); |
| assert_eq!( |
| row.get_int(1).unwrap(), |
| expected_col_a[i], |
| "col_c mismatch at index {}", |
| i |
| ); |
| } |
| |
| // Test error case: empty column names should fail |
| let result = table.new_scan().project_by_name(&[]); |
| assert!( |
| result.is_err(), |
| "project_by_name with empty names should fail" |
| ); |
| |
| // Test error case: non-existent column should fail |
| let result = table.new_scan().project_by_name(&["nonexistent_column"]); |
| assert!( |
| result.is_err(), |
| "project_by_name with non-existent column should fail" |
| ); |
| } |
| |
| async fn scan_table<'a>( |
| table: &FlussTable<'a>, |
| setup_scan: impl FnOnce(TableScan) -> TableScan, |
| ) -> Vec<ScanRecord> { |
| // 1. build log scanner |
| let log_scanner = setup_scan(table.new_scan()) |
| .create_log_scanner() |
| .expect("Failed to create log scanner"); |
| |
| // 2. subscribe |
| let mut bucket_offsets = HashMap::new(); |
| bucket_offsets.insert(0, 0); |
| log_scanner |
| .subscribe_buckets(&bucket_offsets) |
| .await |
| .expect("Failed to subscribe"); |
| |
| // 3. poll records |
| let scan_records = log_scanner |
| .poll(Duration::from_secs(10)) |
| .await |
| .expect("Failed to poll"); |
| |
| // 4. collect and sort |
| let mut records: Vec<_> = scan_records.into_iter().collect(); |
| records.sort_by_key(|r| r.offset()); |
| records |
| } |
| |
| #[tokio::test] |
| async fn test_poll_batches() { |
| let cluster = get_shared_cluster(); |
| let connection = cluster.get_fluss_connection().await; |
| let admin = connection.get_admin().await.expect("Failed to get admin"); |
| |
| let table_path = TablePath::new("fluss", "test_poll_batches"); |
| let schema = Schema::builder() |
| .column("id", DataTypes::int()) |
| .column("name", DataTypes::string()) |
| .build() |
| .unwrap(); |
| |
| create_table( |
| &admin, |
| &table_path, |
| &TableDescriptor::builder().schema(schema).build().unwrap(), |
| ) |
| .await; |
| tokio::time::sleep(Duration::from_secs(1)).await; |
| |
| let table = connection.get_table(&table_path).await.unwrap(); |
| let scanner = table.new_scan().create_record_batch_log_scanner().unwrap(); |
| scanner.subscribe(0, 0).await.unwrap(); |
| |
| // Test 1: Empty table should return empty result |
| assert!( |
| scanner |
| .poll(Duration::from_millis(500)) |
| .await |
| .unwrap() |
| .is_empty() |
| ); |
| |
| let writer = table.new_append().unwrap().create_writer().unwrap(); |
| writer |
| .append_arrow_batch( |
| record_batch!(("id", Int32, [1, 2]), ("name", Utf8, ["a", "b"])).unwrap(), |
| ) |
| .unwrap(); |
| writer |
| .append_arrow_batch( |
| record_batch!(("id", Int32, [3, 4]), ("name", Utf8, ["c", "d"])).unwrap(), |
| ) |
| .unwrap(); |
| writer |
| .append_arrow_batch( |
| record_batch!(("id", Int32, [5, 6]), ("name", Utf8, ["e", "f"])).unwrap(), |
| ) |
| .unwrap(); |
| writer.flush().await.unwrap(); |
| |
| use arrow::array::Int32Array; |
| |
| fn extract_ids(batches: &[fluss::record::ScanBatch]) -> Vec<i32> { |
| batches |
| .iter() |
| .flat_map(|b| { |
| let batch = b.batch(); |
| (0..batch.num_rows()).map(move |i| { |
| batch |
| .column(0) |
| .as_any() |
| .downcast_ref::<Int32Array>() |
| .unwrap() |
| .value(i) |
| }) |
| }) |
| .collect() |
| } |
| |
| // poll may return partial results if not all batches are available yet, |
| // so we accumulate across multiple polls until we have the expected count. |
| let mut all_ids = Vec::new(); |
| let deadline = tokio::time::Instant::now() + Duration::from_secs(10); |
| while all_ids.len() < 6 && tokio::time::Instant::now() < deadline { |
| let batches = scanner.poll(Duration::from_secs(5)).await.unwrap(); |
| all_ids.extend(extract_ids(&batches)); |
| } |
| |
| // Test 2: Order should be preserved across multiple batches |
| assert_eq!(all_ids, vec![1, 2, 3, 4, 5, 6]); |
| |
| writer |
| .append_arrow_batch( |
| record_batch!(("id", Int32, [7, 8]), ("name", Utf8, ["g", "h"])).unwrap(), |
| ) |
| .unwrap(); |
| writer.flush().await.unwrap(); |
| |
| let mut new_ids = Vec::new(); |
| let deadline = tokio::time::Instant::now() + Duration::from_secs(10); |
| while new_ids.len() < 2 && tokio::time::Instant::now() < deadline { |
| let more = scanner.poll(Duration::from_secs(5)).await.unwrap(); |
| new_ids.extend(extract_ids(&more)); |
| } |
| |
| // Test 3: Subsequent polls should not return duplicate data (offset continuation) |
| assert_eq!(new_ids, vec![7, 8]); |
| |
| // Test 4: Subscribing from mid-offset should truncate batch (Arrow batch slicing) |
| // Server returns all records from start of batch, but client truncates to subscription offset |
| let trunc_scanner = table.new_scan().create_record_batch_log_scanner().unwrap(); |
| trunc_scanner.subscribe(0, 3).await.unwrap(); |
| let mut trunc_ids = Vec::new(); |
| let deadline = tokio::time::Instant::now() + Duration::from_secs(10); |
| while trunc_ids.len() < 5 && tokio::time::Instant::now() < deadline { |
| let trunc_batches = trunc_scanner.poll(Duration::from_secs(5)).await.unwrap(); |
| trunc_ids.extend(extract_ids(&trunc_batches)); |
| } |
| |
| // Subscribing from offset 3 should return [4,5,6,7,8], not [1,2,3,4,5,6,7,8] |
| assert_eq!(trunc_ids, vec![4, 5, 6, 7, 8]); |
| |
| // Test 5: Projection should only return requested columns |
| let proj = table |
| .new_scan() |
| .project_by_name(&["id"]) |
| .unwrap() |
| .create_record_batch_log_scanner() |
| .unwrap(); |
| proj.subscribe(0, 0).await.unwrap(); |
| let proj_batches = proj.poll(Duration::from_secs(10)).await.unwrap(); |
| |
| // Projected batch should have 1 column (id), not 2 (id, name) |
| assert_eq!(proj_batches[0].batch().num_columns(), 1); |
| } |
| |
| /// Integration test covering produce and scan operations for all supported datatypes |
| /// in log tables. |
| #[tokio::test] |
| async fn all_supported_datatypes() { |
| use fluss::row::{Date, Datum, Decimal, GenericRow, Time, TimestampLtz, TimestampNtz}; |
| |
| let cluster = get_shared_cluster(); |
| let connection = cluster.get_fluss_connection().await; |
| |
| let admin = connection.get_admin().await.expect("Failed to get admin"); |
| |
| let table_path = TablePath::new("fluss", "test_log_all_datatypes"); |
| |
| // Create a log table with all supported datatypes for append/scan |
| let table_descriptor = TableDescriptor::builder() |
| .schema( |
| Schema::builder() |
| // Integer types |
| .column("col_tinyint", DataTypes::tinyint()) |
| .column("col_smallint", DataTypes::smallint()) |
| .column("col_int", DataTypes::int()) |
| .column("col_bigint", DataTypes::bigint()) |
| // Floating point types |
| .column("col_float", DataTypes::float()) |
| .column("col_double", DataTypes::double()) |
| // Boolean type |
| .column("col_boolean", DataTypes::boolean()) |
| // Char type |
| .column("col_char", DataTypes::char(10)) |
| // String type |
| .column("col_string", DataTypes::string()) |
| // Decimal type |
| .column("col_decimal", DataTypes::decimal(10, 2)) |
| // Date type |
| .column("col_date", DataTypes::date()) |
| // Time types |
| .column("col_time_s", DataTypes::time_with_precision(0)) |
| .column("col_time_ms", DataTypes::time_with_precision(3)) |
| .column("col_time_us", DataTypes::time_with_precision(6)) |
| .column("col_time_ns", DataTypes::time_with_precision(9)) |
| // Timestamp types |
| .column("col_timestamp_s", DataTypes::timestamp_with_precision(0)) |
| .column("col_timestamp_ms", DataTypes::timestamp_with_precision(3)) |
| .column("col_timestamp_us", DataTypes::timestamp_with_precision(6)) |
| .column("col_timestamp_ns", DataTypes::timestamp_with_precision(9)) |
| // Timestamp_ltz types |
| .column( |
| "col_timestamp_ltz_s", |
| DataTypes::timestamp_ltz_with_precision(0), |
| ) |
| .column( |
| "col_timestamp_ltz_ms", |
| DataTypes::timestamp_ltz_with_precision(3), |
| ) |
| .column( |
| "col_timestamp_ltz_us", |
| DataTypes::timestamp_ltz_with_precision(6), |
| ) |
| .column( |
| "col_timestamp_ltz_ns", |
| DataTypes::timestamp_ltz_with_precision(9), |
| ) |
| // Bytes type |
| .column("col_bytes", DataTypes::bytes()) |
| // Fixed-size binary type |
| .column("col_binary", DataTypes::binary(4)) |
| // Timestamp types with negative values (before Unix epoch) |
| .column( |
| "col_timestamp_us_neg", |
| DataTypes::timestamp_with_precision(6), |
| ) |
| .column( |
| "col_timestamp_ns_neg", |
| DataTypes::timestamp_with_precision(9), |
| ) |
| .column( |
| "col_timestamp_ltz_us_neg", |
| DataTypes::timestamp_ltz_with_precision(6), |
| ) |
| .column( |
| "col_timestamp_ltz_ns_neg", |
| DataTypes::timestamp_ltz_with_precision(9), |
| ) |
| .build() |
| .expect("Failed to build schema"), |
| ) |
| .build() |
| .expect("Failed to build table"); |
| |
| create_table(&admin, &table_path, &table_descriptor).await; |
| |
| let table = connection |
| .get_table(&table_path) |
| .await |
| .expect("Failed to get table"); |
| |
| let field_count = table.get_table_info().schema.columns().len(); |
| |
| let append_writer = table |
| .new_append() |
| .expect("Failed to create append") |
| .create_writer() |
| .expect("Failed to create writer"); |
| |
| // Test data for all datatypes |
| let col_tinyint = 127i8; |
| let col_smallint = 32767i16; |
| let col_int = 2147483647i32; |
| let col_bigint = 9223372036854775807i64; |
| let col_float = 3.14f32; |
| let col_double = 2.718281828459045f64; |
| let col_boolean = true; |
| let col_char = "hello"; |
| let col_string = "world of fluss rust client"; |
| let col_decimal = Decimal::from_unscaled_long(12345, 10, 2).unwrap(); // 123.45 |
| let col_date = Date::new(20476); // 2026-01-23 |
| let col_time_s = Time::new(36827000); // 10:13:47 |
| let col_time_ms = Time::new(36827123); // 10:13:47.123 |
| let col_time_us = Time::new(86399999); // 23:59:59.999 |
| let col_time_ns = Time::new(1); // 00:00:00.001 |
| // 2026-01-23 10:13:47 UTC |
| let col_timestamp_s = TimestampNtz::new(1769163227000); |
| // 2026-01-23 10:13:47.123 UTC |
| let col_timestamp_ms = TimestampNtz::new(1769163227123); |
| // 2026-01-23 10:13:47.123456 UTC |
| let col_timestamp_us = TimestampNtz::from_millis_nanos(1769163227123, 456000).unwrap(); |
| // 2026-01-23 10:13:47.123999999 UTC |
| let col_timestamp_ns = TimestampNtz::from_millis_nanos(1769163227123, 999_999).unwrap(); |
| let col_timestamp_ltz_s = TimestampLtz::new(1769163227000); |
| let col_timestamp_ltz_ms = TimestampLtz::new(1769163227123); |
| let col_timestamp_ltz_us = TimestampLtz::from_millis_nanos(1769163227123, 456000).unwrap(); |
| let col_timestamp_ltz_ns = TimestampLtz::from_millis_nanos(1769163227123, 999_999).unwrap(); |
| let col_bytes: Vec<u8> = b"binary data".to_vec(); |
| let col_binary: Vec<u8> = vec![0xDE, 0xAD, 0xBE, 0xEF]; |
| |
| // 1960-06-15 08:30:45.123456 UTC (before 1970) |
| let col_timestamp_us_neg = TimestampNtz::from_millis_nanos(-301234154877, 456000).unwrap(); |
| // 1960-06-15 08:30:45.123999999 UTC (before 1970) |
| let col_timestamp_ns_neg = TimestampNtz::from_millis_nanos(-301234154877, 999_999).unwrap(); |
| let col_timestamp_ltz_us_neg = |
| TimestampLtz::from_millis_nanos(-301234154877, 456000).unwrap(); |
| let col_timestamp_ltz_ns_neg = |
| TimestampLtz::from_millis_nanos(-301234154877, 999_999).unwrap(); |
| |
| // Append a row with all datatypes |
| let mut row = GenericRow::new(field_count); |
| row.set_field(0, col_tinyint); |
| row.set_field(1, col_smallint); |
| row.set_field(2, col_int); |
| row.set_field(3, col_bigint); |
| row.set_field(4, col_float); |
| row.set_field(5, col_double); |
| row.set_field(6, col_boolean); |
| row.set_field(7, col_char); |
| row.set_field(8, col_string); |
| row.set_field(9, col_decimal.clone()); |
| row.set_field(10, col_date); |
| row.set_field(11, col_time_s); |
| row.set_field(12, col_time_ms); |
| row.set_field(13, col_time_us); |
| row.set_field(14, col_time_ns); |
| row.set_field(15, col_timestamp_s); |
| row.set_field(16, col_timestamp_ms); |
| row.set_field(17, col_timestamp_us.clone()); |
| row.set_field(18, col_timestamp_ns.clone()); |
| row.set_field(19, col_timestamp_ltz_s); |
| row.set_field(20, col_timestamp_ltz_ms); |
| row.set_field(21, col_timestamp_ltz_us.clone()); |
| row.set_field(22, col_timestamp_ltz_ns.clone()); |
| row.set_field(23, col_bytes.as_slice()); |
| row.set_field(24, col_binary.as_slice()); |
| row.set_field(25, col_timestamp_us_neg.clone()); |
| row.set_field(26, col_timestamp_ns_neg.clone()); |
| row.set_field(27, col_timestamp_ltz_us_neg.clone()); |
| row.set_field(28, col_timestamp_ltz_ns_neg.clone()); |
| |
| append_writer |
| .append(&row) |
| .expect("Failed to append row with all datatypes"); |
| |
| // Append a row with null values for all columns |
| let mut row_with_nulls = GenericRow::new(field_count); |
| for i in 0..field_count { |
| row_with_nulls.set_field(i, Datum::Null); |
| } |
| |
| append_writer |
| .append(&row_with_nulls) |
| .expect("Failed to append row with nulls"); |
| |
| append_writer.flush().await.expect("Failed to flush"); |
| |
| // Scan the records |
| let records = scan_table(&table, |scan| scan).await; |
| |
| assert_eq!(records.len(), 2, "Expected 2 records"); |
| |
| let found_row = records[0].row(); |
| assert_eq!( |
| found_row.get_byte(0).unwrap(), |
| col_tinyint, |
| "col_tinyint mismatch" |
| ); |
| assert_eq!( |
| found_row.get_short(1).unwrap(), |
| col_smallint, |
| "col_smallint mismatch" |
| ); |
| assert_eq!(found_row.get_int(2).unwrap(), col_int, "col_int mismatch"); |
| assert_eq!( |
| found_row.get_long(3).unwrap(), |
| col_bigint, |
| "col_bigint mismatch" |
| ); |
| assert!( |
| (found_row.get_float(4).unwrap() - col_float).abs() < f32::EPSILON, |
| "col_float mismatch: expected {}, got {}", |
| col_float, |
| found_row.get_float(4).unwrap() |
| ); |
| assert!( |
| (found_row.get_double(5).unwrap() - col_double).abs() < f64::EPSILON, |
| "col_double mismatch: expected {}, got {}", |
| col_double, |
| found_row.get_double(5).unwrap() |
| ); |
| assert_eq!( |
| found_row.get_boolean(6).unwrap(), |
| col_boolean, |
| "col_boolean mismatch" |
| ); |
| assert_eq!( |
| found_row.get_char(7, 10).unwrap(), |
| col_char, |
| "col_char mismatch" |
| ); |
| assert_eq!( |
| found_row.get_string(8).unwrap(), |
| col_string, |
| "col_string mismatch" |
| ); |
| assert_eq!( |
| found_row.get_decimal(9, 10, 2).unwrap(), |
| col_decimal, |
| "col_decimal mismatch" |
| ); |
| assert_eq!( |
| found_row.get_date(10).unwrap().get_inner(), |
| col_date.get_inner(), |
| "col_date mismatch" |
| ); |
| |
| assert_eq!( |
| found_row.get_time(11).unwrap().get_inner(), |
| col_time_s.get_inner(), |
| "col_time_s mismatch" |
| ); |
| |
| assert_eq!( |
| found_row.get_time(12).unwrap().get_inner(), |
| col_time_ms.get_inner(), |
| "col_time_ms mismatch" |
| ); |
| |
| assert_eq!( |
| found_row.get_time(13).unwrap().get_inner(), |
| col_time_us.get_inner(), |
| "col_time_us mismatch" |
| ); |
| |
| assert_eq!( |
| found_row.get_time(14).unwrap().get_inner(), |
| col_time_ns.get_inner(), |
| "col_time_ns mismatch" |
| ); |
| |
| assert_eq!( |
| found_row |
| .get_timestamp_ntz(15, 0) |
| .unwrap() |
| .get_millisecond(), |
| col_timestamp_s.get_millisecond(), |
| "col_timestamp_s mismatch" |
| ); |
| |
| assert_eq!( |
| found_row |
| .get_timestamp_ntz(16, 3) |
| .unwrap() |
| .get_millisecond(), |
| col_timestamp_ms.get_millisecond(), |
| "col_timestamp_ms mismatch" |
| ); |
| |
| let read_ts_us = found_row.get_timestamp_ntz(17, 6).unwrap(); |
| assert_eq!( |
| read_ts_us.get_millisecond(), |
| col_timestamp_us.get_millisecond(), |
| "col_timestamp_us millis mismatch" |
| ); |
| assert_eq!( |
| read_ts_us.get_nano_of_millisecond(), |
| col_timestamp_us.get_nano_of_millisecond(), |
| "col_timestamp_us nanos mismatch" |
| ); |
| |
| let read_ts_ns = found_row.get_timestamp_ntz(18, 9).unwrap(); |
| assert_eq!( |
| read_ts_ns.get_millisecond(), |
| col_timestamp_ns.get_millisecond(), |
| "col_timestamp_ns millis mismatch" |
| ); |
| assert_eq!( |
| read_ts_ns.get_nano_of_millisecond(), |
| col_timestamp_ns.get_nano_of_millisecond(), |
| "col_timestamp_ns nanos mismatch" |
| ); |
| |
| assert_eq!( |
| found_row |
| .get_timestamp_ltz(19, 0) |
| .unwrap() |
| .get_epoch_millisecond(), |
| col_timestamp_ltz_s.get_epoch_millisecond(), |
| "col_timestamp_ltz_s mismatch" |
| ); |
| |
| assert_eq!( |
| found_row |
| .get_timestamp_ltz(20, 3) |
| .unwrap() |
| .get_epoch_millisecond(), |
| col_timestamp_ltz_ms.get_epoch_millisecond(), |
| "col_timestamp_ltz_ms mismatch" |
| ); |
| |
| let read_ts_ltz_us = found_row.get_timestamp_ltz(21, 6).unwrap(); |
| assert_eq!( |
| read_ts_ltz_us.get_epoch_millisecond(), |
| col_timestamp_ltz_us.get_epoch_millisecond(), |
| "col_timestamp_ltz_us millis mismatch" |
| ); |
| assert_eq!( |
| read_ts_ltz_us.get_nano_of_millisecond(), |
| col_timestamp_ltz_us.get_nano_of_millisecond(), |
| "col_timestamp_ltz_us nanos mismatch" |
| ); |
| |
| let read_ts_ltz_ns = found_row.get_timestamp_ltz(22, 9).unwrap(); |
| assert_eq!( |
| read_ts_ltz_ns.get_epoch_millisecond(), |
| col_timestamp_ltz_ns.get_epoch_millisecond(), |
| "col_timestamp_ltz_ns millis mismatch" |
| ); |
| assert_eq!( |
| read_ts_ltz_ns.get_nano_of_millisecond(), |
| col_timestamp_ltz_ns.get_nano_of_millisecond(), |
| "col_timestamp_ltz_ns nanos mismatch" |
| ); |
| assert_eq!( |
| found_row.get_bytes(23).unwrap(), |
| col_bytes, |
| "col_bytes mismatch" |
| ); |
| assert_eq!( |
| found_row.get_binary(24, 4).unwrap(), |
| col_binary, |
| "col_binary mismatch" |
| ); |
| |
| // Verify timestamps before Unix epoch (negative timestamps) |
| let read_ts_us_neg = found_row.get_timestamp_ntz(25, 6).unwrap(); |
| assert_eq!( |
| read_ts_us_neg.get_millisecond(), |
| col_timestamp_us_neg.get_millisecond(), |
| "col_timestamp_us_neg millis mismatch" |
| ); |
| assert_eq!( |
| read_ts_us_neg.get_nano_of_millisecond(), |
| col_timestamp_us_neg.get_nano_of_millisecond(), |
| "col_timestamp_us_neg nanos mismatch" |
| ); |
| |
| let read_ts_ns_neg = found_row.get_timestamp_ntz(26, 9).unwrap(); |
| assert_eq!( |
| read_ts_ns_neg.get_millisecond(), |
| col_timestamp_ns_neg.get_millisecond(), |
| "col_timestamp_ns_neg millis mismatch" |
| ); |
| assert_eq!( |
| read_ts_ns_neg.get_nano_of_millisecond(), |
| col_timestamp_ns_neg.get_nano_of_millisecond(), |
| "col_timestamp_ns_neg nanos mismatch" |
| ); |
| |
| let read_ts_ltz_us_neg = found_row.get_timestamp_ltz(27, 6).unwrap(); |
| assert_eq!( |
| read_ts_ltz_us_neg.get_epoch_millisecond(), |
| col_timestamp_ltz_us_neg.get_epoch_millisecond(), |
| "col_timestamp_ltz_us_neg millis mismatch" |
| ); |
| assert_eq!( |
| read_ts_ltz_us_neg.get_nano_of_millisecond(), |
| col_timestamp_ltz_us_neg.get_nano_of_millisecond(), |
| "col_timestamp_ltz_us_neg nanos mismatch" |
| ); |
| |
| let read_ts_ltz_ns_neg = found_row.get_timestamp_ltz(28, 9).unwrap(); |
| assert_eq!( |
| read_ts_ltz_ns_neg.get_epoch_millisecond(), |
| col_timestamp_ltz_ns_neg.get_epoch_millisecond(), |
| "col_timestamp_ltz_ns_neg millis mismatch" |
| ); |
| assert_eq!( |
| read_ts_ltz_ns_neg.get_nano_of_millisecond(), |
| col_timestamp_ltz_ns_neg.get_nano_of_millisecond(), |
| "col_timestamp_ltz_ns_neg nanos mismatch" |
| ); |
| |
| // Verify row with all nulls (record index 1) |
| let found_row_nulls = records[1].row(); |
| for i in 0..field_count { |
| assert!( |
| found_row_nulls.is_null_at(i).unwrap(), |
| "column {} should be null", |
| i |
| ); |
| } |
| |
| admin |
| .drop_table(&table_path, false) |
| .await |
| .expect("Failed to drop table"); |
| } |
| |
| #[tokio::test] |
| async fn partitioned_table_append_scan() { |
| let cluster = get_shared_cluster(); |
| let connection = cluster.get_fluss_connection().await; |
| |
| let admin = connection.get_admin().await.expect("Failed to get admin"); |
| |
| let table_path = TablePath::new("fluss", "test_partitioned_log_append"); |
| |
| // Create a partitioned log table |
| let table_descriptor = TableDescriptor::builder() |
| .schema( |
| Schema::builder() |
| .column("id", DataTypes::int()) |
| .column("region", DataTypes::string()) |
| .column("value", DataTypes::bigint()) |
| .build() |
| .expect("Failed to build schema"), |
| ) |
| .partitioned_by(vec!["region"]) |
| .build() |
| .expect("Failed to build table"); |
| |
| create_table(&admin, &table_path, &table_descriptor).await; |
| |
| // Create partitions |
| create_partitions(&admin, &table_path, "region", &["US", "EU"]).await; |
| |
| // Wait for partitions to be available |
| tokio::time::sleep(Duration::from_secs(2)).await; |
| |
| let table = connection |
| .get_table(&table_path) |
| .await |
| .expect("Failed to get table"); |
| |
| // Create append writer - this should now work for partitioned tables |
| let append_writer = table |
| .new_append() |
| .expect("Failed to create append") |
| .create_writer() |
| .expect("Failed to create writer"); |
| |
| // Append records with different partitions |
| let test_data = [ |
| (1, "US", 100i64), |
| (2, "US", 200i64), |
| (3, "EU", 300i64), |
| (4, "EU", 400i64), |
| ]; |
| |
| for (id, region, value) in &test_data { |
| let mut row = fluss::row::GenericRow::new(3); |
| row.set_field(0, *id); |
| row.set_field(1, *region); |
| row.set_field(2, *value); |
| append_writer.append(&row).expect("Failed to append row"); |
| } |
| |
| append_writer.flush().await.expect("Failed to flush"); |
| |
| // Test append_arrow_batch for partitioned tables |
| // Each batch must contain rows from the same partition |
| let us_batch = record_batch!( |
| ("id", Int32, [5, 6]), |
| ("region", Utf8, ["US", "US"]), |
| ("value", Int64, [500, 600]) |
| ) |
| .unwrap(); |
| append_writer |
| .append_arrow_batch(us_batch) |
| .expect("Failed to append US batch"); |
| |
| let eu_batch = record_batch!( |
| ("id", Int32, [7, 8]), |
| ("region", Utf8, ["EU", "EU"]), |
| ("value", Int64, [700, 800]) |
| ) |
| .unwrap(); |
| append_writer |
| .append_arrow_batch(eu_batch) |
| .expect("Failed to append EU batch"); |
| |
| append_writer |
| .flush() |
| .await |
| .expect("Failed to flush batches"); |
| |
| // Test list_offsets_for_partition |
| // US partition has 4 records: 2 from row append + 2 from batch append |
| let us_offsets = admin |
| .list_partition_offsets(&table_path, "US", &[0], OffsetSpec::Latest) |
| .await |
| .expect("Failed to list offsets for US partition"); |
| assert_eq!( |
| us_offsets.get(&0), |
| Some(&4), |
| "US partition should have 4 records" |
| ); |
| |
| // EU partition has 4 records: 2 from row append + 2 from batch append |
| let eu_offsets = admin |
| .list_partition_offsets(&table_path, "EU", &[0], OffsetSpec::Latest) |
| .await |
| .expect("Failed to list offsets for EU partition"); |
| assert_eq!( |
| eu_offsets.get(&0), |
| Some(&4), |
| "EU partition should have 4 records" |
| ); |
| |
| // test list a not exist partition should return error |
| let result = admin |
| .list_partition_offsets(&table_path, "NOT Exists", &[0], OffsetSpec::Latest) |
| .await; |
| assert!(result.is_err()); |
| assert!(result.unwrap_err().to_string().contains( |
| "Table partition 'fluss.test_partitioned_log_append(p=NOT Exists)' does not exist." |
| )); |
| |
| let log_scanner = table |
| .new_scan() |
| .create_log_scanner() |
| .expect("Failed to create log scanner"); |
| let partition_info = admin |
| .list_partition_infos(&table_path) |
| .await |
| .expect("Failed to list partition infos"); |
| for partition_info in partition_info { |
| log_scanner |
| .subscribe_partition(partition_info.get_partition_id(), 0, 0) |
| .await |
| .expect("Failed to subscribe to partition"); |
| } |
| |
| let expected_records = vec![ |
| (1, "US", 100i64), |
| (2, "US", 200i64), |
| (3, "EU", 300i64), |
| (4, "EU", 400), |
| (5, "US", 500i64), |
| (6, "US", 600i64), |
| (7, "EU", 700i64), |
| (8, "EU", 800i64), |
| ]; |
| let expected_records: Vec<(i32, String, i64)> = expected_records |
| .into_iter() |
| .map(|(id, region, val)| (id, region.to_string(), val)) |
| .collect(); |
| |
| let mut collected_records: Vec<(i32, String, i64)> = Vec::new(); |
| let start_time = std::time::Instant::now(); |
| while collected_records.len() < expected_records.len() |
| && start_time.elapsed() < Duration::from_secs(10) |
| { |
| let records = log_scanner |
| .poll(Duration::from_millis(500)) |
| .await |
| .expect("Failed to poll log scanner"); |
| for rec in records { |
| let row = rec.row(); |
| collected_records.push(( |
| row.get_int(0).unwrap(), |
| row.get_string(1).unwrap().to_string(), |
| row.get_long(2).unwrap(), |
| )); |
| } |
| } |
| |
| assert_eq!( |
| collected_records.len(), |
| expected_records.len(), |
| "Did not receive all records in time, expect receive {} records, but got {} records", |
| expected_records.len(), |
| collected_records.len() |
| ); |
| collected_records.sort_by_key(|r| r.0); |
| assert_eq!( |
| collected_records, expected_records, |
| "Data mismatch between sent and received" |
| ); |
| |
| // Test unsubscribe_partition: after unsubscribing from one partition, |
| // data from that partition should no longer be read. |
| let log_scanner_unsub = table |
| .new_scan() |
| .create_log_scanner() |
| .expect("Failed to create log scanner for unsubscribe test"); |
| let partition_infos = admin |
| .list_partition_infos(&table_path) |
| .await |
| .expect("Failed to list partition infos"); |
| let eu_partition_id = partition_infos |
| .iter() |
| .find(|p| p.get_partition_name() == "EU") |
| .map(|p| p.get_partition_id()) |
| .expect("EU partition should exist"); |
| for info in &partition_infos { |
| log_scanner_unsub |
| .subscribe_partition(info.get_partition_id(), 0, 0) |
| .await |
| .expect("Failed to subscribe to partition"); |
| } |
| log_scanner_unsub |
| .unsubscribe_partition(eu_partition_id, 0) |
| .await |
| .expect("Failed to unsubscribe from EU partition"); |
| |
| let mut records_after_unsubscribe: Vec<(i32, String, i64)> = Vec::new(); |
| let unsub_deadline = std::time::Instant::now() + Duration::from_secs(5); |
| while records_after_unsubscribe.len() < 4 && std::time::Instant::now() < unsub_deadline { |
| let records = log_scanner_unsub |
| .poll(Duration::from_millis(300)) |
| .await |
| .expect("Failed to poll after unsubscribe"); |
| for rec in records { |
| let row = rec.row(); |
| records_after_unsubscribe.push(( |
| row.get_int(0).unwrap(), |
| row.get_string(1).unwrap().to_string(), |
| row.get_long(2).unwrap(), |
| )); |
| } |
| } |
| |
| assert!( |
| records_after_unsubscribe.iter().all(|r| r.1 == "US"), |
| "After unsubscribe_partition(EU), only US partition data should be read; got regions: {:?}", |
| records_after_unsubscribe |
| .iter() |
| .map(|r| r.1.as_str()) |
| .collect::<Vec<_>>() |
| ); |
| assert_eq!( |
| records_after_unsubscribe.len(), |
| 4, |
| "Should receive exactly 4 US records (ids 1,2,5,6); got {}", |
| records_after_unsubscribe.len() |
| ); |
| |
| // Test subscribe_partition_buckets: batch subscribe to all partitions at once |
| let log_scanner_batch = table |
| .new_scan() |
| .create_log_scanner() |
| .expect("Failed to create log scanner for batch partition subscribe test"); |
| let partition_infos = admin |
| .list_partition_infos(&table_path) |
| .await |
| .expect("Failed to list partition infos"); |
| let partition_bucket_offsets: HashMap<(i64, i32), i64> = partition_infos |
| .iter() |
| .map(|p| ((p.get_partition_id(), 0), 0i64)) |
| .collect(); |
| log_scanner_batch |
| .subscribe_partition_buckets(&partition_bucket_offsets) |
| .await |
| .expect("Failed to batch subscribe to partitions"); |
| |
| let mut batch_collected: Vec<(i32, String, i64)> = Vec::new(); |
| let batch_start = std::time::Instant::now(); |
| while batch_collected.len() < expected_records.len() |
| && batch_start.elapsed() < Duration::from_secs(10) |
| { |
| let records = log_scanner_batch |
| .poll(Duration::from_millis(500)) |
| .await |
| .expect("Failed to poll after batch partition subscribe"); |
| for rec in records { |
| let row = rec.row(); |
| batch_collected.push(( |
| row.get_int(0).unwrap(), |
| row.get_string(1).unwrap().to_string(), |
| row.get_long(2).unwrap(), |
| )); |
| } |
| } |
| assert_eq!( |
| batch_collected.len(), |
| expected_records.len(), |
| "Did not receive all records in time, expect receive {} records, but got {} records", |
| expected_records.len(), |
| batch_collected.len() |
| ); |
| batch_collected.sort_by_key(|r| r.0); |
| assert_eq!( |
| batch_collected, expected_records, |
| "subscribe_partition_buckets should receive the same records as subscribe_partition loop" |
| ); |
| |
| admin |
| .drop_table(&table_path, false) |
| .await |
| .expect("Failed to drop table"); |
| } |
| |
| #[tokio::test] |
| async fn undersized_row_returns_error() { |
| let cluster = get_shared_cluster(); |
| let connection = cluster.get_fluss_connection().await; |
| let admin = connection.get_admin().await.expect("Failed to get admin"); |
| |
| let table_path = TablePath::new("fluss", "test_log_undersized_row"); |
| |
| let table_descriptor = TableDescriptor::builder() |
| .schema( |
| Schema::builder() |
| .column("col_bool", DataTypes::boolean()) |
| .column("col_int", DataTypes::int()) |
| .column("col_string", DataTypes::string()) |
| .column("col_bigint", DataTypes::bigint()) |
| .build() |
| .expect("Failed to build schema"), |
| ) |
| .build() |
| .expect("Failed to build table"); |
| |
| create_table(&admin, &table_path, &table_descriptor).await; |
| |
| let table = connection |
| .get_table(&table_path) |
| .await |
| .expect("Failed to get table"); |
| |
| let append_writer = table |
| .new_append() |
| .expect("Failed to create table append") |
| .create_writer() |
| .expect("Failed to create writer"); |
| |
| // Scenario 1b: GenericRow with only 2 fields for a 4-column table |
| let mut row = fluss::row::GenericRow::new(2); |
| row.set_field(0, true); |
| row.set_field(1, 42_i32); |
| |
| let result = append_writer.append(&row); |
| assert!(result.is_err(), "Undersized row should be rejected"); |
| let err_msg = result.unwrap_err().to_string(); |
| assert!( |
| err_msg.contains("Expected: 4") && err_msg.contains("Actual: 2"), |
| "Error should mention field count mismatch, got: {err_msg}" |
| ); |
| |
| // Correct column count but wrong types: |
| // Schema is (Boolean, Int, String, BigInt) but we put Int64 where String is expected. |
| // This should return an error, not panic. |
| let row_wrong_types = fluss::row::GenericRow::from_data(vec![ |
| fluss::row::Datum::Bool(true), |
| fluss::row::Datum::Int32(42), |
| fluss::row::Datum::Int64(999), // wrong: String column |
| fluss::row::Datum::Int64(100), |
| ]); |
| |
| let result = append_writer.append(&row_wrong_types); |
| assert!( |
| result.is_err(), |
| "Row with mismatched types should be rejected, not panic" |
| ); |
| |
| admin |
| .drop_table(&table_path, false) |
| .await |
| .expect("Failed to drop table"); |
| } |
| } |