use std::collections::HashMap;
use std::path::PathBuf;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use faucet_core::shard::{HashShard, ShardSpec, parse_hash_shard, plan_hash_shards};
use faucet_core::{FaucetError, Stream, StreamPage};
use futures::{StreamExt, TryStreamExt, stream};
use object_store::ObjectStore;
use object_store::aws::AmazonS3Builder;
use object_store::path::Path as ObjectPath;
use parquet::arrow::ProjectionMask;
use parquet::arrow::async_reader::{ParquetObjectReader, ParquetRecordBatchStreamBuilder};
use serde_json::Value;
use crate::config::{ParquetLocation, ParquetS3Config, ParquetSourceConfig};
use crate::convert::record_batch_to_json;
pub struct ParquetSource {
config: ParquetSourceConfig,
s3_store: Option<Arc<dyn ObjectStore>>,
applied_shard: Mutex<Option<HashShard>>,
}
impl ParquetSource {
pub async fn new(config: ParquetSourceConfig) -> Result<Self, FaucetError> {
if config.concurrency == 0 {
return Err(FaucetError::Config(
"parquet source: concurrency must be > 0".into(),
));
}
let s3_store = match &config.source {
ParquetLocation::S3(s3) => Some(build_s3_store(s3)?),
_ => None,
};
Ok(Self {
config,
s3_store,
applied_shard: Mutex::new(None),
})
}
async fn resolve_all_files(
&self,
context: &HashMap<String, Value>,
) -> Result<Vec<FileTarget>, FaucetError> {
match &self.config.source {
ParquetLocation::LocalPath { path } => {
let resolved = substitute(path, context);
Ok(vec![FileTarget::Local(PathBuf::from(resolved))])
}
ParquetLocation::Glob { pattern } => {
let resolved = substitute(pattern, context);
expand_glob(&resolved)
}
ParquetLocation::S3(s3) => self.resolve_s3_files(s3, context).await,
}
}
async fn resolve_files(
&self,
context: &HashMap<String, Value>,
) -> Result<Vec<FileTarget>, FaucetError> {
Ok(self.shard_filter(self.resolve_all_files(context).await?))
}
fn shard_filter(&self, targets: Vec<FileTarget>) -> Vec<FileTarget> {
match *self.applied_shard.lock().expect("shard mutex poisoned") {
Some(member) => targets
.into_iter()
.filter(|t| member.contains(&t.display()))
.collect(),
None => targets,
}
}
async fn resolve_s3_files(
&self,
s3: &ParquetS3Config,
context: &HashMap<String, Value>,
) -> Result<Vec<FileTarget>, FaucetError> {
match (&s3.key, &s3.prefix) {
(Some(_), Some(_)) => Err(FaucetError::Config(
"parquet source: S3 config cannot set both `key` and `prefix`".into(),
)),
(None, None) => Err(FaucetError::Config(
"parquet source: S3 config requires one of `key` or `prefix`".into(),
)),
(Some(key), None) => {
let key = substitute(key, context);
Ok(vec![FileTarget::S3(ObjectPath::from(key))])
}
(None, Some(prefix)) => {
let prefix = substitute(prefix, context);
let store = self.s3_store.as_ref().ok_or_else(|| {
FaucetError::Source("parquet source: S3 store not initialised".into())
})?;
list_s3_prefix(store.as_ref(), &prefix).await
}
}
}
async fn read_file(&self, target: &FileTarget) -> Result<FileOutput, FaucetError> {
let display = target.display();
match target {
FileTarget::Local(path) => {
let file = tokio::fs::File::open(path).await.map_err(|e| {
FaucetError::Source(format!("failed to open parquet file '{display}': {e}"))
})?;
self.decode(file, &display).await
}
FileTarget::S3(path) => {
let store = self.s3_store.as_ref().ok_or_else(|| {
FaucetError::Source("parquet source: S3 store not initialised".into())
})?;
let reader = ParquetObjectReader::new(store.clone(), path.clone());
self.decode(reader, &display).await
}
}
}
async fn decode<R>(&self, reader: R, display: &str) -> Result<FileOutput, FaucetError>
where
R: parquet::arrow::async_reader::AsyncFileReader + Send + Unpin + 'static,
{
let (mut batches, arrow_schema) = self.build_batch_stream(reader, display).await?;
let mut rows: Vec<Value> = Vec::new();
while let Some(batch) = batches.next().await {
let batch = batch.map_err(|e| {
FaucetError::Source(format!("parquet decode error in '{display}': {e}"))
})?;
let batch_rows = record_batch_to_json(&batch)?;
rows.extend(batch_rows);
}
Ok(FileOutput {
path: display.to_string(),
rows,
arrow_schema,
})
}
async fn build_batch_stream<R>(
&self,
reader: R,
display: &str,
) -> Result<(BatchStream, arrow::datatypes::SchemaRef), FaucetError>
where
R: parquet::arrow::async_reader::AsyncFileReader + Send + Unpin + 'static,
{
let mut builder = ParquetRecordBatchStreamBuilder::new(reader)
.await
.map_err(|e| {
FaucetError::Source(format!(
"failed to read parquet metadata for '{display}': {e}"
))
})?;
if self.config.batch_size > 0 {
builder = builder.with_batch_size(self.config.batch_size);
}
if let Some(cols) = self.config.columns.as_deref() {
let parquet_schema = builder.parquet_schema();
validate_projection(cols, parquet_schema, display)?;
let mask = ProjectionMask::columns(parquet_schema, cols.iter().map(String::as_str));
builder = builder.with_projection(mask);
}
let arrow_schema = builder.schema().clone();
let stream = builder.build().map_err(|e| {
FaucetError::Source(format!(
"failed to build parquet stream for '{display}': {e}"
))
})?;
Ok((Box::pin(stream), arrow_schema))
}
async fn open_target_stream(
&self,
target: &FileTarget,
) -> Result<(BatchStream, arrow::datatypes::SchemaRef, String), FaucetError> {
let display = target.display();
match target {
FileTarget::Local(path) => {
let file = tokio::fs::File::open(path).await.map_err(|e| {
FaucetError::Source(format!("failed to open parquet file '{display}': {e}"))
})?;
let (stream, schema) = self.build_batch_stream(file, &display).await?;
Ok((stream, schema, display))
}
FileTarget::S3(path) => {
let store = self.s3_store.as_ref().ok_or_else(|| {
FaucetError::Source("parquet source: S3 store not initialised".into())
})?;
let reader = ParquetObjectReader::new(store.clone(), path.clone());
let (stream, schema) = self.build_batch_stream(reader, &display).await?;
Ok((stream, schema, display))
}
}
}
}
type BatchStream =
Pin<Box<dyn futures::Stream<Item = parquet::errors::Result<arrow::array::RecordBatch>> + Send>>;
#[async_trait]
impl faucet_core::Source for ParquetSource {
async fn fetch_with_context(
&self,
context: &HashMap<String, Value>,
) -> Result<Vec<Value>, FaucetError> {
let targets = self.resolve_files(context).await?;
tracing::info!(files = targets.len(), "Parquet source resolved files");
if targets.is_empty() {
return Ok(Vec::new());
}
let concurrency = self.config.concurrency.max(1);
let outputs: Vec<FileOutput> = stream::iter(targets)
.map(|target| async move {
let out = self.read_file(&target).await?;
tracing::debug!(file = %out.path, rows = out.rows.len(), "Parquet file decoded");
Ok::<FileOutput, FaucetError>(out)
})
.buffered(concurrency)
.try_collect()
.await?;
if outputs.len() > 1 {
let first = &outputs[0];
for other in &outputs[1..] {
if first.arrow_schema != other.arrow_schema {
return Err(FaucetError::Source(schema_mismatch_message(first, other)));
}
}
}
let total: usize = outputs.iter().map(|o| o.rows.len()).sum();
let mut all = Vec::with_capacity(total);
for out in outputs {
all.extend(out.rows);
}
tracing::info!(total_records = all.len(), "Parquet source fetch complete");
Ok(all)
}
fn stream_pages<'a>(
&'a self,
context: &'a HashMap<String, Value>,
_batch_size: usize,
) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
Box::pin(async_stream::try_stream! {
let all_targets = self.resolve_all_files(context).await?;
let targets = self.shard_filter(all_targets.clone());
tracing::info!(
files = targets.len(),
resolved = all_targets.len(),
"Parquet source resolved files",
);
if all_targets.is_empty() {
return;
}
let mut reference: Option<(String, arrow::datatypes::SchemaRef)> = None;
for target in &all_targets {
let (_, arrow_schema, display) = self.open_target_stream(target).await?;
if let Some((first_path, first_schema)) = &reference {
if first_schema != &arrow_schema {
Err(FaucetError::Source(schema_mismatch_message_pair(
first_path,
first_schema,
&display,
&arrow_schema,
)))?;
}
} else {
reference = Some((display, arrow_schema));
}
}
let mut total_records = 0usize;
let mut total_pages = 0usize;
for target in &targets {
let (mut batches, _schema, display) = self.open_target_stream(target).await?;
while let Some(batch) = batches.next().await {
let batch = batch.map_err(|e| {
FaucetError::Source(format!(
"parquet decode error in '{display}': {e}"
))
})?;
let rows = record_batch_to_json(&batch)?;
if rows.is_empty() {
continue;
}
total_records += rows.len();
total_pages += 1;
yield StreamPage { records: rows, bookmark: None };
}
}
tracing::info!(
pages = total_pages,
total_records,
batch_size = self.config.batch_size,
"Parquet source stream complete",
);
})
}
fn config_schema(&self) -> Value {
serde_json::to_value(faucet_core::schema_for!(ParquetSourceConfig))
.expect("schema serialization")
}
fn dataset_uri(&self) -> String {
use crate::config::ParquetLocation;
match &self.config.source {
ParquetLocation::LocalPath { path } => format!("file://{path}"),
ParquetLocation::Glob { pattern } => format!("file://{pattern}"),
ParquetLocation::S3(s3) => match (&s3.key, &s3.prefix) {
(Some(k), _) => format!("s3://{}/{}", s3.bucket, k),
(_, Some(p)) => format!("s3://{}/{}", s3.bucket, p),
_ => format!("s3://{}", s3.bucket),
},
}
}
fn is_shardable(&self) -> bool {
true
}
async fn enumerate_shards(&self, target: usize) -> Result<Vec<ShardSpec>, FaucetError> {
Ok(plan_hash_shards(target))
}
async fn apply_shard(&self, shard: &ShardSpec) -> Result<(), FaucetError> {
*self.applied_shard.lock().expect("shard mutex poisoned") =
parse_hash_shard(shard, "parquet")?;
Ok(())
}
}
struct FileOutput {
path: String,
rows: Vec<Value>,
arrow_schema: arrow::datatypes::SchemaRef,
}
#[derive(Debug, Clone)]
enum FileTarget {
Local(PathBuf),
S3(ObjectPath),
}
impl FileTarget {
fn display(&self) -> String {
match self {
FileTarget::Local(p) => p.display().to_string(),
FileTarget::S3(p) => format!("s3://{p}"),
}
}
}
fn substitute(template: &str, context: &HashMap<String, Value>) -> String {
if context.is_empty() {
template.to_string()
} else {
faucet_core::util::substitute_context(template, context)
}
}
fn expand_glob(pattern: &str) -> Result<Vec<FileTarget>, FaucetError> {
let entries = glob::glob(pattern)
.map_err(|e| FaucetError::Config(format!("invalid glob '{pattern}': {e}")))?;
let mut paths = Vec::new();
for entry in entries {
let p = entry
.map_err(|e| FaucetError::Source(format!("glob entry error for '{pattern}': {e}")))?;
if p.is_file() {
paths.push(p);
}
}
paths.sort();
Ok(paths.into_iter().map(FileTarget::Local).collect())
}
async fn list_s3_prefix(
store: &dyn ObjectStore,
prefix: &str,
) -> Result<Vec<FileTarget>, FaucetError> {
let prefix_path = if prefix.is_empty() {
None
} else {
Some(ObjectPath::from(prefix))
};
let mut listing = store.list(prefix_path.as_ref());
let mut keys = Vec::new();
while let Some(item) = listing.next().await {
let meta = item.map_err(|e| {
FaucetError::Source(format!("S3 list error for prefix '{prefix}': {e}"))
})?;
keys.push(meta.location);
}
keys.sort();
Ok(keys.into_iter().map(FileTarget::S3).collect())
}
fn build_s3_store(s3: &ParquetS3Config) -> Result<Arc<dyn ObjectStore>, FaucetError> {
if s3.bucket.trim().is_empty() {
return Err(FaucetError::Config(
"parquet source: S3 bucket must not be empty".into(),
));
}
let mut builder = AmazonS3Builder::from_env().with_bucket_name(&s3.bucket);
if let Some(region) = &s3.region {
builder = builder.with_region(region);
}
if let Some(endpoint) = &s3.endpoint_url {
builder = builder.with_endpoint(endpoint);
if endpoint.starts_with("http://") {
builder = builder.with_allow_http(true);
}
}
let store = builder
.build()
.map_err(|e| FaucetError::Config(format!("failed to build S3 client: {e}")))?;
Ok(Arc::new(store))
}
fn validate_projection(
requested: &[String],
parquet_schema: &parquet::schema::types::SchemaDescriptor,
display: &str,
) -> Result<(), FaucetError> {
let root = parquet_schema.root_schema();
let parquet::schema::types::Type::GroupType { fields, .. } = root else {
return Err(FaucetError::Source(format!(
"parquet root schema for '{display}' is not a group"
)));
};
let known: std::collections::HashSet<&str> = fields.iter().map(|f| f.name()).collect();
for name in requested {
if !known.contains(name.as_str()) {
return Err(FaucetError::Source(format!(
"parquet source: projected column '{name}' not found in file '{display}' \
(available: {})",
known.iter().copied().collect::<Vec<_>>().join(", ")
)));
}
}
Ok(())
}
fn schema_mismatch_message(first: &FileOutput, other: &FileOutput) -> String {
schema_mismatch_message_pair(
&first.path,
&first.arrow_schema,
&other.path,
&other.arrow_schema,
)
}
fn schema_mismatch_message_pair(
first_path: &str,
first_schema: &arrow::datatypes::SchemaRef,
other_path: &str,
other_schema: &arrow::datatypes::SchemaRef,
) -> String {
let first_fields: Vec<String> = first_schema
.fields()
.iter()
.map(|f| format!("{}:{}", f.name(), f.data_type()))
.collect();
let other_fields: Vec<String> = other_schema
.fields()
.iter()
.map(|f| format!("{}:{}", f.name(), f.data_type()))
.collect();
let max_len = first_fields.len().max(other_fields.len());
let mut first_diff = None;
for i in 0..max_len {
let a = first_fields
.get(i)
.map(String::as_str)
.unwrap_or("<missing>");
let b = other_fields
.get(i)
.map(String::as_str)
.unwrap_or("<missing>");
if a != b {
first_diff = Some((i, a.to_string(), b.to_string()));
break;
}
}
let detail = match first_diff {
Some((i, a, b)) => format!(" (field #{i}: '{a}' vs '{b}')"),
None => String::new(),
};
format!("parquet source: schema mismatch between '{first_path}' and '{other_path}'{detail}")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::ParquetSourceConfig;
use faucet_core::Source;
#[test]
fn substitute_passes_through_when_context_empty() {
let ctx = HashMap::new();
assert_eq!(substitute("/tmp/{x}.parquet", &ctx), "/tmp/{x}.parquet");
}
#[test]
fn substitute_replaces_placeholders() {
let mut ctx = HashMap::new();
ctx.insert("region".to_string(), Value::String("us".into()));
assert_eq!(
substitute("data/{region}/x.parquet", &ctx),
"data/us/x.parquet"
);
}
#[tokio::test]
async fn accepts_zero_batch_size_as_sentinel() {
let cfg = ParquetSourceConfig::local("/tmp/x.parquet").batch_size(0);
let source = ParquetSource::new(cfg)
.await
.expect("batch_size=0 must be accepted as the no-batching sentinel");
assert_eq!(source.config.batch_size, 0);
}
#[tokio::test]
async fn rejects_zero_concurrency() {
let cfg = ParquetSourceConfig::local("/tmp/x.parquet").concurrency(0);
match ParquetSource::new(cfg).await {
Err(FaucetError::Config(msg)) => assert!(msg.contains("concurrency")),
other => panic!("expected Config error, got {:?}", other.err()),
}
}
#[tokio::test]
async fn rejects_s3_with_both_key_and_prefix() {
let mut s3 = ParquetS3Config::object("b", "k.parquet");
s3.prefix = Some("p/".into());
let cfg = ParquetSourceConfig::s3(s3);
let source = ParquetSource::new(cfg).await.unwrap();
let err = source.resolve_files(&HashMap::new()).await.unwrap_err();
assert!(matches!(err, FaucetError::Config(_)));
}
#[tokio::test]
async fn rejects_s3_with_neither_key_nor_prefix() {
let s3 = ParquetS3Config {
bucket: "b".into(),
key: None,
prefix: None,
region: None,
endpoint_url: None,
};
let cfg = ParquetSourceConfig::s3(s3);
let source = ParquetSource::new(cfg).await.unwrap();
let err = source.resolve_files(&HashMap::new()).await.unwrap_err();
assert!(matches!(err, FaucetError::Config(_)));
}
#[test]
fn empty_bucket_rejected() {
let s3 = ParquetS3Config::object("", "k.parquet");
let err = build_s3_store(&s3).unwrap_err();
assert!(matches!(err, FaucetError::Config(_)));
}
#[tokio::test]
async fn dataset_uri_local_path() {
let cfg = ParquetSourceConfig::local("/tmp/data.parquet");
let source = ParquetSource::new(cfg).await.unwrap();
assert_eq!(source.dataset_uri(), "file:///tmp/data.parquet");
}
#[tokio::test]
async fn dataset_uri_glob() {
let cfg = ParquetSourceConfig::glob("/tmp/data/*.parquet");
let source = ParquetSource::new(cfg).await.unwrap();
assert_eq!(source.dataset_uri(), "file:///tmp/data/*.parquet");
}
#[tokio::test]
async fn dataset_uri_s3_with_key() {
let s3 = ParquetS3Config::object("my-bucket", "path/to/file.parquet");
let cfg = ParquetSourceConfig::s3(s3);
let source = ParquetSource::new(cfg).await.unwrap();
assert_eq!(source.dataset_uri(), "s3://my-bucket/path/to/file.parquet");
}
fn glob_fixture(n: usize) -> tempfile::TempDir {
let dir = tempfile::tempdir().expect("tempdir");
for i in 0..n {
std::fs::write(dir.path().join(format!("part-{i:02}.parquet")), b"").expect("touch");
}
dir
}
#[tokio::test]
async fn shards_partition_resolved_files_disjointly_and_completely() {
let dir = glob_fixture(12);
let pattern = format!("{}/*.parquet", dir.path().display());
let source = ParquetSource::new(ParquetSourceConfig::glob(&pattern))
.await
.unwrap();
assert!(source.is_shardable());
let shards = source.enumerate_shards(3).await.unwrap();
assert_eq!(shards.len(), 3);
let ctx = HashMap::new();
let all: Vec<String> = source
.resolve_files(&ctx)
.await
.unwrap()
.iter()
.map(FileTarget::display)
.collect();
assert_eq!(all.len(), 12);
let mut union: Vec<String> = Vec::new();
for shard in &shards {
source.apply_shard(shard).await.unwrap();
union.extend(
source
.resolve_files(&ctx)
.await
.unwrap()
.iter()
.map(FileTarget::display),
);
}
union.sort();
let mut expected = all.clone();
expected.sort();
assert_eq!(
union, expected,
"shards must union to the full file set, disjointly"
);
}
#[tokio::test]
async fn whole_shard_and_target_one_read_everything() {
let dir = glob_fixture(4);
let pattern = format!("{}/*.parquet", dir.path().display());
let source = ParquetSource::new(ParquetSourceConfig::glob(&pattern))
.await
.unwrap();
let shards = source.enumerate_shards(1).await.unwrap();
assert_eq!(shards.len(), 1);
assert!(shards[0].is_whole());
let ctx = HashMap::new();
source.apply_shard(&shards[0]).await.unwrap();
assert_eq!(source.resolve_files(&ctx).await.unwrap().len(), 4);
}
#[tokio::test]
async fn apply_shard_rejects_malformed_descriptor() {
let source = ParquetSource::new(ParquetSourceConfig::local("/tmp/x.parquet"))
.await
.unwrap();
let bad = faucet_core::ShardSpec::new("0", serde_json::json!({ "index": 0 }));
assert!(source.apply_shard(&bad).await.is_err());
}
}