kuni-92 opened a new issue, #18499:
URL: https://github.com/apache/iceberg/issues/18499

   **Apache Iceberg version:** 1.12.0 (reproduced). The code involved appears 
unchanged on `main` (`flink/v2.3`), so the same behaviour is expected there.
   
   **Query engine:** Flink (`DynamicIcebergSink`, DataStream API, upsert mode)
   
   **Please describe the bug 🐞**
   
   ### Summary
   
   `DynamicIcebergSink.TableMetadataCache.schema()` iterates **all historical 
schemas** of a table by schema id ascending and returns the first one that 
`CompareSchemasVisitor` marks `SAME` with the input. The visitor compares by 
field **name** (via `PartnerIdByNameAccessors`) and does not consider the 
input's field ids, so when a dropped column is reused — the typical case is 
`DROP COLUMN c` → `ADD COLUMN c <same type>` — the matcher picks the historical 
schema that still holds the retired field id. The writer then writes the 
Parquet file against the retired id, and the reader with the current schema 
cannot locate the field and returns `null` (or `initial-default` on tables with 
that feature).
   
   The same name-first matching also masks changes that would otherwise trigger 
the sink's auto schema evolution:
   
   - `DROP COLUMN c` followed by inputs that happen to match an older schema 
that still contained `c` → `dropUnusedColumns(true)` never fires, the column 
stays in the current schema.
   - `DROP COLUMN c` → `ADD COLUMN c` with the sink's auto schema evolution 
path → `SCHEMA_UPDATE_NEEDED` is never returned, so the sink never issues 
`addColumn`, and the column is not re-added to the catalog.
   
   ### Root cause
   
   Line numbers at tag `apache-iceberg-1.12.0`, `flink/v2.2`.
   
   1. `TableMetadataCache#schema(TableIdentifier, Schema, boolean)` iterates 
`cached.tableSchemas.entrySet()` (= `table.schemas()`, every historical schema 
sorted by schema id ascending) and returns the first entry whose 
`CompareSchemasVisitor.visit(input, tableSchema, ...)` is `SAME` 
(`TableMetadataCache.java:140-184`).
   2. `CompareSchemasVisitor` resolves the partner on the table side with 
`PartnerIdByNameAccessors`, so matching is name-based; the `field()` method 
only checks name/type/optionality and does not require the input's field id to 
match the table's (`CompareSchemasVisitor.java`).
   3. After `DROP COLUMN c` → `ADD COLUMN c` the current schema (schema id N) 
has `c` with a new field id, but schema id N-1 (or any earlier schema that 
contained `c` with the retired field id) is still in `table.schemas()`. The 
historical schema is iterated first and returned as `SAME`.
   4. `ResolvedSchemaInfo` is built from that historical schema, so the writer 
serializes the Parquet file using the retired field id. Readers consulting the 
current schema look up the new field id, do not find it in the file, and return 
`null`.
   
   The per-input-schema cache (`cached.inputSchemas`) also returns the stale 
`ResolvedSchemaInfo` directly on subsequent writes of the same input shape, 
bypassing the TTL check (`TableMetadataCache.java:141-146`), so a short 
`cacheRefreshMs` does not help.
   
   ### Reproduction
   
   Minimal DataStream job against an Iceberg table (REST catalog, upsert mode, 
equality field `id`):
   
   ```java
   // schema v0:  (id INT PK, val STRING)
   // schema v1:  v0 + (c STRING)   -- addColumn("c", STRING)
   // schema v2:  deleteColumn("c")
   // schema v3:  addColumn("c", STRING)  -- same name, same type
   
   // before each DML, an external process evolves the catalog as above
   sink.collect(dynamicRecord(id=1, val="x", c="bar"));  // under schema v3
   ```
   
   **Expected**: Reader returns `c = "bar"` for `id = 1`.
   
   **Actual**: The Parquet file is written with the retired field id (= `c` 
from schema v1). The reader with schema v3 looks up the new field id, does not 
find it in the file, and returns `null`.
   
   Confirmed via manifest stats:
   
   ```
   # Parquet column stats keyed by field_id
   value_counts:  [{key:1, 1}, {key:2, 1}, {key:3, 1}]    # key=3 = retired id 
for c
   lower_bounds:  [{key:1, 1}, {key:2, "x"}, {key:3, "bar"}]
   
   # metadata.json current-schema-id (schema v3)
   id=1  name=id       required=True
   id=2  name=val      required=False
   id=4  name=c        required=False
   ```
   
   Reader asks for `c = field id 4`, Parquet only has field id 3 → `null`.
   
   ### Impact
   
   - Silent data loss on upsert tables when a column with a reused name is 
written: the record carries the correct value but readers always see `null` (or 
the column's default if one is set).
   - `dropUnusedColumns(true)` is effectively inert on tables that have ever 
contained a column with the same name as a current column, which breaks the 
documented auto-drop behaviour.
   - Any flow that mutates the Iceberg catalog outside the sink (external 
schema sync via `UpdateSchema`, Debezium-driven DDL replication, etc.) can hit 
this because `TableMetadataCache` does not consider external changes to 
historical schemas as invalidating its cache.
   
   ### Proposed fix
   
   Two options, either or both:
   
   1. In `TableMetadataCache.schema()`, prefer the current schema when multiple 
historical schemas match. Simplest form: iterate `cached.tableSchemas` with the 
current schema id first (or iterate the entrySet in descending order), so that 
the current schema's field ids win over a historical schema that matches by 
name.
   2. In `CompareSchemasVisitor.field()`, downgrade a name-match whose input 
field id does not match the resolved `tableSchemaId` from `SAME` to 
`DATA_CONVERSION_NEEDED` (or a dedicated `FIELD_ID_MISMATCH`), so that an input 
carrying an up-to-date field id is only resolved against the current schema.
   
   Either direction lets external catalog mutations be reflected the next time 
`cacheRefreshMs` elapses, and makes `dropUnusedColumns(true)` fire on same-name 
DROP + new-shape inputs.
   
   ### Willingness to contribute
   
   - [ ] I can contribute a fix for this bug independently
   - [ ] I would be willing to contribute a fix for this bug with guidance from 
the Iceberg community
   - [x] I cannot contribute a fix for this bug at this time


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to