fix(manifest): normalize file_format casing when reading manifest entries - #1984
fix(manifest): normalize file_format casing when reading manifest entries#1984wroever wants to merge 3 commits into
Conversation
Route the decoded file_format value through FileFormatFromString in ReadEntry so spec-conformant lowercase spellings match the FileFormat constants, and reject unknown formats there with the offending file path. Signed-off-by: Will Roever <william.roever@gmail.com>
zeroshade
left a comment
There was a problem hiding this comment.
Thanks for this — the fix is correct, minimal, and genuinely spec-justified. I verified it rather than taking the description's word for it, and the substance holds up. One real gap and a couple of description corrections before merge.
What I verified
- Reverting only the 7-line
manifest.gohunk makes 4 of the 5 new subcases fail (spec_lowercasegot"parquet",mixed_casegot"Parquet",lowercase_avrogot"avro",unknown_format"An error is expected but got nil"). It is a real regression test, not a tautology. - Spec grounding:
format/spec.mdnames the values in lowercase (avro,orc,parquet,puffin) while Java writes uppercase viaFileFormat.name(). Case-insensitive read is the only portable behaviour, so the framing in #1983 is right and this isn't a DuckDB quirk. - Java parity by reading the source, not assuming:
FileFormat.fromStringisvalueOf(toUpperCase(Locale.ROOT))and throws on unknown, andBaseFilecalls it. Both the normalization and the new hard error match. - Probes: the v1 fallback decode path normalizes; lowercase
orc→ORC; lowercasepuffinin a delete manifest →PUFFIN; and a round-trip throughManifestWriter.Existingfollowed by a raw non-normalizing decode shows"PARQUET"on disk — normalization is idempotent and doesn't churn manifests. - Perf is a non-issue:
FileFormatFromString("PARQUET")is 10–19 ns/op with 0 allocs (strings.ToUpperreturns all-uppercase ASCII unchanged). A 5000-entryReadEntryloop measures 295,588 allocs/op both with and without the fix — identical. go test . -count=1→ 1396 subtests pass;-raceclean;golangci-lint run ./...→ 0 issues; CI 15/15.
Major — the sibling decoder has the identical bug
data_file_codec.go:132-150 — unmarshalAvroDataFileEntry, the other manifest-entry Avro decoder (public entry point codec.DecodeDataFile), still treats wire casing as authoritative. I built a dataFile with Format = "parquet", ran MarshalAvroEntry then unmarshalAvroDataFileEntry, and got file_format = "parquet" back. Every downstream comparison then mis-fires exactly as #1983 describes — table/internal/interfaces.go:38 (GetFile), table/scan_splits.go:34, table/scanner.go:433 (IsDeletionVector), table/dv/deletion_vector.go:331.
This matters because the codec package doc frames raw manifest bytes as in-contract input: "The bytes it produces are the same Avro bytes a manifest carries for the corresponding value" and "Legacy manifests that already carry the field on the wire still decode correctly through DecodeDataFile."
Fix: the same four lines after the s.Decode at data_file_codec.go:141, or better, factor the normalization into one helper both ReadEntry and unmarshalAvroDataFileEntry call — the shared newDecodeEntry/*dataFile plumbing already exists.
This also makes the description's claim "The Avro manifest reader was the only place treating wire casing as authoritative" false, which is worth correcting either way.
Description omissions
Two real, user-visible behaviour changes aren't mentioned. Both are improvements — I'm asking for disclosure, not changes:
table/inspect_files.go:665doesb.fileFormat.Append(string(file.FileFormat())), so thefiles/data_files/delete_files/all_*metadata tables now reportPARQUETfor a lowercase-written manifest.- Rewriting a manifest persists the normalized spelling, so reading a DuckDB-written table and running a delete silently upgrades the casing on disk.
Minor
manifest.go:920-926— an unrecognized format now aborts the entire manifest read even for entriesiterManifest(discardDeleted=true)would discard (probe:vortexon aDELETEDentry → 0 entries, hard error; previously it decoded and was skipped). Unreachable today and Java hard-fails identically, so I'm not asking for the lenient variant — but the blast radius is wider than scan (inspect_files, expire-snapshots, orphan cleanup never open the data file yet would now fail). Worth a sentence.manifest.go:923usesdf.Pathwhere the adjacent first-row-id block at:960usesdf.FilePath(). Pick one.- Pre-existing asymmetry this PR makes more visible: the reader is now lenient while
NewDataFileBuilder(manifest.go:3082) andtable/transaction.go:1099stay exact-case —NewDataFileBuilder(..., FileFormat("parquet"), ...)still errors. Routing the builder throughFileFormatFromStringwould be a coherent follow-up. - Test coverage gaps (all four verified working by probe, so coverage not correctness): the v1 fallback path,
orc,puffin-in-a-delete-manifest, and emptyfile_format. The v1 case is the one I'd add since the description makes an explicit claim about it — swapping ininternal.NewManifestEntrySchema(partSchema, 1)and encoding afallbackManifestEntryis about 3 lines in the existing table.
Not blocking on any of the above except that I'd like the data_file_codec.go decoder either fixed here or explicitly deferred to a follow-up issue, since it's the same bug the PR exists to fix.
This review was drafted by an AI-assisted tool and confirmed by an Iceberg Go maintainer. The findings cite the project's review criteria; if you think one is mis-applied, please reply and a maintainer will weigh in.
Signed-off-by: Will Roever <william.roever@gmail.com>
|
@zeroshade Thanks for the review. Have addressed the previously-unhandled codec path in 9a12014, happy to make further edits as needed. |
zeroshade
left a comment
There was a problem hiding this comment.
The prior major (unnormalized sibling decoder in unmarshalAvroDataFileEntry) is genuinely fixed and pinned by a mutation-verified test, all four requested coverage gaps are now real non-vacuous subcases, and no un-normalized Avro decode path remains.
Re-review verification: 3 of 7 prior findings confirmed fixed at 9a12014 (each verified by mutating the fix and observing the suite go red, not by taking the claim on trust). Still open:
- not fixed — description omission: table/inspect_files.go:665 means the files/data_files/delete_files/all_* metadata tables now report PARQUET for a lowercase-written manifest
- not fixed — description omission: rewriting a manifest persists the normalized spelling, silently upgrading casing on disk
- partially fixed — minor: an unrecognized format now aborts the entire manifest read even for entries iterManifest(discardDeleted=true) would discard; blast radius wider than scan — worth a sentence
Verification performed
go build ./... (clean); go vet . ./codec (clean); go test . ./codec -count=1 (ok 0.481s / 0.436s); go test ./table/... -count=1 (all 5 packages ok); go test . -race -count=1 -run 'TestManifests|TestUnmarshal|TestDataFileCodec|TestEncodeDecode' (ok, race-clean); gofmt -l on all 4 changed files (clean). Mutation runs: removed manifest.go:917-922 -> 8/9 subcases FAIL; removed data_file_codec.go:143-145 -> 4/5 subcases FAIL. Throwaway probe pr1984_probe_test.go (isFallback assertion + DELETED-entry hard-error) written, run, and deleted. Local golangci-lint v2.12.2 panics under Go 1.26 (goanalysis loader crash, environment issue) — deferred to the green CI lint job.
This review was drafted by an AI-assisted tool and confirmed by an Apache Iceberg Go maintainer. The maintainer approving this PR has read the findings and signed off. If something feels off, please reply on the PR and a maintainer will follow up.
More on how Apache Iceberg Go handles maintainer review: CONTRIBUTING.md.
Signed-off-by: Will Roever <william.roever@gmail.com>
Closes #1983, which has the full write-up and reproduction.
Problem
The Avro manifest decoders assign the wire
file_formatstring directly into the typedFileFormatfield, whose constants are uppercase:GetFile(table/internal/interfaces.go) then matches the decoded value againsticeberg.ParquetFile("PARQUET") to pick a reader. A manifest written with lowercaseparquetnever matches, so the scan fails on the first data file — with a message that renders both spellings identically:Two decode paths are affected:
ManifestReader.ReadEntry, andunmarshalAvroDataFileEntry(data_file_codec.go), which is reachable publicly throughcodec.DecodeDataFile. BeyondGetFile, the un-normalized value also mis-compares attable/scan_splits.go,table/scanner.go(IsDeletionVector), andtable/dv/deletion_vector.go.Nothing unusual is needed to hit this:
LoadTablefollowed by a baretbl.Scan(). DuckDB'sicebergextension writes lowercase, so no table it has written is readable. It also isn't limited to explicit scans —Transaction.Deleteruns a copy-on-write rewrite through the same path, so appends and deletes against such a table fail too.Fix
Factor the normalization into
dataFile.normalizeFormat, which routes the decoded value through the existing, already case-insensitiveFileFormatFromString, and call it from both decode paths —ManifestReader.ReadEntry, before the existing status/content validation and covering the normal and fallback entry paths (they share the same*dataFile), andunmarshalAvroDataFileEntry.This mirrors what the other implementations do on this exact path — Java's
BaseFile.internalSet→FileFormat.fromString, and PyIceberg'sFileFormat._missing_— so it brings Go in line rather than introducing new leniency.It also makes the manifest reader consistent with the rest of this library, which already normalizes everywhere else this field crosses the wire:
catalog/rest/scan_task_decoder.go— the REST scan-planning decoder callsFileFormatFromStringbefore building the data file.table/writer.goandtable/rolling_data_writer.go— both writers normalize.The Avro manifest reader was the only place treating wire casing as authoritative.Correction: this was wrong, as @zeroshade found in review.
unmarshalAvroDataFileEntry(data_file_codec.go), the other manifest-entry Avro decoder and the one behind the publiccodec.DecodeDataFile, had the identical bug.Behavior changes
Two user-visible consequences of normalizing on read, both flagged by @zeroshade in review:
table/inspect_files.goappendsstring(file.FileFormat())directly, sofiles,data_files,delete_filesand theall_*variants now showPARQUETfor a manifest written with lowercaseparquet.PARQUETback to disk. Normalization is idempotent, so this is a one-time upgrade and not manifest churn.Note for reviewers
An unrecognized
file_formatis now a hard error at decode time, naming the offending file path, rather than surfacing later as a confusing reader-selection failure. This matches Java, whereFileFormat.fromStringthrows. The lenient alternative — pass unknown values through untouched and let file-open fail — is a one-line change if reviewers prefer it.The blast radius of that hard error is wider than scan. Because it fires at decode time, it also reaches paths that never open the data file —
inspect_files, expire-snapshots and orphan cleanup — and it aborts the entire manifest read even for entriesiterManifest(discardDeleted=true)would have discarded. That is unreachable with any format this library writes, and Java fails identically, but it is worth stating explicitly.Testing
TestManifestReaderNormalizesFileFormatwrites a manifest through the Avro writer and reads it back, asserting the canonical constant comes out: lowercase/mixed/canonicalparquet, lowercaseavroandorc,puffinin a delete manifest, and the v1 fallback entry path; unrecognizedcsvand an emptyfile_formaterror.TestUnmarshalAvroDataFileEntryNormalizesFileFormatis the equivalent table for the codec decoder, round-tripping throughMarshalAvroEntry→unmarshalAvroDataFileEntry.make testandmake lintare clean.