Skip to main content

faucet_source_azure_blob/
stream.rs

1//! Azure Blob source stream executor.
2
3use std::collections::HashMap;
4use std::pin::Pin;
5use std::sync::Arc;
6
7use async_trait::async_trait;
8use faucet_common_azure::build_store;
9use faucet_core::{FaucetError, Stream, StreamPage};
10use futures::stream::{self, StreamExt, TryStreamExt};
11use object_store::path::Path as ObjectPath;
12use object_store::{ObjectStore, ObjectStoreExt};
13use serde_json::Value;
14use tokio::io::AsyncBufReadExt;
15
16use crate::config::{AzureBlobSourceConfig, AzureFileFormat};
17
18/// An Azure Blob source that lists and reads objects from a container.
19pub struct AzureBlobSource {
20    config: AzureBlobSourceConfig,
21    store: Arc<dyn ObjectStore>,
22}
23
24impl AzureBlobSource {
25    /// Construct the source, building the object store eagerly so it is reused
26    /// across calls.
27    pub async fn new(config: AzureBlobSourceConfig) -> Result<Self, FaucetError> {
28        faucet_core::validate_batch_size(config.batch_size)?;
29        let store = build_store(&config.connection)?;
30        Ok(Self { config, store })
31    }
32
33    /// List object names under the configured (or override) prefix, capped at
34    /// `max_objects` when set. When `object_keys` is configured, listing is
35    /// skipped and those keys are used directly.
36    async fn list_object_names(
37        &self,
38        prefix_override: Option<&str>,
39    ) -> Result<Vec<String>, FaucetError> {
40        if let Some(keys) = &self.config.object_keys {
41            return Ok(cap_keys(keys.clone(), self.config.max_objects));
42        }
43
44        let effective_prefix = prefix_override.or(self.config.prefix.as_deref());
45        let prefix_path = effective_prefix
46            .filter(|p| !p.is_empty())
47            .map(ObjectPath::from);
48
49        let mut listing = self.store.list(prefix_path.as_ref());
50        let mut names: Vec<String> = Vec::new();
51        while let Some(item) = listing.next().await {
52            let meta = item.map_err(|e| {
53                FaucetError::Source(format!(
54                    "azure list error for container '{}': {e}",
55                    self.config.container()
56                ))
57            })?;
58            let name = meta.location.to_string();
59            if name.is_empty() {
60                continue;
61            }
62            names.push(name);
63            if let Some(max) = self.config.max_objects
64                && names.len() >= max
65            {
66                break;
67            }
68        }
69        Ok(names)
70    }
71
72    /// Read the full body of a single object into a UTF-8 `String`.
73    async fn read_object_text(&self, key: &str) -> Result<String, FaucetError> {
74        use tokio::io::AsyncReadExt as _;
75        let mut reader = self.open_object_reader(key).await?;
76        let mut text = String::new();
77        reader.read_to_string(&mut text).await.map_err(|e| {
78            FaucetError::Source(format!(
79                "azure read/decode error for key '{key}' (not valid UTF-8?): {e}"
80            ))
81        })?;
82        Ok(text)
83    }
84
85    /// Open an object as an `AsyncBufRead` over its (optionally decompressed)
86    /// body so callers can decode line-by-line without buffering the whole
87    /// object.
88    async fn open_object_reader(
89        &self,
90        key: &str,
91    ) -> Result<Pin<Box<dyn tokio::io::AsyncBufRead + Send + Unpin>>, FaucetError> {
92        let path = ObjectPath::from(key);
93        let result = self.store.get(&path).await.map_err(|e| {
94            FaucetError::Source(format!(
95                "azure get error for container '{}' key '{key}': {e}",
96                self.config.container()
97            ))
98        })?;
99
100        let byte_stream = result
101            .into_stream()
102            .map_err(|e| std::io::Error::other(e.to_string()));
103        let reader = tokio_util::io::StreamReader::new(byte_stream);
104        let buffered = tokio::io::BufReader::new(reader);
105        #[cfg(feature = "compression")]
106        {
107            let codec = self.config.compression.resolve(key);
108            faucet_core::compression::warn_mismatch(key, codec);
109            Ok(faucet_core::compression::wrap_async_reader(buffered, codec))
110        }
111        #[cfg(not(feature = "compression"))]
112        {
113            Ok(Box::pin(buffered))
114        }
115    }
116
117    /// Parse file content into records based on the configured file format.
118    fn parse_content(&self, key: &str, text: &str) -> Result<Vec<Value>, FaucetError> {
119        parse_file_content(&self.config.file_format, key, text)
120    }
121}
122
123/// Parse object content into records for a given format. Free function (vs. an
124/// `AzureBlobSource` method) so it is unit-testable without an Azure client —
125/// the parsing logic is pure.
126pub(crate) fn parse_file_content(
127    format: &AzureFileFormat,
128    key: &str,
129    text: &str,
130) -> Result<Vec<Value>, FaucetError> {
131    match format {
132        AzureFileFormat::JsonLines => {
133            let mut records = Vec::new();
134            for (line_num, line) in text.lines().enumerate() {
135                let trimmed = line.trim();
136                if trimmed.is_empty() {
137                    continue;
138                }
139                let value: Value = serde_json::from_str(trimmed).map_err(|e| {
140                    FaucetError::Source(format!(
141                        "azure JSON parse error in '{key}' at line {}: {e}",
142                        line_num + 1
143                    ))
144                })?;
145                records.push(value);
146            }
147            Ok(records)
148        }
149        AzureFileFormat::JsonArray => {
150            let value: Value = serde_json::from_str(text).map_err(|e| {
151                FaucetError::Source(format!("azure JSON parse error in '{key}': {e}"))
152            })?;
153            match value {
154                Value::Array(arr) => Ok(arr),
155                other => Err(FaucetError::Source(format!(
156                    "azure expected JSON array in '{key}', got {}",
157                    value_type_name(&other)
158                ))),
159            }
160        }
161        AzureFileFormat::RawText => Ok(vec![serde_json::json!({
162            "key": key,
163            "content": text,
164        })]),
165    }
166}
167
168/// Truncate an explicit object-key list to the `max_objects` cap. `None` leaves
169/// the list untouched.
170fn cap_keys(mut keys: Vec<String>, max: Option<usize>) -> Vec<String> {
171    if let Some(n) = max {
172        keys.truncate(n);
173    }
174    keys
175}
176
177fn value_type_name(v: &Value) -> &'static str {
178    match v {
179        Value::Null => "null",
180        Value::Bool(_) => "boolean",
181        Value::Number(_) => "number",
182        Value::String(_) => "string",
183        Value::Array(_) => "array",
184        Value::Object(_) => "object",
185    }
186}
187
188#[async_trait]
189impl faucet_core::Source for AzureBlobSource {
190    async fn fetch_with_context(
191        &self,
192        context: &HashMap<String, Value>,
193    ) -> Result<Vec<Value>, FaucetError> {
194        let substituted_prefix: Option<String> = if !context.is_empty() {
195            self.config
196                .prefix
197                .as_ref()
198                .map(|p| faucet_core::util::substitute_context(p, context))
199        } else {
200            None
201        };
202
203        let keys = self
204            .list_object_names(substituted_prefix.as_deref())
205            .await?;
206        tracing::info!(
207            container = %self.config.container(),
208            objects = keys.len(),
209            "Listed Azure objects",
210        );
211
212        let concurrency = self.config.concurrency.max(1);
213        let results: Vec<Vec<Value>> = stream::iter(keys)
214            .map(|key| async move {
215                let text = self.read_object_text(&key).await?;
216                let records = self.parse_content(&key, &text)?;
217                tracing::debug!(key = %key, records = records.len(), "Read Azure object");
218                Ok::<Vec<Value>, FaucetError>(records)
219            })
220            .buffer_unordered(concurrency)
221            .try_collect()
222            .await?;
223
224        let all_records: Vec<Value> = results.into_iter().flatten().collect();
225        tracing::info!(total_records = all_records.len(), "Azure fetch complete");
226        Ok(all_records)
227    }
228
229    /// Stream records from listed Azure objects without buffering the full
230    /// scan. Mirrors the S3/GCS object sources — see those for the per-format
231    /// reasoning. `batch_size = 0` emits one page per object.
232    fn stream_pages<'a>(
233        &'a self,
234        context: &'a HashMap<String, Value>,
235        _batch_size: usize,
236    ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
237        let batch_size = self.config.batch_size;
238
239        Box::pin(async_stream::try_stream! {
240            let substituted_prefix: Option<String> = if !context.is_empty() {
241                self.config
242                    .prefix
243                    .as_ref()
244                    .map(|p| faucet_core::util::substitute_context(p, context))
245            } else {
246                None
247            };
248
249            let keys = self.list_object_names(substituted_prefix.as_deref()).await?;
250            tracing::info!(
251                container = %self.config.container(),
252                objects = keys.len(),
253                "Listed Azure objects (stream)",
254            );
255
256            let chunk = if batch_size == 0 { usize::MAX } else { batch_size };
257            let initial_capacity = if batch_size == 0 { 1024 } else { batch_size };
258            let mut buffer: Vec<Value> = Vec::with_capacity(initial_capacity);
259            let mut total = 0usize;
260
261            for key in &keys {
262                match self.config.file_format {
263                    AzureFileFormat::JsonLines => {
264                        let reader = self.open_object_reader(key).await?;
265                        let mut lines = reader.lines();
266                        let mut line_num: usize = 0;
267                        while let Some(line) = lines
268                            .next_line()
269                            .await
270                            .map_err(|e| FaucetError::Source(format!(
271                                "azure read body error for key '{key}': {e}"
272                            )))?
273                        {
274                            line_num += 1;
275                            let trimmed = line.trim();
276                            if trimmed.is_empty() { continue; }
277                            let value: Value = serde_json::from_str(trimmed).map_err(|e| {
278                                FaucetError::Source(format!(
279                                    "azure JSON parse error in '{key}' at line {line_num}: {e}",
280                                ))
281                            })?;
282                            buffer.push(value);
283                            if batch_size != 0 && buffer.len() >= chunk {
284                                let page = std::mem::replace(
285                                    &mut buffer,
286                                    Vec::with_capacity(initial_capacity),
287                                );
288                                total += page.len();
289                                yield StreamPage { records: page, bookmark: None };
290                            }
291                        }
292                        if batch_size == 0 && !buffer.is_empty() {
293                            let page = std::mem::take(&mut buffer);
294                            total += page.len();
295                            yield StreamPage { records: page, bookmark: None };
296                        }
297                    }
298                    AzureFileFormat::RawText => {
299                        let text = self.read_object_text(key).await?;
300                        let record = serde_json::json!({ "key": key, "content": text });
301                        buffer.push(record);
302                        if batch_size == 0 {
303                            let page = std::mem::take(&mut buffer);
304                            total += page.len();
305                            yield StreamPage { records: page, bookmark: None };
306                        } else if buffer.len() >= chunk {
307                            let page = std::mem::replace(
308                                &mut buffer,
309                                Vec::with_capacity(initial_capacity),
310                            );
311                            total += page.len();
312                            yield StreamPage { records: page, bookmark: None };
313                        }
314                    }
315                    AzureFileFormat::JsonArray => {
316                        let text = self.read_object_text(key).await?;
317                        let value: Value = serde_json::from_str(&text).map_err(|e| {
318                            FaucetError::Source(format!("azure JSON parse error in '{key}': {e}"))
319                        })?;
320                        let array = match value {
321                            Value::Array(arr) => arr,
322                            other => Err(FaucetError::Source(format!(
323                                "azure expected JSON array in '{key}', got {}",
324                                value_type_name(&other)
325                            )))?,
326                        };
327                        if batch_size == 0 {
328                            if !buffer.is_empty() {
329                                let page = std::mem::take(&mut buffer);
330                                total += page.len();
331                                yield StreamPage { records: page, bookmark: None };
332                            }
333                            total += array.len();
334                            yield StreamPage { records: array, bookmark: None };
335                        } else {
336                            for record in array {
337                                buffer.push(record);
338                                if buffer.len() >= chunk {
339                                    let page = std::mem::replace(
340                                        &mut buffer,
341                                        Vec::with_capacity(initial_capacity),
342                                    );
343                                    total += page.len();
344                                    yield StreamPage { records: page, bookmark: None };
345                                }
346                            }
347                        }
348                    }
349                }
350            }
351
352            if !buffer.is_empty() {
353                let page = std::mem::take(&mut buffer);
354                total += page.len();
355                yield StreamPage { records: page, bookmark: None };
356            }
357
358            tracing::info!(
359                total_records = total,
360                batch_size,
361                objects = keys.len(),
362                "Azure source stream complete",
363            );
364        })
365    }
366
367    fn config_schema(&self) -> Value {
368        serde_json::to_value(faucet_core::schema_for!(AzureBlobSourceConfig))
369            .expect("schema serialization")
370    }
371
372    fn connector_name(&self) -> &'static str {
373        "azure-blob"
374    }
375
376    fn dataset_uri(&self) -> String {
377        match &self.config.prefix {
378            Some(p) => format!("az://{}/{}", self.config.container(), p),
379            None => format!("az://{}", self.config.container()),
380        }
381    }
382
383    /// Preflight probe: confirm the container is reachable and the credentials
384    /// work via a non-mutating listing capped at a single item. Reads no object
385    /// bodies.
386    async fn check(
387        &self,
388        ctx: &faucet_core::check::CheckContext,
389    ) -> Result<faucet_core::check::CheckReport, FaucetError> {
390        use faucet_core::check::{CheckReport, Probe};
391
392        let started = std::time::Instant::now();
393        let probe = match tokio::time::timeout(ctx.timeout, async {
394            let mut listing = self.store.list(None);
395            listing.next().await
396        })
397        .await
398        {
399            // Reachable — an empty container (None) is still a pass.
400            Ok(None) | Ok(Some(Ok(_))) => Probe::pass("auth", started.elapsed()),
401            Ok(Some(Err(e))) => Probe::fail_hint(
402                "auth",
403                started.elapsed(),
404                e.to_string(),
405                "check account, container, credentials, and network",
406            ),
407            Err(_) => Probe::fail("network", started.elapsed(), "timed out"),
408        };
409        Ok(CheckReport::single(probe))
410    }
411}
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416    use faucet_core::Source as _;
417    use serde_json::json;
418
419    #[test]
420    fn value_type_name_covers_all_json_variants() {
421        assert_eq!(value_type_name(&Value::Null), "null");
422        assert_eq!(value_type_name(&json!(true)), "boolean");
423        assert_eq!(value_type_name(&json!(7)), "number");
424        assert_eq!(value_type_name(&json!("s")), "string");
425        assert_eq!(value_type_name(&json!([1, 2])), "array");
426        assert_eq!(value_type_name(&json!({"k": 1})), "object");
427    }
428
429    #[test]
430    fn parse_json_lines() {
431        let r = parse_file_content(&AzureFileFormat::JsonLines, "t", "{\"id\":1}\n{\"id\":2}\n")
432            .unwrap();
433        assert_eq!(r.len(), 2);
434        assert_eq!(r[0]["id"], 1);
435    }
436
437    #[test]
438    fn parse_json_lines_skips_blanks() {
439        let r = parse_file_content(
440            &AzureFileFormat::JsonLines,
441            "t",
442            "{\"id\":1}\n\n{\"id\":2}\n\n",
443        )
444        .unwrap();
445        assert_eq!(r.len(), 2);
446    }
447
448    #[test]
449    fn parse_json_lines_reports_line_number() {
450        let err = parse_file_content(&AzureFileFormat::JsonLines, "t", "{\"id\":1}\nbad-line\n")
451            .unwrap_err();
452        let msg = err.to_string();
453        assert!(msg.contains("line 2"), "unexpected: {msg}");
454    }
455
456    #[test]
457    fn parse_json_array() {
458        let r = parse_file_content(
459            &AzureFileFormat::JsonArray,
460            "t.json",
461            "[{\"id\":1},{\"id\":2}]",
462        )
463        .unwrap();
464        assert_eq!(r.len(), 2);
465    }
466
467    #[test]
468    fn parse_json_array_rejects_non_array() {
469        let err =
470            parse_file_content(&AzureFileFormat::JsonArray, "t.json", "{\"id\":1}").unwrap_err();
471        assert!(err.to_string().contains("expected JSON array"));
472    }
473
474    #[test]
475    fn parse_json_array_rejects_malformed_json() {
476        let err =
477            parse_file_content(&AzureFileFormat::JsonArray, "t.json", "[not json").unwrap_err();
478        assert!(matches!(err, FaucetError::Source(_)));
479    }
480
481    #[test]
482    fn parse_raw_text_yields_single_record() {
483        let r = parse_file_content(&AzureFileFormat::RawText, "p/f.txt", "hello").unwrap();
484        assert_eq!(r, vec![json!({"key": "p/f.txt", "content": "hello"})]);
485    }
486
487    #[test]
488    fn cap_keys_truncates_explicit_list_to_max_objects() {
489        let keys = vec!["a".to_string(), "b".to_string(), "c".to_string()];
490        assert_eq!(
491            cap_keys(keys, Some(2)),
492            vec!["a".to_string(), "b".to_string()]
493        );
494    }
495
496    #[test]
497    fn cap_keys_passes_through_when_no_max() {
498        let keys = vec!["a".to_string(), "b".to_string(), "c".to_string()];
499        assert_eq!(cap_keys(keys.clone(), None), keys);
500    }
501
502    #[test]
503    fn cap_keys_noop_when_max_exceeds_len() {
504        let keys = vec!["a".to_string(), "b".to_string()];
505        assert_eq!(cap_keys(keys.clone(), Some(10)), keys);
506    }
507
508    // dataset_uri logic mirrors the built source without needing an Azure
509    // client (construction builds the object store).
510    #[test]
511    fn dataset_uri_no_prefix_logic() {
512        let config = AzureBlobSourceConfig::new("my-container");
513        let uri = match &config.prefix {
514            Some(p) => format!("az://{}/{}", config.container(), p),
515            None => format!("az://{}", config.container()),
516        };
517        assert_eq!(uri, "az://my-container");
518    }
519
520    #[test]
521    fn dataset_uri_with_prefix_logic() {
522        let config = AzureBlobSourceConfig::new("my-container").prefix("data/2026/");
523        let uri = match &config.prefix {
524            Some(p) => format!("az://{}/{}", config.container(), p),
525            None => format!("az://{}", config.container()),
526        };
527        assert_eq!(uri, "az://my-container/data/2026/");
528    }
529
530    #[tokio::test]
531    async fn new_rejects_out_of_range_batch_size() {
532        let config =
533            AzureBlobSourceConfig::new("c").with_batch_size(faucet_core::MAX_BATCH_SIZE + 1);
534        match AzureBlobSource::new(config).await {
535            Err(FaucetError::Config(m)) => assert!(m.contains("batch_size"), "got: {m}"),
536            Ok(_) => panic!("expected a batch_size Config error, got Ok(source)"),
537            Err(e) => panic!("expected a batch_size Config error, got {e:?}"),
538        }
539    }
540
541    #[tokio::test]
542    async fn new_builds_lazily_with_emulator() {
543        // The object-store builder is lazy — no I/O — so a well-formed emulator
544        // config constructs a source without a reachable backend.
545        let config = AzureBlobSourceConfig::new("c")
546            .use_emulator(true)
547            .allow_http(true);
548        let source = AzureBlobSource::new(config).await.unwrap();
549        assert_eq!(source.connector_name(), "azure-blob");
550        assert_eq!(source.dataset_uri(), "az://c");
551    }
552}