Skip to content

Commit c5f04cf

Browse files
feat(cluster): pin the iceberg snapshot across distributed scan tasks
Cross-task snapshot consistency: without pinning, each Ballista task re-planned the scan against the table's current snapshot, so a concurrent commit mid-query could leave partitions reading different snapshots. Now the scheduler captures the plan-time snapshot (the scan's own, else the table's current), serializes it in the recipe, and each executor task pins its IcebergTableProvider clone to it via with_snapshot_id — so all partitions of one query read one snapshot. Requires the iceberg fork change adding IcebergTableProvider::with_snapshot_id (spiceai/iceberg-rust#45); re-pins the iceberg deps to that rev.
1 parent bf36aec commit c5f04cf

5 files changed

Lines changed: 69 additions & 17 deletions

File tree

Cargo.lock

Lines changed: 6 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -458,12 +458,12 @@ tokio-rusqlite = { git = "https://github.com/spiceai/tokio-rusqlite.git", rev =
458458
# Tracking Issue: https://github.com/allan2/dotenvy/issues/113
459459
dotenvy = { git = "https://github.com/spiceai/dotenvy.git", rev = "e5cef1871b08003198949dfe2da988633eaad78f" }
460460

461-
iceberg = { git = "https://github.com/spiceai/iceberg-rust.git", rev = "ef85534eca57620fe2a61e3e202ecbbca2471868" } # branch: spiceai-0.9.1-df-54
462-
iceberg-catalog-glue = { git = "https://github.com/spiceai/iceberg-rust.git", rev = "ef85534eca57620fe2a61e3e202ecbbca2471868" } # branch: spiceai-0.9.1-df-54
463-
iceberg-catalog-rest = { git = "https://github.com/spiceai/iceberg-rust.git", rev = "ef85534eca57620fe2a61e3e202ecbbca2471868" } # branch: spiceai-0.9.1-df-54
464-
iceberg-datafusion = { git = "https://github.com/spiceai/iceberg-rust.git", rev = "ef85534eca57620fe2a61e3e202ecbbca2471868" } # branch: spiceai-0.9.1-df-54
465-
iceberg-storage-opendal = { git = "https://github.com/spiceai/iceberg-rust.git", rev = "ef85534eca57620fe2a61e3e202ecbbca2471868" } # branch: spiceai-0.9.1-df-54
466-
iceberg_test_utils = { git = "https://github.com/spiceai/iceberg-rust.git", rev = "ef85534eca57620fe2a61e3e202ecbbca2471868" } # branch: spiceai-0.9.1-df-54
461+
iceberg = { git = "https://github.com/spiceai/iceberg-rust.git", rev = "17183547f127cccf7b47ee25dab99b5dcc8dc340" } # branch: phillip/df-54-snapshot-pinning (snapshot pinning; merge into spiceai-0.9.1-df-54)
462+
iceberg-catalog-glue = { git = "https://github.com/spiceai/iceberg-rust.git", rev = "17183547f127cccf7b47ee25dab99b5dcc8dc340" } # branch: phillip/df-54-snapshot-pinning (snapshot pinning; merge into spiceai-0.9.1-df-54)
463+
iceberg-catalog-rest = { git = "https://github.com/spiceai/iceberg-rust.git", rev = "17183547f127cccf7b47ee25dab99b5dcc8dc340" } # branch: phillip/df-54-snapshot-pinning (snapshot pinning; merge into spiceai-0.9.1-df-54)
464+
iceberg-datafusion = { git = "https://github.com/spiceai/iceberg-rust.git", rev = "17183547f127cccf7b47ee25dab99b5dcc8dc340" } # branch: phillip/df-54-snapshot-pinning (snapshot pinning; merge into spiceai-0.9.1-df-54)
465+
iceberg-storage-opendal = { git = "https://github.com/spiceai/iceberg-rust.git", rev = "17183547f127cccf7b47ee25dab99b5dcc8dc340" } # branch: phillip/df-54-snapshot-pinning (snapshot pinning; merge into spiceai-0.9.1-df-54)
466+
iceberg_test_utils = { git = "https://github.com/spiceai/iceberg-rust.git", rev = "17183547f127cccf7b47ee25dab99b5dcc8dc340" } # branch: phillip/df-54-snapshot-pinning (snapshot pinning; merge into spiceai-0.9.1-df-54)
467467

468468
# candle-* crates are already redirected to the spiceai/candle fork via the
469469
# [patch.crates-io] block higher up in this file. Keeping those here would be

crates/runtime-proto/proto/spice.proto

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,11 @@ message IcebergTableScanExecNode {
328328
// Output partitioning, so the deferred node reports the same partition count
329329
// the scheduler planned before the (lazy) scan actually runs.
330330
IcebergPartitioning partitioning = 6;
331+
// Snapshot pinned at planning time on the scheduler. Every executor task
332+
// plans against this snapshot, so all partitions of one query read a single
333+
// consistent snapshot even if the table is committed to mid-query. Absent =
334+
// read the current snapshot (e.g. table had no snapshot at plan time).
335+
optional int64 snapshot_id = 7;
331336
}
332337

333338
// Serializable form of the scan's output `Partitioning`.

crates/runtime/src/cluster/datafusion/codec/spice_physical_codec.rs

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ use arrow_schema::Schema;
2323
use ballista_core::serde::BallistaPhysicalExtensionCodec;
2424
#[cfg(not(windows))]
2525
use cayenne::provider::CayenneAccelerationExec;
26+
use data_components::iceberg::delete::IcebergDeletionProvider;
27+
use datafusion::catalog::TableProvider;
2628
use datafusion::common::{DataFusionError, Result, TableReference, exec_err};
2729
use datafusion::execution::{FunctionRegistry, TaskContext};
2830
use datafusion::logical_expr::Expr;
@@ -37,6 +39,7 @@ use datafusion_proto::physical_plan::AsExecutionPlan;
3739
use datafusion_proto::physical_plan::PhysicalExtensionCodec;
3840
#[cfg(not(windows))]
3941
use datafusion_proto::protobuf::PhysicalPlanNode;
42+
use iceberg_datafusion::IcebergTableProvider;
4043
use prost::Message;
4144
use runtime_datafusion::execution_plan::schema_cast::SchemaCastScanExec;
4245
use runtime_datafusion::extension::bytes_processed::BytesProcessedExec;
@@ -208,21 +211,31 @@ impl PhysicalExtensionCodec for SpicePhysicalCodec {
208211
distributed Iceberg scans require the Iceberg data connector"
209212
);
210213
};
211-
// The concrete Iceberg provider (not the cluster wrapper), so
212-
// replaying its scan() yields the bare scan without re-wrapping.
213-
let inner_provider = Arc::clone(cluster.inner());
214+
// Resolve the concrete IcebergTableProvider (the cluster wrapper's
215+
// inner, peeling the deletion wrapper on the read-write path) and
216+
// pin it to the snapshot the scheduler chose, so every executor task
217+
// of this query reads the same snapshot. Scanning this provider
218+
// directly also yields the bare scan without re-wrapping.
219+
let Some(iceberg_provider) = concrete_iceberg_provider(cluster.inner()) else {
220+
return exec_err!(
221+
"IcebergClusterTableProvider for {table_ref} does not wrap an \
222+
IcebergTableProvider; cannot reconstruct the distributed scan"
223+
);
224+
};
225+
let pinned: Arc<dyn TableProvider> =
226+
Arc::new(iceberg_provider.clone().with_snapshot_id(node.snapshot_id));
214227

215228
// Output schema = table schema projected by the recipe, taken
216229
// synchronously from the registered provider.
217-
let table_schema = inner_provider.schema();
230+
let table_schema = pinned.schema();
218231
let output_schema = match &projection {
219232
Some(p) => Arc::new(table_schema.project(p)?),
220233
None => table_schema,
221234
};
222235

223236
Ok(Arc::new(IcebergScanExec::new_deferred(
224237
table_ref,
225-
inner_provider,
238+
pinned,
226239
projection,
227240
filters,
228241
limit,
@@ -323,6 +336,8 @@ impl PhysicalExtensionCodec for SpicePhysicalCodec {
323336
filters,
324337
limit,
325338
partitioning: Some(partitioning),
339+
// Pin the plan-time snapshot so every executor task reads it.
340+
snapshot_id: scan_exec.snapshot_id(),
326341
},
327342
)),
328343
}
@@ -355,6 +370,19 @@ impl PhysicalExtensionCodec for SpicePhysicalCodec {
355370
}
356371
}
357372

373+
/// Returns the concrete [`IcebergTableProvider`] behind a cluster wrapper's inner
374+
/// provider — directly (read path) or through the [`IcebergDeletionProvider`] the
375+
/// read-write path inserts. The returned provider is used (cloned + snapshot
376+
/// pinned) to replay the scan on the executor.
377+
fn concrete_iceberg_provider(inner: &Arc<dyn TableProvider>) -> Option<&IcebergTableProvider> {
378+
if let Some(p) = inner.downcast_ref::<IcebergTableProvider>() {
379+
return Some(p);
380+
}
381+
inner
382+
.downcast_ref::<IcebergDeletionProvider>()
383+
.and_then(|d| d.inner().downcast_ref::<IcebergTableProvider>())
384+
}
385+
358386
/// Serializes a scan's output [`Partitioning`] into its wire form, so the
359387
/// deferred node on the executor reports the same partition count the scheduler
360388
/// planned (before the lazy scan runs).

crates/runtime/src/execution_plan/iceberg_scan_exec.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ use datafusion::physical_plan::{
6161
};
6262
use datafusion::prelude::{SessionConfig, SessionContext};
6363
use futures::TryStreamExt;
64+
use iceberg_datafusion::physical_plan::IcebergTableScan;
6465
use runtime_datafusion::config::cluster_config::SpiceClusterConfig;
6566
use tokio::sync::OnceCell;
6667

@@ -202,6 +203,24 @@ impl IcebergScanExec {
202203
pub fn limit(&self) -> Option<usize> {
203204
self.limit
204205
}
206+
207+
/// The snapshot to pin for this scan, derived from the wrapped scan at plan
208+
/// time: the scan's explicit snapshot if any, else the table's current
209+
/// snapshot. The codec serializes this so every executor task plans against
210+
/// the same snapshot — giving one consistent snapshot across all partitions
211+
/// of a distributed query even under concurrent commits. Returns `None` for a
212+
/// deferred node (its provider already carries the pin) or if the wrapped plan
213+
/// isn't an `IcebergTableScan`.
214+
#[must_use]
215+
pub fn snapshot_id(&self) -> Option<i64> {
216+
match &self.source {
217+
ScanSource::Planned(inner) => inner.downcast_ref::<IcebergTableScan>().and_then(|s| {
218+
s.snapshot_id()
219+
.or_else(|| s.table().metadata().current_snapshot_id())
220+
}),
221+
ScanSource::Deferred { .. } => None,
222+
}
223+
}
205224
}
206225

207226
impl DisplayAs for IcebergScanExec {

0 commit comments

Comments
 (0)