Skip to content

feat(iceberg): native Iceberg scans of HDFS-backed tables via iceberg-rust's hdfs-native backend - #5898

Open
mixermt wants to merge 24 commits into
apache:mainfrom
mixermt:feat/iceberg-hdfs-native
Open

mixermt wants to merge 24 commits into
apache:mainfrom
mixermt:feat/iceberg-hdfs-native

Conversation

@mixermt

@mixermt mixermt commented Sep 13, 2026 •

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Closes #5894.

Rationale for this change

Comet's native Iceberg scan had no hdfs arm, so every Iceberg table on HDFS fell back to the JVM reader, while the plain-Parquet native scan already reads HDFS through libhdfs. On-premise Iceberg deployments are commonly HDFS-backed and got no native acceleration.

apache/iceberg-rust now ships a pure-Rust HDFS backend in iceberg-storage-opendal behind the opendal-hdfs-native feature (OpenDAL's services-hdfs-native: no JNI, no libhdfs). This PR is the Comet side of it: native reads of Iceberg tables on HDFS. Writes to HDFS keep Iceberg's own writer.

Two facts about that backend shape the design:

  • It dials an authority with a port as written, but a portless one (hdfs://ns1/..., an HA nameservice) only through a declaration in the FileIO properties: hdfs.name-node.ns1, or Hadoop's own dfs.ha.namenodes.ns1 keys passed as hadoop.*. It never resolves a nameservice from $HADOOP_CONF_DIR, has no default port, and caches one client per effective NameNode.
  • OpenDAL builds every client for a synthetic nameservice, and the client looks delegation tokens up by that name. An executor on a Kerberos cluster holds tokens issued for ha-hdfs:<ns> or a NameNode address and no TGT, so it cannot authenticate. Kerberos clusters therefore fall back.

Worth stating plainly: this is a second, independent HDFS client in the same process. The plain-Parquet path reaches HDFS through libhdfs/JNI; an Iceberg table is opened over pure-Rust RPC. Both read the same $HADOOP_CONF_DIR XML on the executor, but they hold separate connections and separate credentials.

What changes are included in this PR?

  • Moves the iceberg-rust pin to ca21f158, which contains the HDFS backend, and enables opendal-hdfs-native. hdfs-native resolves to 0.14.7; releases before 0.14.5 fail every read on a cluster whose dfs.checksum.type is CRC32 (hdfs-native's CRC32 checksum verification fix shipped in 0.14.5).
  • Native (iceberg_common.rs): storage_factory_for routes hdfs to OpenDalStorageFactory::HdfsNative; hdfs joins the read list of builtin_storage_schemes and stays out of the write list; hdfs. and hadoop. join STORAGE_PROPERTY_PREFIXES, so the declarations reach the FileIO.
  • JVM: validateIcebergFileScanTasks collects the HDFS authorities of the data and delete files. CometIcebergNativeScan.hadoopToIcebergHdfsProperties declares each portless one as hdfs.name-node.<ns> from the session's dfs.ha.namenodes.<ns> and dfs.namenode.rpc-address.<ns>.<id>, all-or-nothing per nameservice (a partial list would turn a failover into an outage), with the NameNodes in declaration order. It is merged before the catalog's properties, so a catalog's own declaration wins. The scan falls back at planning, instead of failing on an executor, when a portless authority stays undeclared or when hadoop.security.authentication is not simple.
  • JVM: a scan whose data or delete files are not all on the same storage backend as the table metadata falls back. The native scan builds one FileIO from the metadata location and opens every file through it, so an hdfs:// data file under local metadata (or a local file under HDFS metadata, or S3 data under local metadata, which was already claimed before this PR) failed at open time. The planner now compares the storage family of every data/delete file (file, s3/s3a, each S3-compliant alias, gs, oss, hdfs) with the metadata location's.
  • JVM: HDFS files in an encryption zone fall back. hdfs-native is built without its kms feature and rejects every encrypted file. When the NameNode advertises a key provider (one client-cached getServerDefaults call) or the session configures one, the planner checks each distinct data/delete directory with FileStatus.isEncrypted(), which the NameNode sets for directories inside a zone too; clusters without a key provider pay nothing further.
  • JVM: a scan of a table with a name mapping (schema.name-mapping.default, which migrate, snapshot and add_files set) that reads a list or map column falls back. iceberg-rust at this pin reads a file without field ids through the name mapping, but cannot open the list layouts Hive and Spark's writeLegacyFormat write that way: the read schema it supplies keeps a field id on the list's element field and the Parquet reader rejects it as incompatible. The layout is only known from a file's footer, so the scan declines at planning. Iceberg's own TestCreateActions migrates such files and reads them back with Comet enabled; without this gate its Hive-style and two-level list cases fail.
  • pom.xml: hadoop.version, which pins the hadoop-client-minicluster the HDFS test fixture needs, now follows each Spark profile's Hadoop line, as hadoop-azure.version already does. With the global 3.3.4, MiniDFSCluster cannot start on the Spark 4.x profiles: its HttpServer2 resolves shaded Jetty classes against Spark's newer hadoop-client-runtime, which left every WithHdfsCluster suite unrunnable there. This is pre-existing and not specific to Iceberg.
  • Docs: an HDFS section in the Iceberg guide, the fallback cases, and the gaps below.

How are these changes tested?

  • CometIcebergHdfsSuite (new): end to end against in-process MiniDFSClusters, on Spark 3.4 through 4.1 (4.2 has no Iceberg runtime; the suite skips there and says so). A plain read, a pushed-down filter, and a partitioned table across several data files, each asserting a single CometIcebergNativeScanExec and result parity with Spark, and the first that the data location really is hdfs://. Then a two-NameNode HA cluster addressed by nameservice, read before and after a failover and again after the first NameNode is shut down, asserting on the planned scan that the declaration Comet derived is exactly what the client was given. Then a cluster with a jceks key provider and an encryption zone: a table inside the zone falls back with the zone named and matches Spark, a table outside it on the same NameNode still scans natively.
  • CometScanSchemeFallbackSuite: planner-level cases that drive the real CometScanRule over a real Iceberg table (its warehouse on a fake hdfs:// filesystem, so metadata and data share a storage) whose manifests name hdfs:// data files, so nothing executes: a declared nameservice is claimed with its NameNode list; a NameNode without an rpc-address, a nameservice listed only in dfs.nameservices, and a portless plain host are declined; a host:port location needs no declaration; two declared nameservices in one scan are both declared; a catalog's hdfs.name-node.<ns> wins over the derived one; a nameservice declared only by the catalog's hadoop.* keys is claimed; any HDFS location is declined on a Kerberos cluster; HDFS data under local metadata, a local file under HDFS metadata, and S3 data under local metadata are each declined for mixing storage. Plus the scheme lists (hdfs readable, not writable) and the hostless hdfs:/// form.
  • CometIcebergNativeSuite: a table imported from legacy-format Parquet with a name mapping falls back for its list column and stays native for the primitives.
  • CometIcebergNativeScanSuite: declaration order, a NameNode without a port, an undeclared nameservice, an authority with a port, a catalog's own declaration, and bracketed IPv6 authorities.
  • Rust (iceberg_common): hdfs resolves for reads and is rejected for writes; hdfs. and hadoop. properties reach the FileIO; the FileIO cache key separates catalogs that differ only in their HDFS declaration or client overrides; and a contract test that the pinned iceberg-rust expands hdfs.name-node.<ns> into Hadoop's own keys and rejects a portless entry when the storage is built.
  • The pin also changes how iceberg-rust matches nested fields in data files that have neither field ids nor a name mapping: a read of only some of a struct's fields now fails instead of matching by position. The test for such files expects that, and the Iceberg guide documents it with the workaround (spark.comet.scan.icebergNative.nestedSchemaPruning.enabled=false).
  • Locally: cargo test -p datafusion-comet --lib (961 tests), cargo clippy --all-targets --workspace -- -D warnings, cargo fmt --check; on Spark 4.1, with scalastyle: CometIcebergHdfsSuite, CometIcebergNativeScanSuite, CometScanSchemeFallbackSuite, CometIcebergWriteDetectionSuite, CometIcebergNativeSuite, CometIcebergWriteActionSuite, CometIcebergResidualPushdownSuite, CometIcebergEncryptionSuite, CometIcebergRewriteActionSuite and CometFuzzIcebergSuite, all passing (405 tests, 2 assume cancels); on Spark 3.5: the HDFS, planner and CometIcebergNativeSuite suites, 183 passing (10 version-gated cancels); spotless, dev/ci/check-suites.py and dev/ci/check-ci-config.py. dev/local-ci.sh iceberg (Iceberg 1.11 on Spark 4.1, the merge-queue target): all four shards, the extensions suite and the runtime integration test pass. Without the name-mapping fallback above, shard-4 failed TestCreateActions' Hive-style and two-level list cases.

Known gaps

  • Kerberos clusters fall back, as described above. Fixing that needs OpenDAL to build the client for the real nameservice, or hdfs-native to find tokens by NameNode address.
  • Without Kerberos, the client connects as HADOOP_USER_NAME, or as the executor's operating system user when that is unset, rather than as the Spark user.
  • Files in an HDFS encryption zone fall back: OpenDAL does not enable hdfs-native's kms feature. Enabling it is not enough on its own: hdfs-native reads the KMS URI only from hadoop.security.key.provider.path, not from the NameNode's default, and rejects any non-kms:// provider when the client is built.
  • Data and delete files on a storage backend other than the metadata's fall back; routing each file to its own backend is a follow-up.
  • Tables with a name mapping lose native reads of their list and map columns, standard layouts included, until iceberg-rust can open legacy list layouts through a name mapping.
  • A pruned read of some fields of a struct in a data file with neither field ids nor a name mapping fails (see above); reading the whole struct, or disabling nested schema pruning, avoids it.
  • A portless single-NameNode location (hdfs://namenode/...) falls back: iceberg-rust has no default port, so to it that is an undeclared nameservice. Write the port, or declare it as hdfs.name-node.namenode on the catalog.
  • The HDFS clients run on iceberg-rust's own Tokio runtime, one per process, and the FileIO cache keys on the metadata location, so each new snapshot of a table builds a new client.

AI Disclosure

Developed with AI assistance (Claude Code): drafting the implementation, tests, and this description. I reviewed the changes and ran all verification locally.

🤖 Generated with Claude Code

mixermt and others added 3 commits September 13, 2026 13:30
…ative backend

Adds `hdfs://` to the native Iceberg scan and write paths, backed by
iceberg-rust's pure-Rust `hdfs-native` OpenDAL backend (apache/iceberg-rust#3111).
This is a second, independent HDFS client: the plain-Parquet native scan reaches
HDFS through libhdfs/JNI (`fs.comet.libhdfs.schemes`), while an Iceberg table is
opened over pure-Rust RPC. The two share only the `$HADOOP_CONF_DIR` XML and
authenticate separately.

The scheme arm alone is not sufficient on an HA cluster. opendal's
`HdfsNativeBuilder` never dials the authority written in the path: it builds its
client against a synthetic authority and synthesizes
`dfs.ha.namenodes.<synthetic>` / `dfs.namenode.rpc-address.<synthetic>.nnN` from
the comma-separated `hdfs.name-node` value (`init_hdfs_config`). iceberg-rust
falls back to the path authority only when that property is absent, which is
correct just for a single-NameNode cluster. An HA table location reads
`hdfs://<nameservice>/...`, and a nameservice is not a routable host, so without
a NameNode list every HA table would fail to connect at execution time -- after
the planner had already committed to the native scan.

`hadoopToIcebergHdfsProperties` therefore resolves the endpoints from the session
Hadoop configuration (`dfs.ha.namenodes.<ns>` plus each
`dfs.namenode.rpc-address.<ns>.<nn>`), joined in declaration order, so a standard
HDFS client configuration needs no extra settings. An explicit catalog
`hdfs.name-node` still wins. A partially resolved list yields nothing rather than
a short failover list, which would silently turn a failover into an outage.

Because one `hdfs.name-node` overrides the authority of every path the FileIO
opens, a scan whose data/delete files span more than one HDFS authority now falls
back: the second nameservice would otherwise be read from the first one's
NameNode at the same relative path, returning wrong data rather than an error.

Changes:
- `storage_factory_for`: `hdfs` arm; `hdfs.`/`hadoop.` added to
  `STORAGE_PROPERTY_PREFIXES` so the NameNode list and client overrides reach FileIO
- `CometScanRule.icebergReadableSchemes` and
  `CometIcebergNativeWrite.SupportedStorageSchemes`: admit `hdfs`
- `IcebergTaskValidationResult.dataFileHdfsAuthorities` + multi-authority fallback
- `native/Cargo.toml`: enable `opendal-hdfs-native`

Testing:
- `CometIcebergHdfsSuite`: end-to-end reads against an in-process MiniDFSCluster
  (plain read, pushdown filter, partitioned table across multiple data files),
  asserting the data location is genuinely `hdfs://`
- 5 unit tests pinning the HA NameNode translation, 2 pinning the native scheme
  and property forwarding
- Full Rust workspace suite (1442 tests) and the three affected JVM suites pass

Note: writes are gated and property-forwarded but have no functional test; only
reads are covered end to end. The HA translation is unit-tested only, as
MiniDFSCluster is single-NameNode.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The opendal synthetic-authority mechanic -- that `hdfs.name-node` overrides the
authority of every path the FileIO opens -- was spelled out at seven sites. Keep
it once, in `hadoopToIcebergHdfsProperties`'s scaladoc, and leave short pointers
at the rest.

Also drops a paragraph that narrated the eight lines of code beneath it, a
`@param` restating its own signature, and a sentence duplicated four lines apart
inside `hadoopToIcebergHdfsProperties`.

Retained the reasoning that cannot be recovered from the code: why opendal
ignores the path authority, why the NameNode list is all-or-nothing, why
`getRawAuthority` rather than `getHost`, and that hdfs-native and libhdfs/JNI are
two separate clients.

Comments only; no behaviour change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`hadoop-client-minicluster` is pinned at 3.3.4 while Spark supplies
`hadoop-client-api`/`runtime` 3.4.2 on the Spark 4.x profiles, so `HttpServer2`
resolves a shaded Jetty class the older jar does not carry and the NameNode web
server fails to start. Record that in `beforeAll` and cancel the tests instead of
aborting the suite, so CI stays green on profiles where the fixture cannot run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added enhancement New feature or request area:writer Native Parquet writer area:scan Parquet scan / data reading area:Iceberg labels Sep 13, 2026
mixermt and others added 3 commits September 13, 2026 17:12
… supplies

`hadoop.version` was a single global 3.3.4, but only Spark 3.4/3.5 ship hadoop
3.3.x. On the Spark 4.x profiles `HttpServer2` comes from Spark's newer
hadoop-client-runtime and resolves shaded Jetty classes the 3.3.4 minicluster does
not carry, so MiniDFSCluster's NameNode web server fails to start and any suite
using `WithHdfsCluster` cannot run.

Set `hadoop.version` per Spark profile, as `parquet.version` already is:
3.4 -> 3.3.4, 3.5 -> 3.3.4, 4.0 -> 3.4.1, 4.1 -> 3.4.2, 4.2 -> 3.5.0. The 3.x
profiles are unchanged; a global bump would only have moved the skew onto them.

`CometIcebergHdfsSuite` now runs on the default profile instead of cancelling.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@comphead

Copy link
Copy Markdown
Contributor

Sounds good, thanks @mixermt

Co-authored-by: Cursor <cursoragent@cursor.com>
@mixermt

mixermt commented Sep 19, 2026

Copy link
Copy Markdown
Contributor Author

Found that CometIcebergNativeScanExec does not bridge bytes read or records read into Spark's standard TaskMetrics.inputMetrics, As a result, the Spark UI reports Input: 0 B / 0 records for stages dominated by Comet native scans.
Fixing this

mixermt and others added 3 commits September 19, 2026 21:26
…scans

The scan input bridge only ran when the native block's root was the scan
itself. `hasScanInput` recognised `CometNativeScanExec` alone, so an Iceberg
scan fused below a filter, project or aggregate, or feeding the native
shuffle writer, never reported bytesRead/recordsRead and the Spark UI showed
"Input: 0 B / 0 records" for those stages. The listener was also registered
after `CometExecIterator`, so it ran before the final native metric flush
whenever the iterator was not drained.

Register the bridge in `CometExecRDD.compute` ahead of the iterator, drop the
per-exec `compute` overrides and the `hasScanInput` gate (the bridge is a
no-op without `bytes_scanned` leaves), and increment rather than set the task
input metrics, claiming each accumulator once per task through the registry
the spill reporter already uses so overlapping or repeated registrations
count once while other input sources still add up.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
CometNativeShuffleWriter registered reportScanInputMetrics after
CometNativeShuffleInputRDD had resolved its inputs, so for a nested native
block that a failed or undrained writer plan left unflushed, the writer's
listener claimed the nested scan leaves before their iterator published
final values and the nested block's own listener then added nothing.

Register scan input reporting next to spill reporting in
CometNativeShuffleInputRDD.compute, before the input producers and the
writer's iterator, and drop the writer's registration. Rename the RDD's
metric node to taskMetricNode since it now feeds both task metrics, and
state that a coalesced task's repeated registrations are deduplicated
rather than summed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…L UI

The SQL UI aggregates accumulator updates from listener events and never
reads driver-side values. Iceberg posts its planning metrics (manifests and
files scanned or skipped, planning duration, file sizes) for the original
BatchScanExec's accumulators, whose ids are not in the Comet plan graph, and
CometIcebergNativeScanExec never posted its own, so every planning metric
showed 0. Under DPP the driver-side values were 0 as well, because planning
runs on a copy of the original plan while the lazy metrics read the
original's accumulators.

Post the planning metrics as a driver metric update once per SQL execution
from the first execution-time read of commonData, read them from the plan
copy that actually planned, register them under Iceberg's descriptions so
the labels match BatchScan, and keep them out of the executor metric tree so
tasks do not report zeros into the per-task statistics. Document that output
rows and recordsRead count rows after iceberg-rust's row filter, and that
row deletes applied has no native counterpart.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@github-actions github-actions Bot added the area:shuffle Shuffle (JVM and native) label Sep 19, 2026
Reconciles the Iceberg planning metrics work with apache#6027,
which posts them through the new CometLeafExec.sendDriverMetrics hook: keep the
hook and Iceberg's metric names, and layer on the per-execution post guard, the
DPP-correct plannedOriginalPlan, and the exclusion of planning metrics from the
executor metric tree.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
mixermt and others added 3 commits October 7, 2026 00:09
Brings in origin/main up to 8c93fbb and moves the iceberg-rust pin to the
head of apache/iceberg-rust#3111 (22db8e6f, HDFS via opendal hdfs-native).

The branch's own Iceberg task-input and planning metrics commits are dropped
in favour of main's equivalents (apache#5880, apache#6085, apache#5265).

The pin also carries iceberg-rust main changes, so two Comet adaptations:
FileWrite::close now returns FileMetadata (LocationScopedFileWrite) and
UnboundPartitionField is built through its builder (planner tests).

Also: CometIcebergHdfsSuite is registered in the CI workflows, the HDFS case
in CometIcebergWriteDetectionSuite no longer treats hdfs as an unsupported
scheme, and the Iceberg HDFS docs are corrected.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Brings in origin/main up to f7eb8aa and moves the iceberg-rust pin to the
current head of apache/iceberg-rust#3111 (b9624086), which contains main's
own pin (af1da4c5) plus the HDFS commits.

That head changes how HDFS NameNodes resolve: a portless path authority is a
logical nameservice resolved only through its declaration,
hdfs.name-node.<nameservice> (or Hadoop's dfs.ha.namenodes keys passed as
hadoop.*); the global hdfs.name-node now serves authority-less paths only,
and there is no default port. Comet's HA translation emitted the global key,
so the HA MiniDFS test failed with "logical nameservice ... is not declared".

Comet now:
- declares HA nameservices as hdfs.name-node.<nameservice>, derived from the
  session Hadoop configuration overlaid with the table FileIO's dfs.* keys;
- declares a portless plain host on port 8020, as the JVM client dials it,
  and no longer invents 8020 for a portless rpc-address, which the JVM
  rejects;
- falls back at planning, instead of failing on an executor, for an
  undeclared nameservice, a NameNode entry that is not host:port in any
  hdfs.name-node* key, DNS-resolved NameNodes (resolve-needed), malformed
  hdfs.host/hdfs.port or a bare hadoop. key, and a declaration of opendal's
  synthetic nameservice name.

The HA test now also checks the declaration before executing and keeps
reading and writing after the first NameNode is shut down. The
pre-epoch partition tripwire follows iceberg-rust apache#3323, which the pin
carries; Comet's own values are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…calastyle limit

The reason for an unresolved nameservice now names the Hadoop configuration
the declaration is derived from (session overlaid with the table FileIO's)
rather than the session alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-rust#3111

ef451b66 merges apache/iceberg-rust main into the PR: typed NULL arrays for
schema-added nested columns on read (apache#2635), an azdls relative-path fix
(apache#3311) and dependency bumps. The HDFS resolver is unchanged, so the
hdfs.name-node.<nameservice> declaration Comet emits still applies, and the
head still contains main's pin (af1da4c5).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@andygrove andygrove added this to the 1.2.0 milestone Oct 10, 2026
Brings in origin/main up to 9e13649 and moves the iceberg-rust pin to the
current head of apache/iceberg-rust#3111 (0145ca6b), which still contains
main's pin (af1da4c5).

Main now loads the Iceberg scheme allow-lists from the native
builtin_storage_schemes over JNI, so hdfs is admitted there (read and write)
instead of in the two Scala sets.

The pin brings apache/iceberg-rust apache#3145, which makes FileScanTaskDeleteFile's
fields private behind a validating builder and its deletion-vector offsets
unsigned: the planner builds delete files through the builder (rejecting a
negative offset or size as iceberg-rust does for a manifest entry), and the
delete-file sizing rebuilds each delete through it.

The reworked HDFS backend validates every declared nameservice and a
forwarded fs.defaultFS when it builds the storage, failing all I/O otherwise.
Comet's gate now simulates its option expansion (hadoop.* keys win over the
hdfs.name-node.<ns> sugar key by key) and declines those cases at planning.
It also builds HDFS clients in a runtime of its own, so the AbortOnDrop
comment about runtime-bound operators is replaced.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@comphead

Copy link
Copy Markdown
Contributor

@mixermt congrats on landing apache/iceberg-rust#3111. I opened #7157 as a draft that wires the merged backend into Comet, building on your design here.

It follows the merged semantics: each portless authority is declared as hdfs.name-node.<ns> from the session's Hadoop config, and there is no single-NameNode gate. It also falls back on Kerberos clusters. opendal's hdfs-native service connects every client to a synthetic nameservice, so hdfs-native looks up an ha-hdfs:nameservice token and never finds an executor's delegation tokens.

Reviews welcome, and happy to coordinate if you would rather carry this in your PR.

@mixermt

mixermt commented Oct 23, 2026

Copy link
Copy Markdown
Contributor Author

@mixermt congrats on landing apache/iceberg-rust#3111. I opened #7157 as a draft that wires the merged backend into Comet, building on your design here.

It follows the merged semantics: each portless authority is declared as hdfs.name-node.<ns> from the session's Hadoop config, and there is no single-NameNode gate. It also falls back on Kerberos clusters. opendal's hdfs-native service connects every client to a synthetic nameservice, so hdfs-native looks up an ha-hdfs:nameservice token and never finds an executor's delegation tokens.

Reviews welcome, and happy to coordinate if you would rather carry this in your PR.

Personally, I prefer to continue this from my PR 🙏 as I've invested not trivial time in actual testing this on high-scale workloads.

mixermt and others added 2 commits October 23, 2026 13:18
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…s hdfs-native backend

Moves the iceberg-rust pin to apache/iceberg-rust ca21f158, which carries the
HDFS backend (opendal-hdfs-native, a pure-Rust HDFS client), and routes hdfs
to it for reads. Writes keep Iceberg's writer.

That backend dials an authority with a port as written and resolves a
portless one, an HA nameservice, only through a declaration in the FileIO
properties, never through $HADOOP_CONF_DIR. The scan rule now collects the
HDFS authorities of the data and delete files and declares each portless one
as hdfs.name-node.<ns> from the session's dfs.ha.namenodes.<ns> and
dfs.namenode.rpc-address.<ns>.<id>, merged before the catalog's properties so
a catalog's own declaration wins. A nameservice that stays undeclared, and any
HDFS read on a cluster whose hadoop.security.authentication is not simple
(the client cannot use an executor's delegation tokens), fall back at planning
instead of failing on an executor.

hadoop.version, which pins the hadoop-client-minicluster the HDFS test fixture
needs, now follows each Spark profile's Hadoop line: with a global 3.3.4,
MiniDFSCluster cannot start on the Spark 4.x profiles. CometIcebergHdfsSuite
reads tables on a single-NameNode cluster and on a two-NameNode HA cluster
addressed by nameservice, before and after a failover and with the first
NameNode shut down. CometScanSchemeFallbackSuite drives the real scan rule
over Iceberg tables whose manifests name hdfs:// data files.

The pin also changes how iceberg-rust matches nested fields in data files that
have neither field ids nor a name mapping: a read of only some of a struct's
fields now fails rather than guessing by position. The test for such files
expects that, and the Iceberg guide documents it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@mixermt mixermt changed the title feat(iceberg): support HDFS storage via iceberg-rust hdfs-native backend [NOT FOR MERGE: pending apache/iceberg-rust#3111] feat(iceberg): native Iceberg scans of HDFS-backed tables via iceberg-rust's hdfs-native backend Oct 23, 2026
…columns

iceberg-rust at the pinned revision reads a data file that has no field ids
through the table's name mapping, but cannot open the list layouts Hive and
Spark's writeLegacyFormat write (a two-level list, or a three-level one with
bag/array_element groups) that way: the read schema it supplies keeps a field
id on the list's element field, and the Parquet reader rejects it as
incompatible. A file's layout is only known from its footer, so a scan of a
table with schema.name-mapping.default now falls back whenever it reads a
list or map column, at any depth. Iceberg's TestCreateActions migrates such
files and reads them back with Comet enabled; its Hive-style and two-level
list cases failed without this.

CometIcebergNativeSuite imports a legacy-format file under a name mapping and
checks that the list column falls back while the primitives stay native. The
limitation is listed in the Iceberg guide.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@mixermt
mixermt marked this pull request as ready for review October 23, 2026 19:19

@sunchao sunchao left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Reviewed the full 16-file diff at b493b627ecb16d5b2814cfa6b349becef9ba557d against 69111f68a5ff62904342910c445d191f53615aff. The PR remains non-draft. Found three introduced P2 issues.

Read all four conversation comments. There are no submitted reviews, inline comments, or review threads. Routed skills: review-comet-pr, review-comet-expression-pr for the serde, and review-comet-iceberg-write-pr for the dependency pin and shared storage implementation.

Summary

  • Prior state and problem: HDFS-backed Iceberg scans previously fell back to Iceberg’s JVM reader. This PR adds native HDFS reads while retaining JVM writes.
  • Design approach: Enable iceberg-rust’s OpenDAL HDFS backend, translate HA nameservice declarations from Hadoop configuration, and check eligibility during planning.
  • Correctness: The HA declaration handling and name-mapped container fallback have targeted coverage. Three failure cases remain: pruned structs without field IDs, HDFS files paired with metadata on another filesystem, and HDFS encryption-zone files. Checked Spark configuration handling against sources for 3.4.3, 3.5.9, 4.0.4, and 4.1.3, and Iceberg reader behavior across 1.5.2, 1.8.1, 1.10.0, and 1.11.0.
  • Compatibility analysis: Compared against branch-1.2 at 889856dc5af821de1f7c6ca2e9538bd663431d04. Its affected production sources and iceberg-rust pin match the supplied base. Native HDFS routing and the name-mapped container fallback are intended changes. The three reported execution failures are regressions. Spark 4.2 has no configured Iceberg runtime, so the new integration suite skips it.
  • Key design decisions: HDFS remains read-only, complete HA declarations preserve NameNode order, catalog declarations override derived declarations, and Kerberos scans fall back. These are sensible boundaries.
  • Implementation sketch: The change extends the native scheme/property lists, collects HDFS authorities during existing task validation, adds two Scala configuration helpers, aligns MiniDFSCluster dependencies with Spark profiles, and registers the new suite on Linux and macOS.
  • Performance: Authority collection reuses the existing task traversal. The backend caches clients, although snapshot-specific FileIO keys can create additional clients and the backend owns a separate runtime. No benchmark was run, and no separate evidence-backed P1/P2 performance finding was identified.
  • Design: Reusing the existing planner gate and storage factory keeps the implementation understandable. Eligibility must also account for the single backend selected from the metadata URI and for unsupported HDFS encryption.
  • Abstraction & complexity: The small nameservice helpers and existing property forwarding avoid unnecessary protocol changes. A conservative filesystem-consistency guard would address the mixed-filesystem failure without requiring a new general routing abstraction.
  • Behavioral changes worth calling out: Native Iceberg scanning is enabled by default, so eligible HDFS tables automatically change readers. Name-mapped list/map projections now fall back, while primitive projections can remain native. The dependency bump also changes some previously successful nested projections into exceptions.
  • Suggested improvements: Preserve Spark-compatible handling of pruned files without field IDs, decline incompatible metadata/data filesystem combinations, and retain JVM reads for encryption-zone files until native KMS support is wired and validated. Add parity coverage for these specific triggers.

Exact-head CI: Comet CI and CodeQL both report action_required. Only the labeling workflow succeeded. There is no exact-head build or test verdict.

Validation: Ran standalone reproductions against both exact iceberg-rust revisions with Arrow/Parquet 59.3.1 and OpenDAL 0.58.2. The old pin returns two NULL rows for the pruned-struct case, while the new pin errors. The FileIO reproduction confirms that HDFS paths are incorrectly opened through the local backend when selected from local metadata. check-suites.py and check-ci-config.py passed. Full Comet JVM/native suites and live HDFS/HA/KMS tests were not run. The encryption-zone finding is verified from the pinned dependency’s feature configuration and unconditional error path. The author’s reported integration runs were not independently reproduced. Project code is unchanged.

Comment thread native/Cargo.toml
# version requirement once a release ships on arrow 59. Bump policy: #5645.
iceberg = { git = "https://github.com/apache/iceberg-rust", rev = "1f3bc34058e3f3e45336bc5428f37b92649a608b" }
iceberg-storage-opendal = { git = "https://github.com/apache/iceberg-rust", rev = "1f3bc34058e3f3e45336bc5428f37b92649a608b", features = ["opendal-memory", "opendal-fs", "opendal-s3", "opendal-gcs", "opendal-oss", "opendal-azdls"] }
iceberg = { git = "https://github.com/apache/iceberg-rust", rev = "ca21f1587c6caaeb88dd3a9e1cc245747e1aba08" }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[P2] Preserve compatible reads of pruned structs when updating this pin. Import a Parquet file containing s STRUCT<a: INT, b: INT> without field IDs or a name mapping, then run SELECT s.b with the default nested pruning enabled. The new revision throws cannot reconcile struct fields by id; Spark completes the query. With b containing only NULLs, the base also returns the same NULL rows as Spark, isolating a newly introduced failure. The changed test now expects this exception instead of preventing it. Please add compatible handling or a JVM fallback for this case and retain a Spark-parity assertion.

Evidence: Executed standalone ArrowReader reproductions with iceberg-rust 1f3bc34058e3f3e45336bc5428f37b92649a608b and ca21f1587c6caaeb88dd3a9e1cc245747e1aba08, both using Arrow/Parquet 59.3.1. For rows s=(10,NULL),(20,NULL), the pruned base read succeeds with two NULLs and the head read errors. Logs: /tmp/5898-b493-base-exact-arrow.log and /tmp/5898-b493-head-exact-arrow-online.log. Iceberg Java’s fallback schema assigns only top-level IDs and its optional-field reader supplies NULLs. The PR’s CometIcebergNativeSuite.scala:2814 expects the same new failure.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Pushing back on this one, under these assumptions: (a) files written by Iceberg writers carry field ids, so this only concerns tables imported from pre-existing Parquet without a name mapping (migrate/snapshot/add_files set one; only the low-level importSparkTable does not); (b) Spark returns NULL for every id-less nested field (#6794), so the base read of s.b that returned the file's values was not parity either — the repro agrees only because b is all-NULL; (c) the pin has to pass apache/iceberg-rust#3255 to reach the HDFS backend, and nothing at planning time tells id-less files apart. Parity (NULL, pruned or not) lands with apache/iceberg-rust#3437; once the pin passes it, this test becomes a checkSparkAnswer assertion. Until then the documented knob applies.

Comment thread native/core/src/execution/operators/iceberg_common.rs
Comment thread spark/src/main/scala/org/apache/comet/rules/CometScanRule.scala Outdated
mixermt and others added 3 commits October 24, 2026 00:38
…cryption zones

The native scan builds one FileIO from the metadata location and opens every data and
delete file through it, so a file on another backend (hdfs:// data under local metadata,
a local file under HDFS metadata, S3 data under local metadata) failed at open time.
CometScanRule now declines a scan whose data/delete files are not all in the metadata
location's storage family (file, s3/s3a, each S3-compliant alias, gs, oss, hdfs).

hdfs-native is built without its kms feature and rejects every file of an HDFS encryption
zone. When the NameNode advertises a key provider or the session configures one, the rule
checks each distinct data/delete directory with FileStatus.isEncrypted() and falls back.

The planner tests keep their warehouse on the fake hdfs:// filesystem so metadata and data
share a storage; CometIcebergHdfsSuite gains a cluster with a jceks key provider and an
encryption zone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:Iceberg area:scan Parquet scan / data reading area:shuffle Shuffle (JVM and native) area:writer Native Parquet writer enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support Iceberg tables on HDFS in the native scan

4 participants