risingwave_connector/source/iceberg/
parquet_file_handler.rs1use std::collections::HashMap;
16use std::future::IntoFuture;
17use std::ops::Range;
18use std::pin::Pin;
19use std::sync::Arc;
20
21use anyhow::{Context, bail};
22use bytes::Bytes;
23use futures::future::BoxFuture;
24use futures::{FutureExt, Stream, TryFutureExt};
25use iceberg::io::{
26 FileIOBuilder, FileMetadata, FileRead, S3_ACCESS_KEY_ID, S3_REGION, S3_SECRET_ACCESS_KEY,
27};
28use iceberg_storage_opendal::OpenDalStorageFactory;
29use itertools::Itertools;
30use opendal::Operator;
31use opendal::layers::{LoggingLayer, RetryLayer};
32use opendal::services::{Azblob, Gcs, S3};
33use parquet::arrow::async_reader::AsyncFileReader;
34use parquet::arrow::{ParquetRecordBatchStreamBuilder, ProjectionMask, parquet_to_arrow_schema};
35use parquet::file::metadata::{FileMetaData, ParquetMetaData, ParquetMetaDataReader};
36use prometheus::core::GenericCounter;
37use risingwave_common::array::StreamChunk;
38use risingwave_common::array::arrow::{IcebergArrowConvert, is_parquet_field_match_source_schema};
39use risingwave_common::catalog::{ColumnDesc, ColumnId};
40use risingwave_common::metrics::LabelGuardedMetric;
41use risingwave_common::util::tokio_util::compat::FuturesAsyncReadCompatExt;
42use url::Url;
43
44use crate::error::ConnectorResult;
45use crate::parser::ParquetParser;
46use crate::source::{Column, SourceColumnDesc};
47
48pub struct ParquetFileReader {
49 meta: FileMetadata,
50 r: Box<dyn FileRead>,
51}
52
53impl ParquetFileReader {
54 pub fn new(meta: FileMetadata, r: Box<dyn FileRead>) -> Self {
55 Self { meta, r }
56 }
57}
58
59impl AsyncFileReader for ParquetFileReader {
60 fn get_bytes(&mut self, range: Range<u64>) -> BoxFuture<'_, parquet::errors::Result<Bytes>> {
61 Box::pin(
62 self.r
63 .read(range.start as _..range.end as _)
64 .map_err(|err| parquet::errors::ParquetError::External(Box::new(err))),
65 )
66 }
67
68 fn get_metadata(
69 &mut self,
70 _options: Option<&parquet::arrow::arrow_reader::ArrowReaderOptions>,
71 ) -> BoxFuture<'_, parquet::errors::Result<Arc<ParquetMetaData>>> {
72 async move {
73 let reader = ParquetMetaDataReader::new();
74 let size = self.meta.size;
75 let meta = reader.load_and_finish(self, size).await?;
76
77 Ok(Arc::new(meta))
78 }
79 .boxed()
80 }
81}
82
83pub async fn create_parquet_stream_builder(
84 s3_region: String,
85 s3_access_key: String,
86 s3_secret_key: String,
87 location: String,
88) -> Result<ParquetRecordBatchStreamBuilder<ParquetFileReader>, anyhow::Error> {
89 let mut props = HashMap::new();
90 props.insert(S3_REGION, s3_region.clone());
91 props.insert(S3_ACCESS_KEY_ID, s3_access_key.clone());
92 props.insert(S3_SECRET_ACCESS_KEY, s3_secret_key.clone());
93
94 let file_io = FileIOBuilder::new(Arc::new(OpenDalStorageFactory::s3()))
95 .with_props(props)
96 .build();
97 let parquet_file = file_io.new_input(&location)?;
98
99 let parquet_metadata = parquet_file.metadata().await?;
100 let parquet_reader = parquet_file.reader().await?;
101 let parquet_file_reader = ParquetFileReader::new(parquet_metadata, parquet_reader);
102
103 ParquetRecordBatchStreamBuilder::new(parquet_file_reader)
104 .await
105 .map_err(Into::into)
106}
107
108pub fn new_s3_operator(
109 s3_region: String,
110 s3_access_key: String,
111 s3_secret_key: String,
112 bucket: String,
113 s3_endpoint: String,
114) -> ConnectorResult<Operator> {
115 let mut builder = S3::default();
116 builder = builder
117 .region(&s3_region)
118 .endpoint(&s3_endpoint)
119 .access_key_id(&s3_access_key)
120 .secret_access_key(&s3_secret_key)
121 .bucket(&bucket)
122 .disable_config_load();
123 let op: Operator = Operator::new(builder)?
124 .layer(LoggingLayer::default())
125 .layer(RetryLayer::default());
126
127 Ok(op)
128}
129
130pub fn new_gcs_operator(credential: String, bucket: String) -> ConnectorResult<Operator> {
131 let builder = Gcs::default().bucket(&bucket).credential(&credential);
133
134 let operator: Operator = Operator::new(builder)?
135 .layer(LoggingLayer::default())
136 .layer(RetryLayer::default());
137 Ok(operator)
138}
139
140pub fn new_azblob_operator(
141 endpoint: String,
142 account_name: String,
143 account_key: String,
144 container_name: String,
145) -> ConnectorResult<Operator> {
146 let mut builder = Azblob::default();
148 builder = builder
149 .container(&container_name)
150 .endpoint(&endpoint)
151 .account_name(&account_name)
152 .account_key(&account_key);
153
154 let operator: Operator = Operator::new(builder)?
155 .layer(LoggingLayer::default())
156 .layer(RetryLayer::default());
157 Ok(operator)
158}
159
160#[derive(Debug, Clone)]
161pub enum FileScanBackend {
162 S3,
163 Gcs,
164 Azblob,
165}
166
167pub fn extract_bucket_and_file_name(
168 location: &str,
169 file_scan_backend: &FileScanBackend,
170) -> ConnectorResult<(String, String)> {
171 let url = Url::parse(location)?;
172 let bucket = url
173 .host_str()
174 .with_context(|| format!("Invalid url: {}, missing bucket", location))?
175 .to_owned();
176 let prefix = match file_scan_backend {
177 FileScanBackend::S3 => format!("s3://{}/", bucket),
178 FileScanBackend::Gcs => format!("gcs://{}/", bucket),
179 FileScanBackend::Azblob => format!("azblob://{}/", bucket),
180 };
181 let file_name = location[prefix.len()..].to_string();
182 Ok((bucket, file_name))
183}
184
185pub async fn list_data_directory(
186 op: Operator,
187 dir: String,
188 file_scan_backend: &FileScanBackend,
189) -> Result<Vec<String>, anyhow::Error> {
190 let (bucket, file_name) = extract_bucket_and_file_name(&dir, file_scan_backend)?;
191 let prefix = match file_scan_backend {
192 FileScanBackend::S3 => format!("s3://{}/", bucket),
193 FileScanBackend::Gcs => format!("gcs://{}/", bucket),
194 FileScanBackend::Azblob => format!("azblob://{}/", bucket),
195 };
196 if dir.starts_with(&prefix) {
197 op.list(&file_name).await.map_err(Into::into).map(|list| {
198 list.into_iter()
199 .map(|entry| prefix.clone() + entry.path())
200 .collect()
201 })
202 } else {
203 bail!("Invalid url: {}, should start with {}", dir, prefix)
204 }
205}
206
207pub fn get_project_mask(
224 columns: Option<Vec<Column>>,
225 metadata: &FileMetaData,
226 case_insensitive: bool,
227) -> ConnectorResult<ProjectionMask> {
228 match columns {
229 Some(rw_columns) => {
230 let root_column_names = metadata
231 .schema_descr()
232 .root_schema()
233 .get_fields()
234 .iter()
235 .map(|field| field.name())
236 .collect_vec();
237
238 let converted_arrow_schema =
239 parquet_to_arrow_schema(metadata.schema_descr(), metadata.key_value_metadata())
240 .map_err(anyhow::Error::from)?;
241 let mut lowercase_name_to_index: HashMap<String, Option<usize>> = HashMap::new();
242 if case_insensitive {
243 for (index, name) in root_column_names.iter().enumerate() {
244 let key = name.to_ascii_lowercase();
245 match lowercase_name_to_index.get(&key) {
246 Some(Some(_)) => {
247 lowercase_name_to_index.insert(key, None);
248 }
249 Some(None) => {}
250 None => {
251 lowercase_name_to_index.insert(key, Some(index));
252 }
253 }
254 }
255 }
256
257 let valid_column_indices: Vec<usize> = rw_columns
258 .iter()
259 .filter_map(|column| {
260 let exact_pos = root_column_names
261 .iter()
262 .position(|&name| name == column.name);
263 let pos = match (case_insensitive, exact_pos) {
264 (_, Some(pos)) => Some(pos),
265 (true, None) => lowercase_name_to_index
266 .get(&column.name.to_ascii_lowercase())
267 .copied()
268 .flatten(),
269 _ => None,
270 }?;
271 let arrow_field = converted_arrow_schema.fields.get(pos)?;
272 let rw_data_type: &risingwave_common::types::DataType = &column.data_type;
273 if is_parquet_field_match_source_schema(arrow_field, rw_data_type) {
274 Some(pos)
275 } else {
276 tracing::warn!(
279 column = %column.name,
280 declared_type = %rw_data_type,
281 parquet_type = %arrow_field.data_type(),
282 parquet_extension = ?arrow_field.extension_type_name(),
283 "parquet column does not match the declared source schema; it will be NULL-filled",
284 );
285 None
286 }
287 })
288 .collect();
289
290 Ok(ProjectionMask::roots(
291 metadata.schema_descr(),
292 valid_column_indices,
293 ))
294 }
295 None => Ok(ProjectionMask::all()),
296 }
297}
298
299pub async fn read_parquet_file(
301 op: Operator,
302 file_name: String,
303 rw_columns: Option<Vec<Column>>,
304 parser_columns: Option<Vec<SourceColumnDesc>>,
305 case_insensitive: bool,
306 batch_size: usize,
307 offset: usize,
308 file_source_input_row_count_metrics: Option<
309 LabelGuardedMetric<GenericCounter<prometheus::core::AtomicU64>>,
310 >,
311 parquet_source_skip_row_count_metrics: Option<
312 LabelGuardedMetric<GenericCounter<prometheus::core::AtomicU64>>,
313 >,
314) -> ConnectorResult<
315 Pin<Box<dyn Stream<Item = Result<StreamChunk, crate::error::ConnectorError>> + Send>>,
316> {
317 let mut reader: tokio_util::compat::Compat<opendal::FuturesAsyncReader> = op
318 .reader_with(&file_name)
319 .into_future() .await?
321 .into_futures_async_read(..)
322 .await?
323 .compat();
324 let parquet_metadata = reader
325 .get_metadata(None)
326 .await
327 .map_err(anyhow::Error::from)?;
328
329 let file_metadata = parquet_metadata.file_metadata();
330 {
331 tracing::info!(
333 "Reading parquet file: {}, from offset {}, num_row_groups={}, total_rows={}, kv_len={}",
334 file_name,
335 offset,
336 parquet_metadata.row_groups().len(),
337 file_metadata.num_rows(),
338 file_metadata
339 .key_value_metadata()
340 .map(|m| m.len())
341 .unwrap_or(0)
342 );
343 let schema_descr = file_metadata.schema_descr();
345 for col in schema_descr.columns() {
346 let path = col.path().string();
347 let physical = col.physical_type();
348 let logical = col.logical_type_ref();
349 tracing::debug!(
350 file = %file_name,
351 column_path = path,
352 physical_type = ?physical,
353 logical_type = ?logical,
354 type_length = ?col.type_length(),
355 max_def_level = col.max_def_level(),
356 max_rep_level = col.max_rep_level(),
357 "Parquet file column schema: "
358 );
359 }
360 }
361 let projection_mask = get_project_mask(rw_columns, file_metadata, case_insensitive)?;
362
363 let record_batch_stream = ParquetRecordBatchStreamBuilder::new(reader)
366 .await?
367 .with_batch_size(batch_size)
368 .with_projection(projection_mask)
369 .with_offset(offset)
370 .build()?;
371 let converted_arrow_schema = parquet_to_arrow_schema(
372 file_metadata.schema_descr(),
373 file_metadata.key_value_metadata(),
374 )?;
375 let columns = match parser_columns {
376 Some(columns) => columns,
377 None => converted_arrow_schema
378 .fields
379 .iter()
380 .enumerate()
381 .map(|(index, field_ref)| {
382 let data_type = IcebergArrowConvert
383 .type_from_field(field_ref)
384 .with_context(|| {
385 format!(
386 "cannot infer the RisingWave type of parquet column `{}` in file {}",
387 field_ref.name(),
388 file_name
389 )
390 })?;
391 let column_desc = ColumnDesc::named(
392 field_ref.name().clone(),
393 ColumnId::new(index as i32),
394 data_type,
395 );
396 Ok(SourceColumnDesc::from(&column_desc))
397 })
398 .collect::<ConnectorResult<Vec<_>>>()?,
399 };
400 let parquet_parser = ParquetParser::new(columns, file_name, offset, case_insensitive)?;
401 let msg_stream: Pin<
402 Box<dyn Stream<Item = Result<StreamChunk, crate::error::ConnectorError>> + Send>,
403 > = parquet_parser.into_stream(
404 record_batch_stream,
405 file_source_input_row_count_metrics,
406 parquet_source_skip_row_count_metrics,
407 );
408 Ok(msg_stream)
409}
410
411pub async fn get_parquet_fields(
412 op: Operator,
413 file_name: String,
414) -> ConnectorResult<risingwave_common::array::arrow::arrow_schema_iceberg::Fields> {
415 let mut reader: tokio_util::compat::Compat<opendal::FuturesAsyncReader> = op
416 .reader_with(&file_name)
417 .into_future() .await?
419 .into_futures_async_read(..)
420 .await?
421 .compat();
422 let parquet_metadata = reader.get_metadata(None).await?;
423
424 let file_metadata = parquet_metadata.file_metadata();
425 let converted_arrow_schema = parquet_to_arrow_schema(
426 file_metadata.schema_descr(),
427 file_metadata.key_value_metadata(),
428 )?;
429 let fields: risingwave_common::array::arrow::arrow_schema_iceberg::Fields =
430 converted_arrow_schema.fields;
431 Ok(fields)
432}