Skip to content

Commit 5c4551f

Browse files
authored
feat: support hdfs using hdfs native (#242)
1 parent 1b0e605 commit 5c4551f

4 files changed

Lines changed: 258 additions & 2 deletions

File tree

crates/paimon/Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,14 @@ version.workspace = true
3030

3131
[features]
3232
default = ["storage-memory", "storage-fs", "storage-oss"]
33-
storage-all = ["storage-memory", "storage-fs", "storage-oss", "storage-s3"]
33+
storage-all = ["storage-memory", "storage-fs", "storage-oss", "storage-s3", "storage-hdfs"]
3434
fulltext = ["tantivy", "tempfile"]
3535

3636
storage-memory = ["opendal/services-memory"]
3737
storage-fs = ["opendal/services-fs"]
3838
storage-oss = ["opendal/services-oss"]
3939
storage-s3 = ["opendal/services-s3"]
40+
storage-hdfs = ["opendal/services-hdfs-native"]
4041

4142
[dependencies]
4243
url = "2.5.2"

crates/paimon/src/io/mod.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,8 @@ use storage_oss::*;
4040
mod storage_s3;
4141
#[cfg(feature = "storage-s3")]
4242
use storage_s3::*;
43+
44+
#[cfg(feature = "storage-hdfs")]
45+
mod storage_hdfs;
46+
#[cfg(feature = "storage-hdfs")]
47+
use storage_hdfs::*;

crates/paimon/src/io/storage.rs

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,17 @@
1616
// under the License.
1717

1818
use std::collections::HashMap;
19+
#[cfg(any(
20+
feature = "storage-oss",
21+
feature = "storage-s3",
22+
feature = "storage-hdfs"
23+
))]
24+
use std::sync::Mutex;
1925
#[cfg(any(feature = "storage-oss", feature = "storage-s3"))]
20-
use std::sync::{Mutex, MutexGuard};
26+
use std::sync::MutexGuard;
2127

28+
#[cfg(feature = "storage-hdfs")]
29+
use opendal::services::HdfsNativeConfig;
2230
#[cfg(feature = "storage-oss")]
2331
use opendal::services::OssConfig;
2432
#[cfg(feature = "storage-s3")]
@@ -48,6 +56,11 @@ pub enum Storage {
4856
config: Box<S3Config>,
4957
operators: Mutex<HashMap<String, Operator>>,
5058
},
59+
#[cfg(feature = "storage-hdfs")]
60+
Hdfs {
61+
config: Box<HdfsNativeConfig>,
62+
op: Mutex<Option<Operator>>,
63+
},
5164
}
5265

5366
impl Storage {
@@ -80,6 +93,14 @@ impl Storage {
8093
operators: Mutex::new(HashMap::new()),
8194
})
8295
}
96+
#[cfg(feature = "storage-hdfs")]
97+
Scheme::HdfsNative => {
98+
let config = super::hdfs_config_parse(props)?;
99+
Ok(Self::Hdfs {
100+
config: Box::new(config),
101+
op: Mutex::new(None),
102+
})
103+
}
83104
_ => Err(error::Error::IoUnsupported {
84105
message: "Unsupported storage feature".to_string(),
85106
}),
@@ -104,6 +125,22 @@ impl Storage {
104125
let op = Self::cached_s3_operator(config, operators, path, &bucket)?;
105126
Ok((op, relative_path))
106127
}
128+
#[cfg(feature = "storage-hdfs")]
129+
Storage::Hdfs { config, op } => {
130+
let relative_path = super::hdfs_relative_path(path)?;
131+
let mut guard = op.lock().map_err(|_| error::Error::UnexpectedError {
132+
message: "Failed to lock HDFS operator".to_string(),
133+
source: None,
134+
})?;
135+
// HDFS uses a single operator per Storage instance (unlike S3/OSS
136+
// which cache per bucket). The operator is lazily initialized from
137+
// the first path's NameNode if not set in config. One FileIO
138+
// instance should target exactly one HDFS cluster.
139+
if guard.is_none() {
140+
*guard = Some(super::hdfs_config_build(config, path)?);
141+
}
142+
Ok((guard.as_ref().unwrap().clone(), relative_path))
143+
}
107144
}
108145
}
109146

@@ -231,6 +268,7 @@ impl Storage {
231268
"memory" => Ok(Scheme::Memory),
232269
"file" | "" => Ok(Scheme::Fs),
233270
"s3" | "s3a" => Ok(Scheme::S3),
271+
"hdfs" => Ok(Scheme::HdfsNative),
234272
s => Ok(s.parse::<Scheme>()?),
235273
}
236274
}
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
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+
use std::collections::HashMap;
19+
20+
use opendal::services::HdfsNativeConfig;
21+
use opendal::Operator;
22+
use url::Url;
23+
24+
use crate::error::Error;
25+
use crate::Result;
26+
27+
/// Parse HDFS path to get relative path from root.
28+
///
29+
/// Example: "hdfs://namenode:8020/warehouse/db/table" -> "warehouse/db/table"
30+
pub(crate) fn hdfs_relative_path(path: &str) -> Result<&str> {
31+
let after_scheme = path
32+
.strip_prefix("hdfs://")
33+
.ok_or_else(|| Error::ConfigInvalid {
34+
message: format!("Invalid HDFS path: {path}, should start with hdfs://"),
35+
})?;
36+
match after_scheme.find('/') {
37+
Some(pos) => Ok(&after_scheme[pos + 1..]),
38+
None => Err(Error::ConfigInvalid {
39+
message: format!("Invalid HDFS path: {path}, missing path component"),
40+
}),
41+
}
42+
}
43+
44+
/// Configuration key for HDFS name node URL.
45+
///
46+
/// Example: "hdfs://namenode:8020" or "hdfs://nameservice1" (HA).
47+
const HDFS_NAME_NODE: &str = "hdfs.name-node";
48+
49+
/// Configuration key to enable HDFS append capability.
50+
const HDFS_ENABLE_APPEND: &str = "hdfs.enable-append";
51+
52+
/// Parse paimon catalog options into an [`HdfsNativeConfig`].
53+
///
54+
/// Extracts HDFS-related configuration from the properties map.
55+
/// The `hdfs.name-node` key is optional — if omitted, the name node
56+
/// will be extracted from the file path URL at operator build time.
57+
pub(crate) fn hdfs_config_parse(props: HashMap<String, String>) -> Result<HdfsNativeConfig> {
58+
let mut cfg = HdfsNativeConfig::default();
59+
60+
cfg.name_node = props.get(HDFS_NAME_NODE).cloned();
61+
62+
if let Some(v) = props.get(HDFS_ENABLE_APPEND) {
63+
if v.eq_ignore_ascii_case("true") {
64+
cfg.enable_append = true;
65+
}
66+
}
67+
68+
Ok(cfg)
69+
}
70+
71+
/// Build an [`Operator`] for the given HDFS path.
72+
///
73+
/// If the config has no `name_node` set, it will be extracted from the path URL.
74+
/// The root is set to "/" so that relative paths work correctly.
75+
///
76+
/// Example path: "hdfs://namenode:8020/warehouse/db/table"
77+
pub(crate) fn hdfs_config_build(cfg: &HdfsNativeConfig, path: &str) -> Result<Operator> {
78+
let url = Url::parse(path).map_err(|_| Error::ConfigInvalid {
79+
message: format!("Invalid HDFS url: {path}"),
80+
})?;
81+
82+
let mut cfg = cfg.clone();
83+
84+
if cfg.name_node.is_none() {
85+
let host = url.host_str().ok_or_else(|| Error::ConfigInvalid {
86+
message: format!("Invalid HDFS url: {path}, missing name node host"),
87+
})?;
88+
let port_part = url.port().map(|p| format!(":{p}")).unwrap_or_default();
89+
cfg.name_node = Some(format!("hdfs://{host}{port_part}"));
90+
}
91+
92+
cfg.root = Some("/".to_string());
93+
94+
Ok(Operator::from_config(cfg)?.finish())
95+
}
96+
97+
#[cfg(test)]
98+
mod tests {
99+
use super::*;
100+
101+
fn make_props(pairs: &[(&str, &str)]) -> HashMap<String, String> {
102+
pairs
103+
.iter()
104+
.map(|(k, v)| (k.to_string(), v.to_string()))
105+
.collect()
106+
}
107+
108+
#[test]
109+
fn test_hdfs_config_parse_with_name_node() {
110+
let props = make_props(&[("hdfs.name-node", "hdfs://namenode:8020")]);
111+
let cfg = hdfs_config_parse(props).unwrap();
112+
assert_eq!(cfg.name_node.as_deref(), Some("hdfs://namenode:8020"));
113+
assert!(!cfg.enable_append);
114+
}
115+
116+
#[test]
117+
fn test_hdfs_config_parse_with_enable_append() {
118+
let props = make_props(&[
119+
("hdfs.name-node", "hdfs://namenode:8020"),
120+
("hdfs.enable-append", "true"),
121+
]);
122+
let cfg = hdfs_config_parse(props).unwrap();
123+
assert!(cfg.enable_append);
124+
}
125+
126+
#[test]
127+
fn test_hdfs_config_parse_empty_props() {
128+
let cfg = hdfs_config_parse(HashMap::new()).unwrap();
129+
assert!(cfg.name_node.is_none());
130+
assert!(!cfg.enable_append);
131+
}
132+
133+
#[test]
134+
fn test_hdfs_config_build_extracts_name_node_from_path() {
135+
let cfg = HdfsNativeConfig::default();
136+
let op = hdfs_config_build(&cfg, "hdfs://namenode:8020/warehouse/db").unwrap();
137+
assert_eq!(op.info().scheme().to_string(), "hdfs-native");
138+
}
139+
140+
#[test]
141+
fn test_hdfs_config_build_uses_config_name_node() {
142+
let mut cfg = HdfsNativeConfig::default();
143+
cfg.name_node = Some("hdfs://my-cluster:9000".to_string());
144+
let op = hdfs_config_build(&cfg, "hdfs://my-cluster:9000/warehouse").unwrap();
145+
assert_eq!(op.info().scheme().to_string(), "hdfs-native");
146+
}
147+
148+
#[test]
149+
fn test_hdfs_config_build_invalid_url() {
150+
let cfg = HdfsNativeConfig::default();
151+
let result = hdfs_config_build(&cfg, "not-a-valid-url");
152+
assert!(result.is_err());
153+
}
154+
155+
#[test]
156+
fn test_hdfs_config_build_missing_host() {
157+
let cfg = HdfsNativeConfig::default();
158+
let result = hdfs_config_build(&cfg, "hdfs:///path/without/host");
159+
assert!(result.is_err());
160+
}
161+
162+
#[test]
163+
fn test_hdfs_config_parse_enable_append_false() {
164+
let props = make_props(&[
165+
("hdfs.name-node", "hdfs://namenode:8020"),
166+
("hdfs.enable-append", "false"),
167+
]);
168+
let cfg = hdfs_config_parse(props).unwrap();
169+
assert!(!cfg.enable_append);
170+
}
171+
172+
#[test]
173+
fn test_hdfs_config_parse_unrelated_keys_ignored() {
174+
let props = make_props(&[
175+
("s3.endpoint", "https://s3.amazonaws.com"),
176+
("fs.oss.endpoint", "https://oss.aliyuncs.com"),
177+
("hdfs.name-node", "hdfs://namenode:8020"),
178+
]);
179+
let cfg = hdfs_config_parse(props).unwrap();
180+
assert_eq!(cfg.name_node.as_deref(), Some("hdfs://namenode:8020"));
181+
}
182+
183+
#[test]
184+
fn test_hdfs_relative_path_normal() {
185+
let result = hdfs_relative_path("hdfs://namenode:8020/warehouse/db/table");
186+
assert_eq!(result.unwrap(), "warehouse/db/table");
187+
}
188+
189+
#[test]
190+
fn test_hdfs_relative_path_root_slash() {
191+
let result = hdfs_relative_path("hdfs://namenode:8020/");
192+
assert_eq!(result.unwrap(), "");
193+
}
194+
195+
#[test]
196+
fn test_hdfs_relative_path_no_port() {
197+
let result = hdfs_relative_path("hdfs://nameservice1/warehouse/data");
198+
assert_eq!(result.unwrap(), "warehouse/data");
199+
}
200+
201+
#[test]
202+
fn test_hdfs_relative_path_missing_path_component() {
203+
let result = hdfs_relative_path("hdfs://namenode:8020");
204+
assert!(result.is_err());
205+
}
206+
207+
#[test]
208+
fn test_hdfs_relative_path_wrong_scheme() {
209+
let result = hdfs_relative_path("s3://bucket/key");
210+
assert!(result.is_err());
211+
}
212+
}

0 commit comments

Comments
 (0)