risingwave_connector/source/filesystem/opendal_source/
opendal_enumerator.rs1use std::marker::PhantomData;
16
17use anyhow::Context;
18use async_trait::async_trait;
19use chrono::{DateTime, Utc};
20use futures::stream::{self, BoxStream};
21use futures::{StreamExt, TryStreamExt};
22use opendal::Operator;
23use risingwave_common::types::Timestamptz;
24
25use super::OpendalSource;
26use crate::error::ConnectorResult;
27use crate::source::filesystem::file_common::CompressionFormat;
28use crate::source::filesystem::{FsPageItem, OpendalFsSplit};
29use crate::source::{SourceEnumeratorContextRef, SplitEnumerator};
30
31#[inline]
32fn to_timestamptz(ts: Option<opendal::raw::Timestamp>) -> Timestamptz {
33 let system_time = ts
34 .map(std::time::SystemTime::from)
35 .unwrap_or(std::time::UNIX_EPOCH);
36 Timestamptz::from(DateTime::<Utc>::from(system_time))
37}
38
39#[derive(Debug, Clone)]
40pub struct OpendalEnumerator<Src: OpendalSource> {
41 pub op: Operator,
42 pub(crate) prefix: Option<String>,
44 pub(crate) matcher: Option<glob::Pattern>,
45 pub(crate) marker: PhantomData<Src>,
46 pub(crate) compression_format: CompressionFormat,
47}
48
49#[async_trait]
50impl<Src: OpendalSource> SplitEnumerator for OpendalEnumerator<Src> {
51 type Properties = Src::Properties;
52 type Split = OpendalFsSplit<Src>;
53
54 async fn new(
55 properties: Src::Properties,
56 _context: SourceEnumeratorContextRef,
57 ) -> ConnectorResult<Self> {
58 Src::new_enumerator(properties)
59 }
60
61 async fn list_splits(&mut self) -> ConnectorResult<Vec<OpendalFsSplit<Src>>> {
62 let empty_split: OpendalFsSplit<Src> = OpendalFsSplit::empty_split();
63 let prefix = self.prefix.as_deref().unwrap_or("/");
64 let list_prefix = Self::extract_list_prefix(prefix);
65
66 let mut lister = self.op.lister(&list_prefix).await?;
67 lister
69 .try_next()
70 .await
71 .context("fail to create source, please check your config")?;
72 Ok(vec![empty_split])
73 }
74}
75
76impl<Src: OpendalSource> OpendalEnumerator<Src> {
77 fn extract_list_prefix(prefix: &str) -> String {
81 if prefix.ends_with("/") {
82 prefix.to_owned()
83 } else if let Some(parent_pos) = prefix.rfind('/') {
84 prefix[..=parent_pos].to_owned()
85 } else {
86 "/".to_owned()
87 }
88 }
89
90 pub async fn list(&self) -> ConnectorResult<ObjectMetadataIter> {
91 let prefix = self.prefix.as_deref().unwrap_or("/");
92 let list_prefix = Self::extract_list_prefix(prefix);
93 let object_lister = self.op.lister_with(&list_prefix).recursive(true).await?;
94
95 let op = self.op.clone();
96 let stream = stream::unfold(object_lister, move |mut object_lister| {
97 let op = op.clone();
98
99 async move {
100 match object_lister.next().await {
101 Some(Ok(object)) => {
102 let name = object.path().to_owned();
103
104 let meta = object.metadata();
109 let mut t = meta.last_modified();
110 let mut size = meta.content_length() as i64;
111 if t.is_none() || size == 0 {
112 let stat_meta = match op
113 .stat(&name)
114 .await
115 .with_context(|| format!("failed to stat listed object {name}"))
116 {
117 Ok(stat_meta) => stat_meta,
118 Err(err) => return Some((Err(err.into()), object_lister)),
119 };
120 t = stat_meta.last_modified();
121 size = stat_meta.content_length() as i64;
122 }
123
124 let timestamp = to_timestamptz(t);
125 let metadata = FsPageItem {
126 name,
127 size,
128 timestamp,
129 };
130 Some((Ok(metadata), object_lister))
131 }
132 Some(Err(err)) => Some((Err(err.into()), object_lister)),
133 None => {
134 tracing::info!("list object completed.");
135 None
136 }
137 }
138 }
139 });
140
141 Ok(stream.boxed())
142 }
143
144 pub fn get_matcher(&self) -> &Option<glob::Pattern> {
145 &self.matcher
146 }
147
148 pub fn get_prefix(&self) -> &str {
149 self.prefix.as_deref().unwrap_or("/")
150 }
151}
152pub type ObjectMetadataIter = BoxStream<'static, ConnectorResult<FsPageItem>>;
153
154#[cfg(test)]
155mod tests {
156 use super::*;
157 use crate::source::filesystem::opendal_source::OpendalS3;
158 use crate::source::filesystem::s3::enumerator::get_prefix;
159
160 fn calculate_list_prefix(prefix: &str) -> String {
161 OpendalEnumerator::<OpendalS3>::extract_list_prefix(prefix)
162 }
163
164 #[test]
165 fn test_prefix_logic() {
166 let test_cases = vec![
167 ("a/b/c/hello*/*.json", "a/b/c/hello", "a/b/c/"),
168 ("a/b/c.json", "a/b/c.json", "a/b/"),
169 ("a/b/c/", "a/b/c/", "a/b/c/"),
170 ("a/b/c", "a/b/c", "a/b/"),
171 ("file.json", "file.json", "/"),
172 ("*.json", "", "/"),
173 ("a/b/c/[h]ello*/*.json", "a/b/c/", "a/b/c/"),
174 ];
175
176 for (pattern, expected_prefix, expected_list_prefix) in test_cases {
177 let prefix = get_prefix(pattern);
178 let list_prefix = calculate_list_prefix(&prefix);
179
180 assert_eq!(
181 prefix, expected_prefix,
182 "get_prefix failed for: {}",
183 pattern
184 );
185 assert_eq!(
186 list_prefix, expected_list_prefix,
187 "list_prefix failed for: {}",
188 pattern
189 );
190 }
191 }
192
193 #[test]
194 fn test_bug_fix() {
195 let problematic_pattern = "a/b/c/hello*/*.json";
196 let prefix = get_prefix(problematic_pattern);
197 let list_prefix = calculate_list_prefix(&prefix);
198
199 assert_eq!(prefix, "a/b/c/hello");
202 assert_eq!(list_prefix, "a/b/c/");
203 }
204}