Skip to content

Commit 859810e

Browse files
committed
feat!: preserve reader timezone in parsed scan partitions
1 parent 4e85633 commit 859810e

9 files changed

Lines changed: 700 additions & 68 deletions

File tree

docs/user-guide/src/reading/scan_metadata.md

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -249,9 +249,9 @@ If the scan has no predicate, this returns `None`.
249249
## Typed partition values
250250

251251
Kernel reads each file's partition values from the Delta log and exposes them on its
252-
`ScanFile` as a raw string map. Kernel's `transform_to_logical` could materialize them as
253-
typed columns. If your connector assembles output rows itself instead of using that transform,
254-
it parses the string map per file.
252+
`ScanFile` as a raw string map. `transform_to_logical` materializes them as typed columns. A
253+
connector that assembles output rows itself instead of using that transform would otherwise need
254+
to parse the string map per file.
255255

256256
To have Kernel hand you the typed values directly, opt in with `with_partition_values`:
257257

@@ -270,7 +270,10 @@ To have Kernel hand you the typed values directly, opt in with `with_partition_v
270270
# let snapshot = Snapshot::builder_for(url).build(&engine)?;
271271
let scan = snapshot
272272
.scan_builder()
273-
.with_partition_values(PartitionValuesOptions::with_struct())
273+
.with_partition_values(
274+
PartitionValuesOptions::with_struct()
275+
.with_timestamp_timezone("America/Los_Angeles"),
276+
)
274277
.build()?;
275278
# Ok(())
276279
# }
@@ -281,9 +284,16 @@ nullable field per partition column (by physical name). You read it as a typed c
281284
of parsing the string map per file. The raw string map is still present, so this option only
282285
adds the typed column.
283286

287+
By default, offset-less `TIMESTAMP` partition strings are interpreted in UTC. Use
288+
`with_timestamp_timezone` with an IANA timezone or fixed offset when the reader uses another
289+
timezone. An explicit offset in a partition value takes precedence. This setting affects typed
290+
`scan_metadata` output and its internal partition pruning; it does not change the transforms used
291+
by `Scan::execute`. Incremental scans expose the raw partition-value map.
292+
284293
> [!TIP]
285294
> When the checkpoint already stores typed partition values, Kernel reads that column directly
286-
> and skips parsing entirely.
295+
> and reparses each zoned `TIMESTAMP` field when a reader timezone is requested. Other fields retain
296+
> their native checkpoint values.
287297
288298
## Cancelling a scan
289299

kernel/src/checkpoint/checkpoint_shape.rs

Lines changed: 81 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,6 @@
33
//! When stats are requested, also reports whether the checkpoint has compatible parsed stats.
44
//! Driven through a [`PlanExecutor`].
55
6-
// No in-crate caller yet; following PRs will use this.
7-
#![allow(dead_code)]
8-
96
use url::Url;
107

118
use crate::actions::visitors::SidecarVisitor;
@@ -29,23 +26,27 @@ pub(crate) enum CheckpointType {
2926
Manifest,
3027
}
3128

32-
/// A snapshot's resolved checkpoint type and parsed-stats schema.
29+
/// A snapshot's resolved checkpoint type and compatible parsed schemas.
3330
#[derive(Clone, Debug, PartialEq)]
3431
pub(crate) struct CheckpointShape {
3532
/// What kind of checkpoint this is.
3633
pub(crate) checkpoint_type: CheckpointType,
3734
/// The requested stats schema when the checkpoint has a compatible `add.stats_parsed` struct
3835
/// to read it from; `None` when stats were not requested or no compatible parsed stats exist.
3936
pub(crate) parsed_stats_schema: Option<SchemaRef>,
37+
/// The requested partition schema when the checkpoint has a compatible
38+
/// `add.partitionValues_parsed` struct; `None` when partition values were not requested or no
39+
/// compatible parsed partition values exist.
40+
pub(crate) parsed_partition_schema: Option<SchemaRef>,
4041
}
4142

4243
impl CheckpointShape {
43-
/// Resolve `snapshot`'s checkpoint shape. Determines the checkpoint type and, when
44-
/// `stats_schema` is `Some`, whether the checkpoint contains parsed stats compatible with it.
44+
/// Resolve `snapshot`'s checkpoint shape and compatible parsed stats and partition schemas.
4545
pub(crate) fn try_new(
4646
exec: &dyn PlanExecutor,
4747
snapshot: &Snapshot,
4848
stats_schema: Option<&SchemaRef>,
49+
partition_schema: Option<&SchemaRef>,
4950
) -> DeltaResult<CheckpointShape> {
5051
let segment = snapshot.log_segment();
5152

@@ -56,15 +57,21 @@ impl CheckpointShape {
5657
return Ok(CheckpointShape {
5758
checkpoint_type: CheckpointType::None,
5859
parsed_stats_schema: None,
60+
parsed_partition_schema: None,
5961
})
6062
}
6163
};
6264

6365
// Classify from a V2 checkpoint's `_last_checkpoint` hint when possible, else inspect the
6466
// file.
65-
if let Some(shape) =
66-
Self::from_v2_checkpoint_hint(exec, segment, root_checkpoint, file_type, stats_schema)?
67-
{
67+
if let Some(shape) = Self::from_v2_checkpoint_hint(
68+
exec,
69+
segment,
70+
root_checkpoint,
71+
file_type,
72+
stats_schema,
73+
partition_schema,
74+
)? {
6875
return Ok(shape);
6976
}
7077

@@ -77,22 +84,34 @@ impl CheckpointShape {
7784
};
7885
// No `sidecar` column means the file actions are inline, so this is a leaf.
7986
if !cp_schema.contains(SIDECAR_NAME) {
80-
return Ok(Self::try_new_leaf(Some(cp_schema), stats_schema));
87+
return Ok(Self::try_new_leaf(
88+
Some(cp_schema),
89+
stats_schema,
90+
partition_schema,
91+
));
8192
}
8293
// The `sidecar` column may still be all-null (not a manifest), so scan it to
8394
// confirm whether a sidecar is actually present.
8495
match collect_single_sidecar(exec, root_checkpoint, file_type, &segment.log_root)? {
85-
Some(sidecar) => Self::try_new_manifest(exec, sidecar, stats_schema),
86-
None => Ok(Self::try_new_leaf(Some(cp_schema), stats_schema)),
96+
Some(sidecar) => {
97+
Self::try_new_manifest(exec, sidecar, stats_schema, partition_schema)
98+
}
99+
None => Ok(Self::try_new_leaf(
100+
Some(cp_schema),
101+
stats_schema,
102+
partition_schema,
103+
)),
87104
}
88105
}
89106
// A JSON checkpoint has no footer schema to inspect, so try to collect a sidecar to
90107
// decide if it is a manifest or a leaf. A JSON leaf has no readable schema,
91108
// hence no parsed stats.
92109
FileType::Json => {
93110
match collect_single_sidecar(exec, root_checkpoint, file_type, &segment.log_root)? {
94-
Some(sidecar) => Self::try_new_manifest(exec, sidecar, stats_schema),
95-
None => Ok(Self::try_new_leaf(None, stats_schema)),
111+
Some(sidecar) => {
112+
Self::try_new_manifest(exec, sidecar, stats_schema, partition_schema)
113+
}
114+
None => Ok(Self::try_new_leaf(None, stats_schema, partition_schema)),
96115
}
97116
}
98117
}
@@ -109,26 +128,32 @@ impl CheckpointShape {
109128
root_checkpoint: &FileMeta,
110129
file_type: FileType,
111130
stats_schema: Option<&SchemaRef>,
131+
partition_schema: Option<&SchemaRef>,
112132
) -> DeltaResult<Option<CheckpointShape>> {
113133
match segment.checkpoint_hint_sidecars().map(Vec::as_slice) {
114134
Some([sidecar, ..]) => {
115135
let sidecar_meta = sidecar.to_filemeta(&segment.log_root)?;
116-
let result = Self::try_new_manifest(exec, sidecar_meta, stats_schema)?;
136+
let result =
137+
Self::try_new_manifest(exec, sidecar_meta, stats_schema, partition_schema)?;
117138
Ok(Some(result))
118139
}
119140
Some([]) => {
120141
// A parquet leaf's stats live in its own schema; read it only when stats are
121142
// requested. A JSON leaf has no readable schema.
122143
let leaf_schema = match file_type {
123-
FileType::Parquet if stats_schema.is_some() => {
144+
FileType::Parquet if stats_schema.is_some() || partition_schema.is_some() => {
124145
Some(match segment.checkpoint_hint_schema() {
125146
Some(schema) => schema,
126147
None => exec.read_parquet_footer(root_checkpoint.clone())?.schema,
127148
})
128149
}
129150
_ => None,
130151
};
131-
Ok(Some(Self::try_new_leaf(leaf_schema, stats_schema)))
152+
Ok(Some(Self::try_new_leaf(
153+
leaf_schema,
154+
stats_schema,
155+
partition_schema,
156+
)))
132157
}
133158
None => Ok(None),
134159
}
@@ -141,18 +166,30 @@ impl CheckpointShape {
141166
exec: &dyn PlanExecutor,
142167
sidecar: FileMeta,
143168
stats_schema: Option<&SchemaRef>,
169+
partition_schema: Option<&SchemaRef>,
144170
) -> DeltaResult<CheckpointShape> {
145-
let parsed_stats_schema = match stats_schema {
146-
Some(stats_schema) => {
147-
let footer_schema = exec.read_parquet_footer(sidecar)?.schema;
148-
LogSegment::schema_has_compatible_stats_parsed(footer_schema.as_ref(), stats_schema)
149-
.then(|| stats_schema.clone())
150-
}
151-
None => None,
171+
let footer_schema = if stats_schema.is_some() || partition_schema.is_some() {
172+
Some(exec.read_parquet_footer(sidecar)?.schema)
173+
} else {
174+
None
152175
};
176+
let parsed_stats_schema = stats_schema.filter(|stats_schema| {
177+
footer_schema.as_ref().is_some_and(|footer_schema| {
178+
LogSegment::schema_has_compatible_stats_parsed(footer_schema, stats_schema)
179+
})
180+
});
181+
let parsed_partition_schema = partition_schema.filter(|partition_schema| {
182+
footer_schema.as_ref().is_some_and(|footer_schema| {
183+
LogSegment::schema_has_compatible_partition_values_parsed(
184+
footer_schema,
185+
partition_schema,
186+
)
187+
})
188+
});
153189
Ok(CheckpointShape {
154190
checkpoint_type: CheckpointType::Manifest,
155-
parsed_stats_schema,
191+
parsed_stats_schema: parsed_stats_schema.cloned(),
192+
parsed_partition_schema: parsed_partition_schema.cloned(),
156193
})
157194
}
158195

@@ -161,15 +198,25 @@ impl CheckpointShape {
161198
fn try_new_leaf(
162199
leaf_schema: Option<SchemaRef>,
163200
stats_schema: Option<&SchemaRef>,
201+
partition_schema: Option<&SchemaRef>,
164202
) -> CheckpointShape {
165203
let parsed_stats_schema = stats_schema.filter(|stats_schema| {
166204
leaf_schema.as_ref().is_some_and(|leaf_schema| {
167205
LogSegment::schema_has_compatible_stats_parsed(leaf_schema.as_ref(), stats_schema)
168206
})
169207
});
208+
let parsed_partition_schema = partition_schema.filter(|partition_schema| {
209+
leaf_schema.as_ref().is_some_and(|leaf_schema| {
210+
LogSegment::schema_has_compatible_partition_values_parsed(
211+
leaf_schema,
212+
partition_schema,
213+
)
214+
})
215+
});
170216
CheckpointShape {
171217
checkpoint_type: CheckpointType::Leaf,
172218
parsed_stats_schema: parsed_stats_schema.cloned(),
219+
parsed_partition_schema: parsed_partition_schema.cloned(),
173220
}
174221
}
175222
}
@@ -301,8 +348,8 @@ mod tests {
301348
let exec = SyncPlanExecutor::default();
302349
let stats_schema = expect_parsed.map(|_| probe_stats_schema());
303350

304-
let shape =
305-
CheckpointShape::try_new(&exec, snapshot.as_ref(), stats_schema.as_ref()).unwrap();
351+
let shape = CheckpointShape::try_new(&exec, snapshot.as_ref(), stats_schema.as_ref(), None)
352+
.unwrap();
306353

307354
assert_eq!(
308355
shape.checkpoint_type, expected_checkpoint,
@@ -353,8 +400,9 @@ mod tests {
353400
load_test_table("v2-checkpoints-parquet-with-sidecars").unwrap();
354401
let exec = CountingExecutor::new();
355402

356-
let shape = CheckpointShape::try_new(&exec, snapshot.as_ref(), Some(&probe_stats_schema()))
357-
.unwrap();
403+
let shape =
404+
CheckpointShape::try_new(&exec, snapshot.as_ref(), Some(&probe_stats_schema()), None)
405+
.unwrap();
358406

359407
assert_eq!(shape.checkpoint_type, CheckpointType::Manifest);
360408
assert_eq!(
@@ -391,8 +439,9 @@ mod tests {
391439
.unwrap();
392440

393441
let exec = CountingExecutor::new();
394-
let shape = CheckpointShape::try_new(&exec, snapshot.as_ref(), Some(&probe_stats_schema()))
395-
.unwrap();
442+
let shape =
443+
CheckpointShape::try_new(&exec, snapshot.as_ref(), Some(&probe_stats_schema()), None)
444+
.unwrap();
396445

397446
assert_eq!(shape.checkpoint_type, CheckpointType::Manifest);
398447
assert!(
@@ -468,6 +517,7 @@ mod tests {
468517
root,
469518
file_type,
470519
stats_schema.as_ref(),
520+
None,
471521
)
472522
.unwrap()
473523
.expect("an empty-sidecars hint must classify without falling through");

kernel/src/engine/arrow_expression/evaluate_expression.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3267,6 +3267,10 @@ mod tests {
32673267
None => Expr::cast(col!("s"), target.clone()),
32683268
};
32693269
let result = evaluate_expression(&expr, &batch, Some(&target))?;
3270+
assert_eq!(
3271+
result.data_type(),
3272+
&ArrowDataType::try_from_kernel(&target)?
3273+
);
32703274
let timestamps = result
32713275
.as_any()
32723276
.downcast_ref::<TimestampMicrosecondArray>()

0 commit comments

Comments
 (0)