use std::path::{Path, PathBuf};
use arrow_array::RecordBatch;
use arrow_schema::DataType;
use super::config::ClickHouseWriterConfig;
use super::dead_letter::{DEAD_LETTER_DIR, DeadLetterError};
fn record_batch_to_json_each_row(batch: &RecordBatch) -> Result<String, DeadLetterError> {
use arrow_array::{
Array, BooleanArray, Float64Array, Int64Array, StringArray, UInt8Array, UInt32Array,
};
let schema = batch.schema();
let num_rows = batch.num_rows();
let num_cols = batch.num_columns();
let mut lines = Vec::with_capacity(num_rows);
for row_idx in 0..num_rows {
let mut obj = serde_json::Map::with_capacity(num_cols);
for col_idx in 0..num_cols {
let field = schema.field(col_idx);
let col = batch.column(col_idx);
if col.is_null(row_idx) {
obj.insert(field.name().clone(), serde_json::Value::Null);
continue;
}
let value = match field.data_type() {
DataType::Utf8 => {
let arr = col.as_any().downcast_ref::<StringArray>().ok_or_else(|| {
DeadLetterError::Arrow(format!("Column {} not Utf8", field.name()))
})?;
serde_json::Value::String(arr.value(row_idx).to_string())
}
DataType::Float64 => {
let arr = col.as_any().downcast_ref::<Float64Array>().ok_or_else(|| {
DeadLetterError::Arrow(format!("Column {} not Float64", field.name()))
})?;
serde_json::json!(arr.value(row_idx))
}
DataType::Int64 => {
let arr = col.as_any().downcast_ref::<Int64Array>().ok_or_else(|| {
DeadLetterError::Arrow(format!("Column {} not Int64", field.name()))
})?;
serde_json::json!(arr.value(row_idx))
}
DataType::UInt32 => {
let arr = col.as_any().downcast_ref::<UInt32Array>().ok_or_else(|| {
DeadLetterError::Arrow(format!("Column {} not UInt32", field.name()))
})?;
serde_json::json!(arr.value(row_idx))
}
DataType::UInt8 => {
let arr = col.as_any().downcast_ref::<UInt8Array>().ok_or_else(|| {
DeadLetterError::Arrow(format!("Column {} not UInt8", field.name()))
})?;
serde_json::json!(arr.value(row_idx))
}
DataType::Boolean => {
let arr = col.as_any().downcast_ref::<BooleanArray>().ok_or_else(|| {
DeadLetterError::Arrow(format!("Column {} not Boolean", field.name()))
})?;
serde_json::json!(arr.value(row_idx))
}
dt => {
return Err(DeadLetterError::Arrow(format!(
"Unsupported Arrow type {:?} for column {}",
dt,
field.name()
)));
}
};
obj.insert(field.name().clone(), value);
}
let json_str = serde_json::to_string(&obj)
.map_err(|e| DeadLetterError::Arrow(format!("JSON serialization: {e}")))?;
lines.push(json_str);
}
Ok(lines.join("\n"))
}
pub fn niffler_replay(
rt: &tokio::runtime::Runtime,
http_client: &reqwest::Client,
config: &ClickHouseWriterConfig,
) -> usize {
niffler_replay_dir(rt, http_client, config, Path::new(DEAD_LETTER_DIR))
}
pub fn niffler_replay_dir(
rt: &tokio::runtime::Runtime,
http_client: &reqwest::Client,
config: &ClickHouseWriterConfig,
dead_letter_dir: &Path,
) -> usize {
if !dead_letter_dir.exists() {
return 0;
}
let mut parquet_files: Vec<PathBuf> = match std::fs::read_dir(dead_letter_dir) {
Ok(entries) => entries
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|ext| ext == "parquet"))
.collect(),
Err(e) => {
tracing::warn!(error = %e, "failed to read dead-letter directory");
return 0;
}
};
parquet_files.sort();
if parquet_files.is_empty() {
return 0;
}
let mut total_rows = 0;
for parquet_path in parquet_files {
let replaying_path = parquet_path.with_extension("parquet.replaying");
if let Err(e) = std::fs::rename(&parquet_path, &replaying_path) {
tracing::warn!(
path = %parquet_path.display(),
error = %e,
"failed to acquire .replaying advisory lock, skipping"
);
continue;
}
let file = match std::fs::File::open(&replaying_path) {
Ok(f) => f,
Err(e) => {
tracing::error!(
path = %replaying_path.display(),
error = %e,
"failed to open .replaying file"
);
let _ = std::fs::rename(&replaying_path, &parquet_path);
break;
}
};
let reader =
match parquet::arrow::arrow_reader::ParquetRecordBatchReader::try_new(file, 8192) {
Ok(r) => r,
Err(e) => {
tracing::error!(
path = %replaying_path.display(),
error = %e,
"failed to create Parquet reader"
);
let _ = std::fs::rename(&replaying_path, &parquet_path);
break;
}
};
let batches: Vec<RecordBatch> = match reader.into_iter().collect::<Result<_, _>>() {
Ok(b) => b,
Err(e) => {
tracing::error!(
path = %replaying_path.display(),
error = %e,
"failed to read Parquet batches"
);
let _ = std::fs::rename(&replaying_path, &parquet_path);
break;
}
};
let batch_row_count: usize = batches.iter().map(|b| b.num_rows()).sum();
if batch_row_count == 0 {
let _ = std::fs::remove_file(&replaying_path);
continue;
}
let mut all_json_lines = Vec::new();
let mut conversion_failed = false;
for batch in &batches {
match record_batch_to_json_each_row(batch) {
Ok(lines) => all_json_lines.push(lines),
Err(e) => {
tracing::error!(
path = %replaying_path.display(),
error = %e,
"failed to convert Parquet batch to JSONEachRow"
);
conversion_failed = true;
break;
}
}
}
if conversion_failed {
let _ = std::fs::rename(&replaying_path, &parquet_path);
break;
}
let body = all_json_lines.join("\n");
let insert_sql = format!(
"INSERT INTO {}.{} FORMAT JSONEachRow",
config.database, config.table
);
let post_result = rt.block_on(async {
http_client
.post(&config.url)
.query(&[
("database", config.database.as_str()),
("query", insert_sql.as_str()),
("wait_end_of_query", "1"),
])
.header("Content-Type", "application/json")
.body(body)
.send()
.await
});
match post_result {
Ok(resp) if resp.status().is_success() => {
let _ = std::fs::remove_file(&replaying_path);
total_rows += batch_row_count;
tracing::info!(
rows = batch_row_count,
path = %parquet_path.display(),
"niffler replayed dead-letter file"
);
}
Ok(resp) => {
let status = resp.status().as_u16();
tracing::warn!(
status,
path = %parquet_path.display(),
"niffler replay POST failed, restoring file"
);
let _ = std::fs::rename(&replaying_path, &parquet_path);
break; }
Err(e) => {
tracing::warn!(
error = %e,
path = %parquet_path.display(),
"niffler replay network error, restoring file"
);
let _ = std::fs::rename(&replaying_path, &parquet_path);
break; }
}
}
total_rows
}
#[cfg(test)]
mod tests {
use super::*;
use crate::clickhouse_writer::dead_letter::dead_letter_schema;
use crate::clickhouse_writer::row::ClickHouseBarRow;
use crate::live_engine::CompletedBar;
use opendeviationbar_core::OpenDeviationBar;
use opendeviationbar_core::fixed_point::FixedPoint;
use std::sync::Arc;
fn test_row(first_tid: i64, last_tid: i64) -> ClickHouseBarRow {
let mut bar = OpenDeviationBar::default();
bar.open = FixedPoint::from_str("50000.0").unwrap();
bar.high = FixedPoint::from_str("50100.0").unwrap();
bar.low = FixedPoint::from_str("49900.0").unwrap();
bar.close = FixedPoint::from_str("50050.0").unwrap();
bar.vwap = FixedPoint::from_str("50025.0").unwrap();
bar.open_time = 1_700_000_000_000_000;
bar.close_time = 1_700_000_100_000_000;
bar.first_agg_trade_id = first_tid;
bar.last_agg_trade_id = last_tid;
bar.individual_trade_count = 100;
bar.agg_record_count = 50;
bar.duration_us = 100_000_000;
bar.lookback_trade_count = Some(200);
bar.lookback_ofi = Some(0.1);
let completed = CompletedBar {
symbol: Arc::from("BTCUSDT"),
threshold_decimal_bps: 250,
bar,
};
ClickHouseBarRow::from_completed_bar(&completed)
}
fn test_dead_letter_dir() -> PathBuf {
let pid = std::process::id();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let dir = std::env::temp_dir().join(format!("opendeviationbar-niffler-test-{pid}-{nanos}"));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn write_test_parquet(dir: &Path, filename: &str, rows: &[ClickHouseBarRow]) -> PathBuf {
let schema = dead_letter_schema();
let path = dir.join(filename);
let batch =
crate::clickhouse_writer::dead_letter::rows_to_record_batch_public(rows, &schema)
.unwrap();
let props = parquet::file::properties::WriterProperties::builder()
.set_compression(parquet::basic::Compression::ZSTD(
parquet::basic::ZstdLevel::try_new(3).unwrap(),
))
.build();
let file = std::fs::File::create(&path).unwrap();
let mut writer =
parquet::arrow::ArrowWriter::try_new(file, std::sync::Arc::new(schema), Some(props))
.unwrap();
writer.write(&batch).unwrap();
writer.close().unwrap();
path
}
fn test_config(url: &str) -> ClickHouseWriterConfig {
ClickHouseWriterConfig {
url: url.to_string(),
max_rows: 500,
flush_period_ms: 60_000,
max_retries: 0,
..Default::default()
}
}
#[test]
fn test_niffler_replay_no_directory() {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let client = reqwest::Client::new();
let config = test_config("http://127.0.0.1:1");
let nonexistent = Path::new("/tmp/opendeviationbar-niffler-nonexistent-dir");
let result = niffler_replay_dir(&rt, &client, &config, nonexistent);
assert_eq!(result, 0);
}
#[test]
fn test_niffler_replay_empty_directory() {
let dir = test_dead_letter_dir();
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let client = reqwest::Client::new();
let config = test_config("http://127.0.0.1:1");
let result = niffler_replay_dir(&rt, &client, &config, &dir);
assert_eq!(result, 0);
let _ = std::fs::remove_dir_all(&dir);
}
fn start_mock_server(status: u16) -> (String, std::thread::JoinHandle<()>) {
let (tx, rx) = std::sync::mpsc::channel();
let handle = std::thread::spawn(move || {
let mock_rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
mock_rt.block_on(async {
let mock_server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.respond_with(wiremock::ResponseTemplate::new(status))
.mount(&mock_server)
.await;
tx.send(mock_server.uri()).unwrap();
tokio::time::sleep(std::time::Duration::from_secs(10)).await;
});
});
let uri = rx.recv().unwrap();
(uri, handle)
}
#[test]
fn test_niffler_replay_success_deletes_file() {
let dir = test_dead_letter_dir();
let rows = vec![test_row(1, 10), test_row(11, 20)];
let parquet_path = write_test_parquet(&dir, "BTCUSDT_250_1000.parquet", &rows);
assert!(parquet_path.exists());
let (uri, _server) = start_mock_server(200);
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let client = reqwest::Client::new();
let config = test_config(&uri);
let result = niffler_replay_dir(&rt, &client, &config, &dir);
assert_eq!(result, 2);
assert!(!parquet_path.exists());
assert!(!parquet_path.with_extension("parquet.replaying").exists());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_niffler_replay_failure_restores_file() {
let dir = test_dead_letter_dir();
let rows = vec![test_row(1, 10)];
let parquet_path = write_test_parquet(&dir, "BTCUSDT_250_2000.parquet", &rows);
assert!(parquet_path.exists());
let (uri, _server) = start_mock_server(503);
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let client = reqwest::Client::new();
let config = test_config(&uri);
let result = niffler_replay_dir(&rt, &client, &config, &dir);
assert_eq!(result, 0);
assert!(parquet_path.exists());
assert!(!parquet_path.with_extension("parquet.replaying").exists());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_niffler_replay_ignores_non_parquet() {
let dir = test_dead_letter_dir();
std::fs::write(dir.join("notes.txt"), "not a parquet file").unwrap();
std::fs::write(dir.join("test.parquet.replaying"), "locked").unwrap();
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let client = reqwest::Client::new();
let config = test_config("http://127.0.0.1:1");
let result = niffler_replay_dir(&rt, &client, &config, &dir);
assert_eq!(result, 0);
assert!(dir.join("notes.txt").exists());
assert!(dir.join("test.parquet.replaying").exists());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_niffler_replay_uses_replaying_extension() {
let dir = test_dead_letter_dir();
let rows = vec![test_row(1, 10)];
let parquet_path = write_test_parquet(&dir, "BTCUSDT_250_3000.parquet", &rows);
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_millis(100))
.build()
.unwrap();
let config = test_config("http://192.0.2.1:1");
let result = niffler_replay_dir(&rt, &client, &config, &dir);
assert_eq!(result, 0);
assert!(parquet_path.exists());
assert!(!parquet_path.with_extension("parquet.replaying").exists());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_record_batch_to_json_each_row() {
let rows = vec![test_row(1, 10)];
let schema = dead_letter_schema();
let batch =
crate::clickhouse_writer::dead_letter::rows_to_record_batch_public(&rows, &schema)
.unwrap();
let json = record_batch_to_json_each_row(&batch).unwrap();
let lines: Vec<&str> = json.lines().collect();
assert_eq!(lines.len(), 1);
let parsed: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
assert_eq!(parsed["symbol"], "BTCUSDT");
assert_eq!(parsed["threshold_decimal_bps"], 250);
assert_eq!(parsed["first_agg_trade_id"], 1);
assert_eq!(parsed["last_agg_trade_id"], 10);
assert_eq!(parsed["lookback_trade_count"], 200);
assert!(parsed["lookback_duration_us"].is_null());
}
}