feat: add delete rows to table stats (#3783)

add to table stats the number of rows that has
been marked for deletion, but not yet deleted.
note that when a table fragment is removed due to
deletion of all of its rows, these rows are not counted. since the main
use of this feature is to determine when table compaction is needed, the
fact that these rows are not reported should not impact that decision
(deleted table fragments don't need compaction).

Fixes: https://github.com/lancedb/lancedb/issues/3761

Co-authored-by: Claude Opus 5.0 <noreply@anthropic.com>
This commit is contained in:
Yuval Lifshitz
2026-10-07 08:38:31 -07:00
committed by GitHub
co-authored by Claude Opus 5.0
parent becbda70dd
commit 069b2f7d31
10 changed files with 180 additions and 10 deletions
+8
View File
@@ -7329,6 +7329,13 @@ class TableStatistics:
and manifests.
num_rows: int
The total number of rows in the table.
num_deleted_rows: Optional[int]
The total number of rows marked as deleted across all fragments of the
table. These rows are not counted in ``num_rows``, but still occupy space
on disk until the table is compacted, so a large value here indicates
that the table should be optimized. Fragments in which every row was
deleted are dropped outright, so their rows are not counted here.
``None`` when the backend does not report deletion counts.
num_indices: int
The total number of indices in the table.
fragment_stats: FragmentStatistics
@@ -7337,6 +7344,7 @@ class TableStatistics:
total_bytes: int
num_rows: int
num_deleted_rows: Optional[int]
num_indices: int
fragment_stats: FragmentStatistics
+3 -1
View File
@@ -1474,7 +1474,9 @@ def test_stats():
table = db.create_table("test", [{"id": 1}])
res = table.stats()
print(f"{res=}")
assert res == stats
# The server does not report num_deleted_rows, and that unsupported
# state is preserved as None.
assert res == {**stats, "num_deleted_rows": None}
@contextlib.contextmanager
+19 -9
View File
@@ -4541,22 +4541,25 @@ def test_stats(mem_db: DBConnection):
"my_table",
data=[{"text": "foo", "id": 0}, {"text": "bar", "id": 1}],
)
assert len(table) == 2
table.add([{"text": "baz", "id": 1}])
assert len(table) == 3
stats = table.stats()
print(f"{stats=}")
assert stats == {
# Full on-disk size of the data file, footer and metadata included.
"total_bytes": 637,
"num_rows": 2,
# Full on-disk size of the data files, footer and metadata included:
# 637 bytes for the two-row fragment plus 415 for the one-row fragment.
"total_bytes": 1052,
"num_rows": 3,
"num_deleted_rows": 0,
"num_indices": 0,
"fragment_stats": {
"num_fragments": 1,
"num_small_fragments": 1,
"num_fragments": 2,
"num_small_fragments": 2,
"lengths": {
"min": 2,
"min": 1,
"max": 2,
"mean": 2,
"p25": 2,
"mean": 1,
"p25": 1,
"p50": 2,
"p75": 2,
"p99": 2,
@@ -4564,12 +4567,19 @@ def test_stats(mem_db: DBConnection):
},
}
# Both rows with id = 1 are deleted, but that empties the second fragment,
# which is dropped outright rather than kept with a deletion file, so only
# the row marked in the surviving fragment is counted.
table.delete("id = 1")
# 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"]
assert stats_with_index["num_rows"] == 1
assert stats_with_index["num_deleted_rows"] == 1
assert stats_with_index["fragment_stats"]["num_fragments"] == 1
def test_create_table_empty_list_with_schema(mem_db: DBConnection):
+1
View File
@@ -1266,6 +1266,7 @@ impl Table {
let dict = PyDict::new(py);
dict.set_item("total_bytes", stats.total_bytes)?;
dict.set_item("num_rows", stats.num_rows)?;
dict.set_item("num_deleted_rows", stats.num_deleted_rows)?;
dict.set_item("num_indices", stats.num_indices)?;
let fragment_stats = PyDict::new(py);