mirror of
https://github.com/lancedb/lancedb.git
synced 2026-08-18 03:58:26 +00:00
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:
Generated
+1
@@ -5480,6 +5480,7 @@ dependencies = [
|
||||
"random_word",
|
||||
"regex",
|
||||
"reqwest 0.12.28",
|
||||
"roaring",
|
||||
"rstest",
|
||||
"semver",
|
||||
"serde",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user