Describe the situation
On an Iceberg v3 merge-on-read table written entirely by Spark (no crafted or hand-edited metadata), SELECT count() disagrees with SELECT * from the same table after Spark compacts the data files: the scan correctly returns 90 rows while count() returns 80.
Found by
Regression suite tests (rerun from the iceberg suite directory):
python3 regression.py --local --minio-root-user admin --minio-root-password password --clickhouse <build url or path> --only "/iceberg/deletion vectors/time travel/compaction/*" -l compaction.log
python3 regression.py --local --minio-root-user admin --minio-root-password password --clickhouse <build url or path> --only "/iceberg/deletion vectors/count paths/count from files cache/*" -l count_cache.log
How to reproduce the behavior
Environment
- Version: 26.6.2.20000.altinityantalya
- Writer: Spark with Iceberg (e.g.
tabulario/spark-iceberg), REST catalog, MinIO storage
Steps
- In Spark, create an Iceberg v3 merge-on-read table with 100 rows and delete 10 of them (the delete produces a deletion vector):
CREATE TABLE demo.db.tbl (id BIGINT, data STRING)
USING iceberg
TBLPROPERTIES (
'format-version' = '3',
'write.delete.mode' = 'merge-on-read',
'write.update.mode' = 'merge-on-read',
'write.merge.mode' = 'merge-on-read'
);
INSERT INTO demo.db.tbl
SELECT id, concat('row-', CAST(id AS STRING)) FROM range(100);
DELETE FROM demo.db.tbl WHERE id % 10 = 0;
- In ClickHouse, verify the table reads correctly before compaction (both return 90):
SELECT count()
FROM icebergS3('http://minio:9000/warehouse/db/tbl', '<key>', '<secret>');
SELECT count()
FROM (SELECT * FROM icebergS3('http://minio:9000/warehouse/db/tbl', '<key>', '<secret>'));
- In Spark, compact the table with the standard procedure:
CALL demo.system.rewrite_data_files(
table => 'db.tbl',
options => map('delete-file-threshold', '1')
);
- In ClickHouse, read the table again:
SELECT count()
FROM icebergS3('http://minio:9000/warehouse/db/tbl', '<key>', '<secret>')
SETTINGS use_iceberg_metadata_files_cache = 0;
SELECT count()
FROM (SELECT * FROM icebergS3('http://minio:9000/warehouse/db/tbl', '<key>', '<secret>'))
SETTINGS use_iceberg_metadata_files_cache = 0;
Expected behavior
Both queries return the same number of rows — 90. Compaction rewrites the data files with the deletion vector already applied, so the logical content of the table is unchanged.
Actual behavior
The full scan is correct, the count() fast path is not:
SELECT count() FROM icebergS3(...)
┌─count()─┐
│ 80 │
└─────────┘
SELECT count() FROM (SELECT * FROM icebergS3(...))
┌─count()─┐
│ 90 │
└─────────┘
SELECT * also returns the correct 90 rows with the correct ids.
Observations
The wrong value is exactly 90 − 10. After compaction, the snapshot summary of the rewrite_data_files snapshot describes 90 total records but still carries total-position-deletes = 10, even though those deletes were already applied during the rewrite and no live delete files remain. The count() result matches "summary total records minus summary total position deletes", which suggests the count is answered from snapshot summary statistics and double-counts deletes that the compaction already materialized.
Snapshot-summary totals are incremental statistics maintained by writers; after a rewrite they do not necessarily describe the live snapshot content, so subtracting them cannot be relied on for a correct count.
Question
Should the metadata count() fast path be applied at all when the snapshot summary reports a non-zero total-position-deletes? Falling back to the manifest/scan path in that case would make count() agree with the scan.
Related to: #2183
Describe the situation
On an Iceberg v3 merge-on-read table written entirely by Spark (no crafted or hand-edited metadata),
SELECT count()disagrees withSELECT *from the same table after Spark compacts the data files: the scan correctly returns 90 rows whilecount()returns 80.Found by
Regression suite tests (rerun from the
icebergsuite directory):How to reproduce the behavior
Environment
tabulario/spark-iceberg), REST catalog, MinIO storageSteps
Expected behavior
Both queries return the same number of rows — 90. Compaction rewrites the data files with the deletion vector already applied, so the logical content of the table is unchanged.
Actual behavior
The full scan is correct, the
count()fast path is not:SELECT *also returns the correct 90 rows with the correct ids.Observations
The wrong value is exactly
90 − 10. After compaction, the snapshot summary of therewrite_data_filessnapshot describes 90 total records but still carriestotal-position-deletes = 10, even though those deletes were already applied during the rewrite and no live delete files remain. Thecount()result matches "summary total records minus summary total position deletes", which suggests the count is answered from snapshot summary statistics and double-counts deletes that the compaction already materialized.Snapshot-summary totals are incremental statistics maintained by writers; after a rewrite they do not necessarily describe the live snapshot content, so subtracting them cannot be relied on for a correct count.
Question
Should the metadata
count()fast path be applied at all when the snapshot summary reports a non-zerototal-position-deletes? Falling back to the manifest/scan path in that case would makecount()agree with the scan.Related to: #2183