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 #[cfg(feature = "fpml")]
163 if format == FileFormat::Fpml {
164 return Err(ConnectorError::ConfigurationError(
165 "the file sink does not write FpML; write projected rows as parquet, arrow, csv, or json".into(),
166 ));
167 }
168
169 if props.contains_key("mode") {
170 return Err(ConnectorError::ConfigurationError(
171 "file sink option 'mode' was removed; files are always published as immutable rolling files"
172 .into(),
173 ));
174 }
175
176 let prefix = props
177 .get("prefix")
178 .cloned()
179 .unwrap_or_else(|| "part".to_string());
180 if prefix.is_empty()
181 || prefix == "."
182 || prefix == ".."
183 || prefix.contains('/')
184 || prefix.contains('\\')
185 {
186 return Err(ConnectorError::ConfigurationError(
187 "file sink 'prefix' must be a non-empty file-name component".into(),
188 ));
189 }
190
191 let max_file_size = props
192 .get("max_file_size")
193 .map(|s| {
194 s.parse::<usize>().map_err(|e| {
195 ConnectorError::ConfigurationError(format!("invalid max_file_size: {e}"))
196 })
197 })
198 .transpose()?;
199
200 let compression = props
201 .get("compression")
202 .cloned()
203 .unwrap_or_else(|| "snappy".to_string());
204
205 if props.contains_key("max_epoch_batches") {
206 return Err(ConnectorError::ConfigurationError(
207 "file sink option 'max_epoch_batches' was replaced by 'max_buffered_batches'"
208 .into(),
209 ));
210 }
211 let max_buffered_batches = props
212 .get("max_buffered_batches")
213 .map(|s| {
214 s.parse::<usize>().map_err(|e| {
215 ConnectorError::ConfigurationError(format!("invalid max_buffered_batches: {e}"))
216 })
217 })
218 .transpose()?
219 .unwrap_or(10_000);
220 if max_buffered_batches == 0 {
221 return Err(ConnectorError::ConfigurationError(
222 "max_buffered_batches must be > 0".into(),
223 ));
224 }
225
226 Ok(Self {
227 path,
228 format,
229 prefix,
230 max_file_size,
231 compression,
232 max_buffered_batches,
233 })
234 }
235}
236
237pub(super) fn local_files_path(path: &str) -> Result<String, ConnectorError> {
238 if path.trim().is_empty() {
239 return Err(ConnectorError::ConfigurationError(
240 "file connector 'path' must not be empty".into(),
241 ));
242 }
243 if std::path::Path::new(path).is_absolute() || !path.contains("://") {
244 return Ok(path.to_string());
245 }
246 let location = match StorageLocation::parse(path) {
247 Ok(location) => location,
248 Err(StorageLocationError::UnsupportedScheme(_)) => return Err(remote_files_error()),
249 Err(error) => {
250 return Err(ConnectorError::ConfigurationError(format!(
251 "invalid file connector path: {error}"
252 )))
253 }
254 };
255 if location.provider != StorageProvider::Local {
256 return Err(remote_files_error());
257 }
258 let adapted = location
259 .adapt(StorageConsumer::ObjectStore)
260 .map_err(|error| ConnectorError::ConfigurationError(error.to_string()))?;
261 let url = url::Url::parse(&adapted.url).map_err(|error| {
262 ConnectorError::ConfigurationError(format!("invalid canonical file URL: {error}"))
263 })?;
264 url.to_file_path()
265 .map_err(|()| {
266 ConnectorError::ConfigurationError(
267 "file connector URL must identify an absolute local path".into(),
268 )
269 })?
270 .into_os_string()
271 .into_string()
272 .map_err(|_| {
273 ConnectorError::ConfigurationError(
274 "file connector path cannot be represented as UTF-8".into(),
275 )
276 })
277}
278
279fn remote_files_error() -> ConnectorError {
280 ConnectorError::FeatureUnsupported(
281 "remote Files connector backends are not enabled; use a local path or absolute file:// URL"
282 .into(),
283 )
284}
285
286#[derive(Debug, Clone, Copy, PartialEq, Eq)]
288pub enum FileFormat {
289 Csv,
291 Json,
293 Text,
295 Parquet,
297 ArrowIpc,
299 #[cfg(feature = "fpml")]
301 Fpml,
302}
303
304impl FileFormat {
305 pub fn parse(s: &str) -> Result<Self, ConnectorError> {
311 match s.to_lowercase().as_str() {
312 "csv" | "tsv" => Ok(Self::Csv),
313 "json" | "jsonl" | "ndjson" | "json_lines" => Ok(Self::Json),
314 "text" | "txt" | "plain" => Ok(Self::Text),
315 "parquet" | "parq" => Ok(Self::Parquet),
316 "arrow" | "ipc" | "arrow_ipc" => Ok(Self::ArrowIpc),
317 #[cfg(feature = "fpml")]
318 "fpml" => Ok(Self::Fpml),
319 other => Err(ConnectorError::ConfigurationError(format!(
320 "unknown file format: '{other}' (expected csv, json, text, parquet, or arrow)"
321 ))),
322 }
323 }
324
325 pub fn from_extension(path: &str) -> Option<Self> {
327 let ext = path.rsplit('.').next()?.to_lowercase();
328 match ext.as_str() {
329 "csv" | "tsv" => Some(Self::Csv),
330 "json" | "jsonl" | "ndjson" => Some(Self::Json),
331 "txt" | "log" => Some(Self::Text),
332 "parquet" | "parq" => Some(Self::Parquet),
333 "arrow" | "ipc" => Some(Self::ArrowIpc),
334 _ => None,
335 }
336 }
337
338 #[must_use]
340 pub fn extension(&self) -> &'static str {
341 match self {
342 Self::Csv => "csv",
343 Self::Json => "jsonl",
344 Self::Text => "txt",
345 Self::Parquet => "parquet",
346 Self::ArrowIpc => "arrow",
347 #[cfg(feature = "fpml")]
348 Self::Fpml => "xml",
349 }
350 }
351
352 #[must_use]
354 pub fn is_bulk_format(&self) -> bool {
355 matches!(self, Self::Parquet | Self::ArrowIpc)
356 }
357}
358
359fn parse_duration(
362 props: &HashMap<String, String>,
363 key: &str,
364 default: Duration,
365) -> Result<Duration, ConnectorError> {
366 match props.get(key) {
367 Some(s) => parse_duration_str(s).ok_or_else(|| {
368 ConnectorError::ConfigurationError(format!("invalid duration for {key}: '{s}'"))
369 }),
370 None => Ok(default),
371 }
372}
373
374fn parse_usize(
375 props: &HashMap<String, String>,
376 key: &str,
377 default: usize,
378) -> Result<usize, ConnectorError> {
379 match props.get(key) {
380 Some(s) => s
381 .parse()
382 .map_err(|e| ConnectorError::ConfigurationError(format!("invalid {key}: {e}"))),
383 None => Ok(default),
384 }
385}
386
387fn parse_bool(
388 props: &HashMap<String, String>,
389 key: &str,
390 default: bool,
391) -> Result<bool, ConnectorError> {
392 match props.get(key) {
393 Some(s) => match s.to_lowercase().as_str() {
394 "true" | "1" | "yes" => Ok(true),
395 "false" | "0" | "no" => Ok(false),
396 other => Err(ConnectorError::ConfigurationError(format!(
397 "invalid boolean for {key}: '{other}'"
398 ))),
399 },
400 None => Ok(default),
401 }
402}
403#[cfg(test)]
404mod tests;