andygrove commented on code in PR #2638: URL: https://github.com/apache/datafusion-ballista/pull/2638#discussion_r4505755569
########## integrations/iceberg/src/physical_codec.rs: ########## @@ -0,0 +1,119 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Sends Iceberg scans from the scheduler to executors. + +use std::sync::Arc; + +use ballista_core::serde::BallistaPhysicalExtensionCodec; +use datafusion::arrow::datatypes::SchemaRef; +use datafusion::common::{Result, not_impl_err}; +use datafusion::datasource::source::DataSourceExec; +use datafusion::execution::TaskContext; +use datafusion::physical_plan::{ExecutionPlan, ExecutionPlanProperties}; +use datafusion_proto::physical_plan::{ + ComposedPhysicalExtensionCodec, PhysicalExtensionCodec, + PhysicalProtoConverterExtension, +}; +use iceberg::expr::Predicate; +use iceberg_datafusion::read::IcebergDataSource; +use serde::{Deserialize, Serialize}; + +use crate::wire::{self, TableWire}; + +#[derive(Debug, Serialize, Deserialize)] +struct ScanWire { + table: TableWire, + snapshot_id: Option<i64>, + /// The scan's output schema, already projected. + schema: SchemaRef, + predicate: Option<Predicate>, + fetch: Option<usize>, +} + +/// A [`PhysicalExtensionCodec`] for scans of an [`IcebergDataSource`]. It +/// fails on anything else, so compose it after other codecs with +/// [`ComposedPhysicalExtensionCodec`], as [`register_iceberg`] does. +/// +/// [`register_iceberg`]: crate::register_iceberg +#[derive(Debug, Default)] +pub struct IcebergPhysicalCodec; + +impl IcebergPhysicalCodec { + /// Composes `codec`, or Ballista's when `None`, with this codec after it. + pub(crate) fn after( + codec: Option<Arc<dyn PhysicalExtensionCodec>>, + ) -> Arc<dyn PhysicalExtensionCodec> { + let codec = + codec.unwrap_or_else(|| Arc::new(BallistaPhysicalExtensionCodec::default())); + Arc::new(ComposedPhysicalExtensionCodec::new(vec![ + codec, + Arc::new(Self), + ])) + } +} + +impl PhysicalExtensionCodec for IcebergPhysicalCodec { + fn try_decode( + &self, + buf: &[u8], + _inputs: &[Arc<dyn ExecutionPlan>], + _ctx: &TaskContext, + _proto_converter: &dyn PhysicalProtoConverterExtension, + ) -> Result<Arc<dyn ExecutionPlan>> { + let scan: ScanWire = wire::decode(buf)?; + let source = IcebergDataSource::builder(scan.table.load()?, scan.schema) + .with_snapshot_id(scan.snapshot_id) + .with_predicate(scan.predicate) + .with_fetch(scan.fetch) + .build()?; + Ok(DataSourceExec::from_data_source(source)) + } + + fn try_encode( + &self, + node: Arc<dyn ExecutionPlan>, + buf: &mut Vec<u8>, + _proto_converter: &dyn PhysicalProtoConverterExtension, + ) -> Result<()> { + let Some(source) = node + .downcast_ref::<DataSourceExec>() + .and_then(|exec| exec.data_source().downcast_ref::<IcebergDataSource>()) + else { + return not_impl_err!("{} isn't an Iceberg scan", node.name()); + }; + // Ballista restricts each task to its partitions only for the scans it + // knows, such as file scans, so each task would read every partition + // of this one and rows would come back duplicated. + let partitions = node.output_partitioning().partition_count(); + if partitions > 1 { Review Comment: I think the new guard covers one shape of this, but not all of them. A one-partition Iceberg scan under a `UNION ALL` still gets read by every task when the adaptive planner splits the union's stage. The task that owns the other branch asks the Iceberg leaf for zero partitions. `restrict_plan_to_partitions` doesn't know `IcebergDataSource`, so the leaf keeps its one partition and that task reads the whole table again. I reproduced it on standalone Ballista with `SELECT id, name FROM t UNION ALL SELECT id, name FROM t ORDER BY id`. With `ballista.scheduler.max_partitions_per_task = 1`, every row comes back 4 times instead of 2. Default settings do the same when the executor has fewer free vcores than the union has partitions (I used `ballista.standalone.parallelism = 1`), which is normal on a busy cluster. A union of an Iceberg table and a Parquet table counted 9 rows instead of 6. The static planner gets it right, because `can_stay_inline` gives the branch its own stage. None of the current tests run a union, and an idle executor with enough vcores runs the whole stage as one task anyway. The root cause is in Ballista core and is tracked in #2679. #2721 turns this into an error and adds a resolver on `SchedulerConfig`, so `SchedulerConfigExt::with_iceberg` could restrict the Iceberg leaf itself. It would keep the scan for `[0]` and swap in an empty zero-partition `MemorySourceConfig` for `[]`. Would you be OK holding this until #2721 lands, then bumping the pin and adding a `UNION ALL` test? The standalone scheduler can't take a resolver, so the test would need to go through a scheduler readied with `with_iceberg`, like the one in `tests/flight_sql.rs`, with an executor that has fewer vcores than the union has branches. The docs line saying a multi-partition scan fails "rather than being read in full by every task" is also worth updating, since it reads as if a one-partition scan is always safe. Happy to share my repro test. ########## integrations/iceberg/src/scheduler.rs: ########## @@ -0,0 +1,67 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Readies a separately deployed scheduler for Iceberg. + +use std::sync::Arc; + +use ballista_core::utils::default_session_builder; +use ballista_scheduler::config::SchedulerConfig; +use datafusion::catalog::CatalogProvider; + +use crate::{IcebergLogicalCodec, IcebergPhysicalCodec, register_iceberg}; + +/// Readies a [`SchedulerConfig`] for Iceberg. +pub trait SchedulerConfigExt { + /// Adds the Iceberg codecs after any codecs already set, and mounts + /// `catalogs` in each session, readied with [`register_iceberg`]. The + /// scheduler plans queries from Flight SQL clients itself, so they can + /// then query those catalogs. + fn with_iceberg<N: Into<String>>( + self, + catalogs: impl IntoIterator<Item = (N, Arc<dyn CatalogProvider>)>, + ) -> Self; +} + +impl SchedulerConfigExt for SchedulerConfig { + fn with_iceberg<N: Into<String>>( + mut self, + catalogs: impl IntoIterator<Item = (N, Arc<dyn CatalogProvider>)>, + ) -> Self { + let logical = IcebergLogicalCodec::after(self.override_logical_codec.take()); Review Comment: What happens if someone calls `with_iceberg` twice to add catalogs in two steps? The scheduler's codecs get wrapped twice, while executors call `with_iceberg()` once, so the composed positions won't line up between the scheduler and the executors. Maybe a doc line saying to pass every catalog in one call? ########## integrations/iceberg/src/wire.rs: ########## @@ -0,0 +1,181 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! What the codecs send in place of a live [`Table`], and how they encode it. + +use std::collections::VecDeque; +use std::fmt; +use std::sync::{Arc, LazyLock, Mutex}; + +use datafusion::common::{DataFusionError, exec_datafusion_err}; +use iceberg::io::FileIO; +use iceberg::spec::{TableMetadata, TableMetadataRef}; +use iceberg::table::Table; +use iceberg::{Runtime, TableIdent}; +use iceberg_datafusion::to_datafusion_error; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; +use tokio::runtime::{Handle, RuntimeFlavor}; + +/// Runs the codecs' I/O and owns the tables they rebuild: Iceberg plans scans on +/// a table's runtime, which must outlive the table, and a codec's caller may not. +static RUNTIME: LazyLock<tokio::runtime::Runtime> = LazyLock::new(|| { + tokio::runtime::Builder::new_multi_thread() + .thread_name("ballista-iceberg") + .enable_all() + .build() + .expect("failed to build the ballista-iceberg runtime") +}); + +/// Runs `fut` on [`RUNTIME`] and waits for it, from the synchronous codec API. +pub(crate) fn block_on<T: Send + 'static>( + fut: impl Future<Output = T> + Send + 'static, +) -> Result<T, DataFusionError> { + let wait = || futures::executor::block_on(RUNTIME.spawn(fut)); + let joined = match Handle::try_current().map(|handle| handle.runtime_flavor()) { + Ok(RuntimeFlavor::MultiThread) => tokio::task::block_in_place(wait), + _ => wait(), + }; + joined.map_err(|e| DataFusionError::External(Box::new(e))) +} + +/// Bounds memory rather than tuning hits: a query touches a few table versions, +/// but each can hold megabytes of metadata. +const TABLE_CACHE_CAPACITY: usize = 16; + +/// Tables rebuilt by [`TableWire::load`], least recently used first, so each +/// process rebuilds a version once. Metadata files never change, so entries +/// can't go stale. +static TABLES: LazyLock<Mutex<VecDeque<(TableWire, Table)>>> = + LazyLock::new(|| Mutex::new(VecDeque::new())); + +/// A [`Table`] as its metadata file and `FileIO`, from which any node can +/// rebuild exactly that version without a catalog. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct TableWire { + identifier: TableIdent, + metadata_location: String, + file_io: serde_json::Value, + readonly: bool, + /// The content of `metadata_location`, sent from the client so the + /// scheduler needn't read it; scans sent to executors leave it out. + #[serde(default, skip_serializing_if = "Option::is_none")] + metadata: Option<TableMetadataRef>, +} + +impl fmt::Debug for TableWire { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("TableWire") + .field("identifier", &self.identifier) + .field("metadata_location", &self.metadata_location) + .field("file_io", &"<redacted>") + .field("readonly", &self.readonly) + .finish_non_exhaustive() + } +} + +impl TableWire { + pub(crate) fn new(table: &Table) -> Result<Self, DataFusionError> { + let file_io = table + .file_io() + .serialize_all() + .map_err(to_datafusion_error)?; + Ok(Self { + identifier: table.identifier().clone(), + metadata_location: table + .metadata_location_result() + .map_err(to_datafusion_error)? + .to_string(), + file_io: serde_json::from_slice(&file_io).map_err(json_err)?, + readonly: table.readonly(), + metadata: None, + }) + } + + /// Like [`TableWire::new`], also carrying the table's metadata. + pub(crate) fn with_metadata(table: &Table) -> Result<Self, DataFusionError> { + Ok(Self { + metadata: Some(table.metadata_ref()), + ..Self::new(table)? + }) + } + + /// Rebuilds the table, or returns it from [`TABLES`]. + pub(crate) fn load(mut self) -> Result<Table, DataFusionError> { + // Keyed without the metadata, which its location determines. + let metadata = self.metadata.take(); + if let Some(table) = self.touch(&mut TABLES.lock().unwrap()) { + return Ok(table); + } + let table = + block_on(self.clone().read(metadata))?.map_err(to_datafusion_error)?; + let mut tables = TABLES.lock().unwrap(); + // Another thread may have rebuilt it meanwhile. + if let Some(table) = self.touch(&mut tables) { + return Ok(table); + } + if tables.len() == TABLE_CACHE_CAPACITY { + tables.pop_front(); + } + tables.push_back((self.clone(), table.clone())); + Ok(table) + } + + /// Moves this table's entry, if any, to the back of `tables` and returns it. + fn touch(&self, tables: &mut VecDeque<(TableWire, Table)>) -> Option<Table> { + let entry = tables.remove(tables.iter().position(|(wire, _)| wire == self)?)?; + let table = entry.1.clone(); + tables.push_back(entry); + Some(table) + } + + async fn read(self, metadata: Option<TableMetadataRef>) -> iceberg::Result<Table> { + let file_io = FileIO::deserialize_all(&serde_json::to_vec(&self.file_io)?)?; + let metadata = match metadata { + Some(metadata) => metadata, + None => Arc::new( + TableMetadata::read_from(&file_io, &self.metadata_location).await?, + ), + }; + Table::builder() Review Comment: `read` rebuilds the table without a `kms_client`. For a v3 table with `encryption.key-id` set, `TableBuilder::build` fails with "no KeyManagementClient was provided", so encrypted tables can't be read on the scheduler or the executors. It fails cleanly, so a line in the limitations list is probably enough for now. ########## integrations/iceberg/src/wire.rs: ########## @@ -0,0 +1,181 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! What the codecs send in place of a live [`Table`], and how they encode it. + +use std::collections::VecDeque; +use std::fmt; +use std::sync::{Arc, LazyLock, Mutex}; + +use datafusion::common::{DataFusionError, exec_datafusion_err}; +use iceberg::io::FileIO; +use iceberg::spec::{TableMetadata, TableMetadataRef}; +use iceberg::table::Table; +use iceberg::{Runtime, TableIdent}; +use iceberg_datafusion::to_datafusion_error; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; +use tokio::runtime::{Handle, RuntimeFlavor}; + +/// Runs the codecs' I/O and owns the tables they rebuild: Iceberg plans scans on +/// a table's runtime, which must outlive the table, and a codec's caller may not. +static RUNTIME: LazyLock<tokio::runtime::Runtime> = LazyLock::new(|| { + tokio::runtime::Builder::new_multi_thread() + .thread_name("ballista-iceberg") + .enable_all() + .build() + .expect("failed to build the ballista-iceberg runtime") +}); + +/// Runs `fut` on [`RUNTIME`] and waits for it, from the synchronous codec API. +pub(crate) fn block_on<T: Send + 'static>( + fut: impl Future<Output = T> + Send + 'static, +) -> Result<T, DataFusionError> { + let wait = || futures::executor::block_on(RUNTIME.spawn(fut)); + let joined = match Handle::try_current().map(|handle| handle.runtime_flavor()) { + Ok(RuntimeFlavor::MultiThread) => tokio::task::block_in_place(wait), + _ => wait(), + }; + joined.map_err(|e| DataFusionError::External(Box::new(e))) +} + +/// Bounds memory rather than tuning hits: a query touches a few table versions, +/// but each can hold megabytes of metadata. +const TABLE_CACHE_CAPACITY: usize = 16; + +/// Tables rebuilt by [`TableWire::load`], least recently used first, so each +/// process rebuilds a version once. Metadata files never change, so entries Review Comment: Small question on the table cache. Keying on the serialized `FileIO` is the right call for credentials. If a REST catalog returns vended credentials in the `loadTable` `config` and rotates them per call, those keys end up in the `FileIO` props. In that setup I think each query gets a new key, executors re-read the metadata file every query, and the cache can end up holding 16 copies of the same metadata. I haven't run this against a vending catalog, and iceberg-rust only asks for vending if you set the delegation header yourself. Not a blocker. Maybe soften the comment that says each process rebuilds a version once? ########## integrations/iceberg/src/wire.rs: ########## @@ -0,0 +1,181 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! What the codecs send in place of a live [`Table`], and how they encode it. + +use std::collections::VecDeque; +use std::fmt; +use std::sync::{Arc, LazyLock, Mutex}; + +use datafusion::common::{DataFusionError, exec_datafusion_err}; +use iceberg::io::FileIO; +use iceberg::spec::{TableMetadata, TableMetadataRef}; +use iceberg::table::Table; +use iceberg::{Runtime, TableIdent}; +use iceberg_datafusion::to_datafusion_error; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; +use tokio::runtime::{Handle, RuntimeFlavor}; + +/// Runs the codecs' I/O and owns the tables they rebuild: Iceberg plans scans on +/// a table's runtime, which must outlive the table, and a codec's caller may not. +static RUNTIME: LazyLock<tokio::runtime::Runtime> = LazyLock::new(|| { + tokio::runtime::Builder::new_multi_thread() + .thread_name("ballista-iceberg") + .enable_all() + .build() + .expect("failed to build the ballista-iceberg runtime") +}); + +/// Runs `fut` on [`RUNTIME`] and waits for it, from the synchronous codec API. +pub(crate) fn block_on<T: Send + 'static>( + fut: impl Future<Output = T> + Send + 'static, +) -> Result<T, DataFusionError> { + let wait = || futures::executor::block_on(RUNTIME.spawn(fut)); + let joined = match Handle::try_current().map(|handle| handle.runtime_flavor()) { + Ok(RuntimeFlavor::MultiThread) => tokio::task::block_in_place(wait), + _ => wait(), + }; + joined.map_err(|e| DataFusionError::External(Box::new(e))) +} + +/// Bounds memory rather than tuning hits: a query touches a few table versions, +/// but each can hold megabytes of metadata. +const TABLE_CACHE_CAPACITY: usize = 16; + +/// Tables rebuilt by [`TableWire::load`], least recently used first, so each +/// process rebuilds a version once. Metadata files never change, so entries +/// can't go stale. +static TABLES: LazyLock<Mutex<VecDeque<(TableWire, Table)>>> = + LazyLock::new(|| Mutex::new(VecDeque::new())); + +/// A [`Table`] as its metadata file and `FileIO`, from which any node can +/// rebuild exactly that version without a catalog. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct TableWire { + identifier: TableIdent, + metadata_location: String, + file_io: serde_json::Value, + readonly: bool, + /// The content of `metadata_location`, sent from the client so the Review Comment: Sending the metadata from the client is a nice way to save the scheduler a read. I measured how big it gets, though. Each scan of the table adds about 0.7 KB per snapshot to the submitted plan, and every `TableScan` carries its own copy. With 200 snapshots, a plain `SELECT` is 144 KB and a self-join is 288 KB. The client and the scheduler both default to a 16 MiB gRPC limit (`ballista.client.grpc_max_message_size` and `grpc_server_max_decoding_message_size`). So a self-join on a table with around 12k snapshots would fail to submit, and snapshots written by Spark carry bigger summaries than the memory catalog's. What do you think about embedding the metadata only below a size threshold, and sending just the location above it? The table cache would keep the scheduler's extra read to once per version. ########## docs/source/user-guide/iceberg.md: ########## @@ -0,0 +1,162 @@ +<!--- + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. +--> + +# Apache Iceberg + +The `ballista-iceberg` crate (`integrations/iceberg`) lets Ballista scan +[Apache Iceberg](https://iceberg.apache.org/) tables. On top of +[iceberg-datafusion](https://github.com/apache/datafusion-iceberg), it adds the +codecs that send Iceberg tables and scans between the client, the scheduler and +the executors. + +The crate is not published yet. It depends on unreleased revisions of +iceberg-datafusion and iceberg-rust, and needs Rust 1.95. It is its own +workspace, with its own `Cargo.lock` and CI, outside Ballista's, and depends +on a pinned commit of Ballista rather than the crates beside it, so a +DataFusion upgrade in Ballista that iceberg-rust doesn't support yet doesn't +block Ballista's build or release. Build and test it from +`integrations/iceberg`. + +## Usage + +Ready the session with `register_iceberg`, then mount an Iceberg catalog: + +```rust +use std::sync::Arc; + +use ballista::prelude::{SessionConfigExt, SessionContextExt}; +use datafusion::execution::SessionStateBuilder; +use datafusion::prelude::{SessionConfig, SessionContext}; +use ballista_iceberg::register_iceberg; +use iceberg_datafusion::IcebergCatalogProvider; + +let state = SessionStateBuilder::new() + .with_config(SessionConfig::new_with_ballista()) + .with_default_features() + .build(); +let ctx = SessionContext::standalone_with_state(register_iceberg(state)).await?; + +ctx.register_catalog("rest", Arc::new(IcebergCatalogProvider::try_new(catalog).await?)); +ctx.sql("SELECT * FROM rest.ns.t").await?.show().await?; +``` + +A single table registered as an `IcebergCatalogTableProvider` works the same +way. Either kind reads the table's latest version in each query. + +For time travel, register an `IcebergStaticTableProvider`, which keeps one +version: `IcebergStaticTableProvider::try_new_from_table_snapshot(table, snapshot_id)`. + +The example registers both on a table in a temporary local warehouse, and shows +a commit reaching the live table but not the static one: + +```shell +cd integrations/iceberg +cargo run --example standalone-iceberg-scan +``` + +## SQL clients + +Flight SQL clients (JDBC, ADBC, BI tools) send SQL to the scheduler, which plans +it, so they hold no catalog. To let them query Iceberg tables, mount catalogs +on the scheduler with `SchedulerConfigExt::with_iceberg` (the `scheduler` +feature), which also adds the Iceberg codecs: + +```rust +use ballista_iceberg::SchedulerConfigExt; + +let rest = IcebergCatalogProvider::try_new(catalog).await?; +let config = SchedulerConfig::default() + .with_flight_sql(true) + .with_iceberg([("rest", Arc::new(rest) as _)]); +``` + +Executors add the codecs with `ExecutorProcessConfigExt::with_iceberg` (the +`executor` feature): + +```rust +use ballista_iceberg::ExecutorProcessConfigExt; + +let config = ExecutorProcessConfig::default().with_iceberg(); +``` + +Both keep any codecs the config already has, and the scheduler keeps its +session builder, mounting the catalogs on each session it builds. A client can +then run `SELECT * FROM rest.ns.t`. Tables are pinned the same way as on the +client, while the scheduler plans the query. + +## How it works + +Ballista sends the client's plan to the scheduler, and each stage's plan to +the executors, as bytes. The codecs replace each live Iceberg table with one +version of it: the location of its metadata file and its serialized `FileIO`. +Metadata files never change, so every node rebuilds exactly that version. + +- **A catalog-backed table** is pinned to the version its catalog serves when + the client plans the query. DataFusion looks up each table a query names + once, so every scan of it, such as each side of a self-join, reads that + version, and each new query reads the latest one. +- **A static table** is sent as the version it holds. +- **A scan** is sent as the version the scheduler planned it against. + +Tables sent from the client also carry the metadata itself, so the scheduler +needn't read the file. Scans sent to executors carry only the location, which +keeps each task's plan small. + +Only the node that plans a query talks to a catalog: the client, or the +scheduler for SQL clients. Executors never need catalog configuration. + +## Combining with other table formats + +`register_iceberg` and `with_iceberg` add the Iceberg codecs after the codecs +already set, with `ComposedLogicalExtensionCodec` and DataFusion's +`ComposedPhysicalExtensionCodec` (see +[Combining Codecs](extending-components.md#combining-codecs)). To read tables +of another format too, such as Delta, set its codecs first: on the session's +`SessionConfig` before calling `register_iceberg`, and on `SchedulerConfig` and +`ExecutorProcessConfig` before calling `with_iceberg`. Clients, the scheduler +and executors must all set the same codecs in the same order. + +## Limitations + +- **Reads only.** `INSERT INTO` an Iceberg table fails with "isn't supported + on Ballista yet". +- **No metadata tables.** Tables such as `t$snapshots` can't be sent to the + scheduler yet. +- **One task per scan.** Iceberg scans do not split into partitions yet, so one + executor reads the whole table. Operators above the scan still run in Review Comment: Tiny one. Ballista turns off DataFusion's round-robin repartition, so the operators right above the scan run in that same single task. Maybe "Operators after the first shuffle still run in parallel"? -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected] --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
