Skip to main content

iceberg_storage_opendal/
hdfs_native.rs

1// Licensed to the Apache Software Foundation (ASF) under one
2// or more contributor license agreements.  See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership.  The ASF licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License.  You may obtain a copy of the License at
8//
9//   http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied.  See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18//! HDFS storage backend via OpenDAL's `services-hdfs-native` (pure Rust, no JNI).
19
20use std::collections::HashMap;
21use std::sync::{Arc, LazyLock, PoisonError, RwLock};
22
23use iceberg::io::{HDFS_HADOOP_CONF_PREFIX, HDFS_HOST, HDFS_NAME_NODE, HDFS_PORT};
24use iceberg::{Error, ErrorKind, Result};
25use iceberg_property_macro::Properties;
26use opendal::Operator;
27use opendal::services::HdfsNativeConfig;
28use serde::{Deserialize, Serialize};
29use tokio::runtime::Runtime;
30use url::Url;
31
32use crate::OpenDalClientConfig;
33use crate::utils::from_opendal_error;
34
35/// Hadoop's default filesystem, which serves authority-less paths.
36const FS_DEFAULT_FS: &str = "fs.defaultFS";
37const HDFS_DEFAULT_PORT: u16 = 8020;
38/// PyIceberg keys with no equivalent in opendal's config.
39const HDFS_UNSUPPORTED_KEYS: [&str; 2] = ["hdfs.user", "hdfs.kerberos_ticket"];
40/// Hadoop's keys declaring an HA nameservice: `hdfs.name-node.<nameservice>`
41/// expands to them, and they are honored as well when passed through `hadoop.`.
42const HA_NAMENODES_PREFIX: &str = "dfs.ha.namenodes";
43const HA_NAMENODE_RPC_ADDRESS_PREFIX: &str = "dfs.namenode.rpc-address";
44
45/// Normalizes one NameNode spelling to `hdfs://host:port`, the form path
46/// authorities take, so every source shares cache keys. `hdfs-native` dials
47/// a socket address and has no default port, so anything else is `None`:
48/// another scheme, a logical name, port 0, userinfo, a path.
49fn hdfs_native_name_node(entry: &str) -> Option<String> {
50    let rest = entry.trim().trim_end_matches('/');
51    let rest = rest.strip_prefix("hdfs://").unwrap_or(rest);
52    if rest.is_empty() || rest.contains("://") {
53        return None;
54    }
55    let url = Url::parse(&format!("hdfs://{rest}")).ok()?;
56    let (host, port) = (url.host_str()?, url.port()?);
57    let plain = host.is_empty()
58        || port == 0
59        || !url.username().is_empty()
60        || url.password().is_some()
61        || !matches!(url.path(), "" | "/")
62        || url.query().is_some()
63        || url.fragment().is_some();
64    (!plain).then(|| hdfs_native_endpoint(host, port))
65}
66
67/// An endpoint as every source spells it, the host lowercased because host
68/// names are case-insensitive: `NN1:8020` and `nn1:8020` share one client,
69/// as they share one `FileSystem` in Hadoop's cache.
70fn hdfs_native_endpoint(host: &str, port: u16) -> String {
71    format!("hdfs://{}:{port}", host.to_ascii_lowercase())
72}
73
74/// Parses a comma-separated NameNode list, each entry normalized; an empty
75/// list is `Ok` and empty.
76fn hdfs_native_name_node_list(property: &str, value: &str) -> Result<Vec<String>> {
77    value
78        .split(',')
79        .map(str::trim)
80        .filter(|entry| !entry.is_empty())
81        .map(|entry| {
82            hdfs_native_name_node(entry).ok_or_else(|| {
83                Error::new(
84                    ErrorKind::DataInvalid,
85                    format!(
86                        "Invalid `{property}` entry: {}, expected host:port (hdfs:// optional)",
87                        hdfs_native_redact(entry)
88                    ),
89                )
90            })
91        })
92        .collect()
93}
94
95/// Prefix of `hdfs.name-node.<nameservice>`, which declares one HA nameservice.
96const HDFS_NAME_NODE_DECLARATION_PREFIX: &str = "hdfs.name-node.";
97
98/// The properties this backend reads; unknown keys are ignored. Blank values
99/// count as unset, as in PyIceberg.
100#[derive(Properties)]
101struct HdfsNativeProperties {
102    /// `hdfs.name-node`, each entry normalized.
103    #[property(
104        key = HDFS_NAME_NODE,
105        default = Vec::new(),
106        parse_with = parse_name_node_list
107    )]
108    name_node: Vec<String>,
109    /// `hdfs.name-node.<nameservice>` declarations, keyed by nameservice.
110    #[property(prefix = HDFS_NAME_NODE_DECLARATION_PREFIX)]
111    nameservices: HashMap<String, String>,
112    #[property(key = HDFS_HOST, default = "", parse_with = parse_trimmed)]
113    host: String,
114    #[property(key = HDFS_PORT, default = "", parse_with = parse_trimmed)]
115    port: String,
116    /// `hadoop.*` keys with the prefix stripped, forwarded to the client.
117    #[property(prefix = HDFS_HADOOP_CONF_PREFIX)]
118    hadoop: HashMap<String, String>,
119}
120
121// Entries are trimmed one by one: opendal splits the list on `,` as is, so a
122// space after a comma would break failover to that NameNode. An empty list
123// stays unset because `Operator::from_config` bypasses the builder's
124// empty-string guard and `Some("")` would shadow the path-authority fallback.
125fn parse_name_node_list(value: &str) -> Result<Vec<String>> {
126    hdfs_native_name_node_list(HDFS_NAME_NODE, value)
127}
128
129fn parse_trimmed(value: &str) -> Result<String> {
130    Ok(value.trim().to_string())
131}
132
133/// Parse iceberg properties to [`HdfsNativeConfig`]; dropped properties are
134/// logged as warnings.
135pub(crate) fn hdfs_native_config_parse(m: HashMap<String, String>) -> Result<HdfsNativeConfig> {
136    hdfs_native_config_parse_with(m, |warning| tracing::warn!("{warning}"))
137}
138
139/// The parser proper, with the warning sink injected so tests can see what
140/// was dropped without a tracing subscriber.
141fn hdfs_native_config_parse_with(
142    m: HashMap<String, String>,
143    mut warn: impl FnMut(String),
144) -> Result<HdfsNativeConfig> {
145    let props = HdfsNativeProperties::from_properties(&m)?;
146    let mut options = props.hadoop;
147
148    // `hdfs.name-node.<nameservice>` is sugar for Hadoop's own declaration of
149    // an HA nameservice, which the resolver reads back from the options.
150    // Explicit `hadoop.` keys win over the sugar.
151    for (nameservice, value) in props.nameservices {
152        let key = format!("{HDFS_NAME_NODE_DECLARATION_PREFIX}{nameservice}");
153        if nameservice.is_empty() || nameservice.chars().any(char::is_whitespace) {
154            return Err(Error::new(
155                ErrorKind::DataInvalid,
156                format!(
157                    "Invalid property `{key}`: a nameservice name must follow `{HDFS_NAME_NODE_DECLARATION_PREFIX}`"
158                ),
159            ));
160        }
161        let entries = hdfs_native_name_node_list(&key, &value)?;
162        if entries.is_empty() {
163            return Err(Error::new(
164                ErrorKind::DataInvalid,
165                format!("Invalid property `{key}`: no NameNodes"),
166            ));
167        }
168        let ids: Vec<String> = (0..entries.len()).map(|i| format!("nn{i}")).collect();
169        options
170            .entry(format!("{HA_NAMENODES_PREFIX}.{nameservice}"))
171            .or_insert_with(|| ids.join(","));
172        for (id, entry) in ids.iter().zip(&entries) {
173            options
174                .entry(format!(
175                    "{HA_NAMENODE_RPC_ADDRESS_PREFIX}.{nameservice}.{id}"
176                ))
177                .or_insert_with(|| entry.trim_start_matches("hdfs://").to_string());
178        }
179    }
180
181    // A config carried over from PyIceberg would otherwise change identity
182    // silently; the client reads `HADOOP_USER_NAME` and the Kerberos cache.
183    for key in HDFS_UNSUPPORTED_KEYS {
184        if m.contains_key(key) {
185            warn(format!(
186                "`{key}` is not supported by the hdfs-native backend and is ignored"
187            ));
188        }
189    }
190    if m.contains_key(HDFS_HADOOP_CONF_PREFIX) {
191        return Err(Error::new(
192            ErrorKind::DataInvalid,
193            format!(
194                "Invalid property `{HDFS_HADOOP_CONF_PREFIX}`: a Hadoop key must follow the prefix"
195            ),
196        ));
197    }
198
199    let port = match props.port.as_str() {
200        "" => None,
201        port => Some(port.parse::<u16>().map_err(|e| {
202            Error::new(
203                ErrorKind::DataInvalid,
204                format!("Invalid `{HDFS_PORT}`: {port}"),
205            )
206            .with_source(e)
207        })?),
208    };
209    // PyIceberg's `hdfs.host`/`hdfs.port` name the filesystem for
210    // authority-less paths, which is what Hadoop's `fs.defaultFS` means; an
211    // explicit `hadoop.fs.defaultFS` wins.
212    match props.host.as_str() {
213        "" if port.is_some() => {
214            warn(format!(
215                "`{HDFS_PORT}` has no effect without `{HDFS_HOST}` and is ignored"
216            ));
217        }
218        "" => {}
219        host => {
220            // An IPv6 literal needs brackets in a URI authority.
221            let bracketed = if host.contains(':') && !host.starts_with('[') {
222                format!("[{host}]")
223            } else {
224                host.to_string()
225            };
226            let port = port.unwrap_or(HDFS_DEFAULT_PORT);
227            // Validated like every other NameNode spelling, so a host that
228            // carries a scheme or port fails here and not at the first I/O.
229            let default_fs = hdfs_native_name_node(&format!("hdfs://{bracketed}:{port}"))
230                .ok_or_else(|| {
231                    Error::new(
232                        ErrorKind::DataInvalid,
233                        format!(
234                            "Invalid `{HDFS_HOST}`/`{HDFS_PORT}`: {}, expected a host name or IP and a port",
235                            hdfs_native_redact(&format!("{host}:{port}"))
236                        ),
237                    )
238                })?;
239            options
240                .entry(FS_DEFAULT_FS.to_string())
241                .or_insert(default_fs);
242        }
243    }
244
245    let mut cfg = HdfsNativeConfig::default();
246    if !props.name_node.is_empty() {
247        cfg.name_node = Some(props.name_node.join(","));
248    }
249    if !options.is_empty() {
250        cfg.options = Some(options);
251    }
252
253    // Every declaration, sugar or passed through, and the default filesystem
254    // fail here rather than at the first path that uses them.
255    for nameservice in cfg.options.iter().flat_map(|options| {
256        options.keys().filter_map(|key| {
257            key.strip_prefix(HA_NAMENODES_PREFIX)
258                .and_then(|rest| rest.strip_prefix('.'))
259        })
260    }) {
261        hdfs_native_nameservice(&cfg, nameservice)?;
262    }
263    hdfs_native_default_name_node(&cfg)?;
264    Ok(cfg)
265}
266
267/// `s` with everything between its scheme and its last `@` masked, so an
268/// error message never echoes userinfo, even a password holding a raw `/`,
269/// `?`, `#` or `://` that a URL parser would split elsewhere. An `@` further
270/// along the path is masked the same way, which only costs context.
271fn hdfs_native_redact(s: &str) -> String {
272    // Only a real scheme is kept; anything else before `://` may be userinfo.
273    let (scheme, rest) = match s.split_once("://") {
274        Some((scheme, rest))
275            if scheme
276                .chars()
277                .all(|c| c.is_ascii_alphanumeric() || "+-.".contains(c)) =>
278        {
279            (format!("{scheme}://"), rest)
280        }
281        _ => (String::new(), s),
282    };
283    match rest.rsplit_once('@') {
284        Some((_, after)) => format!("{scheme}***@{after}"),
285        None => s.to_string(),
286    }
287}
288
289/// A `DataInvalid` error for an HDFS path, its userinfo masked.
290fn hdfs_native_invalid_path(path: &str, reason: impl std::fmt::Display) -> Error {
291    Error::new(
292        ErrorKind::DataInvalid,
293        format!("Invalid hdfs path: {}, {reason}", hdfs_native_redact(path)),
294    )
295}
296
297/// A path's authority, as the resolver reads it.
298#[derive(Debug, PartialEq)]
299pub(crate) enum HdfsNativeAuthority {
300    /// `hdfs://host:port`, normalized: dialed as is.
301    NameNode(String),
302    /// `hdfs://name` without a port: a nameservice that must be declared.
303    Nameservice(String),
304    /// `hdfs:///path`: served by `hdfs.name-node`, else `fs.defaultFS`.
305    Default,
306}
307
308/// Parse an HDFS path into its authority and the relative path (no leading
309/// `/`, opendal style).
310pub(crate) fn hdfs_native_parse_path(path: &str) -> Result<(HdfsNativeAuthority, &str)> {
311    let url = Url::parse(path).map_err(|e| hdfs_native_invalid_path(path, e))?;
312    // Non-special schemes parse even without `//` (e.g. `hdfs:x` is a valid
313    // non-hierarchical URL), so require the literal prefix before slicing.
314    let (Some(after_scheme), "hdfs") = (path.strip_prefix("hdfs://"), url.scheme()) else {
315        return Err(hdfs_native_invalid_path(path, "expected scheme `hdfs://`"));
316    };
317    // Userinfo has nowhere to go and port 0 cannot be dialed; silently
318    // dropping the one or reading the other as a logical name would mislead.
319    if !url.username().is_empty() || url.password().is_some() {
320        return Err(hdfs_native_invalid_path(path, "userinfo is not supported"));
321    }
322    if url.port() == Some(0) {
323        return Err(hdfs_native_invalid_path(path, "port 0 cannot be dialed"));
324    }
325
326    let authority = match (url.host_str().filter(|h| !h.is_empty()), url.port()) {
327        (Some(host), Some(port)) => HdfsNativeAuthority::NameNode(hdfs_native_endpoint(host, port)),
328        (Some(host), None) => HdfsNativeAuthority::Nameservice(host.to_string()),
329        (None, _) => HdfsNativeAuthority::Default,
330    };
331
332    // `url.path()` borrows from `url` and can't be returned with the input's
333    // lifetime. Take the path from the original input instead: everything
334    // after the first `/` following the `hdfs://` prefix. Opendal paths must
335    // not start with `/` (`Deleter::delete` rejects them).
336    let rel = after_scheme
337        .split_once('/')
338        .map_or("", |(_, path)| path.trim_start_matches('/'));
339
340    Ok((authority, rel))
341}
342
343/// Resolves the effective NameNode for a path, plus the relative path. An
344/// authority with a port is used as is, a portless one must be a declared
345/// nameservice, and an authority-less path uses `hdfs.name-node`, else
346/// `fs.defaultFS`. The operator cache, `delete_stream` batching and
347/// `relativize_path` all go through this, so they cannot drift apart.
348pub(crate) fn hdfs_native_effective_name_node<'a>(
349    config: &HdfsNativeConfig,
350    path: &'a str,
351) -> Result<(String, &'a str)> {
352    let (authority, relative_path) = hdfs_native_parse_path(path)?;
353    let name_node = match authority {
354        HdfsNativeAuthority::NameNode(name_node) => name_node,
355        HdfsNativeAuthority::Nameservice(nameservice) => {
356            hdfs_native_nameservice(config, &nameservice)?.ok_or_else(|| {
357                hdfs_native_invalid_path(
358                    path,
359                    format!(
360                        "`{nameservice}` has no port and is not a declared nameservice; add the port or set `{HDFS_NAME_NODE}.{nameservice}`"
361                    ),
362                )
363            })?
364        }
365        HdfsNativeAuthority::Default => hdfs_native_default_name_node(config)?.ok_or_else(|| {
366            hdfs_native_invalid_path(
367                path,
368                format!("authority-less paths require `{HDFS_NAME_NODE}` or `{HDFS_HOST}`"),
369            )
370        })?,
371    };
372    Ok((name_node, relative_path))
373}
374
375/// The NameNode that serves authority-less paths: `hdfs.name-node`, else
376/// `fs.defaultFS`, which must then be an HDFS `host:port`.
377fn hdfs_native_default_name_node(config: &HdfsNativeConfig) -> Result<Option<String>> {
378    if let Some(name_node) = &config.name_node {
379        return Ok(Some(name_node.clone()));
380    }
381    let Some(default_fs) = config
382        .options
383        .as_ref()
384        .and_then(|options| options.get(FS_DEFAULT_FS))
385        .map(|s| s.trim())
386        .filter(|s| !s.is_empty())
387    else {
388        return Ok(None);
389    };
390    hdfs_native_name_node(default_fs).map(Some).ok_or_else(|| {
391        Error::new(
392            ErrorKind::DataInvalid,
393            format!(
394                "Invalid `{FS_DEFAULT_FS}`: {} is not an HDFS host:port, a logical nameservice requires `{HDFS_NAME_NODE}`",
395                hdfs_native_redact(default_fs)
396            ),
397        )
398    })
399}
400
401/// The NameNodes that Hadoop's keys in the forwarded options declare for a
402/// nameservice, if any. A declaration with a missing or malformed address is
403/// an error rather than a silent fallback.
404fn hdfs_native_nameservice(config: &HdfsNativeConfig, nameservice: &str) -> Result<Option<String>> {
405    let Some(options) = config.options.as_ref() else {
406        return Ok(None);
407    };
408    let Some(ids) = options.get(&format!("{HA_NAMENODES_PREFIX}.{nameservice}")) else {
409        return Ok(None);
410    };
411    let mut name_nodes = Vec::new();
412    for id in ids.split(',').map(str::trim).filter(|id| !id.is_empty()) {
413        let key = format!("{HA_NAMENODE_RPC_ADDRESS_PREFIX}.{nameservice}.{id}");
414        let Some(name_node) = options
415            .get(&key)
416            .and_then(|value| hdfs_native_name_node(value))
417        else {
418            return Err(Error::new(
419                ErrorKind::DataInvalid,
420                format!(
421                    "Nameservice `{nameservice}` declares NameNode `{id}` but `{key}` is missing or not host:port"
422                ),
423            ));
424        };
425        name_nodes.push(name_node);
426    }
427    if name_nodes.is_empty() {
428        return Err(Error::new(
429            ErrorKind::DataInvalid,
430            format!("Nameservice `{nameservice}` declares no NameNodes"),
431        ));
432    }
433    Ok(Some(name_nodes.join(",")))
434}
435
436/// State of [`OpenDalStorage::HdfsNative`](crate::OpenDalStorage::HdfsNative):
437/// the parsed configuration and the per-NameNode operator cache. Only the
438/// storage factories build it.
439#[derive(Clone, Debug, Serialize, Deserialize)]
440pub struct HdfsNativeStorage {
441    pub(crate) config: Arc<HdfsNativeConfig>,
442    #[serde(skip, default)]
443    pub(crate) operators: HdfsNativeOperatorCache,
444    #[serde(default)]
445    pub(crate) client_config: OpenDalClientConfig,
446}
447
448impl HdfsNativeStorage {
449    pub(crate) fn new(config: HdfsNativeConfig, client_config: OpenDalClientConfig) -> Self {
450        Self {
451            config: Arc::new(config),
452            operators: HdfsNativeOperatorCache::default(),
453            client_config,
454        }
455    }
456}
457
458/// Operators cached per effective NameNode: one per endpoint or nameservice
459/// the storage's paths reach, which in practice is a handful of clusters, so
460/// it is not bounded. The cache lives as long as the storage that owns it
461/// (clones share it).
462#[derive(Clone, Debug, Default)]
463pub(crate) struct HdfsNativeOperatorCache(Arc<RwLock<HashMap<String, Operator>>>);
464
465// A poisoned lock is recovered rather than reported: writes only insert
466// whole entries, so a panic elsewhere cannot leave the map broken.
467impl HdfsNativeOperatorCache {
468    pub(crate) fn get(&self, name_node: &str) -> Option<Operator> {
469        self.0
470            .read()
471            .unwrap_or_else(PoisonError::into_inner)
472            .get(name_node)
473            .cloned()
474    }
475
476    /// Inserts `op` unless a concurrent caller got there first, returning
477    /// whichever operator the cache now holds.
478    fn insert(&self, name_node: String, op: Operator) -> Operator {
479        self.0
480            .write()
481            .unwrap_or_else(PoisonError::into_inner)
482            .entry(name_node)
483            .or_insert(op)
484            .clone()
485    }
486
487    #[cfg(test)]
488    fn len(&self) -> usize {
489        self.0.read().unwrap().len()
490    }
491}
492
493/// The runtime `hdfs-native` clients are built in. A client spawns its
494/// NameNode connections on the runtime current when it is built and panics
495/// once that runtime is gone, so building it here keeps a cached operator
496/// usable from any caller's runtime. Started on first use.
497// TODO(#3509): build clients on the catalog's runtime once storage factories
498// receive it, and drop this one.
499static HDFS_NATIVE_RUNTIME: LazyLock<Runtime> = LazyLock::new(|| {
500    tokio::runtime::Builder::new_multi_thread()
501        .enable_all()
502        .thread_name("iceberg-hdfs-native")
503        .build()
504        .expect("failed to start the HDFS client runtime")
505});
506
507/// Creates an operator for the path, reusing the cached one for its
508/// effective NameNode.
509pub(crate) fn hdfs_native_create_operator<'a>(
510    path: &'a str,
511    config: &HdfsNativeConfig,
512    operators: &HdfsNativeOperatorCache,
513) -> Result<(Operator, &'a str)> {
514    let (name_node, relative_path) = hdfs_native_effective_name_node(config, path)?;
515
516    if let Some(op) = operators.get(&name_node) {
517        return Ok((op, relative_path));
518    }
519
520    // Built outside the lock, so a racing first caller may build too; the
521    // loser is dropped before opening any connection. The build reads the
522    // Hadoop XML config synchronously (~0.1 ms locally), once per NameNode
523    // per storage, which is accepted here rather than offloaded.
524    let op = {
525        let _runtime = HDFS_NATIVE_RUNTIME.enter();
526        hdfs_native_operator_build(config, &name_node)?
527    };
528    Ok((operators.insert(name_node, op), relative_path))
529}
530
531/// Returns the `delete_stream` grouping key for a path: the effective
532/// NameNode, so paths that resolve to different operators never share a
533/// deleter. Unresolvable paths key on themselves (as `hf_batch_key` does);
534/// `create_operator` then reports the real error.
535pub(crate) fn hdfs_native_batch_key(config: &HdfsNativeConfig, path: &str) -> String {
536    hdfs_native_effective_name_node(config, path)
537        .map(|(name_node, _)| name_node)
538        .unwrap_or_else(|_| path.to_string())
539}
540
541/// Build a new OpenDAL [`Operator`]: OpenDAL splits `name_node` on commas
542/// into a synthetic HA name service; `$HADOOP_CONF_DIR` XML still merges in.
543fn hdfs_native_operator_build(config: &HdfsNativeConfig, name_node: &str) -> Result<Operator> {
544    let mut cfg = config.clone();
545    cfg.name_node = Some(name_node.to_string());
546    Operator::from_config(cfg).map_err(from_opendal_error)
547}
548
549#[cfg(test)]
550mod tests {
551    use super::*;
552
553    #[test]
554    fn test_hdfs_native_config_parse_name_node_and_options() {
555        let props = HashMap::from([
556            (
557                HDFS_NAME_NODE.to_string(),
558                "hdfs://nn1:8020,hdfs://nn2:8020".to_string(),
559            ),
560            (
561                "hadoop.dfs.client.failover.random.order".to_string(),
562                "true".to_string(),
563            ),
564            ("unrelated.key".to_string(), "ignored".to_string()),
565        ]);
566
567        let cfg = hdfs_native_config_parse(props).unwrap();
568
569        assert_eq!(
570            cfg.name_node.as_deref(),
571            Some("hdfs://nn1:8020,hdfs://nn2:8020")
572        );
573        let options = cfg.options.unwrap();
574        assert_eq!(
575            options.get("dfs.client.failover.random.order"),
576            Some(&"true".to_string())
577        );
578        assert!(!options.contains_key("unrelated.key"));
579    }
580
581    #[test]
582    fn test_hdfs_native_config_parse_empty() {
583        let cfg = hdfs_native_config_parse(HashMap::new()).unwrap();
584
585        assert_eq!(cfg.name_node, None);
586        assert_eq!(cfg.options, None);
587    }
588
589    #[test]
590    fn test_hdfs_native_config_parse_normalizes_name_node() {
591        let parse = |value: &str| {
592            hdfs_native_config_parse(HashMap::from([(
593                HDFS_NAME_NODE.to_string(),
594                value.to_string(),
595            )]))
596            .unwrap()
597            .name_node
598        };
599
600        // Empty must not shadow the path-authority fallback.
601        assert_eq!(parse(""), None);
602        assert_eq!(parse("  "), None);
603        assert_eq!(parse(" , "), None);
604        // `hdfs://` is optional (Hadoop's own rpc-address format is bare) and
605        // every entry is normalized to it, so it keys the cache like a path
606        // authority does; a space after a comma would otherwise break
607        // failover to the entry behind it.
608        for (value, normalized) in [
609            (" hdfs://nn:8020/ ", "hdfs://nn:8020"),
610            ("nn:8020", "hdfs://nn:8020"),
611            ("[::1]:8020", "hdfs://[::1]:8020"),
612            ("NN:8020", "hdfs://nn:8020"),
613            (
614                "hdfs://nn1:8020/, nn2:8020,",
615                "hdfs://nn1:8020,hdfs://nn2:8020",
616            ),
617        ] {
618            assert_eq!(parse(value).as_deref(), Some(normalized), "{value}");
619        }
620    }
621
622    #[test]
623    fn test_hdfs_native_config_parse_host_port_as_default_fs() {
624        let parse = |props: &[(&str, &str)]| {
625            let props = props
626                .iter()
627                .map(|(k, v)| (k.to_string(), v.to_string()))
628                .collect();
629            hdfs_native_config_parse(props)
630                .map(|cfg| cfg.options.and_then(|o| o.get(FS_DEFAULT_FS).cloned()))
631        };
632
633        assert_eq!(
634            parse(&[(HDFS_HOST, "nn"), (HDFS_PORT, " 9000 ")])
635                .unwrap()
636                .as_deref(),
637            Some("hdfs://nn:9000")
638        );
639        assert_eq!(
640            parse(&[(HDFS_HOST, " nn ")]).unwrap().as_deref(),
641            Some("hdfs://nn:8020")
642        );
643        assert_eq!(
644            parse(&[(HDFS_HOST, "::1")]).unwrap().as_deref(),
645            Some("hdfs://[::1]:8020")
646        );
647        // An explicit `hadoop.fs.defaultFS` wins.
648        assert_eq!(
649            parse(&[
650                (HDFS_HOST, "nn"),
651                ("hadoop.fs.defaultFS", "hdfs://explicit:8020")
652            ])
653            .unwrap()
654            .as_deref(),
655            Some("hdfs://explicit:8020")
656        );
657        // An empty host is unset; a port without a host has nothing to apply to.
658        assert_eq!(
659            parse(&[(HDFS_HOST, " "), (HDFS_PORT, "9000")]).unwrap(),
660            None
661        );
662        assert_eq!(parse(&[(HDFS_PORT, "9000")]).unwrap(), None);
663        // A bad port is rejected whether or not a host accompanies it.
664        for props in [
665            &[(HDFS_HOST, "nn"), (HDFS_PORT, "x")][..],
666            &[(HDFS_PORT, "x")][..],
667            &[(HDFS_PORT, "70000")][..],
668        ] {
669            let err = parse(props).unwrap_err();
670            assert!(err.to_string().contains(HDFS_PORT), "{props:?}: {err}");
671            // The parse error rides along as the source, not in the message.
672            assert!(
673                std::error::Error::source(&err).is_some(),
674                "{props:?}: {err}"
675            );
676        }
677        // The composed value is validated like any other NameNode spelling.
678        for props in [
679            &[(HDFS_HOST, "nn:8020")][..],
680            &[(HDFS_HOST, "hdfs://nn")][..],
681            &[(HDFS_HOST, "nn"), (HDFS_PORT, "0")][..],
682            &[(HDFS_HOST, "nn/path")][..],
683        ] {
684            let err = parse(props).unwrap_err();
685            assert!(err.to_string().contains(HDFS_HOST), "{props:?}: {err}");
686        }
687    }
688
689    #[test]
690    fn test_hdfs_native_config_parse_rejects_bare_prefix() {
691        let err = hdfs_native_config_parse(HashMap::from([(
692            HDFS_HADOOP_CONF_PREFIX.to_string(),
693            "x".to_string(),
694        )]))
695        .unwrap_err();
696        assert!(err.to_string().contains(HDFS_HADOOP_CONF_PREFIX), "{err}");
697    }
698
699    /// PyIceberg's identity keys and a port without a host are dropped, but
700    /// visibly: each leaves a warning naming the key, and none is forwarded.
701    #[test]
702    fn test_hdfs_native_config_parse_warns_about_ignored_keys() {
703        let mut warnings = Vec::new();
704        let cfg = hdfs_native_config_parse_with(
705            HashMap::from([
706                ("hdfs.user".to_string(), "alice".to_string()),
707                (
708                    "hdfs.kerberos_ticket".to_string(),
709                    "/tmp/krb5cc".to_string(),
710                ),
711                (HDFS_PORT.to_string(), "9000".to_string()),
712            ]),
713            |warning| warnings.push(warning),
714        )
715        .unwrap();
716
717        assert_eq!(cfg.options, None, "ignored keys must not be forwarded");
718        assert_eq!(warnings, [
719            "`hdfs.user` is not supported by the hdfs-native backend and is ignored",
720            "`hdfs.kerberos_ticket` is not supported by the hdfs-native backend and is ignored",
721            "`hdfs.port` has no effect without `hdfs.host` and is ignored",
722        ]);
723    }
724
725    #[test]
726    fn test_hdfs_native_config_parse_rejects_invalid_name_node_entries() {
727        let parse = |value: &str| {
728            hdfs_native_config_parse(HashMap::from([(
729                HDFS_NAME_NODE.to_string(),
730                value.to_string(),
731            )]))
732        };
733
734        // Logical names, other schemes, userinfo, paths, port 0, unbracketed
735        // IPv6 and malformed ports: every entry must be a dialable host:port.
736        for value in [
737            "hdfs://ns1",
738            "nn",
739            "nn:x",
740            "nn:0",
741            "nn:+80",
742            "::1",
743            "hdfs://[::1]",
744            "viewfs://nn:8020",
745            "foo://nn:8020",
746            "user@nn:8020",
747            "nn:8020/path",
748            "hdfs://nn1:8020,nn2",
749        ] {
750            let err = parse(value).unwrap_err().to_string();
751            assert!(err.contains(HDFS_NAME_NODE), "{value}: {err}");
752        }
753    }
754
755    #[test]
756    fn test_hdfs_native_default_fs_must_be_hdfs() {
757        let parse = |props: &[(&str, &str)]| {
758            hdfs_native_config_parse(
759                props
760                    .iter()
761                    .map(|(k, v)| (k.to_string(), v.to_string()))
762                    .collect(),
763            )
764        };
765        let default_fs = |value| parse(&[("hadoop.fs.defaultFS", value)]);
766
767        let (nn, _) =
768            hdfs_native_effective_name_node(&default_fs("hdfs://nn:8020/").unwrap(), "hdfs:///a")
769                .unwrap();
770        assert_eq!(nn, "hdfs://nn:8020");
771        // Not an HDFS `host:port`, including the portless logical-name form,
772        // which only a declaration resolves: rejected when the config is
773        // parsed, since it serves every authority-less path.
774        for value in ["viewfs://cluster/", "hdfs://", "hdfs://ns1"] {
775            let err = default_fs(value).unwrap_err();
776            assert!(
777                err.to_string().contains(FS_DEFAULT_FS) && err.to_string().contains(HDFS_NAME_NODE),
778                "{value}: {err}"
779            );
780        }
781        // Not checked when `hdfs.name-node` serves those paths instead.
782        parse(&[
783            ("hadoop.fs.defaultFS", "viewfs://cluster/"),
784            (HDFS_NAME_NODE, "nn:8020"),
785        ])
786        .unwrap();
787        // Empty is unset, so the authority-less path has nothing to resolve to.
788        let err =
789            hdfs_native_effective_name_node(&default_fs("").unwrap(), "hdfs:///a").unwrap_err();
790        assert!(err.to_string().contains(HDFS_HOST), "{err}");
791        // Normalized like every other source.
792        let (nn, _) =
793            hdfs_native_effective_name_node(&default_fs("nn:9000").unwrap(), "hdfs:///a").unwrap();
794        assert_eq!(nn, "hdfs://nn:9000");
795    }
796
797    /// A bare configured NameNode and the same NameNode as a path authority
798    /// share one cache entry.
799    #[test]
800    fn test_hdfs_native_create_operator_dedupes_spellings() {
801        let config = Arc::new(
802            hdfs_native_config_parse(HashMap::from([(
803                HDFS_NAME_NODE.to_string(),
804                "nn:8020".to_string(),
805            )]))
806            .unwrap(),
807        );
808        let operators = HdfsNativeOperatorCache::default();
809
810        for path in ["hdfs:///a", "hdfs://nn:8020/b", "hdfs://NN:8020/c"] {
811            hdfs_native_create_operator(path, &config, &operators).unwrap();
812        }
813
814        assert_eq!(operators.len(), 1);
815        assert!(operators.get("hdfs://nn:8020").is_some());
816    }
817
818    #[test]
819    fn test_hdfs_native_config_parse_declares_nameservices() {
820        let cfg = hdfs_native_config_parse(HashMap::from([
821            (
822                format!("{HDFS_NAME_NODE}.ns-a"),
823                "nn1:8020, hdfs://nn2:8020/".to_string(),
824            ),
825            ("hadoop.dfs.ha.namenodes.ns-b".to_string(), "x".to_string()),
826            (
827                "hadoop.dfs.namenode.rpc-address.ns-b.x".to_string(),
828                "nn3:8020".to_string(),
829            ),
830        ]))
831        .unwrap();
832        let options = cfg.options.clone().unwrap();
833
834        // The sugar expands to Hadoop's keys...
835        assert_eq!(
836            options.get("dfs.ha.namenodes.ns-a").map(String::as_str),
837            Some("nn0,nn1")
838        );
839        assert_eq!(
840            options
841                .get("dfs.namenode.rpc-address.ns-a.nn0")
842                .map(String::as_str),
843            Some("nn1:8020")
844        );
845        assert_eq!(
846            options
847                .get("dfs.namenode.rpc-address.ns-a.nn1")
848                .map(String::as_str),
849            Some("nn2:8020")
850        );
851        // ...and the resolver reads both forms back.
852        assert_eq!(
853            hdfs_native_nameservice(&cfg, "ns-a").unwrap().as_deref(),
854            Some("hdfs://nn1:8020,hdfs://nn2:8020")
855        );
856        assert_eq!(
857            hdfs_native_nameservice(&cfg, "ns-b").unwrap().as_deref(),
858            Some("hdfs://nn3:8020")
859        );
860        assert_eq!(hdfs_native_nameservice(&cfg, "ns-c").unwrap(), None);
861
862        for (key, value) in [
863            (format!("{HDFS_NAME_NODE}."), "nn:8020"),
864            (format!("{HDFS_NAME_NODE}.ns"), "nn"),
865            (format!("{HDFS_NAME_NODE}.ns"), " "),
866        ] {
867            let err = hdfs_native_config_parse(HashMap::from([(key.clone(), value.to_string())]))
868                .unwrap_err();
869            assert!(err.to_string().contains(&key), "{key}={value}: {err}");
870        }
871        // A passed-through declaration with a missing address fails when the
872        // config is parsed, not at the first path that names it.
873        let err = hdfs_native_config_parse(HashMap::from([(
874            "hadoop.dfs.ha.namenodes.ns-d".to_string(),
875            "a".to_string(),
876        )]))
877        .unwrap_err();
878        assert!(
879            err.to_string().contains("dfs.namenode.rpc-address.ns-d.a"),
880            "{err}"
881        );
882    }
883
884    #[test]
885    fn test_hdfs_native_effective_name_node_declared_nameservices() {
886        let parse = |props: &[(String, &str)]| {
887            hdfs_native_config_parse(
888                props
889                    .iter()
890                    .map(|(k, v)| (k.clone(), v.to_string()))
891                    .collect(),
892            )
893            .unwrap()
894        };
895        let ns_a = (
896            format!("{HDFS_NAME_NODE}.ns-a"),
897            "hdfs://a1:8020,hdfs://a2:8020",
898        );
899        let declared_only = parse(std::slice::from_ref(&ns_a));
900        let with_default = parse(&[ns_a.clone(), (HDFS_NAME_NODE.to_string(), "hdfs://d:8020")]);
901
902        // A declared nameservice resolves to its own list, ahead of the plain key.
903        for config in [&declared_only, &with_default] {
904            let (nn, rel) = hdfs_native_effective_name_node(config, "hdfs://ns-a/x").unwrap();
905            assert_eq!((nn.as_str(), rel), ("hdfs://a1:8020,hdfs://a2:8020", "x"));
906        }
907        // An undeclared one is an error, with or without the plain key.
908        for config in [&declared_only, &with_default] {
909            let err = hdfs_native_effective_name_node(config, "hdfs://ns-b/x").unwrap_err();
910            assert!(err.to_string().contains("`hdfs.name-node.ns-b`"), "{err}");
911        }
912        // The plain key serves authority-less paths only.
913        let (nn, _) = hdfs_native_effective_name_node(&with_default, "hdfs:///x").unwrap();
914        assert_eq!(nn, "hdfs://d:8020");
915        // A concrete authority is never redirected.
916        let (nn, _) =
917            hdfs_native_effective_name_node(&with_default, "hdfs://other:9000/x").unwrap();
918        assert_eq!(nn, "hdfs://other:9000");
919    }
920
921    #[test]
922    fn test_hdfs_native_create_operator_per_nameservice() {
923        let config = Arc::new(
924            hdfs_native_config_parse(HashMap::from([
925                (
926                    format!("{HDFS_NAME_NODE}.ns-a"),
927                    "hdfs://a:8020".to_string(),
928                ),
929                (
930                    format!("{HDFS_NAME_NODE}.ns-b"),
931                    "hdfs://b:8020".to_string(),
932                ),
933                (HDFS_NAME_NODE.to_string(), "hdfs://d:8020".to_string()),
934            ]))
935            .unwrap(),
936        );
937        let operators = HdfsNativeOperatorCache::default();
938
939        for path in ["hdfs://ns-a/x", "hdfs://ns-b/y", "hdfs:///w"] {
940            hdfs_native_create_operator(path, &config, &operators).unwrap();
941        }
942        // ns-a, ns-b, and the plain key for the authority-less path; an
943        // undeclared nameservice never reaches an operator.
944        assert_eq!(operators.len(), 3);
945        assert_eq!(
946            hdfs_native_batch_key(&config, "hdfs://ns-b/y"),
947            "hdfs://b:8020"
948        );
949        let err = hdfs_native_create_operator("hdfs://ns-c/z", &config, &operators).unwrap_err();
950        assert!(err.to_string().contains("`hdfs.name-node.ns-c`"), "{err}");
951        assert_eq!(operators.len(), 3);
952    }
953
954    #[test]
955    fn test_hdfs_native_effective_name_node_precedence() {
956        let configured = hdfs_native_config_parse(HashMap::from([
957            (
958                HDFS_NAME_NODE.to_string(),
959                "hdfs://nn1:8020,hdfs://nn2:8020".to_string(),
960            ),
961            (HDFS_HOST.to_string(), "ignored".to_string()),
962        ]))
963        .unwrap();
964        let default_fs =
965            hdfs_native_config_parse(HashMap::from([(HDFS_HOST.to_string(), "nn".to_string())]))
966                .unwrap();
967        let unconfigured = HdfsNativeConfig::default();
968
969        // An authority with a port is used as is, whatever is configured.
970        for config in [&configured, &default_fs, &unconfigured] {
971            let (nn, rel) =
972                hdfs_native_effective_name_node(config, "hdfs://other:9000/a/b").unwrap();
973            assert_eq!((nn.as_str(), rel), ("hdfs://other:9000", "a/b"));
974        }
975        // An authority-less path uses the configured list, ahead of `hdfs.host`.
976        let (nn, _) = hdfs_native_effective_name_node(&configured, "hdfs:///y").unwrap();
977        assert_eq!(nn, "hdfs://nn1:8020,hdfs://nn2:8020");
978        // A logical nameservice never falls back to it: it must be declared.
979        let err = hdfs_native_effective_name_node(&configured, "hdfs://ns-a/x").unwrap_err();
980        assert!(err.to_string().contains("`hdfs.name-node.ns-a`"), "{err}");
981        // Only an authority-less path falls back to `fs.defaultFS`.
982        let (nn, rel) = hdfs_native_effective_name_node(&default_fs, "hdfs:///y").unwrap();
983        assert_eq!((nn.as_str(), rel), ("hdfs://nn:8020", "y"));
984        let err = hdfs_native_effective_name_node(&default_fs, "hdfs://ns-a/x").unwrap_err();
985        assert!(err.to_string().contains("`hdfs.name-node.ns-a`"), "{err}");
986        // Nothing applicable: pointed errors.
987        for path in ["hdfs:///a", "hdfs://ns-a/x"] {
988            let err = hdfs_native_effective_name_node(&unconfigured, path).unwrap_err();
989            assert!(err.to_string().contains(HDFS_NAME_NODE), "{err}");
990        }
991    }
992
993    #[test]
994    fn test_hdfs_native_parse_path_with_authority_and_rel() {
995        let (nn, rel) = hdfs_native_parse_path("hdfs://nameservice1/a/b").unwrap();
996
997        assert_eq!(
998            nn,
999            HdfsNativeAuthority::Nameservice("nameservice1".to_string())
1000        );
1001        assert_eq!(rel, "a/b");
1002    }
1003
1004    #[test]
1005    fn test_hdfs_native_parse_path_with_authority_and_port() {
1006        let (nn, rel) = hdfs_native_parse_path("hdfs://nn:8020/foo").unwrap();
1007        // Extra leading slashes go too: opendal rejects paths starting with `/`.
1008        assert_eq!(
1009            hdfs_native_parse_path("hdfs://nn:8020//foo").unwrap().1,
1010            "foo"
1011        );
1012
1013        assert_eq!(
1014            nn,
1015            HdfsNativeAuthority::NameNode("hdfs://nn:8020".to_string())
1016        );
1017        assert_eq!(rel, "foo");
1018    }
1019
1020    #[test]
1021    fn test_hdfs_native_parse_path_ipv6_authority_keeps_brackets() {
1022        let (nn, rel) = hdfs_native_parse_path("hdfs://[::1]:8020/a/b").unwrap();
1023        assert_eq!(
1024            nn,
1025            HdfsNativeAuthority::NameNode("hdfs://[::1]:8020".to_string())
1026        );
1027        assert_eq!(rel, "a/b");
1028    }
1029
1030    #[test]
1031    fn test_hdfs_native_parse_path_rejects_userinfo_and_port_zero() {
1032        for (path, reason) in [
1033            ("hdfs://alice@nn:8020/a", "userinfo"),
1034            ("hdfs://alice:secret@nn:8020/a", "userinfo"),
1035            ("hdfs://nn:0/a", "port 0"),
1036        ] {
1037            let err = hdfs_native_parse_path(path).unwrap_err().to_string();
1038            assert!(err.contains(reason), "{path}: {err}");
1039            // A password must never reach a log.
1040            assert!(!err.contains("alice") && !err.contains("secret"), "{err}");
1041        }
1042    }
1043
1044    #[test]
1045    fn test_hdfs_native_errors_mask_userinfo() {
1046        let path_err = |path: &str| {
1047            hdfs_native_effective_name_node(&HdfsNativeConfig::default(), path)
1048                .unwrap_err()
1049                .to_string()
1050        };
1051        let config_err = |key: &str, value: &str| {
1052            let props = HashMap::from([(key.to_string(), value.to_string())]);
1053            hdfs_native_config_parse(props)
1054                .and_then(|config| hdfs_native_effective_name_node(&config, "hdfs:///x"))
1055                .unwrap_err()
1056                .to_string()
1057        };
1058        // Every error that echoes a path or a NameNode value, including the
1059        // ones that fail before the userinfo check can run.
1060        for (err, masked) in [
1061            (
1062                path_err("hdfs://alice:s3cret@nn:bad/x"),
1063                "hdfs://***@nn:bad/x",
1064            ),
1065            (path_err("s3://alice:s3cret@b/x"), "s3://***@b/x"),
1066            (
1067                path_err("hdfs://alice:s3cret@nn:8020/x"),
1068                "hdfs://***@nn:8020/x",
1069            ),
1070            (
1071                config_err(HDFS_NAME_NODE, "hdfs://alice:s3cret@nn:8020"),
1072                "hdfs://***@nn:8020",
1073            ),
1074            (
1075                config_err("hdfs.name-node.ns", "hdfs://alice:s3cret@nn:8020"),
1076                "hdfs://***@nn:8020",
1077            ),
1078            (config_err(HDFS_HOST, "alice:s3cret@nn"), "***@nn:8020"),
1079            (
1080                config_err("hadoop.fs.defaultFS", "hdfs://alice:s3cret@nn:8020"),
1081                "hdfs://***@nn:8020",
1082            ),
1083            // A raw `/`, `#` or `://` in the password, or a second `@`, must
1084            // not move where the masking stops.
1085            (
1086                path_err("hdfs://alice:s3/cret@nn:8020/x"),
1087                "hdfs://***@nn:8020/x",
1088            ),
1089            (path_err("s3://alice:12#cret@b/x"), "s3://***@b/x"),
1090            (
1091                path_err("hdfs://alice@corp:s3cret@nn:bad/x"),
1092                "hdfs://***@nn:bad/x",
1093            ),
1094            (
1095                config_err(HDFS_NAME_NODE, "hdfs://alice:s3/cret@nn:8020"),
1096                "hdfs://***@nn:8020",
1097            ),
1098            (config_err(HDFS_HOST, "alice:s3://cret@nn"), "***@nn:8020"),
1099        ] {
1100            assert!(err.contains(masked), "{err}");
1101            assert!(!err.contains("alice") && !err.contains("cret"), "{err}");
1102        }
1103    }
1104
1105    #[test]
1106    fn test_hdfs_native_parse_path_with_authority_no_path() {
1107        let (nn, rel) = hdfs_native_parse_path("hdfs://nameservice1").unwrap();
1108
1109        assert_eq!(
1110            nn,
1111            HdfsNativeAuthority::Nameservice("nameservice1".to_string())
1112        );
1113        assert_eq!(rel, "");
1114    }
1115
1116    #[test]
1117    fn test_hdfs_native_parse_path_with_authority_trailing_slash() {
1118        let (nn, rel) = hdfs_native_parse_path("hdfs://nameservice1/").unwrap();
1119
1120        assert_eq!(
1121            nn,
1122            HdfsNativeAuthority::Nameservice("nameservice1".to_string())
1123        );
1124        assert_eq!(rel, "");
1125    }
1126
1127    #[test]
1128    fn test_hdfs_native_parse_path_authority_less_returns_none() {
1129        let (nn, rel) = hdfs_native_parse_path("hdfs:///a/b").unwrap();
1130
1131        assert_eq!(nn, HdfsNativeAuthority::Default);
1132        assert_eq!(rel, "a/b");
1133    }
1134
1135    #[test]
1136    fn test_hdfs_native_parse_path_wrong_scheme_errors() {
1137        let err = hdfs_native_parse_path("file:///tmp/x").unwrap_err();
1138
1139        assert!(err.to_string().contains("expected scheme `hdfs://`"));
1140    }
1141
1142    #[test]
1143    fn test_hdfs_native_parse_path_invalid_url_errors() {
1144        let err = hdfs_native_parse_path("not-a-url").unwrap_err().to_string();
1145
1146        // The URL parser's reason is forwarded; this is not the scheme check.
1147        assert!(
1148            err.contains("Invalid hdfs path") && !err.contains("expected scheme"),
1149            "{err}"
1150        );
1151    }
1152
1153    #[test]
1154    fn test_hdfs_native_parse_path_non_hierarchical_errors() {
1155        // `hdfs:x` parses as a valid non-hierarchical URL; it must be
1156        // rejected rather than panic on slicing.
1157        for path in ["hdfs:x", "hdfs:/x", "hdfs:"] {
1158            let err = hdfs_native_parse_path(path).unwrap_err();
1159            assert!(err.to_string().contains("expected scheme `hdfs://`"));
1160        }
1161    }
1162
1163    #[test]
1164    fn test_hdfs_native_batch_key_distinguishes_ports() {
1165        let config = HdfsNativeConfig::default();
1166
1167        assert_eq!(
1168            hdfs_native_batch_key(&config, "hdfs://namenode:8020/a"),
1169            "hdfs://namenode:8020"
1170        );
1171        assert_eq!(
1172            hdfs_native_batch_key(&config, "hdfs://namenode:9000/b"),
1173            "hdfs://namenode:9000"
1174        );
1175    }
1176
1177    #[test]
1178    fn test_hdfs_native_batch_key_invalid_path_keys_on_itself() {
1179        let config = HdfsNativeConfig::default();
1180
1181        // Unresolvable paths must not collapse onto a shared "" key.
1182        for path in ["not-a-url", "hdfs:///a", "hdfs://nn:0/a"] {
1183            assert_eq!(hdfs_native_batch_key(&config, path), path);
1184        }
1185    }
1186
1187    // Creating an operator offloads the build to a blocking thread, so these
1188    // run under tokio.
1189    /// `relativize_path` follows the same resolution rule as `create_operator`.
1190    #[test]
1191    fn test_hdfs_native_relativize_path() {
1192        let build = |key: String| {
1193            let props = HashMap::from([(key, "hdfs://nn:8020".to_string())]);
1194            crate::OpenDalStorage::HdfsNative(HdfsNativeStorage::new(
1195                hdfs_native_config_parse(props).unwrap(),
1196                OpenDalClientConfig::default(),
1197            ))
1198        };
1199        let unconfigured = crate::OpenDalStorage::HdfsNative(HdfsNativeStorage::new(
1200            HdfsNativeConfig::default(),
1201            OpenDalClientConfig::default(),
1202        ));
1203        let declared = build(format!("{HDFS_NAME_NODE}.nameservice1"));
1204        let plain = build(HDFS_NAME_NODE.to_string());
1205
1206        // A concrete authority needs nothing configured.
1207        assert_eq!(
1208            unconfigured
1209                .relativize_path("hdfs://nn:8020/warehouse/db/t")
1210                .unwrap(),
1211            "warehouse/db/t"
1212        );
1213        // A logical nameservice needs its declaration, an authority-less path
1214        // the plain key.
1215        for (storage, path, rel) in [
1216            (&declared, "hdfs://nameservice1/a/b.parquet", "a/b.parquet"),
1217            (&plain, "hdfs:///a/b", "a/b"),
1218        ] {
1219            let err = unconfigured.relativize_path(path).unwrap_err();
1220            assert!(err.to_string().contains(HDFS_NAME_NODE), "{err}");
1221            assert_eq!(storage.relativize_path(path).unwrap(), rel);
1222        }
1223        // Another scheme is rejected before any resolution.
1224        let err = unconfigured.relativize_path("s3://bucket/x").unwrap_err();
1225        assert!(
1226            err.to_string().contains("expected scheme `hdfs://`"),
1227            "{err}"
1228        );
1229    }
1230
1231    #[test]
1232    fn test_hdfs_native_create_operator_plain_key_serves_authority_less_paths() {
1233        let config = Arc::new(
1234            hdfs_native_config_parse(HashMap::from([(
1235                HDFS_NAME_NODE.to_string(),
1236                "hdfs://nn1:8020/,hdfs://nn2:8020/".to_string(),
1237            )]))
1238            .unwrap(),
1239        );
1240        let operators = HdfsNativeOperatorCache::default();
1241
1242        // Authority-less paths share the configured list's operator.
1243        let (_, rel) = hdfs_native_create_operator("hdfs:///a/b", &config, &operators).unwrap();
1244        assert_eq!(rel, "a/b");
1245        assert_eq!(operators.len(), 1);
1246        assert!(operators.get("hdfs://nn1:8020,hdfs://nn2:8020").is_some());
1247
1248        // A logical nameservice must be declared; a concrete authority is
1249        // another cluster, never the configured one.
1250        let err = hdfs_native_create_operator("hdfs://ns-a/c", &config, &operators).unwrap_err();
1251        assert!(err.to_string().contains("`hdfs.name-node.ns-a`"), "{err}");
1252        hdfs_native_create_operator("hdfs://other:9000/d", &config, &operators).unwrap();
1253        assert_eq!(operators.len(), 2);
1254        assert!(operators.get("hdfs://other:9000").is_some());
1255    }
1256
1257    #[test]
1258    fn test_hdfs_native_create_operator_caches_per_name_node() {
1259        let config = Arc::new(HdfsNativeConfig::default());
1260        let operators = HdfsNativeOperatorCache::default();
1261
1262        for path in [
1263            "hdfs://nn1:8020/a",
1264            "hdfs://nn1:8020/b",
1265            "hdfs://nn2:8020/c",
1266        ] {
1267            hdfs_native_create_operator(path, &config, &operators).unwrap();
1268        }
1269
1270        assert_eq!(operators.len(), 2);
1271    }
1272
1273    #[tokio::test]
1274    async fn test_hdfs_native_operator_cache_keeps_first_insert() {
1275        let operators = HdfsNativeOperatorCache::default();
1276        let build = |root: &str| {
1277            let mut config = hdfs_native_config_parse(HashMap::new()).unwrap();
1278            config.root = Some(root.to_string());
1279            hdfs_native_operator_build(&config, "hdfs://nn:8020").unwrap()
1280        };
1281
1282        // Two callers racing for one NameNode: the first insert wins and both
1283        // get the cached operator.
1284        let first = operators.insert("hdfs://nn:8020".to_string(), build("/first"));
1285        let second = operators.insert("hdfs://nn:8020".to_string(), build("/second"));
1286        assert_eq!(first.info().root(), "/first/");
1287        assert_eq!(second.info().root(), "/first/");
1288        assert_eq!(operators.len(), 1);
1289    }
1290
1291    #[test]
1292    fn test_hdfs_native_operator_cache_survives_a_poisoned_lock() {
1293        let operators = HdfsNativeOperatorCache::default();
1294        // A panic elsewhere while the lock is held must not break HDFS I/O
1295        // for good: every write inserts a whole entry, so the map is intact.
1296        let _ = std::panic::catch_unwind(|| {
1297            let _guard = operators.0.write().unwrap();
1298            panic!("poisoning the operator cache lock");
1299        });
1300        assert!(operators.0.is_poisoned());
1301
1302        let config = Arc::new(HdfsNativeConfig::default());
1303        hdfs_native_create_operator("hdfs://nn:8020/a", &config, &operators).unwrap();
1304        assert!(operators.get("hdfs://nn:8020").is_some());
1305    }
1306
1307    /// The configuration round-trips through serde; the operator cache does
1308    /// not and starts empty.
1309    #[test]
1310    fn test_hdfs_native_storage_serde_round_trip() {
1311        let props = HashMap::from([
1312            (HDFS_NAME_NODE.to_string(), "hdfs://nn:8020".to_string()),
1313            (
1314                "hadoop.dfs.client.use.datanode.hostname".to_string(),
1315                "true".to_string(),
1316            ),
1317        ]);
1318        let storage = crate::OpenDalStorage::HdfsNative(HdfsNativeStorage::new(
1319            hdfs_native_config_parse(props).unwrap(),
1320            OpenDalClientConfig::from_properties(&HashMap::from([(
1321                crate::OPENDAL_IO_TIMEOUT_MS.to_string(),
1322                "45000".to_string(),
1323            )]))
1324            .unwrap(),
1325        ));
1326        // Populate the cache so that its absence after the round trip means
1327        // something.
1328        let crate::OpenDalStorage::HdfsNative(hdfs) = &storage else {
1329            panic!("expected the HdfsNative variant");
1330        };
1331        hdfs_native_create_operator("hdfs:///x", &hdfs.config, &hdfs.operators).unwrap();
1332        assert!(hdfs.operators.get("hdfs://nn:8020").is_some());
1333
1334        let value = serde_json::to_value(&storage).unwrap();
1335        assert!(value["HdfsNative"].get("operators").is_none());
1336        let crate::OpenDalStorage::HdfsNative(restored) = serde_json::from_value(value).unwrap()
1337        else {
1338            panic!("expected the HdfsNative variant");
1339        };
1340        assert_eq!(restored.config.name_node.as_deref(), Some("hdfs://nn:8020"));
1341        assert_eq!(
1342            restored
1343                .config
1344                .options
1345                .as_ref()
1346                .and_then(|o| o.get("dfs.client.use.datanode.hostname")),
1347            Some(&"true".to_string())
1348        );
1349        assert_eq!(
1350            restored.client_config.io_timeout(),
1351            std::time::Duration::from_secs(45)
1352        );
1353        assert!(restored.operators.get("hdfs://nn:8020").is_none());
1354    }
1355}