From fce5224235570fa5847bddfa9f7c534de3af58a3 Mon Sep 17 00:00:00 2001 From: Xander Date: Mon, 18 May 2026 13:57:53 +0100 Subject: [PATCH 1/7] Allow UTC & +00:00 to be written in parquet writer --- parquet/src/arrow/arrow_writer/levels.rs | 72 +++++++++++++++++++++++- 1 file changed, 71 insertions(+), 1 deletion(-) diff --git a/parquet/src/arrow/arrow_writer/levels.rs b/parquet/src/arrow/arrow_writer/levels.rs index 73f922137620..1357601adbda 100644 --- a/parquet/src/arrow/arrow_writer/levels.rs +++ b/parquet/src/arrow/arrow_writer/levels.rs @@ -728,6 +728,18 @@ impl LevelInfoBuilder { return true; } + // Timestamps with matching unit but UTC-equivalent timezone aliases (e.g. "UTC" + // vs "+00:00") are treated as compatible. The on-disk parquet representation + // depends only on whether the timezone is non-empty (see + // `arrow_to_parquet_type` in `schema/mod.rs`), so accepting these aliases + // does not change what is written. This matches DataFusion's + // `temporal_coercion_strict_timezone` rule. + if let (DataType::Timestamp(au, Some(atz)), DataType::Timestamp(bu, Some(btz))) = (a, b) { + if au == bu && is_utc_alias(atz) && is_utc_alias(btz) { + return true; + } + } + // get the values out of the dictionaries let (a, b) = match (a, b) { (DataType::Dictionary(_, va), DataType::Dictionary(_, vb)) => { @@ -763,6 +775,16 @@ impl LevelInfoBuilder { } } +/// Returns true when `tz` is one of the recognized UTC timezone aliases. +/// +/// Producers of arrow batches use a variety of strings to denote UTC: `"UTC"`, +/// `"+00:00"` or `"Z"` are all common and semantically +/// identical. Treating these as interchangeable lets writers accept batches from +/// upstream systems (DataFusion, Iceberg) that disagree on the canonical spelling. +fn is_utc_alias(tz: &str) -> bool { + matches!(tz, "UTC" | "+00:00" | "Z") +} + /// The data necessary to write a primitive Arrow array to parquet, taking into account /// any non-primitive parents it may have in the arrow representation #[derive(Debug, Clone)] @@ -1029,7 +1051,7 @@ mod tests { use arrow_buffer::{Buffer, ToByteSlice}; use arrow_cast::display::array_value_to_string; use arrow_data::{ArrayData, ArrayDataBuilder}; - use arrow_schema::{Fields, Schema}; + use arrow_schema::{Fields, Schema, TimeUnit}; #[test] fn test_calculate_array_levels_twitter_example() { @@ -2297,6 +2319,54 @@ mod tests { assert_eq!(levels[0], expected_level); } + #[test] + fn timestamp_utc_aliases_are_compatible() { + // Arrays produced by upstream systems often tag UTC differently than the + // writer's target schema. The writer should accept these aliases as long + // as the time unit matches; the on-disk parquet representation only cares + // whether a timezone is set, not what string was used. + let aliases = ["UTC", "+00:00", "Z"]; + for &target in &aliases { + for &source in &aliases { + let target_ty = + DataType::Timestamp(TimeUnit::Microsecond, Some(target.into())); + let source_ty = + DataType::Timestamp(TimeUnit::Microsecond, Some(source.into())); + let array = Arc::new( + TimestampMicrosecondArray::from(vec![0_i64, 1]) + .with_timezone(source), + ) as ArrayRef; + let field = Field::new("ts", target_ty.clone(), true); + LevelInfoBuilder::try_new(&field, Default::default(), &array).unwrap_or_else( + |e| panic!("expected {target} ↔ {source} to be compatible, got: {e}"), + ); + assert!( + LevelInfoBuilder::types_compatible(&target_ty, &source_ty), + "{target} should be compatible with {source}", + ); + } + } + } + + #[test] + fn timestamp_non_utc_timezones_remain_incompatible() { + // Only UTC aliases are folded together; named zones and arbitrary offsets + // must still match exactly so we don't silently misinterpret instants. + let target = DataType::Timestamp(TimeUnit::Microsecond, Some("+00:00".into())); + let cases = [ + DataType::Timestamp(TimeUnit::Microsecond, Some("America/New_York".into())), + DataType::Timestamp(TimeUnit::Microsecond, Some("+05:30".into())), + // Different time unit isn't covered either. + DataType::Timestamp(TimeUnit::Nanosecond, Some("UTC".into())), + ]; + for case in cases { + assert!( + !LevelInfoBuilder::types_compatible(&target, &case), + "{case:?} should not be compatible with {target:?}", + ); + } + } + #[test] fn mismatched_types() { let array = Arc::new(Int32Array::from_iter(0..10)) as ArrayRef; From 3ddd8d7effa51d565eb7c0fedfa2fb240170ad51 Mon Sep 17 00:00:00 2001 From: Xander Date: Mon, 18 May 2026 14:12:42 +0100 Subject: [PATCH 2/7] fmt --- parquet/src/arrow/arrow_writer/levels.rs | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/parquet/src/arrow/arrow_writer/levels.rs b/parquet/src/arrow/arrow_writer/levels.rs index 1357601adbda..0d2682e3ea2f 100644 --- a/parquet/src/arrow/arrow_writer/levels.rs +++ b/parquet/src/arrow/arrow_writer/levels.rs @@ -2328,18 +2328,15 @@ mod tests { let aliases = ["UTC", "+00:00", "Z"]; for &target in &aliases { for &source in &aliases { - let target_ty = - DataType::Timestamp(TimeUnit::Microsecond, Some(target.into())); - let source_ty = - DataType::Timestamp(TimeUnit::Microsecond, Some(source.into())); - let array = Arc::new( - TimestampMicrosecondArray::from(vec![0_i64, 1]) - .with_timezone(source), - ) as ArrayRef; + let target_ty = DataType::Timestamp(TimeUnit::Microsecond, Some(target.into())); + let source_ty = DataType::Timestamp(TimeUnit::Microsecond, Some(source.into())); + let array = + Arc::new(TimestampMicrosecondArray::from(vec![0_i64, 1]).with_timezone(source)) + as ArrayRef; let field = Field::new("ts", target_ty.clone(), true); - LevelInfoBuilder::try_new(&field, Default::default(), &array).unwrap_or_else( - |e| panic!("expected {target} ↔ {source} to be compatible, got: {e}"), - ); + LevelInfoBuilder::try_new(&field, Default::default(), &array).unwrap_or_else(|e| { + panic!("expected {target} ↔ {source} to be compatible, got: {e}") + }); assert!( LevelInfoBuilder::types_compatible(&target_ty, &source_ty), "{target} should be compatible with {source}", From dde4a3e4c25218e2be259be54d9fab5c5519a2f7 Mon Sep 17 00:00:00 2001 From: Xander Date: Mon, 18 May 2026 14:15:45 +0100 Subject: [PATCH 3/7] update docs --- parquet/src/arrow/arrow_writer/levels.rs | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/parquet/src/arrow/arrow_writer/levels.rs b/parquet/src/arrow/arrow_writer/levels.rs index 0d2682e3ea2f..847d31e66ae4 100644 --- a/parquet/src/arrow/arrow_writer/levels.rs +++ b/parquet/src/arrow/arrow_writer/levels.rs @@ -730,10 +730,7 @@ impl LevelInfoBuilder { // Timestamps with matching unit but UTC-equivalent timezone aliases (e.g. "UTC" // vs "+00:00") are treated as compatible. The on-disk parquet representation - // depends only on whether the timezone is non-empty (see - // `arrow_to_parquet_type` in `schema/mod.rs`), so accepting these aliases - // does not change what is written. This matches DataFusion's - // `temporal_coercion_strict_timezone` rule. + // does not change for "UTC" vs "+00:00" so it's fine to accept either as being valid. if let (DataType::Timestamp(au, Some(atz)), DataType::Timestamp(bu, Some(btz))) = (a, b) { if au == bu && is_utc_alias(atz) && is_utc_alias(btz) { return true; @@ -778,9 +775,9 @@ impl LevelInfoBuilder { /// Returns true when `tz` is one of the recognized UTC timezone aliases. /// /// Producers of arrow batches use a variety of strings to denote UTC: `"UTC"`, -/// `"+00:00"` or `"Z"` are all common and semantically -/// identical. Treating these as interchangeable lets writers accept batches from -/// upstream systems (DataFusion, Iceberg) that disagree on the canonical spelling. +/// `"+00:00"` or `"Z"` are all common and semantically identical. Treating these as +/// interchangeable lets writers accept batches from upstream systems (DataFusion, Iceberg) +/// that disagree on the canonical spelling. fn is_utc_alias(tz: &str) -> bool { matches!(tz, "UTC" | "+00:00" | "Z") } From 0576be6269e0294edf988df820149cb5c99dd48f Mon Sep 17 00:00:00 2001 From: Xander Date: Mon, 18 May 2026 14:41:46 +0100 Subject: [PATCH 4/7] move to datatype --- arrow-schema/src/datatype.rs | 17 +++++ parquet/src/arrow/arrow_writer/levels.rs | 88 ++++++++++++++++++------ 2 files changed, 85 insertions(+), 20 deletions(-) diff --git a/arrow-schema/src/datatype.rs b/arrow-schema/src/datatype.rs index 927ad221e9ff..3b995881bffc 100644 --- a/arrow-schema/src/datatype.rs +++ b/arrow-schema/src/datatype.rs @@ -722,6 +722,13 @@ impl DataType { }) }) } + // Timestamps with matching unit whose timezones are both recognized UTC + // aliases are considered equivalent. + (DataType::Timestamp(a_unit, Some(a_tz)), DataType::Timestamp(b_unit, Some(b_tz))) + if a_unit == b_unit && a_tz != b_tz => + { + is_utc_alias(a_tz) && is_utc_alias(b_tz) + } _ => self == other, } } @@ -874,6 +881,16 @@ impl DataType { } } +/// Returns true when `tz` is one of the recognized UTC timezone aliases. +/// +/// Producers of arrow batches use a variety of strings to denote UTC: `"UTC"`, +/// `"+00:00"` and `"Z"` are all common and semantically +/// identical. Treating these as interchangeable lets writers accept batches from +/// upstream systems that disagree on the canonical spelling. +pub fn is_utc_alias(tz: &str) -> bool { + matches!(tz, "UTC" | "+00:00" | "Z") +} + /// The maximum precision for [DataType::Decimal32] values pub const DECIMAL32_MAX_PRECISION: u8 = 9; diff --git a/parquet/src/arrow/arrow_writer/levels.rs b/parquet/src/arrow/arrow_writer/levels.rs index 847d31e66ae4..01e7639bcca6 100644 --- a/parquet/src/arrow/arrow_writer/levels.rs +++ b/parquet/src/arrow/arrow_writer/levels.rs @@ -723,20 +723,10 @@ impl LevelInfoBuilder { /// and the other is a native array, the dictionary values must have the same type as the /// native array fn types_compatible(a: &DataType, b: &DataType) -> bool { - // if the Arrow data types are equal, the types are deemed compatible if a.equals_datatype(b) { return true; } - // Timestamps with matching unit but UTC-equivalent timezone aliases (e.g. "UTC" - // vs "+00:00") are treated as compatible. The on-disk parquet representation - // does not change for "UTC" vs "+00:00" so it's fine to accept either as being valid. - if let (DataType::Timestamp(au, Some(atz)), DataType::Timestamp(bu, Some(btz))) = (a, b) { - if au == bu && is_utc_alias(atz) && is_utc_alias(btz) { - return true; - } - } - // get the values out of the dictionaries let (a, b) = match (a, b) { (DataType::Dictionary(_, va), DataType::Dictionary(_, vb)) => { @@ -772,16 +762,6 @@ impl LevelInfoBuilder { } } -/// Returns true when `tz` is one of the recognized UTC timezone aliases. -/// -/// Producers of arrow batches use a variety of strings to denote UTC: `"UTC"`, -/// `"+00:00"` or `"Z"` are all common and semantically identical. Treating these as -/// interchangeable lets writers accept batches from upstream systems (DataFusion, Iceberg) -/// that disagree on the canonical spelling. -fn is_utc_alias(tz: &str) -> bool { - matches!(tz, "UTC" | "+00:00" | "Z") -} - /// The data necessary to write a primitive Arrow array to parquet, taking into account /// any non-primitive parents it may have in the arrow representation #[derive(Debug, Clone)] @@ -2342,6 +2322,74 @@ mod tests { } } + #[test] + fn timestamp_utc_aliases_are_compatible_when_nested() { + let leaf_target = DataType::Timestamp(TimeUnit::Microsecond, Some("+00:00".into())); + let leaf_source = DataType::Timestamp(TimeUnit::Microsecond, Some("UTC".into())); + + let nests: &[(DataType, DataType)] = &[ + ( + DataType::List(Arc::new(Field::new_list_field(leaf_target.clone(), true))), + DataType::List(Arc::new(Field::new_list_field(leaf_source.clone(), true))), + ), + ( + DataType::LargeList(Arc::new(Field::new_list_field(leaf_target.clone(), true))), + DataType::LargeList(Arc::new(Field::new_list_field(leaf_source.clone(), true))), + ), + ( + DataType::FixedSizeList( + Arc::new(Field::new_list_field(leaf_target.clone(), true)), + 3, + ), + DataType::FixedSizeList( + Arc::new(Field::new_list_field(leaf_source.clone(), true)), + 3, + ), + ), + ( + DataType::Struct(vec![Field::new("ts", leaf_target.clone(), true)].into()), + DataType::Struct(vec![Field::new("ts", leaf_source.clone(), true)].into()), + ), + ( + DataType::Map( + Arc::new(Field::new( + "entries", + DataType::Struct( + vec![ + Field::new("keys", DataType::Utf8, false), + Field::new("values", leaf_target.clone(), true), + ] + .into(), + ), + false, + )), + false, + ), + DataType::Map( + Arc::new(Field::new( + "entries", + DataType::Struct( + vec![ + Field::new("keys", DataType::Utf8, false), + Field::new("values", leaf_source.clone(), true), + ] + .into(), + ), + false, + )), + false, + ), + ), + ]; + + for (target, source) in nests { + assert!( + LevelInfoBuilder::types_compatible(target, source), + "nested UTC alias mismatch should be compatible: {target:?} vs {source:?}", + ); + } + } + #[test] fn timestamp_non_utc_timezones_remain_incompatible() { // Only UTC aliases are folded together; named zones and arbitrary offsets From cbc595e511ab1c8c0319618c14ceac7c58072851 Mon Sep 17 00:00:00 2001 From: Xander Date: Mon, 18 May 2026 14:45:07 +0100 Subject: [PATCH 5/7] tests --- arrow-schema/src/datatype.rs | 97 ++++++++++++++++++ parquet/src/arrow/arrow_writer/levels.rs | 123 +++-------------------- 2 files changed, 110 insertions(+), 110 deletions(-) diff --git a/arrow-schema/src/datatype.rs b/arrow-schema/src/datatype.rs index 3b995881bffc..aa9a57ea19ea 100644 --- a/arrow-schema/src/datatype.rs +++ b/arrow-schema/src/datatype.rs @@ -1307,4 +1307,101 @@ mod tests { ) "); } + + #[test] + fn test_is_utc_alias() { + assert!(is_utc_alias("UTC")); + assert!(is_utc_alias("+00:00")); + assert!(is_utc_alias("Z")); + assert!(!is_utc_alias("America/New_York")); + assert!(!is_utc_alias("+05:30")); + assert!(!is_utc_alias("")); + assert!(!is_utc_alias("utc")); + assert!(!is_utc_alias("+0000")); + assert!(!is_utc_alias("+00")); + } + + #[test] + fn test_equals_datatype_utc_aliases_flat() { + use crate::TimeUnit; + let aliases = ["UTC", "+00:00", "Z"]; + for a in &aliases { + for b in &aliases { + let ta = DataType::Timestamp(TimeUnit::Microsecond, Some((*a).into())); + let tb = DataType::Timestamp(TimeUnit::Microsecond, Some((*b).into())); + assert!(ta.equals_datatype(&tb), "{a} should be equivalent to {b}",); + } + } + } + + #[test] + fn test_equals_datatype_utc_aliases_nested() { + use crate::TimeUnit; + let leaf_a = DataType::Timestamp(TimeUnit::Microsecond, Some("+00:00".into())); + let leaf_b = DataType::Timestamp(TimeUnit::Microsecond, Some("UTC".into())); + + // List + let list_a = DataType::List(Arc::new(Field::new("item", leaf_a.clone(), true))); + let list_b = DataType::List(Arc::new(Field::new("item", leaf_b.clone(), true))); + assert!(list_a.equals_datatype(&list_b)); + + // LargeList + let ll_a = DataType::LargeList(Arc::new(Field::new("item", leaf_a.clone(), true))); + let ll_b = DataType::LargeList(Arc::new(Field::new("item", leaf_b.clone(), true))); + assert!(ll_a.equals_datatype(&ll_b)); + + // FixedSizeList + let fsl_a = DataType::FixedSizeList(Arc::new(Field::new("item", leaf_a.clone(), true)), 3); + let fsl_b = DataType::FixedSizeList(Arc::new(Field::new("item", leaf_b.clone(), true)), 3); + assert!(fsl_a.equals_datatype(&fsl_b)); + + // Struct + let struct_a = DataType::Struct(vec![Field::new("ts", leaf_a.clone(), true)].into()); + let struct_b = DataType::Struct(vec![Field::new("ts", leaf_b.clone(), true)].into()); + assert!(struct_a.equals_datatype(&struct_b)); + + // Map + let map_a = DataType::Map( + Arc::new(Field::new( + "entries", + DataType::Struct( + vec![ + Field::new("keys", DataType::Utf8, false), + Field::new("values", leaf_a.clone(), true), + ] + .into(), + ), + false, + )), + false, + ); + let map_b = DataType::Map( + Arc::new(Field::new( + "entries", + DataType::Struct( + vec![ + Field::new("keys", DataType::Utf8, false), + Field::new("values", leaf_b.clone(), true), + ] + .into(), + ), + false, + )), + false, + ); + assert!(map_a.equals_datatype(&map_b)); + } + + #[test] + fn test_equals_datatype_non_utc_timezones_differ() { + use crate::TimeUnit; + let utc = DataType::Timestamp(TimeUnit::Microsecond, Some("+00:00".into())); + let est = DataType::Timestamp(TimeUnit::Microsecond, Some("America/New_York".into())); + let offset = DataType::Timestamp(TimeUnit::Microsecond, Some("+05:30".into())); + let diff_unit = DataType::Timestamp(TimeUnit::Nanosecond, Some("UTC".into())); + + assert!(!utc.equals_datatype(&est)); + assert!(!utc.equals_datatype(&offset)); + assert!(!utc.equals_datatype(&diff_unit)); + } } diff --git a/parquet/src/arrow/arrow_writer/levels.rs b/parquet/src/arrow/arrow_writer/levels.rs index 01e7639bcca6..05f7e6e67fa4 100644 --- a/parquet/src/arrow/arrow_writer/levels.rs +++ b/parquet/src/arrow/arrow_writer/levels.rs @@ -2297,116 +2297,19 @@ mod tests { } #[test] - fn timestamp_utc_aliases_are_compatible() { - // Arrays produced by upstream systems often tag UTC differently than the - // writer's target schema. The writer should accept these aliases as long - // as the time unit matches; the on-disk parquet representation only cares - // whether a timezone is set, not what string was used. - let aliases = ["UTC", "+00:00", "Z"]; - for &target in &aliases { - for &source in &aliases { - let target_ty = DataType::Timestamp(TimeUnit::Microsecond, Some(target.into())); - let source_ty = DataType::Timestamp(TimeUnit::Microsecond, Some(source.into())); - let array = - Arc::new(TimestampMicrosecondArray::from(vec![0_i64, 1]).with_timezone(source)) - as ArrayRef; - let field = Field::new("ts", target_ty.clone(), true); - LevelInfoBuilder::try_new(&field, Default::default(), &array).unwrap_or_else(|e| { - panic!("expected {target} ↔ {source} to be compatible, got: {e}") - }); - assert!( - LevelInfoBuilder::types_compatible(&target_ty, &source_ty), - "{target} should be compatible with {source}", - ); - } - } - } - - #[test] - fn timestamp_utc_aliases_are_compatible_when_nested() { - let leaf_target = DataType::Timestamp(TimeUnit::Microsecond, Some("+00:00".into())); - let leaf_source = DataType::Timestamp(TimeUnit::Microsecond, Some("UTC".into())); - - let nests: &[(DataType, DataType)] = &[ - ( - DataType::List(Arc::new(Field::new_list_field(leaf_target.clone(), true))), - DataType::List(Arc::new(Field::new_list_field(leaf_source.clone(), true))), - ), - ( - DataType::LargeList(Arc::new(Field::new_list_field(leaf_target.clone(), true))), - DataType::LargeList(Arc::new(Field::new_list_field(leaf_source.clone(), true))), - ), - ( - DataType::FixedSizeList( - Arc::new(Field::new_list_field(leaf_target.clone(), true)), - 3, - ), - DataType::FixedSizeList( - Arc::new(Field::new_list_field(leaf_source.clone(), true)), - 3, - ), - ), - ( - DataType::Struct(vec![Field::new("ts", leaf_target.clone(), true)].into()), - DataType::Struct(vec![Field::new("ts", leaf_source.clone(), true)].into()), - ), - ( - DataType::Map( - Arc::new(Field::new( - "entries", - DataType::Struct( - vec![ - Field::new("keys", DataType::Utf8, false), - Field::new("values", leaf_target.clone(), true), - ] - .into(), - ), - false, - )), - false, - ), - DataType::Map( - Arc::new(Field::new( - "entries", - DataType::Struct( - vec![ - Field::new("keys", DataType::Utf8, false), - Field::new("values", leaf_source.clone(), true), - ] - .into(), - ), - false, - )), - false, - ), - ), - ]; - - for (target, source) in nests { - assert!( - LevelInfoBuilder::types_compatible(target, source), - "nested UTC alias mismatch should be compatible: {target:?} vs {source:?}", - ); - } - } - - #[test] - fn timestamp_non_utc_timezones_remain_incompatible() { - // Only UTC aliases are folded together; named zones and arbitrary offsets - // must still match exactly so we don't silently misinterpret instants. - let target = DataType::Timestamp(TimeUnit::Microsecond, Some("+00:00".into())); - let cases = [ - DataType::Timestamp(TimeUnit::Microsecond, Some("America/New_York".into())), - DataType::Timestamp(TimeUnit::Microsecond, Some("+05:30".into())), - // Different time unit isn't covered either. - DataType::Timestamp(TimeUnit::Nanosecond, Some("UTC".into())), - ]; - for case in cases { - assert!( - !LevelInfoBuilder::types_compatible(&target, &case), - "{case:?} should not be compatible with {target:?}", - ); - } + fn timestamp_utc_aliases_accepted_by_writer() { + // Verifies that LevelInfoBuilder::try_new (the writer's entry point) + // accepts arrays whose timezone is a UTC alias different from the field's. + // Detailed equivalence tests live in arrow_schema::datatype::tests. + let field = Field::new( + "ts", + DataType::Timestamp(TimeUnit::Microsecond, Some("+00:00".into())), + true, + ); + let array = Arc::new(TimestampMicrosecondArray::from(vec![0_i64, 1]).with_timezone("UTC")) + as ArrayRef; + LevelInfoBuilder::try_new(&field, Default::default(), &array) + .expect("UTC and +00:00 should be treated as compatible"); } #[test] From 7f8007b44c91ecc4a924649e3e360a3252ecb726 Mon Sep 17 00:00:00 2001 From: Xander Date: Mon, 18 May 2026 14:49:58 +0100 Subject: [PATCH 6/7] put this back --- parquet/src/arrow/arrow_writer/levels.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/parquet/src/arrow/arrow_writer/levels.rs b/parquet/src/arrow/arrow_writer/levels.rs index 05f7e6e67fa4..a6472bd7949d 100644 --- a/parquet/src/arrow/arrow_writer/levels.rs +++ b/parquet/src/arrow/arrow_writer/levels.rs @@ -723,6 +723,7 @@ impl LevelInfoBuilder { /// and the other is a native array, the dictionary values must have the same type as the /// native array fn types_compatible(a: &DataType, b: &DataType) -> bool { + // if the Arrow data types are equal, the types are deemed compatible if a.equals_datatype(b) { return true; } From efb9e3cf9af157d3013b8d3a3601facc36a17c30 Mon Sep 17 00:00:00 2001 From: Xander Date: Mon, 18 May 2026 20:27:56 +0100 Subject: [PATCH 7/7] ci