fix(scan): derive manifest schema/spec from table metadata (cf. iceberg-java specsById) - #2683
fix(scan): derive manifest schema/spec from table metadata (cf. iceberg-java specsById)#2683raghav-reglobe wants to merge 12 commits into
Conversation
The manifest reader parses the table schema and partition spec from the
manifest Avro file's own `schema`/`partition-spec` key-value metadata and
hard-fails if `schema` is not a valid Iceberg schema. This makes tables
written by some engines unreadable (e.g. duckdb-iceberg serializes the
manifest_entry Avro schema there, using Avro type names like `array`/
`record`), while pyiceberg, Doris, and Spark (iceberg-java) read them fine.
The manifest's embedded schema is redundant with the authoritative table
metadata. Thread the table metadata through the scan's manifest decode
(ObjectCache::get_manifest -> ManifestFile::load_manifest_with ->
Manifest::try_from_avro_bytes_with -> ManifestMetadata::parse_with) and
prefer table_metadata.{schema_by_id, partition_spec_by_id} (looked up by the
manifest's schema-id / partition-spec-id) over the manifest's own keys,
falling back to the manifest metadata when none is available. Mirrors
iceberg-java's ManifestReader(specsById), whose reading of the schema from
file metadata is deprecated.
Closes apache#2682.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Raghvendra Singh <raghav@cashify.in>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Raghvendra Singh <raghav@cashify.in>
Some writers omit the schema-id / partition-spec-id keys from the manifest's avro metadata. Defaulting the missing id to 0 and looking it up in the table metadata mis-types column bounds after a type promotion (e.g. a post-promotion manifest's 8-byte long bounds decoded with the historical int schema 0). The lookup now applies only to ids the manifest actually records; manifests without them stay on the self-describing path. Caught by test_evolved_schema. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Raghvendra Singh <raghav@cashify.in>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Raghvendra Singh <raghav@cashify.in>
…ilience Signed-off-by: Raghvendra Singh <raghav@cashify.in>
| /// | ||
| /// This method will also initialize inherited values of [`ManifestEntry`](crate::spec::ManifestEntry), such as `sequence_number`. | ||
| pub async fn load_manifest(&self, file_io: &FileIO) -> Result<Manifest> { | ||
| self.load_manifest_with(file_io, None).await |
There was a problem hiding this comment.
This None default is what the write path still hits: SnapshotProducer loads existing manifests through plain load_manifest() (transaction/snapshot.rs), so a fast_append commit to a table with a DuckDB written delete manifest still fails with the SchemaEnum parse error.
Verified on this branch against a real S3 Tables table: reads are fixed but commits still break.
Could the transaction path be plumbed through load_manifest_with too?
There was a problem hiding this comment.
Good catch — confirmed. validate_duplicate_files was still loading existing manifests through the self-describing path, so the duplicate check on append hit the same parse error the scan path used to. Pushed a commit routing it through load_manifest_with (table metadata preferred, same as the scan path), plus a regression test that corrupts a written manifest's schema user metadata in place and asserts fast_append with the duplicate check enabled succeeds.
That was the only remaining production site loading existing manifests — the other load_manifest() callers are tests reading manifests they just wrote. If your S3 Tables repro still breaks anywhere after this, I'd be interested in the error.
…icate-file validation validate_duplicate_files loaded every manifest of the current snapshot through the self-describing path, so a fast_append onto a table whose current snapshot carries a manifest with non-conformant self-described schema metadata still failed at commit even after the scan path learned to prefer the table metadata. Route the load through load_manifest_with so the recorded schema-id / partition-spec-id resolve against the table metadata, matching the scan path. Adds a regression test that corrupts a written manifest's schema user metadata in place (recorded ids intact), asserts the self-describing load fails, and asserts fast_append with the duplicate check enabled succeeds. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Raghvendra Singh <raghav@cashify.in>
…nc-2683 # Conflicts: # crates/iceberg/src/io/object_cache.rs # crates/iceberg/src/spec/manifest_list/manifest_file.rs
What
When reading a manifest, iceberg-rust parses the table schema and partition spec from that manifest's own
schema/partition-specAvro key-value metadata and uses them to decode entries — hard-failing ifschemais not a valid Iceberg schema. This PR derives the schema + spec from the authoritative table metadata (by the manifest'sschema-id/partition-spec-id) instead, falling back to the manifest's own keys when no table metadata is available.Why
TableMetadata(ObjectCache::get_manifest_listtakes it;schema_by_id/partition_spec_by_idexist). A manifest's embedded schema is a redundant copy.ManifestReadertakesspecsByIdfrom table metadata and has deprecated reading the schema from manifest file metadata — the warning is literally "Pass specsById to avoid reading from file metadata" (removed in 1.12.0). pyiceberg and iceberg-go don't read it on the scan path either. iceberg-rust is currently the only implementation that hard-depends on it.schemakey holds a non-conformant value are readable by pyiceberg, Doris, and Spark (iceberg-java) but not iceberg-rust. For example duckdb-iceberg serializes the manifest_entry Avro schema there (Avro type names likearray/record), producingdata did not match any variant of untagged enum SchemaEnum.How
ObjectCache::get_manifestnow takes&TableMetadataRefand threads it throughManifestFile::load_manifest_with→Manifest::try_from_avro_bytes_with→ManifestMetadata::parse_with, which preferstable_metadata.{schema_by_id, partition_spec_by_id}. The existing publicparse/parse_avro/load_manifest/try_from_avro_bytesare preserved (they delegate withNone), so behaviour is unchanged when no table metadata is available.Tests
test_manifest_metadata_parse_prefers_table_metadata_over_bad_schema: a manifest with a non-conformantschemakey parses successfully via table metadata, while the manifest-only path rejects it.Closes #2682.