1use 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
35const FS_DEFAULT_FS: &str = "fs.defaultFS";
37const HDFS_DEFAULT_PORT: u16 = 8020;
38const HDFS_UNSUPPORTED_KEYS: [&str; 2] = ["hdfs.user", "hdfs.kerberos_ticket"];
40const HA_NAMENODES_PREFIX: &str = "dfs.ha.namenodes";
43const HA_NAMENODE_RPC_ADDRESS_PREFIX: &str = "dfs.namenode.rpc-address";
44
45fn 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
67fn hdfs_native_endpoint(host: &str, port: u16) -> String {
71 format!("hdfs://{}:{port}", host.to_ascii_lowercase())
72}
73
74fn 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
95const HDFS_NAME_NODE_DECLARATION_PREFIX: &str = "hdfs.name-node.";
97
98#[derive(Properties)]
101struct HdfsNativeProperties {
102 #[property(
104 key = HDFS_NAME_NODE,
105 default = Vec::new(),
106 parse_with = parse_name_node_list
107 )]
108 name_node: Vec<String>,
109 #[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 #[property(prefix = HDFS_HADOOP_CONF_PREFIX)]
118 hadoop: HashMap<String, String>,
119}
120
121fn 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
133pub(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
139fn 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 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 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 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 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 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 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
267fn hdfs_native_redact(s: &str) -> String {
272 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
289fn 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#[derive(Debug, PartialEq)]
299pub(crate) enum HdfsNativeAuthority {
300 NameNode(String),
302 Nameservice(String),
304 Default,
306}
307
308pub(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 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 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 let rel = after_scheme
337 .split_once('/')
338 .map_or("", |(_, path)| path.trim_start_matches('/'));
339
340 Ok((authority, rel))
341}
342
343pub(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
375fn 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
401fn 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#[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#[derive(Clone, Debug, Default)]
463pub(crate) struct HdfsNativeOperatorCache(Arc<RwLock<HashMap<String, Operator>>>);
464
465impl 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 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
493static 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
507pub(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 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
531pub(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
541fn 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 assert_eq!(parse(""), None);
602 assert_eq!(parse(" "), None);
603 assert_eq!(parse(" , "), None);
604 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 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 assert_eq!(
659 parse(&[(HDFS_HOST, " "), (HDFS_PORT, "9000")]).unwrap(),
660 None
661 );
662 assert_eq!(parse(&[(HDFS_PORT, "9000")]).unwrap(), None);
663 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 assert!(
673 std::error::Error::source(&err).is_some(),
674 "{props:?}: {err}"
675 );
676 }
677 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 #[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 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 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 parse(&[
783 ("hadoop.fs.defaultFS", "viewfs://cluster/"),
784 (HDFS_NAME_NODE, "nn:8020"),
785 ])
786 .unwrap();
787 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 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 #[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 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 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 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 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 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 let (nn, _) = hdfs_native_effective_name_node(&with_default, "hdfs:///x").unwrap();
914 assert_eq!(nn, "hdfs://d:8020");
915 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 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 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 let (nn, _) = hdfs_native_effective_name_node(&configured, "hdfs:///y").unwrap();
977 assert_eq!(nn, "hdfs://nn1:8020,hdfs://nn2:8020");
978 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 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 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 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 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 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 (
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 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 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 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 #[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 assert_eq!(
1208 unconfigured
1209 .relativize_path("hdfs://nn:8020/warehouse/db/t")
1210 .unwrap(),
1211 "warehouse/db/t"
1212 );
1213 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 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 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 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 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 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 #[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 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}