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