Skip to content

feat(iceberg): support HDFS storage via iceberg-rust hdfs-native backend [NOT FOR MERGE: pending apache/iceberg-rust#3111] - #5898

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

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

Conversation

@mixermt

@mixermt mixermt commented Sep 13, 2026 •

Copy link
Copy Markdown
Contributor

Important

NOT FOR MERGE — pending apache/iceberg-rust#3111.

This PR pins iceberg / iceberg-storage-opendal to a personal fork
(mixermt/iceberg-rust@9d7d2d89) because the HDFS backend it depends on has not merged
upstream yet. That pin must not be merged into Comet. It is opened now for early review of
the Comet-side design; once apache/iceberg-rust#3111 lands and Comet's pinned rev includes
it, the dependency change drops out and the diff becomes feature-only.

Upstream status: apache/iceberg-rust#3111 is open with changes requested.

Which issue does this PR close?

Part of #5894.

Rationale for this change

Comet's native Iceberg scan cannot read a table whose data lives on HDFS: hdfs:// is absent from the scheme allowlist that mirrors storage_factory_for, so every Iceberg table on HDFS falls back to the JVM reader. That is a gap relative to the plain-Parquet native scan, which does read HDFS through libhdfs/JNI (fs.comet.libhdfs.schemes). On-premise Iceberg deployments are commonly HDFS-backed and get no native Iceberg acceleration today.

iceberg-rust is gaining a pure-Rust HDFS backend (hdfs-native, no JNI or libhdfs) in apache/iceberg-rust#3111. This PR is the Comet side of that.

Worth stating plainly, because it surprises: this introduces a second, independent HDFS client into the same process. The plain-Parquet path reaches HDFS through libhdfs/JNI; an Iceberg table is opened over pure-Rust RPC. Both link into libcomet, they read the same $HADOOP_CONF_DIR XML, but they hold separate connections and separate Kerberos state — the Rust client does not reuse the JVM's Kerberos subject.

What changes are included in this PR?

Native (iceberg_common.rs):

  • storage_factory_for: route hdfs to OpenDalStorageFactory::HdfsNative.
  • STORAGE_PROPERTY_PREFIXES: forward hdfs. and hadoop. to the native FileIO. Without these the NameNode list never reaches iceberg-rust.

JVM gates:

  • CometScanRule.icebergReadableSchemes and CometIcebergNativeWrite.SupportedStorageSchemes admit hdfs, keeping both in lockstep with storage_factory_for as their docstrings require. Only hdfs itself — a libhdfs alias scheme has no iceberg-rust arm.

NameNode resolution (CometIcebergNativeScan.hadoopToIcebergHdfsProperties) — the part that is not obvious:

  • opendal's HdfsNativeBuilder never dials the authority written in the path. It builds one client against a synthetic authority and synthesizes the HA config from the comma-separated hdfs.name-node value (init_hdfs_config in opendal-service-hdfs-native). iceberg-rust falls back to the path authority only when that property is absent, which is correct just for a real host:port.
  • An HA location reads hdfs://<nameservice>/..., and a nameservice is not a routable host. So without a resolved NameNode list, every HA table would fail to connect at execution time — after the planner had already committed to the native scan.
  • The endpoints are therefore derived 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 new 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.

Test-fixture fix (pom.xml), needed for the end-to-end suite to run at all:

  • 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 every suite using WithHdfsCluster is unrunnable. This is pre-existing and not specific to HDFS Iceberg support.
  • 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. Verified with reactor builds on spark-4.0 and spark-3.5.

Single-NameNode-per-scan gate:

  • One hdfs.name-node overrides the authority of every path the FileIO opens, so a scan whose data/delete files span more than one HDFS authority now falls back. Otherwise the second nameservice would be read from the first one's NameNode at the same relative path — wrong data rather than an error. This mirrors the existing multi-bucket S3 check.

How are these changes tested?

  • CometIcebergHdfsSuite: end-to-end reads against an in-process MiniDFSCluster, running on the default (Spark 4.1) profile — plain read, pushed-down filter, and a partitioned table spanning multiple data files. Each asserts a single CometIcebergNativeScanExec in the plan and result parity with Spark, and the first asserts the resolved data location really is hdfs://, so the suite cannot silently degrade into duplicate local-filesystem coverage.
  • CometIcebergNativeScanSuite: five cases pinning the HA translation — declaration order, non-HA authority yielding nothing, all-or-nothing on a partial list, no double hdfs:// prefix, and non-hdfs/authority-less inputs ignored.
  • CometScanSchemeFallbackSuite: hdfs admitted by the Iceberg gate; hostless hdfs:/// declined.
  • Rust unit tests in iceberg_common: the hdfs arm resolves for read and write, and the hdfs./hadoop. prefixes survive the property narrowing.
  • Full Rust workspace suite (1442 tests) and the affected JVM suites pass locally.

Known gaps, stated rather than hidden

  • Writes are gated and property-forwarded but have no functional test. Only reads are covered end to end. Reviewers may reasonably prefer hdfs be dropped from SupportedStorageSchemes until a write test exists.
  • The HA path is unit-tested only. MiniDFSCluster is single-NameNode, so hdfs.name-node has never resolved a live nameservice; the translation logic is pinned by tests, real failover is not.
  • The dependency pin advances iceberg-rust 29 commits beyond the previous rev in addition to adding HDFS. When this is rebased for merge, that bump belongs in its own deps: PR.

AI Disclosure

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

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>
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.

2 participants