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 <willjones127@gmail.com>
This commit is contained in:
Dan Tasse
2026-08-07 17:41:41 -04:00
committed by GitHub
parent 7bb501839a
commit 77a93fee76
8 changed files with 253 additions and 11 deletions
Generated
+1
View File
@@ -5480,6 +5480,7 @@ dependencies = [
"random_word",
"regex",
"reqwest 0.12.28",
"roaring",
"rstest",
"semver",
"serde",
+4 -1
View File
@@ -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.
+9 -1
View File
@@ -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 () => {
+4 -1
View File
@@ -1043,7 +1043,10 @@ impl From<lancedb::index::IndexStatistics> 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
+3 -1
View File
@@ -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
+9 -1
View File
@@ -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
+1
View File
@@ -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"
+222 -6
View File
@@ -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::<u64>() 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::<Vec<_>>()
.into();
data_file.column_indices = writer
.field_id_to_column_indices()
.iter()
.map(|(_, column_index)| *column_index as i32)
.collect::<Vec<_>>()
.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
);
}
}