Bug: Incremental partition stats compute produces collapsed/negative rows after partition spec evolution
- Dominant language
- Java
- Stars
- 9.2k
- Forks
- 3.5k
- Avg merge
- 2d 11h
- Merged PRs (30d)
- 132
Description
### Apache Iceberg version
1.10.0
### Query engine
None
### Please describe the bug 🐞
### Apache Iceberg version
1.10.0, 1.11.0 (latest as of filing; still present on `main`)
### Latest installation method and version
Maven / Gradle build from source (`main`), also verified on released 1.10.x and 1.11.0
### Bug Description
`compute_partition_stats` in incremental mode merges the previous stats file (base) with a
delta computed from newly added manifests. The merge map (`PartitionMap`) compares partition
tuples using **each spec's own partition type**, while the two sides use different layouts:
| Side | Key layout |
|---|---|
| Base rows from the stats file | Unified partition type: union of **all historical specs'** fields, ordered by field ID |
| Delta rows from manifests | The manifest's spec partition type: only that spec's fields |
When the current spec's field IDs are not a **prefix** of the unified field list — which
happens after dropping a non-last partition field, or replacing one (e.g. changing a bucket
count, which drops the old field and adds a new one with a higher field ID) — the per-spec
comparator misaligns positions and two failure modes appear:
1. **Partition row collapse.** All base rows sharing the same leading partition value compare
"equal" (the misaligned slot is null on every row), so loading the base file keeps only the
last row per leading value. E.g. a `dt + bucket(8, user_id)` table ends up with **1 row per
`dt` instead of 8**; the other buckets silently vanish. The collapsed file becomes the next
round's base, so the error propagates.
2. **Negative counters.** Deleted-file delta rows can no longer match their base row, so the
decrement is written as a standalone row, e.g. `data_file_count = -1`,
`data_record_count = -1` (we observed `-100615` in production). Subsequent rounds compound it.
Both corrupt a file that downstream consumers (partition expiry / retention) trust: partitions
missing from stats never expire, and negative counters break "partition is empty" checks.
The defect was introduced by #12629 (incremental compute), first released in **1.10.0**.
### Apache Iceberg version
1.10.0, 1.11.0 (latest as of filing; still present on `main`)
### Latest installation method and version
Maven / Gradle build from source (`main`), also verified on released 1.10.x and 1.11.0
### Bug Description
`compute_partition_stats` in incremental mode merges the previous stats file (base) with a
delta computed from newly added manifests. The merge map (`PartitionMap`) compares partition
tuples using **each spec's own partition type**, while the two sides use different layouts:
| Side | Key layout |
|---|---|
| Base rows from the stats file | Unified partition type: union of **all historical specs'** fields, ordered by field ID |
| Delta rows from manifests | The manifest's spec partition type: only that spec's fields |
When the current spec's field IDs are not a **prefix** of the unified field list — which
happens after dropping a non-last partition field, or replacing one (e.g. changing a bucket
count, which drops the old field and adds a new one with a higher field ID) — the per-spec
comparator misaligns positions and two failure modes appear:
1. **Partition row collapse.** All base rows sharing the same leading partition value compare
"equal" (the misaligned slot is null on every row), so loading the base file keeps only the
last row per leading value. E.g. a `dt + bucket(8, user_id)` table ends up with **1 row per
`dt` instead of 8**; the other buckets silently vanish. The collapsed file becomes the next
round's base, so the error propagates.
2. **Negative counters.** Deleted-file delta rows can no longer match their base row, so the
decrement is written as a standalone row, e.g. `data_file_count = -1`,
`data_record_count = -1` (we observed `-100615` in production). Subsequent rounds compound it.
Both corrupt a file that downstream consumers (partition expiry / retention) trust: partitions
missing from stats never expire, and negative counters break "partition is empty" checks.
The defect was introduced by #12629 (incremental compute), first released in **1.10.0**.
### Reproduction (Spark SQL)
```sql
CREATE TABLE t (dt string, user_id int, data string)
USING iceberg PARTITIONED BY (dt, bucket(4, user_id));
-- round 1: full compute (no base file) -> correct
INSERT INTO t SELECT '2024-01-01', id, 'a' FROM range(1, 40);
CALL catalog.system.compute_partition_stats('default.t');
-- spec evolution: bucket(4) -> bucket(8)
ALTER TABLE t DROP PARTITION FIELD bucket(4, user_id);
ALTER TABLE t ADD PARTITION FIELD bucket(8, user_id);
-- round 2: incremental; dt2 rows are new, this round is still correct
INSERT INTO t SELECT '2024-01-02', id, 'b' FROM range(41, 80);
CALL catalog.system.compute_partition_stats('default.t');
-- round 3: incremental; loading the round-2 base collapses dt2's 8 bucket rows into 1
INSERT INTO t SELECT '2024-01-03', id, 'c' FROM range(81, 120);
CALL catalog.system.compute_partition_stats('default.t');
SELECT partition, spec_id, file_count, record_count
FROM default.t.partitions WHERE partition.dt = '2024-01-02';
-- expected: 8 rows (one per bucket); actual on unfixed code: 1 row
```
A negative-counter variant (drop the first partition field, insert, compute, delete the row,
compute) yields a [null, b] row with file_count = -1, record_count = -1.
Expected Behavior
Incremental compute must merge delta rows onto their base rows regardless of spec evolution:
bucket rows should survive one-per-bucket, and counters should never go negative. If a merge
result does contain negative counters, the incremental compute should fail (falling back to a
full compute) rather than write a corrupt stats file.
### Root cause
computeAndMergeStatsIncremental builds PartitionMap.create(table.specs()); each spec's
StructLikeMap is created from spec.partitionType() and compares tuples positionally with
that spec's arity. Base keys are deserialized in the unified layout while delta keys are
spec-typed (file.partition() via partitionDataToRecord). For a spec whose fields sit at
unified positions with a gap (e.g. positions 0 and 2 after position 1 was dropped), the
comparator reads positions 0..1 — position 1 is the dropped field's slot, null on every row of
this spec — collapsing rows and unmatched deltas as described above.
### Proposed fix
I have a working fix and would like to contribute it as a PR:
- Key the merge map per spec ID with StructLikeMap.create(unifiedPartitionType) instead of
PartitionMap, so every key is compared in the unified layout the stats file actually uses.
- Coerce delta keys with PartitionUtil.coercePartition before merging, so delete/overwrite
deltas always land on their base row.
- Fail incremental compute with InvalidStatsFileException when merged counters go negative,
so callers fall back to full compute instead of writing a corrupt file.
Verified with new regression tests covering spec evolution + delete, multi-round propagation,
stale/wrong-base fallbacks, and parquet/Spark end-to-end paths; 6 of them fail on unfixed code
and all pass with the fix, with existing partition-stats suites green.
Affected tables
A table is affected iff some spec with rows in the stats file has a field-ID list that is not a
prefix of the unified field-ID list: dropping a middle/front partition field, or changing a
bucket count / transform. Never-evolved tables, pure field additions, and dropping the last
field are safe. v1 tables (void placeholders for dropped fields) are naturally safe.
### Willingness to contribute
- [ ] I can contribute a fix for this bug independently
- [x] I would be willing to contribute a fix for this bug with guidance from the Iceberg community
- [ ] I cannot contribute a fix for this bug at this time
Contributor guide
Research direction
Start with computeAndMergeStatsIncremental, PartitionMap, and PartitionUtil.coercePartition, then run the Spark SQL reproduction for partition-spec evolution. Add or inspect the regression tests covering spec evolution, deletes, multi-round propagation, stale or wrong bases, and Parquet/Spark paths; done means bucket rows remain distinct, counters do not go negative, and existing partition-stats suites stay green.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- java
- Domain
- databases
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 52/100