Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions kernel/src/engine/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ pub use self::arrow_utils::{parse_json, to_json_bytes};

#[cfg(feature = "default-engine-base")]
pub mod default;
#[cfg(feature = "default-engine-base")]
pub mod plan;

#[cfg(test)]
pub(crate) mod sync;
Expand Down
86 changes: 86 additions & 0 deletions kernel/src/engine/plan/engine.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
//! A plan-based [`Engine`] implementation.
//!
//! [`PlanBasedEngine`] delegates operations to a [`PlanExecutor`] when possible, otherwise
//! falling back to using DefaultEngine implementations.

use std::fmt;
use std::sync::Arc;

use super::storage::PlanBasedStorageHandler;
use crate::engine::arrow_expression::ArrowEvaluationHandler;
use crate::engine::default::executor::TaskExecutor;
use crate::engine::default::json::DefaultJsonHandler;
use crate::engine::default::parquet::DefaultParquetHandler;
use crate::object_store::DynObjectStore;
use crate::plan::PlanExecutor;
use crate::{Engine, EvaluationHandler, JsonHandler, ParquetHandler, StorageHandler};

/// An [`Engine`] that routes storage operations through a [`PlanExecutor`].
///
/// The storage handler converts [`StorageHandler`] calls into [`DeclarativePlanNode`]s and
/// delegates them to the plan executor. JSON, Parquet, and evaluation handlers use the same
/// default implementations as [`DefaultEngine`].
///
/// [`DeclarativePlanNode`]: crate::plan::DeclarativePlanNode
/// [`DefaultEngine`]: crate::engine::default::DefaultEngine
pub struct PlanBasedEngine<E: TaskExecutor> {
executor: Arc<dyn PlanExecutor>,
storage: Arc<PlanBasedStorageHandler>,
json: Arc<DefaultJsonHandler<E>>,
parquet: Arc<DefaultParquetHandler<E>>,
evaluation: Arc<ArrowEvaluationHandler>,
}

impl<E: TaskExecutor> fmt::Debug for PlanBasedEngine<E> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("PlanBasedEngine")
.field("storage", &self.storage)
.finish_non_exhaustive()
}
}

impl<E: TaskExecutor> PlanBasedEngine<E> {
/// Create a new `PlanBasedEngine`.
///
/// Storage operations are delegated to `plan_executor`. The JSON, Parquet, and evaluation
/// handlers are constructed from the given `object_store` and `task_executor`, identically
/// to [`DefaultEngine`](crate::engine::default::DefaultEngine).
pub fn new(
object_store: Arc<DynObjectStore>,
task_executor: Arc<E>,
plan_executor: Arc<dyn PlanExecutor>,
) -> Self {
Self {
storage: Arc::new(PlanBasedStorageHandler::new(plan_executor.clone())),
json: Arc::new(DefaultJsonHandler::new(
object_store.clone(),
task_executor.clone(),
)),
parquet: Arc::new(DefaultParquetHandler::new(object_store, task_executor)),
executor: plan_executor,
evaluation: Arc::new(ArrowEvaluationHandler {}),
}
}
}

impl<E: TaskExecutor> Engine for PlanBasedEngine<E> {
fn evaluation_handler(&self) -> Arc<dyn EvaluationHandler> {
self.evaluation.clone()
}

fn storage_handler(&self) -> Arc<dyn StorageHandler> {
self.storage.clone()
}

fn json_handler(&self) -> Arc<dyn JsonHandler> {
self.json.clone()
}

fn parquet_handler(&self) -> Arc<dyn ParquetHandler> {
self.parquet.clone()
}

fn plan_executor(&self) -> Arc<dyn PlanExecutor> {
self.executor.clone()
}
}
20 changes: 20 additions & 0 deletions kernel/src/engine/plan/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
//! Plan-based engine implementation.
//!
//! This module contains an implementation of the [`Engine`](crate::Engine) trait that is
//! backed by a [`PlanExecutor`](crate::plan::PlanExecutor). The engine delegates handler
//! operations (storage, JSON, parquet, evaluation) to declarative plan execution, rather than
//! implementing each handler independently.
//!
//! [`PlanBasedEngine`] routes storage operations through a
//! [`PlanExecutor`](crate::plan::PlanExecutor) via [`PlanBasedStorageHandler`], while using the
//! default Arrow-based handlers for JSON, Parquet, and expression evaluation. [`NaivePlanExecutor`]
//! provides a concrete executor
//! backed by [`ObjectStoreStorageHandler`](crate::engine::default::filesystem::ObjectStoreStorageHandler).

pub mod engine;
pub mod naive;
pub mod storage;

pub use engine::PlanBasedEngine;
pub use naive::NaivePlanExecutor;
pub use storage::PlanBasedStorageHandler;
118 changes: 118 additions & 0 deletions kernel/src/engine/plan/naive.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
//! A naive [`PlanExecutor`] implementation backed by [`ObjectStoreStorageHandler`].
//!
//! [`NaivePlanExecutor`] interprets each [`DeclarativePlanNode`] variant by delegating to the
//! default engine's existing handler implementations and converting results into columnar
//! [`EngineData`] batches.

use std::sync::Arc;

use bytes::Bytes;

use crate::arrow::array::{ArrayRef, Int64Array, RecordBatch, StringArray};
use crate::arrow::datatypes::{
DataType as ArrowDataType, Field as ArrowField, Schema as ArrowSchema,
};
use crate::engine::arrow_data::ArrowEngineData;
use crate::engine::default::executor::TaskExecutor;
use crate::engine::default::filesystem::ObjectStoreStorageHandler;
use crate::object_store::DynObjectStore;
use crate::plan::{DeclarativePlanNode, PlanExecutor, PlanResult, FILE_META_SCHEMA};
use crate::{DeltaResult, EngineData, Error, FileMeta, FileSlice, StorageHandler as _};

/// A naive [`PlanExecutor`] that delegates to [`ObjectStoreStorageHandler`] and converts
/// results into [`ArrowEngineData`] batches.
#[derive(Debug)]
pub struct NaivePlanExecutor<E: TaskExecutor> {
storage: ObjectStoreStorageHandler<E>,
}

impl<E: TaskExecutor> NaivePlanExecutor<E> {
/// Create a new `NaivePlanExecutor` backed by the given object store and task executor.
pub fn new(store: Arc<DynObjectStore>, task_executor: Arc<E>) -> Self {
Self {
storage: ObjectStoreStorageHandler::new(store, task_executor),
}
}
}

impl<E: TaskExecutor> PlanExecutor for NaivePlanExecutor<E> {
fn execute_plan(&self, plan: DeclarativePlanNode) -> DeltaResult<PlanResult> {
match plan {
DeclarativePlanNode::FileListing { url } => self.execute_file_listing(&url),
DeclarativePlanNode::ReadBytes { files } => self.execute_read_bytes(files),
DeclarativePlanNode::WriteBytes {
url,
data,
overwrite,
} => self.execute_write_bytes(&url, data, overwrite),
DeclarativePlanNode::HeadFile { url } => self.execute_head_file(&url),
}
}
}

impl<E: TaskExecutor> NaivePlanExecutor<E> {
fn execute_file_listing(&self, url: &url::Url) -> DeltaResult<PlanResult> {
let file_metas: Vec<FileMeta> = self
.storage
.list_from(url)?
.collect::<DeltaResult<Vec<_>>>()?;
let batch = file_metas_to_engine_data(&file_metas)?;
let iter: Box<dyn Iterator<Item = DeltaResult<Box<dyn EngineData>>>> =
Box::new(std::iter::once(Ok(batch)));
Ok(PlanResult::Data(iter))
}

fn execute_read_bytes(&self, files: Vec<FileSlice>) -> DeltaResult<PlanResult> {
let iter = self.storage.read_files(files)?;
Ok(PlanResult::ByteStream(iter))

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here I chose to introduce a separate Result variant for Bytes instead of having to map bytes to EngineData and then back again. We should more generally discuss how we want to handle generic return types from plan execution. Should it always be EngineData?

}

fn execute_write_bytes(
&self,
url: &url::Url,
data: Bytes,
overwrite: bool,
) -> DeltaResult<PlanResult> {
self.storage.put(url, data, overwrite)?;
Ok(PlanResult::Unit)
}

fn execute_head_file(&self, url: &url::Url) -> DeltaResult<PlanResult> {
let meta = self.storage.head(url)?;
let batch = file_metas_to_engine_data(std::slice::from_ref(&meta))?;
let iter: Box<dyn Iterator<Item = DeltaResult<Box<dyn EngineData>>>> =
Box::new(std::iter::once(Ok(batch)));
Ok(PlanResult::Data(iter))
}
}

/// Convert a slice of [`FileMeta`] into a single [`ArrowEngineData`] batch matching
/// [`FILE_META_SCHEMA`].
fn file_metas_to_engine_data(metas: &[FileMeta]) -> DeltaResult<Box<dyn EngineData>> {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's an inefficiency here of having to go from file meta -> engine data -> file meta for the sake of preserving the existing StorageHandler interface. Do we have better ideas?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That does seem really annoying. Even if we're not worried about the performance impact, the round trip increases code complexity and expands the bug surface.

On the other hand, every new type we're able to return increases FFI complexity quite a bit, because an extern source of FileMeta would have to represent the stream in some portable way, which likely leads to a per-call overhead. Tho EngineData just shifts that overhead to the row visitor that eventually extracts the FileMeta, which could be even worse.

If we have a relatively small set of things we'd ever need to return, we could consider adding a plan result variant for each unique Box<dyn Iterator<Item=Foo>>? Basically generalizing the PlanResult::ByteStream case. That would give a modicum of compile-time safety, tho each plan node type would have to document clearly what result variant(s) it can return, and consumers of results would still have to code against the possibility of getting the wrong variant.

If we worry there would be too many enum variants, we could also imagine some kind of generic PlanResult::Any(Box<dyn Any>) that can then be downcast to a specific Box<T> for plundering. One T of particular interest would be Box<dyn Iterator<Item=Foo>> (outer box for plundering, inner box for holding a dyn iterator). Allows a result to be almost anything, which is both a plus and a minus. Documentation becomes even more critical than before, because there's no longer a closed enum to give compile-time hints of what to expect.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Notes from our discussion, plan result could be something like:

PlanResult {
  Scalar(Scalar)
  Bounded(EngineData)
  Unbounded(iter of EngineData)
  FileMeta
  FileMetaIter
  Bytes
  BytesIter
}

strongly typed results principles:

  • main benefit is that if engine produces the type naturally and kernel wants that same type, then it makes sense to add some concrete type to the PlanResult (instead of arrow)
  • if engine would have produced some other type and then have to transform it anyway, might as well just put it into arrow

Other notes:

  • for multi-node plans, we may want file listing to return generic relation data. we can introduce typed nodes for this in the future
  • how to optimize case where kernel "reads some data as engine data then immediately wants to eval an expression":
    • option 1: rewrite kernel code to use proper plans
    • option 2: implement custom engine data type (complexity around how to implement visitors on ths engine data)

let paths: StringArray = metas.iter().map(|m| Some(m.location.as_str())).collect();
let last_modified: Int64Array = metas.iter().map(|m| Some(m.last_modified)).collect();
let sizes: Int64Array = metas
.iter()
.map(|m| {
i64::try_from(m.size)
.ok()
.map(Some)
.unwrap_or(Some(i64::MAX))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we default to 0 instead of MAX? Or even just None, given that this is a nullable column?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BTW: Int64Array has both From and FromIterator for both i64 and Option.
AFAICT the current code has an unnecessary Option layer (None never appears).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update: all three columns are non-nullable. So we need to decide about 0 vs. MAX and can definitely skip the spurious Option stuff.

})
.collect();

let _ = &*FILE_META_SCHEMA;

let arrow_schema = Arc::new(ArrowSchema::new(vec![
ArrowField::new("path", ArrowDataType::Utf8, false),
ArrowField::new("last_modified", ArrowDataType::Int64, false),
ArrowField::new("size", ArrowDataType::Int64, false),
]));

let columns: Vec<ArrayRef> = vec![Arc::new(paths), Arc::new(last_modified), Arc::new(sizes)];

let batch = RecordBatch::try_new(arrow_schema, columns)
.map_err(|e| Error::generic(format!("Failed to create RecordBatch: {e}")))?;

Ok(Box::new(ArrowEngineData::new(batch)))
}
Loading
Loading