laminar_connectors/files/config/
mod.rs1use std::collections::HashMap;
4use std::time::Duration;
5
6use crate::config::ConnectorConfig;
7use crate::error::ConnectorError;
8use crate::storage::{StorageConsumer, StorageLocation, StorageLocationError, StorageProvider};
9use laminar_core::time::parse_duration_str;
10
11#[derive(Debug, Clone)]
13pub struct FileSourceConfig {
14 pub path: String,
16
17 pub format: Option<FileFormat>,
19
20 pub poll_interval: Duration,
22
23 pub stabilisation_delay: Duration,
25
26 pub max_files_per_poll: usize,
28
29 pub include_metadata: bool,
31
32 pub max_file_bytes: usize,
34
35 pub glob_pattern: Option<String>,
37
38 pub csv_delimiter: u8,
40
41 pub csv_has_header: bool,
43}
44
45impl FileSourceConfig {
46 pub fn from_connector_config(config: &ConnectorConfig) -> Result<Self, ConnectorError> {
53 let props = config.properties();
54
55 let path = props
56 .get("path")
57 .ok_or_else(|| ConnectorError::ConfigurationError("'path' is required".into()))
58 .and_then(|path| local_files_path(path))?;
59
60 let format = props
61 .get("format")
62 .map(|s| FileFormat::parse(s))
63 .transpose()?;
64
65 let poll_interval = parse_duration(props, "poll_interval", Duration::from_secs(10))?;
66 let stabilisation_delay =
67 parse_duration(props, "stabilisation_delay", Duration::from_secs(1))?;
68 let max_files_per_poll = parse_usize(props, "max_files_per_poll", 100)?;
69 if max_files_per_poll == 0 {
70 return Err(ConnectorError::ConfigurationError(
71 "'max_files_per_poll' must be greater than zero".into(),
72 ));
73 }
74 let include_metadata = parse_bool(props, "include_metadata", false)?;
75 for removed in [
76 "allow_overwrites",
77 "manifest_retention_count",
78 "manifest_retention_age_days",
79 ] {
80 if props.contains_key(removed) {
81 return Err(ConnectorError::ConfigurationError(format!(
82 "file source option '{removed}' was removed; processed paths are immutable and retained in one exact checkpoint inventory"
83 )));
84 }
85 }
86 let max_file_bytes = parse_usize(props, "max_file_bytes", 256 * 1024 * 1024)?;
87 if max_file_bytes == 0 {
88 return Err(ConnectorError::ConfigurationError(
89 "'max_file_bytes' must be greater than zero".into(),
90 ));
91 }
92 let glob_pattern = props.get("glob_pattern").cloned();
93
94 let is_tsv = props
96 .get("format")
97 .is_some_and(|f| f.eq_ignore_ascii_case("tsv"));
98 let csv_delimiter = props
99 .get("csv.delimiter")
100 .and_then(|s| s.as_bytes().first().copied())
101 .unwrap_or(if is_tsv { b'\t' } else { b',' });
102 let csv_has_header = parse_bool(props, "csv.has_header", true)?;
103
104 Ok(Self {
105 path,
106 format,
107 poll_interval,
108 stabilisation_delay,
109 max_files_per_poll,
110 include_metadata,
111 max_file_bytes,
112 glob_pattern,
113 csv_delimiter,
114 csv_has_header,
115 })
116 }
117}
118
119#[derive(Debug, Clone)]
121pub struct FileSinkConfig {
122 pub path: String,
124
125 pub format: FileFormat,
127
128 pub prefix: String,
130
131 pub max_file_size: Option<usize>,
133
134 pub compression: String,
136
137 pub max_buffered_batches: usize,
139}
140
141impl FileSinkConfig {
142 pub fn from_connector_config(config: &ConnectorConfig) -> Result<Self, ConnectorError> {
149 let props = config.properties();
150
151 let path = props
152 .get("path")
153 .ok_or_else(|| ConnectorError::ConfigurationError("'path' is required".into()))
154 .and_then(|path| local_files_path(path))?;
155
156 let format = props
157 .get("format")
158 .ok_or_else(|| {
159 ConnectorError::ConfigurationError("'format' is required for file sink".into())
160 })
161 .and_then(|s| FileFormat::parse(s))?;
162
163 if props.contains_key("mode") {
164 return Err(ConnectorError::ConfigurationError(
165 "file sink option 'mode' was removed; files are always published as immutable rolling files"
166 .into(),
167 ));
168 }
169
170 let prefix = props
171 .get("prefix")
172 .cloned()
173 .unwrap_or_else(|| "part".to_string());
174 if prefix.is_empty()
175 || prefix == "."
176 || prefix == ".."
177 || prefix.contains('/')
178 || prefix.contains('\\')
179 {
180 return Err(ConnectorError::ConfigurationError(
181 "file sink 'prefix' must be a non-empty file-name component".into(),
182 ));
183 }
184
185 let max_file_size = props
186 .get("max_file_size")
187 .map(|s| {
188 s.parse::<usize>().map_err(|e| {
189 ConnectorError::ConfigurationError(format!("invalid max_file_size: {e}"))
190 })
191 })
192 .transpose()?;
193
194 let compression = props
195 .get("compression")
196 .cloned()
197 .unwrap_or_else(|| "snappy".to_string());
198
199 if props.contains_key("max_epoch_batches") {
200 return Err(ConnectorError::ConfigurationError(
201 "file sink option 'max_epoch_batches' was replaced by 'max_buffered_batches'"
202 .into(),
203 ));
204 }
205 let max_buffered_batches = props
206 .get("max_buffered_batches")
207 .map(|s| {
208 s.parse::<usize>().map_err(|e| {
209 ConnectorError::ConfigurationError(format!("invalid max_buffered_batches: {e}"))
210 })
211 })
212 .transpose()?
213 .unwrap_or(10_000);
214 if max_buffered_batches == 0 {
215 return Err(ConnectorError::ConfigurationError(
216 "max_buffered_batches must be > 0".into(),
217 ));
218 }
219
220 Ok(Self {
221 path,
222 format,
223 prefix,
224 max_file_size,
225 compression,
226 max_buffered_batches,
227 })
228 }
229}
230
231pub(super) fn local_files_path(path: &str) -> Result<String, ConnectorError> {
232 if path.trim().is_empty() {
233 return Err(ConnectorError::ConfigurationError(
234 "file connector 'path' must not be empty".into(),
235 ));
236 }
237 if std::path::Path::new(path).is_absolute() || !path.contains("://") {
238 return Ok(path.to_string());
239 }
240 let location = match StorageLocation::parse(path) {
241 Ok(location) => location,
242 Err(StorageLocationError::UnsupportedScheme(_)) => return Err(remote_files_error()),
243 Err(error) => {
244 return Err(ConnectorError::ConfigurationError(format!(
245 "invalid file connector path: {error}"
246 )))
247 }
248 };
249 if location.provider != StorageProvider::Local {
250 return Err(remote_files_error());
251 }
252 let adapted = location
253 .adapt(StorageConsumer::ObjectStore)
254 .map_err(|error| ConnectorError::ConfigurationError(error.to_string()))?;
255 let url = url::Url::parse(&adapted.url).map_err(|error| {
256 ConnectorError::ConfigurationError(format!("invalid canonical file URL: {error}"))
257 })?;
258 url.to_file_path()
259 .map_err(|()| {
260 ConnectorError::ConfigurationError(
261 "file connector URL must identify an absolute local path".into(),
262 )
263 })?
264 .into_os_string()
265 .into_string()
266 .map_err(|_| {
267 ConnectorError::ConfigurationError(
268 "file connector path cannot be represented as UTF-8".into(),
269 )
270 })
271}
272
273fn remote_files_error() -> ConnectorError {
274 ConnectorError::FeatureUnsupported(
275 "remote Files connector backends are not enabled; use a local path or absolute file:// URL"
276 .into(),
277 )
278}
279
280#[derive(Debug, Clone, Copy, PartialEq, Eq)]
282pub enum FileFormat {
283 Csv,
285 Json,
287 Text,
289 Parquet,
291 ArrowIpc,
293}
294
295impl FileFormat {
296 pub fn parse(s: &str) -> Result<Self, ConnectorError> {
302 match s.to_lowercase().as_str() {
303 "csv" | "tsv" => Ok(Self::Csv),
304 "json" | "jsonl" | "ndjson" | "json_lines" => Ok(Self::Json),
305 "text" | "txt" | "plain" => Ok(Self::Text),
306 "parquet" | "parq" => Ok(Self::Parquet),
307 "arrow" | "ipc" | "arrow_ipc" => Ok(Self::ArrowIpc),
308 other => Err(ConnectorError::ConfigurationError(format!(
309 "unknown file format: '{other}' (expected csv, json, text, parquet, or arrow)"
310 ))),
311 }
312 }
313
314 pub fn from_extension(path: &str) -> Option<Self> {
316 let ext = path.rsplit('.').next()?.to_lowercase();
317 match ext.as_str() {
318 "csv" | "tsv" => Some(Self::Csv),
319 "json" | "jsonl" | "ndjson" => Some(Self::Json),
320 "txt" | "log" => Some(Self::Text),
321 "parquet" | "parq" => Some(Self::Parquet),
322 "arrow" | "ipc" => Some(Self::ArrowIpc),
323 _ => None,
324 }
325 }
326
327 #[must_use]
329 pub fn extension(&self) -> &'static str {
330 match self {
331 Self::Csv => "csv",
332 Self::Json => "jsonl",
333 Self::Text => "txt",
334 Self::Parquet => "parquet",
335 Self::ArrowIpc => "arrow",
336 }
337 }
338
339 #[must_use]
341 pub fn is_bulk_format(&self) -> bool {
342 matches!(self, Self::Parquet | Self::ArrowIpc)
343 }
344}
345
346fn parse_duration(
349 props: &HashMap<String, String>,
350 key: &str,
351 default: Duration,
352) -> Result<Duration, ConnectorError> {
353 match props.get(key) {
354 Some(s) => parse_duration_str(s).ok_or_else(|| {
355 ConnectorError::ConfigurationError(format!("invalid duration for {key}: '{s}'"))
356 }),
357 None => Ok(default),
358 }
359}
360
361fn parse_usize(
362 props: &HashMap<String, String>,
363 key: &str,
364 default: usize,
365) -> Result<usize, ConnectorError> {
366 match props.get(key) {
367 Some(s) => s
368 .parse()
369 .map_err(|e| ConnectorError::ConfigurationError(format!("invalid {key}: {e}"))),
370 None => Ok(default),
371 }
372}
373
374fn parse_bool(
375 props: &HashMap<String, String>,
376 key: &str,
377 default: bool,
378) -> Result<bool, ConnectorError> {
379 match props.get(key) {
380 Some(s) => match s.to_lowercase().as_str() {
381 "true" | "1" | "yes" => Ok(true),
382 "false" | "0" | "no" => Ok(false),
383 other => Err(ConnectorError::ConfigurationError(format!(
384 "invalid boolean for {key}: '{other}'"
385 ))),
386 },
387 None => Ok(default),
388 }
389}
390#[cfg(test)]
391mod tests;