#![cfg(feature = "parquet")]
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use anyhow::Context as _;
use arrow::array::{ArrayRef, StringBuilder, TimestampMillisecondBuilder};
use arrow::datatypes::{DataType, Field, Schema, TimeUnit};
use arrow::record_batch::RecordBatch;
use async_trait::async_trait;
#[cfg(test)]
use chrono::{DateTime, Utc};
use parquet::arrow::arrow_writer::ArrowWriter;
use parquet::file::properties::WriterProperties;
use pulse_core::{Record, Result, Sink};
use tokio::fs;
#[derive(Clone, Debug)]
pub enum PartitionSpec {
ByDate { field: String, fmt: String },
ByField { field: String },
}
#[derive(Clone, Debug)]
pub struct ParquetSinkConfig {
pub out_dir: PathBuf,
pub partition_by: PartitionSpec,
pub max_rows: usize,
pub max_age: std::time::Duration,
pub compression: Option<String>,
pub max_bytes: Option<usize>,
}
impl Default for ParquetSinkConfig {
fn default() -> Self {
Self {
out_dir: PathBuf::from("./out"),
partition_by: PartitionSpec::ByDate {
field: "event_time".into(),
fmt: "%Y-%m-%d".into(),
},
max_rows: 100_000,
max_age: Duration::from_secs(60),
compression: Some("snappy".into()),
max_bytes: None,
}
}
}
struct ActiveFile {
writer: ArrowWriter<std::fs::File>, started: Instant,
rows: usize,
path: PathBuf,
approx_bytes: usize,
}
pub struct ParquetSink {
cfg: ParquetSinkConfig,
current_part: Option<String>,
active: Option<ActiveFile>,
schema: std::sync::Arc<Schema>,
}
impl ParquetSink {
pub fn new(cfg: ParquetSinkConfig) -> Self {
let fields = vec![
Field::new(
"event_time",
DataType::Timestamp(TimeUnit::Millisecond, None),
false,
),
Field::new("payload", DataType::Utf8, false),
];
let schema = std::sync::Arc::new(Schema::new(fields));
Self {
cfg,
current_part: None,
active: None,
schema,
}
}
fn partition_value(&self, rec: &Record) -> String {
match &self.cfg.partition_by {
PartitionSpec::ByDate { field, fmt } => {
if field == "event_time" {
rec.event_time.format(fmt).to_string()
} else {
let s = rec.value.get(field).and_then(|v| v.as_str()).unwrap_or("");
s.to_string()
}
}
PartitionSpec::ByField { field } => {
if let Some(v) = rec.value.get(field) {
match v {
serde_json::Value::String(s) => s.clone(),
other => other.to_string(),
}
} else {
String::new()
}
}
}
}
async fn ensure_dir(path: &Path) -> Result<()> {
fs::create_dir_all(path).await?;
Ok(())
}
fn build_batch(&self, records: &[Record]) -> Result<RecordBatch> {
let mut tsb = TimestampMillisecondBuilder::new();
let mut payload = StringBuilder::new();
for r in records {
tsb.append_value(r.event_time.timestamp_millis());
payload.append_value(serde_json::to_string(&r.value)?);
}
let arrays: Vec<ArrayRef> = vec![
std::sync::Arc::new(tsb.finish()),
std::sync::Arc::new(payload.finish()),
];
let batch = RecordBatch::try_new(self.schema.clone(), arrays).context("record batch")?;
Ok(batch)
}
fn rotate_needed(&self) -> bool {
if let Some(active) = &self.active {
let by_rows = active.rows >= self.cfg.max_rows;
let by_age = active.started.elapsed() >= self.cfg.max_age;
let by_bytes = self
.cfg
.max_bytes
.map(|b| active.approx_bytes >= b)
.unwrap_or(false);
by_rows || by_age || by_bytes
} else {
true
}
}
fn new_writer(
path: &Path,
schema: std::sync::Arc<Schema>,
compression: Option<&str>,
) -> Result<ActiveFile> {
let file = std::fs::File::create(path)?;
let mut builder = WriterProperties::builder();
match compression.unwrap_or("snappy").to_lowercase().as_str() {
"snappy" => {
builder = builder.set_compression(parquet::basic::Compression::SNAPPY);
}
"zstd" => {
builder = builder.set_compression(parquet::basic::Compression::ZSTD(
parquet::basic::ZstdLevel::default(),
));
}
_ => {
builder = builder.set_compression(parquet::basic::Compression::UNCOMPRESSED);
}
}
let props = builder.build();
let writer = ArrowWriter::try_new(file, schema, Some(props)).map_err(|e| anyhow::anyhow!(e))?;
Ok(ActiveFile {
writer,
started: Instant::now(),
rows: 0,
path: path.to_path_buf(),
approx_bytes: 0,
})
}
fn open_partition(&mut self, part: &str) -> Result<()> {
if self.current_part.as_deref() != Some(part) {
if let Some(active) = self.active.take() {
let _ = active.writer.close();
}
let dir = self.cfg.out_dir.join(format!("dt={}", part));
std::fs::create_dir_all(&dir)?;
let fname = format!("part-{}.parquet", chrono::Utc::now().timestamp_millis());
let path = dir.join(fname);
self.active = Some(Self::new_writer(
&path,
self.schema.clone(),
self.cfg.compression.as_deref(),
)?);
self.current_part = Some(part.to_string());
}
Ok(())
}
fn close_active(&mut self) {
if let Some(active) = self.active.take() {
let _ = active.writer.close();
}
}
}
#[async_trait]
impl Sink for ParquetSink {
async fn on_element(&mut self, record: Record) -> Result<()> {
let part = self.partition_value(&record);
self.open_partition(&part)?;
if self.rotate_needed() {
if let Some(active) = self.active.take() {
let _ = active.writer.close();
}
if let Some(p) = &self.current_part {
let dir = self.cfg.out_dir.join(format!("dt={}", p));
let fname = format!("part-{}.parquet", chrono::Utc::now().timestamp_millis());
let path = dir.join(fname);
self.active = Some(Self::new_writer(
&path,
self.schema.clone(),
self.cfg.compression.as_deref(),
)?);
}
}
let batch = self.build_batch(std::slice::from_ref(&record))?;
if let Some(active) = &mut self.active {
active.writer.write(&batch).map_err(|e| anyhow::anyhow!(e))?;
active.rows += 1;
if let Some(s) = record.value.as_str() {
active.approx_bytes += s.len();
} else {
active.approx_bytes += serde_json::to_string(&record.value)?.len();
}
}
Ok(())
}
async fn on_watermark(&mut self, _wm: pulse_core::Watermark) -> Result<()> {
self.close_active();
Ok(())
}
}
impl Drop for ParquetSink {
fn drop(&mut self) {
self.close_active();
}
}
#[cfg(test)]
mod tests {
use super::*;
use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
fn tmp_dir(prefix: &str) -> PathBuf {
let mut d = std::env::temp_dir();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
d.push(format!("pulse_parquet_test_{}_{}", prefix, nanos));
d
}
#[tokio::test]
async fn writes_and_reads_back() {
let out_dir = tmp_dir("parquet");
let cfg = ParquetSinkConfig {
out_dir: out_dir.clone(),
partition_by: PartitionSpec::ByDate {
field: "event_time".into(),
fmt: "%Y-%m-%d".into(),
},
max_rows: 10,
max_age: Duration::from_secs(60),
compression: Some("snappy".into()),
max_bytes: None,
};
let mut sink = ParquetSink::new(cfg);
let ts = DateTime::<Utc>::from_timestamp(1_700_000_000, 0).unwrap();
for i in 0..3 {
let rec = Record {
event_time: ts,
value: serde_json::json!({"n": i, "s": format!("v{}", i)}),
};
sink.on_element(rec).await.unwrap();
}
if let Some(active) = sink.active.take() {
let _ = active.writer.close();
}
let part_dir = out_dir.join(format!("dt={}", ts.format("%Y-%m-%d")));
let mut total = 0usize;
if let Ok(read_dir) = std::fs::read_dir(&part_dir) {
for e in read_dir.flatten() {
if e.path().extension().map(|s| s == "parquet").unwrap_or(false) {
let file = std::fs::File::open(e.path()).unwrap();
let builder = ParquetRecordBatchReaderBuilder::try_new(file).unwrap();
let mut reader = builder.build().unwrap();
while let Some(batch) = reader.next() {
let batch = batch.unwrap();
total += batch.num_rows();
}
}
}
}
assert_eq!(total, 3);
let _ = std::fs::remove_dir_all(out_dir);
}
}