markhoerth opened a new issue, #14429:
URL: https://github.com/apache/gravitino/issues/14429
### Version
main branch, commit bb634defdc5663a8124be5fca1c8d9e52ac096ac (2.0.0-SNAPSHOT)
### Describe what's wrong
A client commits a schema change through the Iceberg REST service (`POST
/iceberg/v1/{prefix}/namespaces/{ns}/tables/{t}` with `add-schema` and
`set-current-schema` updates) that Iceberg's schema evolution rules do not
allow:
- narrowing or unrelated type changes (long->int, float->int, double->float,
string->int, int->string)
- decimal scale changes or precision reductions (decimal(9,2)->decimal(9,3),
decimal(9,2)->decimal(8,2))
- making an optional column required
- adding a required column without a default
The user expects the commit to be rejected with a 4xx and the schema left
unchanged. Instead the commit returns 200 and the new schema becomes current.
Data files written before the change can then no longer be read: after
long->int a pyiceberg scan fails with `Cannot promote long to int`, and after
adding a required column it fails with `Field is required, and could not be
found in the file`.
The same change sent through the Gravitino API (`PUT
/api/metalakes/{ml}/catalogs/{c}/schemas/{s}/tables/{t}`) on an identical table
is refused with 400, so the two APIs over the same catalog disagree on which
schema changes are legal. The behavior shows up with format-version 2 and
format-version 3 tables.
Cause, in Gravitino's code:
-
`iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/rest/IcebergTableOperations.java`
`updateTable` dispatches at line 252 to
`iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergTableOperationExecutor.java`
`updateTable` (lines 118-130).
- The only check applied there to `AddSchema` updates is
`IcebergColumnFieldValidator.validateUpdate` (line 126). In
`iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergColumnFieldValidator.java`
lines 40-46 it validates column names and comments of the new schema only. The
new schema is never compared with the table's current schema.
- The request then goes unchanged to
`iceberg/iceberg-common/src/main/java/org/apache/gravitino/iceberg/common/ops/IcebergCatalogWrapper.java`
`updateTable` (lines 351-360), which calls upstream
`CatalogHandlers.updateTable` at line 355. The federated path does the same at
`iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/FederatedCatalogWrapper.java:582`.
Upstream Iceberg's server side does not check either.
`CatalogHandlers.updateTable` applies the updates to `TableMetadata.Builder`,
whose `addSchema` only runs `Schema.checkCompatibility(schema, formatVersion)`:
a format-version check, not a check against the previous schema. Iceberg leaves
the promotion and nullability rules to the client-side `UpdateSchema`, so any
REST client that builds the schema itself can commit a schema that makes
existing files unreadable.
The Gravitino API refuses the change because
`catalogs/catalog-lakehouse-iceberg/src/main/java/org/apache/gravitino/catalog/lakehouse/iceberg/ops/IcebergCatalogWrapperHelper.java`
routes column changes through Iceberg's `UpdateSchema` (`updateColumn` at line
160, `requireColumn` at line 108, `addRequiredColumn` at line 177), which
rejects them.
A fix belongs in Gravitino's IRC commit path, for example next to the
existing `AddSchema` hook in `IcebergTableOperationExecutor.updateTable` /
`IcebergColumnFieldValidator.validateUpdate`: compare each added schema with
the current table schema by field id, require type changes to satisfy
`TypeUtil.isPromotionAllowed`, refuse optional to required changes, and require
an initial default for a new required field.
### Error message and/or stacktrace
IRC commit (long->int), HTTP 200; the current schema becomes:
```
{"type":"struct","schema-id":1,"fields":[{"id":1,"name":"l","required":false,"type":"int"},{"id":2,"name":"s","required":false,"type":"string"}]}
```
Gravitino API, same change on an identical table, HTTP 400:
```
{"code":1001,"type":"IllegalArgumentException","message":"Failed to operate
table(s) [gi] operation [ALTER] under schema [vrf_evo], reason [Cannot change
column type: l: long -> int]","stack":["java.lang.IllegalArgumentException:
Cannot change column type: l: long -> int","\tat
org.apache.iceberg.relocated.com.google.common.base.Preconditions.checkArgument(Preconditions.java:484)","\tat
org.apache.iceberg.SchemaUpdate.updateColumn(SchemaUpdate.java:285)","\tat
org.apache.gravitino.catalog.lakehouse.iceberg.ops.IcebergCatalogWrapperHelper.doUpdateColumnType(IcebergCatalogWrapperHelper.java:160)",
...]}
```
IRC commit adding required column `nr`, HTTP 200; the new field is
`{"id":3,"name":"nr","required":true,"type":"string"}`. Gravitino API, same
change, HTTP 400:
```
{"code":1001,"type":"IllegalArgumentException","message":"Failed to operate
table(s) [gr] operation [ALTER] under schema [vrf_evo], reason [Incompatible
change: cannot add required column without a default value: nr]", ...}
```
Reading existing data after the IRC commits (pyiceberg 0.12.0, tables
holding two rows written before the change):
```
ResolveError: Cannot promote long to int
ResolveError: Field is required, and could not be found in the file: 3: nr:
required string
```
The same 200 versus 400 split occurs for float->int, double->float,
string->int, int->string, decimal(9,2)->decimal(9,3),
decimal(9,2)->decimal(8,2) and optional int to required int.
### How to reproduce
Precondition: the Iceberg REST service runs with
`gravitino.iceberg-rest.catalog-config-provider = dynamic-config-provider` and
`gravitino.iceberg-rest.gravitino-metalake = vrf_ml`, so the IRC prefix is the
Gravitino catalog name.
1. Create a metalake:
```
curl -X POST -H "Authorization: Basic YWRtaW46eA==" -H "Content-Type:
application/json" http://localhost:8090/api/metalakes -d
'{"name":"vrf_ml","comment":"","properties":{}}'
```
2. Create an Iceberg catalog:
```
curl -X POST -H "Authorization: Basic YWRtaW46eA==" -H "Content-Type:
application/json" http://localhost:8090/api/metalakes/vrf_ml/catalogs -d
'{"name":"vrf_evo_cat","type":"RELATIONAL","provider":"lakehouse-iceberg","comment":"","properties":{"catalog-backend":"jdbc","uri":"jdbc:h2:file:/tmp/vrf_evo_wh/h2;AUTO_SERVER=TRUE;DATABASE_TO_LOWER=TRUE","jdbc-driver":"org.h2.Driver","jdbc-user":"sa","jdbc-password":"","jdbc-initialize":"true","warehouse":"/tmp/vrf_evo_wh/wh"}}'
```
3. Create a namespace and two identical tables through the IRC:
```
curl -X POST -H "Authorization: Basic YWRtaW46eA==" -H "Content-Type:
application/json" http://localhost:9001/iceberg/v1/vrf_evo_cat/namespaces -d
'{"namespace":["vrf_evo"],"properties":{}}'
for t in li gi; do curl -X POST -H "Authorization: Basic YWRtaW46eA==" -H
"Content-Type: application/json"
http://localhost:9001/iceberg/v1/vrf_evo_cat/namespaces/vrf_evo/tables -d
'{"name":"'$t'","properties":{"format-version":"2"},"schema":{"type":"struct","schema-id":0,"fields":[{"id":1,"name":"l","required":false,"type":"long"},{"id":2,"name":"s","required":false,"type":"string"}]}}';
done
```
4. Change `l` from long to int through the IRC. The response is 200:
```
curl -X POST -H "Authorization: Basic YWRtaW46eA==" -H "Content-Type:
application/json"
http://localhost:9001/iceberg/v1/vrf_evo_cat/namespaces/vrf_evo/tables/li -d
'{"requirements":[{"type":"assert-current-schema-id","current-schema-id":0}],"updates":[{"action":"add-schema","schema":{"type":"struct","fields":[{"id":1,"name":"l","required":false,"type":"int"},{"id":2,"name":"s","required":false,"type":"string"}]},"last-column-id":2},{"action":"set-current-schema","schema-id":-1}]}'
```
5. Make the same change through the Gravitino API. The response is 400:
```
curl -X PUT -H "Authorization: Basic YWRtaW46eA==" -H "Content-Type:
application/json"
http://localhost:8090/api/metalakes/vrf_ml/catalogs/vrf_evo_cat/schemas/vrf_evo/tables/gi
-d
'{"updates":[{"@type":"updateColumnType","fieldName":["l"],"newType":"integer"}]}'
```
6. Adding a required column behaves the same way. Create tables `ar` and
`gr` as in step 3, then run both requests. The first returns 200, the second
400:
```
curl -X POST -H "Authorization: Basic YWRtaW46eA==" -H "Content-Type:
application/json"
http://localhost:9001/iceberg/v1/vrf_evo_cat/namespaces/vrf_evo/tables/ar -d
'{"requirements":[{"type":"assert-current-schema-id","current-schema-id":0}],"updates":[{"action":"add-schema","schema":{"type":"struct","fields":[{"id":1,"name":"l","required":false,"type":"long"},{"id":2,"name":"s","required":false,"type":"string"},{"id":3,"name":"nr","required":true,"type":"string"}]},"last-column-id":3},{"action":"set-current-schema","schema-id":-1}]}'
curl -X PUT -H "Authorization: Basic YWRtaW46eA==" -H "Content-Type:
application/json"
http://localhost:8090/api/metalakes/vrf_ml/catalogs/vrf_evo_cat/schemas/vrf_evo/tables/gr
-d
'{"updates":[{"@type":"addColumn","fieldName":["nr"],"type":"string","nullable":false}]}'
```
7. To see the read failure, append rows to a table with any Iceberg client
(for example pyiceberg against the REST catalog with `warehouse="vrf_evo_cat"`)
before step 4 or 6, then scan the table after the commit. The scan fails with
`Cannot promote long to int` or `Field is required, and could not be found in
the file`.
### Additional context
Related: #899 (closed, incompatible changes such as required columns through
the Gravitino API) and #11954 (closed, format-version 3 table creation). No
existing issue covers the missing validation on the Iceberg REST commit path.
The missing check is in Gravitino's IRC commit path, which forwards the
update to upstream `CatalogHandlers.updateTable`; upstream Iceberg's server
side does not validate schema evolution either, so Gravitino currently inherits
that gap.
Found by the 2.0 OSS test suite check
`test_schema_evolution_verdict_matches_gravitino` (parameters `long->int`,
`float->int`, `decimal(9,2)->decimal(9,3)`, `decimal(9,2)->decimal(8,2)`,
`string->int`, `int->string`, `double->float`, `int->required`, `add_required`).
--
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]