Skip to content

fix(scan): derive manifest schema/spec from table metadata (cf. iceberg-java specsById) - #2683

Open
raghav-reglobe wants to merge 12 commits into
apache:mainfrom
raghav-reglobe:manifest-schema-resilience
Open

fix(scan): derive manifest schema/spec from table metadata (cf. iceberg-java specsById)#2683
raghav-reglobe wants to merge 12 commits into
apache:mainfrom
raghav-reglobe:manifest-schema-resilience

Conversation

@raghav-reglobe

Copy link
Copy Markdown

What

When reading a manifest, iceberg-rust parses the table schema and partition spec from that manifest's own schema / partition-spec Avro key-value metadata and uses them to decode entries — hard-failing if schema is not a valid Iceberg schema. This PR derives the schema + spec from the authoritative table metadata (by the manifest's schema-id / partition-spec-id) instead, falling back to the manifest's own keys when no table metadata is available.

Why

  1. Redundant + brittle. The scan already holds TableMetadata (ObjectCache::get_manifest_list takes it; schema_by_id/partition_spec_by_id exist). A manifest's embedded schema is a redundant copy.
  2. Ecosystem alignment. iceberg-java's ManifestReader takes specsById from 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.
  3. Observable impact. Tables whose manifest schema key 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 like array/record), producing data did not match any variant of untagged enum SchemaEnum.

How

ObjectCache::get_manifest now takes &TableMetadataRef and threads it through ManifestFile::load_manifest_withManifest::try_from_avro_bytes_withManifestMetadata::parse_with, which prefers table_metadata.{schema_by_id, partition_spec_by_id}. The existing public parse / parse_avro / load_manifest / try_from_avro_bytes are preserved (they delegate with None), so behaviour is unchanged when no table metadata is available.

Tests

  • New test_manifest_metadata_parse_prefers_table_metadata_over_bad_schema: a manifest with a non-conformant schema key parses successfully via table metadata, while the manifest-only path rejects it.
  • Full lib suite green (1359 passed), clippy + fmt clean.

Closes #2682.

raghav-reglobe and others added 10 commits June 20, 2026 22:38
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

@roberto-trani roberto-trani Jul 29, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

raghav-reglobe and others added 2 commits August 4, 2026 20:07
…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Manifest reader hard-depends on the manifest schema/partition-spec keys; should derive from table metadata (cf. iceberg-java specsById)

2 participants