From 77a93fee76f091445450513dfa32252e3bda00fa Mon Sep 17 00:00:00 2001 From: Dan Tasse <105866+dantasse@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:41:41 -0400 Subject: [PATCH] fix: get table size from metadata, not files (#3790) Some issues: - file_size_bytes is optional in the manifest, so if it's not there (old writer I guess) it'll under-report the table size. - it changes results a little bit from the old way by including per-file footers and metadata (probably not a big difference at real scale) --------- Co-authored-by: Will Jones --- Cargo.lock | 1 + docs/src/js/interfaces/TableStatistics.md | 5 +- nodejs/__test__/table.test.ts | 10 +- nodejs/src/table.rs | 5 +- python/python/lancedb/table.py | 4 +- python/python/tests/test_table.py | 10 +- rust/lancedb/Cargo.toml | 1 + rust/lancedb/src/table.rs | 228 +++++++++++++++++++++- 8 files changed, 253 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 04ab7b099..20f20a0ac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5480,6 +5480,7 @@ dependencies = [ "random_word", "regex", "reqwest 0.12.28", + "roaring", "rstest", "semver", "serde", diff --git a/docs/src/js/interfaces/TableStatistics.md b/docs/src/js/interfaces/TableStatistics.md index e19cba119..e2e8ef34d 100644 --- a/docs/src/js/interfaces/TableStatistics.md +++ b/docs/src/js/interfaces/TableStatistics.md @@ -44,4 +44,7 @@ The number of rows in the table totalBytes: number; ``` -The total number of bytes in the table +The total size, in bytes, of the table's data files, index files, and +overlay files + +Read from the manifest, so this excludes deletion files and manifests. diff --git a/nodejs/__test__/table.test.ts b/nodejs/__test__/table.test.ts index 15d6e0804..d263d9cab 100644 --- a/nodejs/__test__/table.test.ts +++ b/nodejs/__test__/table.test.ts @@ -277,8 +277,16 @@ describe.each([arrow15, arrow16, arrow17, arrow18])( }, numIndices: 0, numRows: 3, - totalBytes: 44, + // Full on-disk size of the two data files, footers and metadata included. + totalBytes: 684, }); + + // Index files count toward totalBytes too (only deletion files and + // manifests are excluded). + await table.createIndex("id", { config: Index.btree() }); + const statsWithIndex = await table.stats(); + expect(statsWithIndex.numIndices).toBe(1); + expect(statsWithIndex.totalBytes).toBeGreaterThan(684); }); it("should overwrite data if asked", async () => { diff --git a/nodejs/src/table.rs b/nodejs/src/table.rs index d26a44845..c4ece20e2 100644 --- a/nodejs/src/table.rs +++ b/nodejs/src/table.rs @@ -1043,7 +1043,10 @@ impl From for IndexStatistics { #[napi(object)] pub struct TableStatistics { - /// The total number of bytes in the table + /// The total size, in bytes, of the table's data files, index files, and + /// overlay files + /// + /// Read from the manifest, so this excludes deletion files and manifests. pub total_bytes: i64, /// The number of rows in the table diff --git a/python/python/lancedb/table.py b/python/python/lancedb/table.py index f0d7dc8c8..c566fc532 100644 --- a/python/python/lancedb/table.py +++ b/python/python/lancedb/table.py @@ -6341,7 +6341,9 @@ class TableStatistics: Attributes ---------- total_bytes: int - The total number of bytes in the table. + The total size, in bytes, of the table's data files, index files, and + overlay files. Read from the manifest, so this excludes deletion files + and manifests. num_rows: int The total number of rows in the table. num_indices: int diff --git a/python/python/tests/test_table.py b/python/python/tests/test_table.py index eb6eaefaa..2a069c712 100644 --- a/python/python/tests/test_table.py +++ b/python/python/tests/test_table.py @@ -3713,7 +3713,8 @@ def test_stats(mem_db: DBConnection): stats = table.stats() print(f"{stats=}") assert stats == { - "total_bytes": 60, + # Full on-disk size of the data file, footer and metadata included. + "total_bytes": 633, "num_rows": 2, "num_indices": 0, "fragment_stats": { @@ -3731,6 +3732,13 @@ def test_stats(mem_db: DBConnection): }, } + # Index files count toward total_bytes too (only deletion files and + # manifests are excluded). + table.create_index("id", config=BTree()) + stats_with_index = table.stats() + assert stats_with_index["num_indices"] == 1 + assert stats_with_index["total_bytes"] > stats["total_bytes"] + def test_create_table_empty_list_with_schema(mem_db: DBConnection): """Test creating table with empty list data and schema diff --git a/rust/lancedb/Cargo.toml b/rust/lancedb/Cargo.toml index 05251f59a..2dbd9d895 100644 --- a/rust/lancedb/Cargo.toml +++ b/rust/lancedb/Cargo.toml @@ -100,6 +100,7 @@ anyhow = "1" lance-testing = { workspace = true } tempfile = "3.5.0" random_word = { version = "0.4.3", features = ["en"] } +roaring = "0.11.4" tokio = { version = "1.23", features = ["io-util", "macros", "net", "rt-multi-thread", "sync", "test-util"] } uuid = { version = "1.7.0", features = ["v4"] } walkdir = "2" diff --git a/rust/lancedb/src/table.rs b/rust/lancedb/src/table.rs index e23bb7c47..5120c48b7 100644 --- a/rust/lancedb/src/table.rs +++ b/rust/lancedb/src/table.rs @@ -95,7 +95,6 @@ pub use delete::DeleteResult; use futures::future::join_all; pub use lance::dataset::refs::{BranchContents, Ref, TagContents, Tags as LanceTags}; pub use lance::dataset::scanner::DatasetRecordBatchStream; -use lance::dataset::statistics::DatasetStatisticsExt; pub use lance_index::optimize::OptimizeOptions; pub use lsm_stats::{BucketStats, GenerationStats, LsmStats, MemtableStats}; pub use optimize::{CompactionOptions, OptimizeAction, OptimizeStats}; @@ -3570,9 +3569,24 @@ impl BaseTable for NativeTable { let num_rows = self.count_rows(None).await?; let num_indices = self.list_indices().await?.len(); let ds = self.dataset.get().await?; - let ds_clone = (*ds).clone(); - let ds_stats = Arc::new(ds_clone).calculate_data_stats().await?; - let total_bytes = ds_stats.fields.iter().map(|f| f.bytes_on_disk).sum::() as usize; + // Sizes come from the manifest. Summing per-field `bytes_on_disk` instead + // would open every data file to read its column metadata, which costs one + // IO per fragment and reports 0 for legacy v1 storage. + // + // The manifest summary covers only the fragments' base data files, so + // overlay files (recorded on each fragment) and index files (recorded in + // the manifest's index section) are added separately. + let mut total_bytes = ds.manifest().summary().total_files_size as usize; + for frag in ds.manifest().fragments.iter() { + for overlay in &frag.overlays { + if let Some(size) = overlay.data_file.file_size_bytes.get() { + total_bytes += size.get() as usize; + } + } + } + for index in ds.load_indices().await?.iter() { + total_bytes += index.total_size_bytes().unwrap_or(0) as usize; + } let frags = ds.get_fragments(); let mut sorted_sizes = join_all( @@ -3644,7 +3658,12 @@ impl BaseTable for NativeTable { #[skip_serializing_none] #[derive(Debug, Deserialize, PartialEq)] pub struct TableStatistics { - /// The total number of bytes in the table + /// The total size, in bytes, of the table's data files, index files, and + /// overlay files + /// + /// Read from the manifest, so this excludes deletion files and manifests, + /// and it excludes any file whose size the manifest does not record + /// (tables and indices written before writers persisted file sizes). pub total_bytes: usize, /// The number of rows in the table @@ -3705,6 +3724,7 @@ mod tests { use super::*; use crate::connect; use crate::connection::ConnectBuilder; + use crate::io::object_store::io_tracking::IoTrackingStore; use crate::query::Select; use crate::query::{ExecutableQuery, QueryBase}; use crate::test_utils::connection::new_test_connection; @@ -5263,12 +5283,16 @@ mod tests { let res = table.stats().await.unwrap(); println!("{:#?}", res); + // `total_bytes` is the full on-disk size of the 11 data files (this table + // has no index or overlay files), so it is well above the 2000 bytes of + // column data these 250 int32 pairs hold: each file carries its own footer + // and metadata. assert_eq!( res, TableStatistics { num_rows: 250, num_indices: 0, - total_bytes: 2300, + total_bytes: 8925, fragment_stats: FragmentStatistics { num_fragments: 11, num_small_fragments: 11, @@ -5308,4 +5332,196 @@ mod tests { } ) } + + /// `total_bytes` counts more than the base data files: index files and + /// overlay files recorded in the manifest are included too. + #[tokio::test] + pub async fn test_stats_includes_index_and_overlay_files() { + use lance::dataset::WriteDestination; + use lance::dataset::transaction::{DataOverlayGroup, Operation}; + use lance_file::version::{ConcreteFileVersion, LanceFileVersion}; + use lance_file::writer::FileWriterOptions; + use lance_io::utils::CachedFileSize; + use lance_table::format::DataFile; + use lance_table::format::overlay::{DataOverlayFile, OverlayCoverage}; + use roaring::RoaringBitmap; + + let tmp_dir = tempdir().unwrap(); + let uri = tmp_dir.path().to_str().unwrap(); + let conn = ConnectBuilder::new(uri) + .read_consistency_interval(Duration::from_secs(0)) + .execute() + .await + .unwrap(); + + let schema = Arc::new(Schema::new(vec![ + Field::new("id", DataType::Int32, false), + Field::new("foo", DataType::Int32, true), + ])); + let batch = RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(Int32Array::from_iter_values(0..100)), + Arc::new(Int32Array::from_iter_values(0..100)), + ], + ) + .unwrap(); + let table = conn + .create_table("test_stats_extra_files", batch) + .execute() + .await + .unwrap(); + + let data_only = table.stats().await.unwrap().total_bytes; + assert!(data_only > 0); + + // A scalar index adds index files whose sizes are recorded in the + // manifest's index section. + table + .create_index(&["id"], Index::Auto) + .execute() + .await + .unwrap(); + let with_index = table.stats().await.unwrap().total_bytes; + let dataset = { + let native = table.as_native().unwrap(); + (*native.dataset.get().await.unwrap()).clone() + }; + let index_bytes: usize = dataset + .load_indices() + .await + .unwrap() + .iter() + .map(|idx| idx.total_size_bytes().unwrap_or(0) as usize) + .sum(); + assert!(index_bytes > 0); + assert_eq!(with_index, data_only + index_bytes); + + // Commit an overlay file supplying new `foo` values for the first three + // rows of fragment 0. There is no high-level API that writes overlays + // yet, so write the overlay's data file and commit the `DataOverlay` + // operation by hand. + let read_version = dataset.version().version; + let fragment_id = dataset.get_fragments()[0].id() as u64; + let foo_field_id = dataset.schema().field("foo").unwrap().id; + let overlay_schema = dataset.schema().project_by_ids(&[foo_field_id], true); + let file_version = ConcreteFileVersion::from(LanceFileVersion::Stable); + + let filename = "overlay.lance".to_string(); + let store = dataset.object_store(None).await.unwrap(); + let path = dataset.data_dir().child(filename.clone()); + let obj_writer = store.create(&path).await.unwrap(); + let mut writer = lance_file::versions::create_writer( + file_version, + obj_writer, + overlay_schema, + FileWriterOptions::default(), + ) + .unwrap(); + writer + .write_column(0, Arc::new(Int32Array::from(vec![1000, 1001, 1002])) as _) + .await + .unwrap(); + let summary = writer.finish().await.unwrap(); + let overlay_bytes = summary.size_bytes as usize; + assert!(overlay_bytes > 0); + + let mut data_file = DataFile::new_unstarted(filename, file_version); + data_file.fields = writer + .field_id_to_column_indices() + .iter() + .map(|(field_id, _)| *field_id as i32) + .collect::>() + .into(); + data_file.column_indices = writer + .field_id_to_column_indices() + .iter() + .map(|(_, column_index)| *column_index as i32) + .collect::>() + .into(); + data_file.file_size_bytes = CachedFileSize::new(summary.size_bytes); + + let overlay = DataOverlayFile { + data_file, + coverage: OverlayCoverage::dense(RoaringBitmap::from_iter(0..3)), + committed_version: 0, + }; + Dataset::commit( + WriteDestination::Dataset(Arc::new(dataset)), + Operation::DataOverlay { + groups: vec![DataOverlayGroup { + fragment_id, + overlays: vec![overlay], + }], + }, + Some(read_version), + None, + None, + Arc::new(Default::default()), + false, + ) + .await + .unwrap(); + + table.checkout_latest().await.unwrap(); + let with_overlay = table.stats().await.unwrap().total_bytes; + assert_eq!(with_overlay, with_index + overlay_bytes); + } + + /// `stats()` must stay manifest-only. Summing per-field `bytes_on_disk` + /// instead opens every data file, so cost would grow with fragment count. + #[tokio::test] + pub async fn test_stats_does_not_read_data_files() { + let tmp_dir = tempdir().unwrap(); + let uri = tmp_dir.path().to_str().unwrap(); + + let conn = ConnectBuilder::new(uri).execute().await.unwrap(); + + let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)])); + let batch = RecordBatch::try_new( + schema.clone(), + vec![Arc::new(Int32Array::from_iter_values(0..10))], + ) + .unwrap(); + + conn.create_table("test_stats_io", batch.clone()) + .execute() + .await + .unwrap(); + let table = conn.open_table("test_stats_io").execute().await.unwrap(); + const NUM_APPENDS: usize = 20; + for _ in 0..NUM_APPENDS { + table.add(batch.clone()).execute().await.unwrap(); + } + + // Reopen through a tracking store so the counters cover `stats()` alone and + // not the writes above. + let (wrapper, io_stats) = IoTrackingStore::new_wrapper(); + let table = conn + .open_table("test_stats_io") + .lance_read_params(ReadParams { + store_options: Some(ObjectStoreParams { + object_store_wrapper: Some(wrapper), + ..Default::default() + }), + ..Default::default() + }) + .execute() + .await + .unwrap(); + io_stats.lock().unwrap().read_iops = 0; + + let stats = table.stats().await.unwrap(); + let read_iops = io_stats.lock().unwrap().read_iops; + + assert_eq!(stats.fragment_stats.num_fragments, NUM_APPENDS + 1); + assert!(stats.total_bytes > 0); + // Reading the fragments' data files would take at least one IOP each. + assert!( + read_iops < stats.fragment_stats.num_fragments as u64, + "stats() issued {} read IOPs across {} fragments", + read_iops, + stats.fragment_stats.num_fragments + ); + } }