1use std::collections::{BTreeMap, HashMap};
16use std::fmt::Write;
17use std::marker::PhantomData;
18use std::sync::Arc;
19use std::time::{Duration, SystemTime, UNIX_EPOCH};
20
21use anyhow::anyhow;
22use async_trait::async_trait;
23use bytes::BytesMut;
24use chrono::{TimeZone, Utc};
25use opendal::{FuturesAsyncWriter, Operator, Writer as OpendalWriter};
26use parquet::arrow::AsyncArrowWriter;
27use parquet::basic::Compression;
28use parquet::file::properties::WriterProperties;
29use risingwave_common::array::arrow::IcebergArrowConvert;
30use risingwave_common::array::arrow::arrow_schema_iceberg::{self, SchemaRef};
31use risingwave_common::array::{Op, StreamChunk};
32use risingwave_common::catalog::Schema;
33use risingwave_pb::id::ExecutorId;
34use serde::Deserialize;
35use serde_json::Value;
36use serde_with::{DisplayFromStr, serde_as};
37use strum_macros::{Display, EnumString};
38use tokio_util::compat::{Compat, FuturesAsyncWriteCompatExt};
39use uuid::Uuid;
40use with_options::WithOptions;
41
42use crate::enforce_secret::EnforceSecret;
43use crate::sink::batching_log_sink::{BatchingLogSinker, BatchingSinkWriter};
44use crate::sink::catalog::SinkEncode;
45use crate::sink::encoder::{
46 JsonEncoder, JsonbHandlingMode, RowEncoder, TimeHandlingMode, TimestampHandlingMode,
47 TimestamptzHandlingMode,
48};
49use crate::sink::{
50 Result, Sink, SinkDecouple, SinkError, SinkFormatDesc, SinkParam, UnknownFields,
51};
52use crate::source::TryFromBTreeMap;
53use crate::with_options::WithOptions;
54
55pub const DEFAULT_ROLLOVER_SECONDS: usize = 10;
56pub const DEFAULT_MAX_ROW_COUNR: usize = 10240;
57
58pub fn default_rollover_seconds() -> usize {
59 DEFAULT_ROLLOVER_SECONDS
60}
61pub fn default_max_row_count() -> usize {
62 DEFAULT_MAX_ROW_COUNR
63}
64#[derive(Debug, Clone)]
70pub struct FileSink<S: OpendalSinkBackend> {
71 pub(crate) op: Operator,
72 pub(crate) path: String,
74 pub(crate) schema: Schema,
76 pub(crate) is_append_only: bool,
77 pub(crate) batching_strategy: BatchingStrategy,
79
80 pub(crate) format_desc: SinkFormatDesc,
82 pub(crate) engine_type: EngineType,
83 pub(crate) unknown_fields: HashMap<String, String>,
84 pub(crate) _marker: PhantomData<S>,
85}
86
87impl<S: OpendalSinkBackend> EnforceSecret for FileSink<S> {}
88
89pub trait OpendalSinkBackend: Send + Sync + 'static + Clone + PartialEq {
106 type Properties: TryFromBTreeMap + UnknownFields + Send + Sync + Clone + WithOptions;
107 const SINK_NAME: &'static str;
108
109 fn from_btreemap(btree_map: BTreeMap<String, String>) -> Result<Self::Properties>;
110 fn new_operator(properties: Self::Properties) -> Result<Operator>;
111 fn get_path(properties: Self::Properties) -> String;
112 fn get_engine_type() -> EngineType;
113 fn get_batching_strategy(properties: Self::Properties) -> BatchingStrategy;
114}
115
116#[derive(Clone, Debug)]
117pub enum EngineType {
118 Gcs,
119 S3,
120 Fs,
121 Azblob,
122 Webhdfs,
123 Snowflake,
124}
125
126impl<S: OpendalSinkBackend> Sink for FileSink<S> {
127 type LogSinker = BatchingLogSinker<OpenDalSinkWriter>;
128
129 const SINK_NAME: &'static str = S::SINK_NAME;
130
131 fn validate_unknown_fields(&self) -> Result<()> {
132 crate::sink::validate_sink_unknown_fields(&self.unknown_fields)
133 }
134
135 fn is_sink_decouple(user_specified: &SinkDecouple) -> Result<bool> {
138 match user_specified {
139 SinkDecouple::Default | SinkDecouple::Enable => Ok(true),
140 SinkDecouple::Disable => Err(SinkError::Config(anyhow!(
141 "File sink can only be created with sink_decouple enabled. Please run `set sink_decouple = true` first."
142 ))),
143 }
144 }
145
146 async fn validate(&self) -> Result<()> {
147 if matches!(self.engine_type, EngineType::Snowflake) {
148 risingwave_common::license::Feature::SnowflakeSink
149 .check_available()
150 .map_err(|e| anyhow::anyhow!(e))?;
151 }
152 if !self.is_append_only {
153 return Err(SinkError::Config(anyhow!(
154 "File sink only supports append-only mode at present. \
155 Please change the query to append-only, and specify it \
156 explicitly after the `FORMAT ... ENCODE ...` statement. \
157 For example, `FORMAT xxx ENCODE xxx(force_append_only='true')`"
158 )));
159 }
160
161 if self.format_desc.encode != SinkEncode::Parquet
162 && self.format_desc.encode != SinkEncode::Json
163 {
164 return Err(SinkError::Config(anyhow!(
165 "File sink only supports `PARQUET` and `JSON` encode at present."
166 )));
167 }
168
169 match self.op.list(&self.path).await {
170 Ok(_) => Ok(()),
171 Err(e) => Err(anyhow!(e).into()),
172 }
173 }
174
175 async fn new_log_sinker(
176 &self,
177 writer_param: crate::sink::SinkWriterParam,
178 ) -> Result<Self::LogSinker> {
179 let writer = OpenDalSinkWriter::new(
180 self.op.clone(),
181 &self.path,
182 self.schema.clone(),
183 writer_param.executor_id,
184 &self.format_desc,
185 self.engine_type.clone(),
186 self.batching_strategy.clone(),
187 )?;
188 Ok(BatchingLogSinker::new(writer))
189 }
190}
191
192impl<S: OpendalSinkBackend> TryFrom<SinkParam> for FileSink<S> {
193 type Error = SinkError;
194
195 fn try_from(param: SinkParam) -> std::result::Result<Self, Self::Error> {
196 let schema = param.schema();
197 let config = S::from_btreemap(param.properties)?;
198 let unknown_fields = crate::sink::UnknownFields::unknown_fields(&config);
199 let path = S::get_path(config.clone());
200 let op = S::new_operator(config.clone())?;
201 let batching_strategy = S::get_batching_strategy(config);
202 let engine_type = S::get_engine_type();
203 let format_desc = match param.format_desc {
204 Some(desc) => desc,
205 None => {
206 if let EngineType::Snowflake = engine_type {
207 SinkFormatDesc::plain_json_for_snowflake_only()
208 } else {
209 return Err(SinkError::Config(anyhow!("missing FORMAT ... ENCODE ...")));
210 }
211 }
212 };
213 Ok(Self {
214 op,
215 path,
216 schema,
217 is_append_only: param.sink_type.is_append_only(),
218 batching_strategy,
219 format_desc,
220 engine_type,
221 unknown_fields,
222 _marker: PhantomData,
223 })
224 }
225}
226
227pub struct OpenDalSinkWriter {
228 schema: SchemaRef,
229 operator: Operator,
230 sink_writer: Option<FileWriterEnum>,
231 write_path: String,
232 executor_id: ExecutorId,
233 unique_writer_id: Uuid,
234 encode_type: SinkEncode,
235 row_encoder: JsonEncoder,
236 engine_type: EngineType,
237 pub(crate) batching_strategy: BatchingStrategy,
238 current_bached_row_num: usize,
239 created_time: SystemTime,
240 file_seq: u64,
241}
242
243enum FileWriterEnum {
255 ParquetFileWriter(AsyncArrowWriter<Compat<FuturesAsyncWriter>>),
256 FileWriter(OpendalWriter),
257}
258
259#[async_trait]
260impl BatchingSinkWriter for OpenDalSinkWriter {
261 async fn write_batch(&mut self, chunk: StreamChunk) -> Result<()> {
262 if self.sink_writer.is_none() {
263 assert_eq!(self.current_bached_row_num, 0);
264 self.create_sink_writer().await?;
265 };
266 self.append_only(chunk).await?;
267 Ok(())
268 }
269
270 async fn try_commit(&mut self) -> Result<bool> {
271 if self.can_commit() {
272 return self.commit().await;
273 }
274 Ok(false)
275 }
276
277 async fn commit_on_barrier(&mut self) -> Result<bool> {
279 Ok(self.try_commit().await? || self.sink_writer.is_none())
280 }
281}
282
283impl OpenDalSinkWriter {
285 fn can_commit(&self) -> bool {
286 self.duration_seconds_since_writer_created() >= self.batching_strategy.rollover_seconds
287 || self.current_bached_row_num >= self.batching_strategy.max_row_count
288 }
289
290 async fn commit(&mut self) -> Result<bool> {
292 if let Some(sink_writer) = self.sink_writer.take() {
293 match sink_writer {
294 FileWriterEnum::ParquetFileWriter(w) => {
295 let bytes_written = w.bytes_written();
296 if bytes_written > 0 {
297 w.close().await?;
298 tracing::debug!(
299 "writer {} (executor_id: {}, created_time: {}) finish write file, bytes_written: {}",
300 self.unique_writer_id,
301 self.executor_id,
302 self.created_time
303 .duration_since(UNIX_EPOCH)
304 .expect("Time went backwards")
305 .as_secs(),
306 bytes_written
307 );
308 }
309 }
310 FileWriterEnum::FileWriter(mut w) => {
311 w.close().await?;
312 }
313 };
314 self.current_bached_row_num = 0;
315 return Ok(true);
316 }
317 Ok(false)
318 }
319
320 fn path_partition_prefix(&self, duration: &Duration) -> String {
321 let datetime = Utc
322 .timestamp_opt(duration.as_secs() as i64, 0)
323 .single()
324 .expect("Failed to convert timestamp to DateTime<Utc>")
325 .with_timezone(&Utc);
326 let path_partition_prefix = self
327 .batching_strategy
328 .path_partition_prefix
329 .as_ref()
330 .unwrap_or(&PathPartitionPrefix::None);
331 match path_partition_prefix {
332 PathPartitionPrefix::None => "".to_owned(),
333 PathPartitionPrefix::Day => datetime.format("%Y-%m-%d/").to_string(),
334 PathPartitionPrefix::Month => datetime.format("/%Y-%m/").to_string(),
335 PathPartitionPrefix::Hour => datetime.format("/%Y-%m-%d %H:00/").to_string(),
336 }
337 }
338
339 fn duration_seconds_since_writer_created(&self) -> usize {
340 let now = SystemTime::now();
341 now.duration_since(self.created_time)
342 .expect("Time went backwards")
343 .as_secs() as usize
344 }
345
346 async fn append_only(&mut self, chunk: StreamChunk) -> Result<()> {
348 match self
349 .sink_writer
350 .as_mut()
351 .ok_or_else(|| SinkError::File("Sink writer is not created.".to_owned()))?
352 {
353 FileWriterEnum::ParquetFileWriter(w) => {
354 let batch =
355 IcebergArrowConvert.to_record_batch(self.schema.clone(), chunk.data_chunk())?;
356 let batch_row_nums = batch.num_rows();
357 w.write(&batch).await?;
358 self.current_bached_row_num += batch_row_nums;
359 }
360 FileWriterEnum::FileWriter(w) => {
361 let mut chunk_buf = BytesMut::new();
362 let batch_row_nums = chunk.data_chunk().capacity();
363 for (op, row) in chunk.rows() {
365 assert_eq!(op, Op::Insert, "expect all `op(s)` to be `Op::Insert`");
366 writeln!(
369 chunk_buf,
370 "{}",
371 Value::Object(self.row_encoder.encode(row)?)
372 )
373 .unwrap(); }
375 w.write(chunk_buf.freeze()).await?;
376 self.current_bached_row_num += batch_row_nums;
377 }
378 }
379 Ok(())
380 }
381}
382
383impl OpenDalSinkWriter {
385 pub fn new(
386 operator: Operator,
387 write_path: &str,
388 rw_schema: Schema,
389 executor_id: ExecutorId,
390 format_desc: &SinkFormatDesc,
391 engine_type: EngineType,
392 batching_strategy: BatchingStrategy,
393 ) -> Result<Self> {
394 let arrow_schema = convert_rw_schema_to_arrow_schema(rw_schema.clone())?;
395 let jsonb_handling_mode = JsonbHandlingMode::from_options(&format_desc.options)?;
396 let row_encoder = JsonEncoder::new(
397 rw_schema,
398 None,
399 crate::sink::encoder::DateHandlingMode::String,
400 TimestampHandlingMode::String,
401 TimestamptzHandlingMode::UtcString,
402 TimeHandlingMode::String,
403 jsonb_handling_mode,
404 );
405 Ok(Self {
406 schema: Arc::new(arrow_schema),
407 write_path: write_path.to_owned(),
408 operator,
409 sink_writer: None,
410 executor_id,
411 unique_writer_id: Uuid::now_v7(),
412 encode_type: format_desc.encode.clone(),
413 row_encoder,
414 engine_type,
415 batching_strategy,
416 current_bached_row_num: 0,
417 created_time: SystemTime::now(),
418 file_seq: 0,
419 })
420 }
421
422 async fn create_object_writer(&mut self) -> Result<OpendalWriter> {
423 let suffix = match self.encode_type {
425 SinkEncode::Parquet => "parquet",
426 SinkEncode::Json => "json",
427 _ => unimplemented!(),
428 };
429
430 let create_time = self
431 .created_time
432 .duration_since(UNIX_EPOCH)
433 .expect("Time went backwards");
434
435 let object_name = {
443 let base_path = match self.engine_type {
444 EngineType::Fs => "".to_owned(),
445 EngineType::Snowflake if self.write_path.is_empty() => "".to_owned(),
446 _ => format!("{}/", self.write_path),
447 };
448 let current_file_seq = self.file_seq;
449 self.file_seq = self.file_seq.checked_add(1).expect("file seq overflow");
450
451 format!(
452 "{}{}{}_{}_{}.{}",
453 base_path,
454 self.path_partition_prefix(&create_time),
455 self.unique_writer_id,
456 create_time.as_secs(),
457 current_file_seq,
458 suffix,
459 )
460 };
461 Ok(self
462 .operator
463 .writer_with(&object_name)
464 .concurrent(8)
465 .await?)
466 }
467
468 async fn create_sink_writer(&mut self) -> Result<()> {
469 self.created_time = SystemTime::now();
471
472 let object_writer = self.create_object_writer().await?;
473 match self.encode_type {
474 SinkEncode::Parquet => {
475 let props = WriterProperties::builder().set_compression(Compression::SNAPPY);
476 let parquet_writer: tokio_util::compat::Compat<opendal::FuturesAsyncWriter> =
477 object_writer.into_futures_async_write().compat_write();
478 self.sink_writer = Some(FileWriterEnum::ParquetFileWriter(
479 AsyncArrowWriter::try_new(
480 parquet_writer,
481 self.schema.clone(),
482 Some(props.build()),
483 )?,
484 ));
485 }
486 _ => {
487 self.sink_writer = Some(FileWriterEnum::FileWriter(object_writer));
488 }
489 }
490 self.current_bached_row_num = 0;
491
492 Ok(())
493 }
494}
495
496fn convert_rw_schema_to_arrow_schema(
497 rw_schema: risingwave_common::catalog::Schema,
498) -> anyhow::Result<arrow_schema_iceberg::Schema> {
499 let mut schema_fields = HashMap::new();
500 rw_schema.fields.iter().for_each(|field| {
501 let res = schema_fields.insert(&field.name, &field.data_type);
502 assert!(res.is_none())
504 });
505 let mut arrow_fields = vec![];
506 for rw_field in &rw_schema.fields {
507 let arrow_field = IcebergArrowConvert
508 .to_arrow_field(&rw_field.name.clone(), &rw_field.data_type.clone())?;
509
510 arrow_fields.push(arrow_field);
511 }
512
513 Ok(arrow_schema_iceberg::Schema::new(arrow_fields))
514}
515
516#[serde_as]
528#[derive(Default, Deserialize, Debug, Clone, WithOptions)]
529pub struct BatchingStrategy {
530 #[serde(default = "default_max_row_count")]
531 #[serde_as(as = "DisplayFromStr")]
532 pub max_row_count: usize,
533 #[serde(default = "default_rollover_seconds")]
534 #[serde_as(as = "DisplayFromStr")]
535 pub rollover_seconds: usize,
536 #[serde(default)]
537 #[serde_as(as = "Option<DisplayFromStr>")]
538 pub path_partition_prefix: Option<PathPartitionPrefix>,
539}
540
541#[derive(Default, Debug, Clone, PartialEq, Display, Deserialize, EnumString)]
549#[strum(serialize_all = "snake_case")]
550pub enum PathPartitionPrefix {
551 #[default]
552 None = 0,
553 #[serde(alias = "day")]
554 Day = 1,
555 #[serde(alias = "month")]
556 Month = 2,
557 #[serde(alias = "hour")]
558 Hour = 3,
559}
560
561#[cfg(test)]
562mod tests {
563 use super::*;
564 use crate::sink::file_sink::fs::FsSink;
565 use crate::sink::file_sink::gcs::GcsSink;
566 use crate::sink::file_sink::s3::{S3Sink, SnowflakeSink};
567
568 #[test]
569 fn test_requires_sink_decouple() {
570 fn assert_requires_decouple<S: OpendalSinkBackend>() {
571 assert!(FileSink::<S>::is_sink_decouple(&SinkDecouple::Default).unwrap());
572 assert!(FileSink::<S>::is_sink_decouple(&SinkDecouple::Enable).unwrap());
573 let err = FileSink::<S>::is_sink_decouple(&SinkDecouple::Disable).unwrap_err();
574 assert!(
575 err.to_string()
576 .contains("File sink can only be created with sink_decouple enabled")
577 );
578 }
579
580 assert_requires_decouple::<FsSink>();
581 assert_requires_decouple::<GcsSink>();
582 assert_requires_decouple::<S3Sink>();
583 assert_requires_decouple::<SnowflakeSink>();
584 }
585}