Skip to content

Commit 10111fe

Browse files
committed
feat: honor reader timezone in partition values
1 parent deb65cd commit 10111fe

20 files changed

Lines changed: 1108 additions & 299 deletions

File tree

Cargo.lock

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

datafusion-executor/Cargo.lock

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

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

Lines changed: 19 additions & 6 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,19 @@ 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 a recognized IANA timezone or a normalized `+HH:MM`, `-HH:MM`,
289+
`+HH:MM:SS`, or `-HH:MM:SS` fixed offset when the reader uses another timezone. An explicit offset
290+
in a partition value takes precedence. This setting affects typed `scan_metadata` output,
291+
partition predicate evaluation after log replay, and the partition-column row transforms used by
292+
`Scan::execute`. Checkpoint footer pruning continues to use the checkpoint's native parsed
293+
partition values. Incremental scans expose the raw partition-value map. For daylight-saving
294+
transitions, ambiguous local times use the earlier instant, and nonexistent local times use the
295+
offset from before the transition.
296+
284297
> [!TIP]
285-
> When the checkpoint already stores typed partition values, Kernel reads that column directly
286-
> and skips parsing entirely.
298+
> Kernel parses typed partition values from the raw map for both commits and checkpoints. This
299+
> keeps reader-timezone behavior independent of the timezone used to write a checkpoint.
287300
288301
## Cancelling a scan
289302

kernel/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ pre-release-hook = [
4242
delta_kernel_derive = { path = "../derive-macros", version = "0.28.0" }
4343
bytes = "1.10"
4444
chrono = "0.4.41"
45+
chrono-tz = "0.10.4"
4546
crc = "3.2.2"
4647
indexmap = "2.10.0"
4748
itertools = "0.14"

kernel/src/checkpoint/checkpoint_shape.rs

Lines changed: 87 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
//! Resolves a checkpoint shape. This falls into the following cases: no checkpoint,
22
//! leaf (file actions inline, including multi-part), or manifest (which references sidecar files).
3-
//! When stats are requested, also reports whether the checkpoint has compatible parsed stats.
3+
//! When typed metadata is requested, also reports whether the checkpoint has compatible parsed
4+
//! stats and partition values.
45
//! Driven through a [`PlanExecutor`].
5-
// No in-crate caller yet; following PRs will use this.
6-
#![allow(dead_code)]
6+
use std::sync::Arc;
77

88
use url::Url;
99

@@ -28,23 +28,27 @@ pub(crate) enum CheckpointType {
2828
Manifest,
2929
}
3030

31-
/// A snapshot's resolved checkpoint type and parsed-stats schema.
31+
/// A snapshot's resolved checkpoint type and compatible typed metadata schemas.
3232
#[derive(Clone, Debug, PartialEq)]
3333
pub(crate) struct CheckpointShape {
3434
/// What kind of checkpoint this is.
3535
pub(crate) checkpoint_type: CheckpointType,
3636
/// The requested stats schema when the checkpoint has a compatible `add.stats_parsed` struct
3737
/// to read it from; `None` when stats were not requested or no compatible parsed stats exist.
3838
pub(crate) parsed_stats_schema: Option<SchemaRef>,
39+
/// The requested partition schema when the checkpoint has a compatible
40+
/// `add.partitionValues_parsed` struct; `None` when partitions were not requested or no
41+
/// compatible parsed values exist.
42+
pub(crate) parsed_partition_values_schema: Option<SchemaRef>,
3943
}
4044

4145
impl CheckpointShape {
42-
/// Resolve `snapshot`'s checkpoint shape. Determines the checkpoint type and, when
43-
/// `stats_schema` is `Some`, whether the checkpoint contains parsed stats compatible with it.
46+
/// Resolve `snapshot`'s checkpoint shape and compatible typed metadata fields.
4447
pub(crate) fn try_new(
4548
exec: &dyn PlanExecutor,
4649
snapshot: &Snapshot,
4750
stats_schema: Option<&SchemaRef>,
51+
partition_schema: Option<&SchemaRef>,
4852
) -> DeltaResult<CheckpointShape> {
4953
let segment = snapshot.log_segment();
5054

@@ -55,15 +59,21 @@ impl CheckpointShape {
5559
return Ok(CheckpointShape {
5660
checkpoint_type: CheckpointType::None,
5761
parsed_stats_schema: None,
62+
parsed_partition_values_schema: None,
5863
})
5964
}
6065
};
6166

6267
// Classify from a V2 checkpoint's `_last_checkpoint` hint when possible, else inspect the
6368
// file.
64-
if let Some(shape) =
65-
Self::from_v2_checkpoint_hint(exec, segment, root_checkpoint, file_type, stats_schema)?
66-
{
69+
if let Some(shape) = Self::from_v2_checkpoint_hint(
70+
exec,
71+
segment,
72+
root_checkpoint,
73+
file_type,
74+
stats_schema,
75+
partition_schema,
76+
)? {
6777
return Ok(shape);
6878
}
6979

@@ -76,7 +86,11 @@ impl CheckpointShape {
7686
};
7787
// No `sidecar` column means the file actions are inline, so this is a leaf.
7888
if !cp_schema.contains(SIDECAR_NAME) {
79-
return Ok(Self::try_new_leaf(Some(cp_schema), stats_schema));
89+
return Ok(Self::try_new_leaf(
90+
Some(cp_schema),
91+
stats_schema,
92+
partition_schema,
93+
));
8094
}
8195
// The `sidecar` column may still be all-null (not a manifest), so scan it to
8296
// confirm whether a sidecar is actually present.
@@ -85,9 +99,14 @@ impl CheckpointShape {
8599
exec,
86100
sidecar,
87101
stats_schema,
102+
partition_schema,
88103
segment.checkpoint_hint_sidecar_file_schema(),
89104
),
90-
None => Ok(Self::try_new_leaf(Some(cp_schema), stats_schema)),
105+
None => Ok(Self::try_new_leaf(
106+
Some(cp_schema),
107+
stats_schema,
108+
partition_schema,
109+
)),
91110
}
92111
}
93112
// A JSON checkpoint has no footer schema to inspect, so try to collect a sidecar to
@@ -99,9 +118,10 @@ impl CheckpointShape {
99118
exec,
100119
sidecar,
101120
stats_schema,
121+
partition_schema,
102122
segment.checkpoint_hint_sidecar_file_schema(),
103123
),
104-
None => Ok(Self::try_new_leaf(None, stats_schema)),
124+
None => Ok(Self::try_new_leaf(None, stats_schema, partition_schema)),
105125
}
106126
}
107127
}
@@ -118,6 +138,7 @@ impl CheckpointShape {
118138
root_checkpoint: &FileMeta,
119139
file_type: FileType,
120140
stats_schema: Option<&SchemaRef>,
141+
partition_schema: Option<&SchemaRef>,
121142
) -> DeltaResult<Option<CheckpointShape>> {
122143
match segment.checkpoint_hint_sidecars().map(Vec::as_slice) {
123144
Some([sidecar, ..]) => {
@@ -126,62 +147,67 @@ impl CheckpointShape {
126147
exec,
127148
sidecar_meta,
128149
stats_schema,
150+
partition_schema,
129151
segment.checkpoint_hint_sidecar_file_schema(),
130152
)?;
131153
Ok(Some(result))
132154
}
133155
Some([]) => {
134-
// A parquet leaf's stats live in its own schema; read it only when stats are
135-
// requested. A JSON leaf has no readable schema.
156+
// A parquet leaf's typed metadata lives in its own schema; read it only when typed
157+
// metadata was requested. A JSON leaf has no readable schema.
136158
let leaf_schema = match file_type {
137-
FileType::Parquet if stats_schema.is_some() => {
159+
FileType::Parquet if stats_schema.is_some() || partition_schema.is_some() => {
138160
Some(match segment.checkpoint_hint_schema() {
139161
Some(schema) => schema,
140162
None => exec.read_parquet_footer(root_checkpoint.clone())?.schema,
141163
})
142164
}
143165
_ => None,
144166
};
145-
Ok(Some(Self::try_new_leaf(leaf_schema, stats_schema)))
167+
Ok(Some(Self::try_new_leaf(
168+
leaf_schema,
169+
stats_schema,
170+
partition_schema,
171+
)))
146172
}
147173
None => Ok(None),
148174
}
149175
}
150176

151-
/// Build the shape for a manifest checkpoint. Its file actions and their stats live in the
152-
/// sidecars, so we need a sidecar's schema to answer the stats-compatibility question -- but
153-
/// only when stats were requested. All sidecars of a checkpoint share one schema, so any one is
154-
/// sufficient.
155-
///
156-
/// If the `_last_checkpoint` hint carries a `sidecarFileSchema`, use it directly,
157-
/// Otherwise read the sidecar's footer to get the schema.
177+
/// Build the shape for a manifest checkpoint. Its file actions and typed metadata live in the
178+
/// sidecars. Use the `_last_checkpoint` sidecar schema when present; otherwise read one
179+
/// sidecar footer when typed metadata was requested. All sidecars share one schema.
158180
fn try_new_manifest(
159181
exec: &dyn PlanExecutor,
160182
sidecar: FileMeta,
161183
stats_schema: Option<&SchemaRef>,
184+
partition_schema: Option<&SchemaRef>,
162185
hint_sidecar_schema: Option<StructType>,
163186
) -> DeltaResult<CheckpointShape> {
164-
let parsed_stats_schema = match stats_schema {
165-
Some(stats_schema) => {
166-
let compatible = match hint_sidecar_schema {
167-
Some(schema) => {
168-
LogSegment::schema_has_compatible_stats_parsed(&schema, stats_schema)
169-
}
170-
None => {
171-
let footer_schema = exec.read_parquet_footer(sidecar)?.schema;
172-
LogSegment::schema_has_compatible_stats_parsed(
173-
footer_schema.as_ref(),
174-
stats_schema,
175-
)
176-
}
177-
};
178-
compatible.then(|| stats_schema.clone())
187+
let sidecar_schema = match hint_sidecar_schema {
188+
Some(schema) => Some(Arc::new(schema)),
189+
None if stats_schema.is_some() || partition_schema.is_some() => {
190+
Some(exec.read_parquet_footer(sidecar)?.schema)
179191
}
180192
None => None,
181193
};
194+
let parsed_stats_schema = stats_schema.filter(|stats_schema| {
195+
sidecar_schema.as_ref().is_some_and(|sidecar_schema| {
196+
LogSegment::schema_has_compatible_stats_parsed(sidecar_schema, stats_schema)
197+
})
198+
});
199+
let parsed_partition_values_schema = partition_schema.filter(|partition_schema| {
200+
sidecar_schema.as_ref().is_some_and(|sidecar_schema| {
201+
LogSegment::schema_has_compatible_partition_values_parsed(
202+
sidecar_schema,
203+
partition_schema,
204+
)
205+
})
206+
});
182207
Ok(CheckpointShape {
183208
checkpoint_type: CheckpointType::Manifest,
184-
parsed_stats_schema,
209+
parsed_stats_schema: parsed_stats_schema.cloned(),
210+
parsed_partition_values_schema: parsed_partition_values_schema.cloned(),
185211
})
186212
}
187213

@@ -190,15 +216,25 @@ impl CheckpointShape {
190216
fn try_new_leaf(
191217
leaf_schema: Option<SchemaRef>,
192218
stats_schema: Option<&SchemaRef>,
219+
partition_schema: Option<&SchemaRef>,
193220
) -> CheckpointShape {
194221
let parsed_stats_schema = stats_schema.filter(|stats_schema| {
195222
leaf_schema.as_ref().is_some_and(|leaf_schema| {
196223
LogSegment::schema_has_compatible_stats_parsed(leaf_schema.as_ref(), stats_schema)
197224
})
198225
});
226+
let parsed_partition_values_schema = partition_schema.filter(|partition_schema| {
227+
leaf_schema.as_ref().is_some_and(|leaf_schema| {
228+
LogSegment::schema_has_compatible_partition_values_parsed(
229+
leaf_schema,
230+
partition_schema,
231+
)
232+
})
233+
});
199234
CheckpointShape {
200235
checkpoint_type: CheckpointType::Leaf,
201236
parsed_stats_schema: parsed_stats_schema.cloned(),
237+
parsed_partition_values_schema: parsed_partition_values_schema.cloned(),
202238
}
203239
}
204240
}
@@ -334,8 +370,8 @@ mod tests {
334370
let exec = SyncPlanExecutor::default();
335371
let stats_schema = expect_parsed.map(|_| probe_stats_schema());
336372

337-
let shape =
338-
CheckpointShape::try_new(&exec, snapshot.as_ref(), stats_schema.as_ref()).unwrap();
373+
let shape = CheckpointShape::try_new(&exec, snapshot.as_ref(), stats_schema.as_ref(), None)
374+
.unwrap();
339375

340376
assert_eq!(
341377
shape.checkpoint_type, expected_checkpoint,
@@ -381,8 +417,9 @@ mod tests {
381417
load_test_table("v2-checkpoints-parquet-with-sidecars").unwrap();
382418
let exec = CountingExecutor::new();
383419

384-
let shape = CheckpointShape::try_new(&exec, snapshot.as_ref(), Some(&probe_stats_schema()))
385-
.unwrap();
420+
let shape =
421+
CheckpointShape::try_new(&exec, snapshot.as_ref(), Some(&probe_stats_schema()), None)
422+
.unwrap();
386423

387424
assert_eq!(shape.checkpoint_type, CheckpointType::Manifest);
388425
assert_eq!(
@@ -419,8 +456,9 @@ mod tests {
419456
.unwrap();
420457

421458
let exec = CountingExecutor::new();
422-
let shape = CheckpointShape::try_new(&exec, snapshot.as_ref(), Some(&probe_stats_schema()))
423-
.unwrap();
459+
let shape =
460+
CheckpointShape::try_new(&exec, snapshot.as_ref(), Some(&probe_stats_schema()), None)
461+
.unwrap();
424462

425463
assert_eq!(shape.checkpoint_type, CheckpointType::Manifest);
426464
assert!(
@@ -492,6 +530,7 @@ mod tests {
492530
root,
493531
file_type,
494532
stats_schema.as_ref(),
533+
None,
495534
)
496535
.unwrap()
497536
.expect("an empty-sidecars hint must classify without falling through");
@@ -602,6 +641,7 @@ mod tests {
602641
root,
603642
file_type,
604643
Some(&stats_schema),
644+
None,
605645
)
606646
.unwrap()
607647
.expect("a sidecar-listing hint must classify as a manifest");
@@ -636,7 +676,7 @@ mod tests {
636676

637677
// Full resolution reads the sidecar footer to answer compatibility.
638678
let footer_shape =
639-
CheckpointShape::try_new(&exec, snapshot.as_ref(), Some(&stats_schema)).unwrap();
679+
CheckpointShape::try_new(&exec, snapshot.as_ref(), Some(&stats_schema), None).unwrap();
640680
assert_eq!(footer_shape.checkpoint_type, CheckpointType::Manifest);
641681

642682
// Read that same footer schema ourselves and feed it as the hint schema.
@@ -651,6 +691,7 @@ mod tests {
651691
&exec,
652692
sidecar,
653693
Some(&stats_schema),
694+
None,
654695
Some(footer_schema.as_ref().clone()),
655696
)
656697
.unwrap();

kernel/src/checkpoint/checkpoint_transform.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -442,8 +442,8 @@ mod tests {
442442
}
443443

444444
/// The checkpoint falls back to `MAP_TO_STRUCT` over `partitionValues` when no native
445-
/// `partitionValues_parsed` column is present, so a checkpoint reconstructs the same typed
446-
/// struct the scan reads and the two can never disagree on a value.
445+
/// `partitionValues_parsed` column is present, so footer skipping still has typed partition
446+
/// values for checkpoints that do not provide them natively.
447447
#[test]
448448
fn build_partition_values_parsed_expr_falls_back_to_map_to_struct() {
449449
let expr = build_partition_values_parsed_expr();

0 commit comments

Comments
 (0)