#![cfg(feature = "native")]
use crate::error::{Result, ThundError};
use datafusion::arrow::array::RecordBatch;
use datafusion::arrow::datatypes::Schema;
use datafusion::object_store::{
Error as OsError, ObjectStore, ObjectStoreExt, PutPayload, parse_url, path::Path as StorePath,
};
use datafusion::parquet::arrow::ArrowWriter;
use datafusion::parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
use datafusion::parquet::file::reader::ChunkReader;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
fn cp_err(ctx: &str, e: impl std::fmt::Display) -> ThundError {
ThundError::Backend(format!("checkpoint {ctx}: {e}"))
}
pub(crate) const COMMITTED: &str = "COMMITTED";
const OPERATOR_FILE: &str = "operator-state.parquet";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct CheckpointManifest {
pub epoch: u64,
pub status: String,
pub watermark: Option<i64>,
pub input_maxima: Vec<(String, i64)>,
pub offset: usize,
pub late_rows_dropped: u64,
pub windows_closed: usize,
pub emitted_windows: Vec<(i64, i64)>,
pub operator_files: Vec<String>,
}
impl CheckpointManifest {
pub(crate) fn is_committed(&self) -> bool {
self.status == COMMITTED
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct LatestPointer {
epoch: u64,
}
pub(crate) struct ParquetStateStore {
store: Arc<dyn ObjectStore>,
base: StorePath,
}
impl ParquetStateStore {
pub(crate) fn open(state_root: &str, pipeline: &str, flow_target: &str) -> Result<Self> {
let url = url::Url::parse(state_root)
.map_err(|e| cp_err(&format!("parse state_root `{state_root}`"), e))?;
let (store, prefix) = parse_url(&url).map_err(|e| cp_err("open object store", e))?;
let base = prefix.join(pipeline).join(flow_target);
Ok(Self {
store: Arc::from(store),
base,
})
}
fn epoch_dir(&self, epoch: u64) -> StorePath {
self.base.clone().join(format!("epoch={epoch:06}"))
}
pub(crate) async fn commit(
&self,
mut manifest: CheckpointManifest,
state: &[RecordBatch],
) -> Result<()> {
let dir = self.epoch_dir(manifest.epoch);
let has_state = state.iter().any(|b| b.num_rows() > 0);
manifest.operator_files = if has_state {
let schema = state
.iter()
.find(|b| b.num_rows() > 0)
.map(|b| b.schema())
.unwrap_or_else(|| Arc::new(Schema::empty()));
let bytes = batches_to_parquet(state, schema)?;
let path = dir.clone().join(OPERATOR_FILE);
self.store
.put(&path, PutPayload::from(bytes))
.await
.map_err(|e| cp_err("put operator state", e))?;
vec![OPERATOR_FILE.to_string()]
} else {
Vec::new()
};
manifest.status = COMMITTED.to_string();
let mbytes = serde_json::to_vec(&manifest).map_err(|e| cp_err("encode manifest", e))?;
self.store
.put(
&dir.clone().join("_thund_checkpoint.json"),
PutPayload::from(mbytes),
)
.await
.map_err(|e| cp_err("put manifest", e))?;
let lbytes = serde_json::to_vec(&LatestPointer {
epoch: manifest.epoch,
})
.map_err(|e| cp_err("encode latest", e))?;
self.store
.put(
&self.base.clone().join("_thund_latest.json"),
PutPayload::from(lbytes),
)
.await
.map_err(|e| cp_err("put latest", e))?;
Ok(())
}
pub(crate) async fn latest(&self) -> Result<Option<CheckpointManifest>> {
let latest = match self
.get_json::<LatestPointer>(&self.base.clone().join("_thund_latest.json"))
.await?
{
Some(l) => l,
None => return Ok(None),
};
let manifest_path = self.epoch_dir(latest.epoch).join("_thund_checkpoint.json");
match self.get_json::<CheckpointManifest>(&manifest_path).await? {
Some(m) if m.is_committed() => Ok(Some(m)),
_ => Ok(None), }
}
pub(crate) async fn load_operator_state(
&self,
manifest: &CheckpointManifest,
) -> Result<Vec<RecordBatch>> {
let mut out = Vec::new();
let dir = self.epoch_dir(manifest.epoch);
for f in &manifest.operator_files {
let data = self
.store
.get(&dir.clone().join(f.as_str()))
.await
.map_err(|e| cp_err("get operator state", e))?
.bytes()
.await
.map_err(|e| cp_err("read operator state", e))?;
out.extend(parquet_to_batches(data)?);
}
Ok(out)
}
async fn get_json<T: for<'de> Deserialize<'de>>(&self, path: &StorePath) -> Result<Option<T>> {
match self.store.get(path).await {
Ok(r) => {
let data = r.bytes().await.map_err(|e| cp_err("read json", e))?;
let v = serde_json::from_slice::<T>(&data).map_err(|e| cp_err("decode json", e))?;
Ok(Some(v))
}
Err(OsError::NotFound { .. }) => Ok(None),
Err(e) => Err(cp_err("get json", e)),
}
}
}
fn batches_to_parquet(batches: &[RecordBatch], schema: Arc<Schema>) -> Result<Vec<u8>> {
let mut buf: Vec<u8> = Vec::new();
let mut w = ArrowWriter::try_new(&mut buf, schema, None)
.map_err(|e| cp_err("open parquet writer", e))?;
for b in batches {
if b.num_rows() > 0 {
w.write(b).map_err(|e| cp_err("write parquet", e))?;
}
}
w.close().map_err(|e| cp_err("close parquet", e))?;
Ok(buf)
}
fn parquet_to_batches<R: ChunkReader + 'static>(data: R) -> Result<Vec<RecordBatch>> {
let reader = ParquetRecordBatchReaderBuilder::try_new(data)
.map_err(|e| cp_err("open parquet reader", e))?
.build()
.map_err(|e| cp_err("build parquet reader", e))?;
let mut out = Vec::new();
for b in reader {
out.push(b.map_err(|e| cp_err("read parquet batch", e))?);
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use datafusion::arrow::array::Int64Array;
use datafusion::arrow::datatypes::{DataType, Field};
fn state_batch(ids: &[i64]) -> RecordBatch {
let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int64, false)]));
RecordBatch::try_new(schema, vec![Arc::new(Int64Array::from(ids.to_vec()))]).unwrap()
}
fn rt() -> tokio::runtime::Runtime {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
}
#[test]
fn cold_store_has_no_latest() {
let dir = tempfile::tempdir().unwrap();
let root = format!("file://{}", dir.path().display());
let store = ParquetStateStore::open(&root, "p", "t").unwrap();
rt().block_on(async {
assert!(
store.latest().await.unwrap().is_none(),
"cold store: no latest"
);
});
}
#[test]
fn commit_then_latest_and_restore_roundtrips() {
let dir = tempfile::tempdir().unwrap();
let root = format!("file://{}", dir.path().display());
let store = ParquetStateStore::open(&root, "pipe", "flow").unwrap();
let manifest = CheckpointManifest {
epoch: 7,
status: String::new(), watermark: Some(250),
input_maxima: vec![("events".into(), 300)],
offset: 8,
late_rows_dropped: 3,
windows_closed: 2,
emitted_windows: vec![(0, 100), (100, 200)],
operator_files: Vec::new(),
};
let state = vec![state_batch(&[1, 2, 3])];
rt().block_on(async {
store.commit(manifest.clone(), &state).await.unwrap();
let got = store.latest().await.unwrap().expect("a committed latest");
assert_eq!(got.epoch, 7);
assert!(got.is_committed());
assert_eq!(got.watermark, Some(250));
assert_eq!(got.offset, 8);
assert_eq!(got.late_rows_dropped, 3);
assert_eq!(got.windows_closed, 2);
assert_eq!(got.emitted_windows, vec![(0, 100), (100, 200)]);
assert_eq!(
got.operator_files,
vec!["operator-state.parquet".to_string()]
);
let restored = store.load_operator_state(&got).await.unwrap();
let rows: usize = restored.iter().map(|b| b.num_rows()).sum();
assert_eq!(rows, 3, "operator state restored row-for-row");
});
}
#[test]
fn latest_tracks_newest_and_empty_state_is_ok() {
let dir = tempfile::tempdir().unwrap();
let root = format!("file://{}", dir.path().display());
let store = ParquetStateStore::open(&root, "p", "t").unwrap();
let base = CheckpointManifest {
epoch: 0,
status: String::new(),
watermark: None,
input_maxima: Vec::new(),
offset: 0,
late_rows_dropped: 0,
windows_closed: 0,
emitted_windows: Vec::new(),
operator_files: Vec::new(),
};
rt().block_on(async {
store
.commit(
CheckpointManifest {
epoch: 0,
offset: 1,
..base.clone()
},
&[state_batch(&[1])],
)
.await
.unwrap();
store
.commit(
CheckpointManifest {
epoch: 1,
offset: 2,
..base.clone()
},
&[],
)
.await
.unwrap();
let got = store.latest().await.unwrap().expect("latest");
assert_eq!(got.epoch, 1, "latest is the newest committed epoch");
assert!(
got.operator_files.is_empty(),
"an evicted epoch stores no state"
);
assert!(store.load_operator_state(&got).await.unwrap().is_empty());
});
}
}