Skip to main content

common_meta/ddl/alter_table/
partition_compat.rs

1// Copyright 2023 Greptime Team
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Preflight validation of persisted partition expressions against an ALTER TABLE schema change.
16//!
17//! Persisted partition bounds keep the types they were written with. A column whose
18//! type changes while a partition expression references it breaks the region on both
19//! paths that consume the bounds:
20//!
21//! * On scan, the region coerces the bound literal and the new column type to a
22//!   common comparison type while building the partition predicate. If no common type
23//!   exists, or the bound cannot be cast to it (a classic example is a millisecond
24//!   timestamp bound that overflows when the time index column is widened to
25//!   nanoseconds), every scan of that region fails at that point.
26//! * On write, the region routes rows through `MultiDimPartitionRule::find_region`,
27//!   which compares the persisted bound against row values directly (`Value::cmp`): a
28//!   bound whose variant no longer matches the column's panics on ordered comparisons
29//!   and misroutes silently on equality. Only a timestamp unit widening keeps the two
30//!   comparable — `Timestamp` comparisons convert units before comparing — so every
31//!   other type change of a referenced column is rejected outright, and the widening
32//!   keeps the scan check below.
33//!
34//! Such an ALTER must be rejected before it is submitted to any region, otherwise
35//! already-altered regions keep failing while the rest of the table is untouched.
36//!
37//! The `partition` crate depends on `common-meta`, so this crate cannot depend on
38//! `partition` back and cannot reuse [`partition::expr::PartitionExpr`]. The `Persisted*`
39//! types below mirror the JSON representation of `partition::partition::PartitionBound` and
40//! `partition::expr::PartitionExpr` and must be kept in sync with `partition/src/partition.rs`
41//! and `partition/src/expr.rs`; round-trip tests in `partition` run real serialized
42//! expressions through this validator so drift fails in CI. The compatibility check itself
43//! deliberately repeats the same conversion (value -> [`ScalarValue`]) and the same
44//! comparison coercion the region performs when it builds the partition predicate
45//! (`ExprSimplifier::coerce` / [`comparison_coercion`]): both sides are cast to the common
46//! comparison type, and the persisted bound must be castable to that type with
47//! [`ScalarValue::cast_to`]. A bound that would fail on scan is rejected here as well.
48
49use std::collections::HashMap;
50
51use common_telemetry::warn;
52use datafusion_common::ScalarValue;
53use datafusion_expr::type_coercion::binary::comparison_coercion;
54use datatypes::data_type::{ConcreteDataType, DataType};
55use datatypes::schema::Schema;
56use datatypes::value::{
57    Value, duration_to_scalar_value, time_to_scalar_value, timestamp_to_scalar_value,
58};
59use serde::Deserialize;
60use store_api::storage::RegionId;
61
62use crate::error::{PartitionExprIncompatibleSnafu, Result};
63use crate::rpc::router::RegionRoute;
64
65/// Mirrors `partition::partition::PartitionBound`.
66#[derive(Debug, Deserialize)]
67enum PersistedPartitionBound {
68    /// Deprecated since 0.9.0. Only deserialized to keep unknown old formats parseable.
69    #[allow(dead_code)]
70    Value(Value),
71    /// Deprecated since 0.15.0.
72    MaxValue,
73    Expr(PersistedPartitionExpr),
74}
75
76/// Mirrors `partition::expr::PartitionExpr`.
77#[derive(Debug, Deserialize)]
78struct PersistedPartitionExpr {
79    lhs: Box<PersistedOperand>,
80    op: PersistedOp,
81    rhs: Box<PersistedOperand>,
82}
83
84/// Mirrors `partition::expr::Operand`.
85#[derive(Debug, Deserialize)]
86enum PersistedOperand {
87    Column(String),
88    Value(Value),
89    Expr(PersistedPartitionExpr),
90}
91
92/// Mirrors `partition::expr::RestrictedOp`.
93#[derive(Debug, Deserialize)]
94enum PersistedOp {
95    Eq,
96    NotEq,
97    Lt,
98    LtEq,
99    Gt,
100    GtEq,
101    And,
102    Or,
103}
104
105/// Returns the columns whose data type differs between `old_schema` and `new_schema`,
106/// mapped to (old type, new type). Columns only present in one of the schemas (added or
107/// dropped) are not included; dropping a column referenced by a partition expression is
108/// already rejected by the table metadata validation.
109pub(crate) fn changed_column_types(
110    old_schema: &Schema,
111    new_schema: &Schema,
112) -> HashMap<String, (ConcreteDataType, ConcreteDataType)> {
113    let mut changed = HashMap::new();
114    for new_column in new_schema.column_schemas() {
115        let Some(old_column) = old_schema.column_schema_by_name(&new_column.name) else {
116            continue;
117        };
118        if old_column.data_type != new_column.data_type {
119            changed.insert(
120                new_column.name.clone(),
121                (old_column.data_type.clone(), new_column.data_type.clone()),
122            );
123        }
124    }
125
126    changed
127}
128
129/// Validates that the persisted partition bounds of every region are still representable
130/// with the types of `changed_columns`. Returns an error naming the first offending
131/// region without modifying anything.
132///
133/// Public so that round-trip tests in the `partition` crate can run real serialized
134/// partition expressions through the same mirror used here.
135pub fn validate_region_partition_bounds(
136    changed_columns: &HashMap<String, (ConcreteDataType, ConcreteDataType)>,
137    region_routes: &[RegionRoute],
138) -> Result<()> {
139    if changed_columns.is_empty() {
140        return Ok(());
141    }
142
143    for route in region_routes {
144        let expr_json = route.region.partition_expr();
145        if expr_json.is_empty() {
146            continue;
147        }
148
149        let bound: PersistedPartitionBound = match serde_json::from_str(&expr_json) {
150            Ok(bound) => bound,
151            Err(err) => {
152                // A malformed persisted expression already fails every scan of this
153                // region. It is unrelated to the schema change, so don't block the ALTER.
154                warn!(
155                    "Skipping partition expression validation for region {}, \
156                     the persisted expression is not parseable: {}, expr: {}",
157                    route.region.id, err, expr_json
158                );
159                continue;
160            }
161        };
162        let PersistedPartitionBound::Expr(expr) = bound else {
163            // Deprecated bound formats don't carry expressions and are not used
164            // when the region builds its partition predicate.
165            continue;
166        };
167
168        validate_expr(&expr, changed_columns, route.region.id)?;
169    }
170
171    Ok(())
172}
173
174fn validate_expr(
175    expr: &PersistedPartitionExpr,
176    changed_columns: &HashMap<String, (ConcreteDataType, ConcreteDataType)>,
177    region_id: RegionId,
178) -> Result<()> {
179    match expr.op {
180        PersistedOp::And | PersistedOp::Or => {
181            validate_operand(&expr.lhs, changed_columns, region_id)?;
182            validate_operand(&expr.rhs, changed_columns, region_id)?;
183        }
184        PersistedOp::Eq
185        | PersistedOp::NotEq
186        | PersistedOp::Lt
187        | PersistedOp::LtEq
188        | PersistedOp::Gt
189        | PersistedOp::GtEq => {
190            // Comparison between a column and a bound literal in either order.
191            validate_comparison(&expr.lhs, &expr.rhs, changed_columns, region_id)?;
192            validate_comparison(&expr.rhs, &expr.lhs, changed_columns, region_id)?;
193        }
194    }
195
196    Ok(())
197}
198
199fn validate_operand(
200    operand: &PersistedOperand,
201    changed_columns: &HashMap<String, (ConcreteDataType, ConcreteDataType)>,
202    region_id: RegionId,
203) -> Result<()> {
204    match operand {
205        PersistedOperand::Expr(expr) => validate_expr(expr, changed_columns, region_id),
206        PersistedOperand::Column(_) | PersistedOperand::Value(_) => Ok(()),
207    }
208}
209
210fn validate_comparison(
211    operand: &PersistedOperand,
212    other: &PersistedOperand,
213    changed_columns: &HashMap<String, (ConcreteDataType, ConcreteDataType)>,
214    region_id: RegionId,
215) -> Result<()> {
216    let (PersistedOperand::Column(column), PersistedOperand::Value(value)) = (operand, other)
217    else {
218        return Ok(());
219    };
220    let Some((old_type, target_type)) = changed_columns.get(column) else {
221        return Ok(());
222    };
223    // Values not representable as a partition bound (lists, structs, json) are
224    // rejected by the partition parser and cannot appear in a persisted expression.
225    let Some(scalar) = value_to_scalar_value(value) else {
226        return Ok(());
227    };
228
229    // The write path (`MultiDimPartitionRule::find_region`) compares the persisted
230    // bound against row values directly: `Value::cmp` panics on mismatched variants
231    // and equality misroutes silently. Only a timestamp unit widening keeps the
232    // bound and the rows comparable (`Timestamp` comparisons convert units first),
233    // so every other type change of a referenced column is rejected here.
234    if !old_type.is_timestamp_unit_widening_to(target_type) {
235        warn!(
236            "Persisted partition bound would be compared against values of a different \
237             type after the ALTER, region_id: {}, column: '{}', target_type: {}",
238            region_id, column, target_type
239        );
240        return PartitionExprIncompatibleSnafu {
241            region_id,
242            column: column.clone(),
243            target_type: target_type.to_string(),
244        }
245        .fail();
246    }
247
248    // For the widening, mirror `ExprSimplifier::coerce` in `PredicateGroup::new`:
249    // the region casts both the column and the persisted bound to the common
250    // comparison type. The bound is not cast to the column type itself, so the
251    // widening is accepted as long as the bound survives the common type; a bound
252    // that cannot be cast to it — or a pair of types without a common comparison
253    // type — fails every scan.
254    let target_arrow_type = target_type.as_arrow_type();
255    let Some(common_type) = comparison_coercion(&target_arrow_type, &scalar.data_type()) else {
256        warn!(
257            "Persisted partition bound has no common comparison type with the altered \
258             column type, region_id: {}, column: '{}', target_type: {}",
259            region_id, column, target_type
260        );
261        return PartitionExprIncompatibleSnafu {
262            region_id,
263            column: column.clone(),
264            target_type: target_type.to_string(),
265        }
266        .fail();
267    };
268    if scalar.data_type() != common_type
269        && let Err(error) = scalar.cast_to(&common_type)
270    {
271        warn!(
272            error;
273            "Persisted partition bound is not representable with the altered column type, \
274             region_id: {}, column: '{}', target_type: {}",
275            region_id,
276            column,
277            target_type
278        );
279        return PartitionExprIncompatibleSnafu {
280            region_id,
281            column: column.clone(),
282            target_type: target_type.to_string(),
283        }
284        .fail();
285    }
286
287    Ok(())
288}
289
290/// Converts a persisted bound value to a [`ScalarValue`], mirroring
291/// `partition::expr::Operand::try_as_logical_expr`. Returns `None` for values that
292/// cannot appear in a persisted partition expression.
293fn value_to_scalar_value(value: &Value) -> Option<ScalarValue> {
294    Some(match value {
295        Value::Boolean(v) => ScalarValue::Boolean(Some(*v)),
296        Value::UInt8(v) => ScalarValue::UInt8(Some(*v)),
297        Value::UInt16(v) => ScalarValue::UInt16(Some(*v)),
298        Value::UInt32(v) => ScalarValue::UInt32(Some(*v)),
299        Value::UInt64(v) => ScalarValue::UInt64(Some(*v)),
300        Value::Int8(v) => ScalarValue::Int8(Some(*v)),
301        Value::Int16(v) => ScalarValue::Int16(Some(*v)),
302        Value::Int32(v) => ScalarValue::Int32(Some(*v)),
303        Value::Int64(v) => ScalarValue::Int64(Some(*v)),
304        Value::Float32(v) => ScalarValue::Float32(Some(v.0)),
305        Value::Float64(v) => ScalarValue::Float64(Some(v.0)),
306        Value::String(v) => ScalarValue::Utf8(Some(v.as_utf8().to_string())),
307        Value::Binary(v) => ScalarValue::Binary(Some(v.to_vec())),
308        Value::Date(v) => ScalarValue::Date32(Some(v.val())),
309        Value::Null => ScalarValue::Null,
310        Value::Timestamp(t) => timestamp_to_scalar_value(t.unit(), Some(t.value())),
311        Value::Time(t) => time_to_scalar_value(*t.unit(), Some(t.value())).ok()?,
312        Value::IntervalYearMonth(v) => ScalarValue::IntervalYearMonth(Some(v.to_i32())),
313        Value::IntervalDayTime(v) => ScalarValue::IntervalDayTime(Some((*v).into())),
314        Value::IntervalMonthDayNano(v) => ScalarValue::IntervalMonthDayNano(Some((*v).into())),
315        Value::Duration(d) => duration_to_scalar_value(d.unit(), Some(d.value())),
316        Value::Decimal128(d) => {
317            let (v, p, s) = d.to_scalar_value();
318            ScalarValue::Decimal128(v, p, s)
319        }
320        Value::List(_) | Value::Struct(_) | Value::Json(_) => return None,
321    })
322}
323
324#[cfg(test)]
325mod tests {
326    use datatypes::schema::ColumnSchema;
327    use store_api::storage::RegionId;
328
329    use super::*;
330    use crate::rpc::router::Region;
331
332    fn schema(ts_type: ConcreteDataType) -> Schema {
333        Schema::new(vec![
334            ColumnSchema::new("ts", ts_type, false),
335            ColumnSchema::new("host", ConcreteDataType::string_datatype(), true),
336        ])
337    }
338
339    fn ts_bound(millis: i64) -> String {
340        format!(
341            r#"{{"Expr":{{"lhs":{{"Column":"ts"}},"op":"Lt","rhs":{{"Value":{{"Timestamp":{{"value":{millis},"unit":"Millisecond"}}}}}}}}}}"#
342        )
343    }
344
345    fn routes(exprs: &[&str]) -> Vec<RegionRoute> {
346        exprs
347            .iter()
348            .enumerate()
349            .map(|(index, expr)| RegionRoute {
350                region: Region {
351                    id: RegionId::new(1024, index as u32 + 1),
352                    partition_expr: expr.to_string(),
353                    ..Default::default()
354                },
355                ..Default::default()
356            })
357            .collect()
358    }
359
360    fn changed(
361        column: &str,
362        old_type: ConcreteDataType,
363        new_type: ConcreteDataType,
364    ) -> HashMap<String, (ConcreteDataType, ConcreteDataType)> {
365        HashMap::from([(column.to_string(), (old_type, new_type))])
366    }
367
368    #[test]
369    fn test_changed_column_types() {
370        let old = schema(ConcreteDataType::timestamp_millisecond_datatype());
371        let new = schema(ConcreteDataType::timestamp_nanosecond_datatype());
372        let changed = changed_column_types(&old, &new);
373        assert_eq!(changed.len(), 1);
374        assert_eq!(
375            changed["ts"],
376            (
377                ConcreteDataType::timestamp_millisecond_datatype(),
378                ConcreteDataType::timestamp_nanosecond_datatype()
379            )
380        );
381
382        // Added and dropped columns are not type changes.
383        let mut new_columns = old.column_schemas().to_vec();
384        new_columns.push(ColumnSchema::new(
385            "extra",
386            ConcreteDataType::int64_datatype(),
387            true,
388        ));
389        let new = Schema::new(new_columns);
390        assert!(changed_column_types(&old, &new).is_empty());
391    }
392
393    #[test]
394    fn test_overflowing_bound_is_rejected() {
395        // Year 3000 fits milliseconds but overflows nanoseconds.
396        let routes = routes(&[&ts_bound(32_503_680_000_000)]);
397        let err = validate_region_partition_bounds(
398            &changed(
399                "ts",
400                ConcreteDataType::timestamp_millisecond_datatype(),
401                ConcreteDataType::timestamp_nanosecond_datatype(),
402            ),
403            &routes,
404        )
405        .unwrap_err();
406        assert!(
407            err.to_string().contains("region 4398046511105"),
408            "unexpected error: {err}"
409        );
410        assert!(err.to_string().contains("'ts'"), "unexpected error: {err}");
411    }
412
413    #[test]
414    fn test_representable_bound_is_accepted() {
415        // 2024-01-01T00:00:00Z fits both microseconds and nanoseconds.
416        let routes = routes(&[&ts_bound(1_704_067_200_000)]);
417        validate_region_partition_bounds(
418            &changed(
419                "ts",
420                ConcreteDataType::timestamp_millisecond_datatype(),
421                ConcreteDataType::timestamp_nanosecond_datatype(),
422            ),
423            &routes,
424        )
425        .unwrap();
426        validate_region_partition_bounds(
427            &changed(
428                "ts",
429                ConcreteDataType::timestamp_millisecond_datatype(),
430                ConcreteDataType::timestamp_microsecond_datatype(),
431            ),
432            &routes,
433        )
434        .unwrap();
435    }
436
437    #[test]
438    fn test_later_region_rejection_names_the_region() {
439        let routes = routes(&[&ts_bound(1_704_067_200_000), &ts_bound(32_503_680_000_000)]);
440        let err = validate_region_partition_bounds(
441            &changed(
442                "ts",
443                ConcreteDataType::timestamp_millisecond_datatype(),
444                ConcreteDataType::timestamp_nanosecond_datatype(),
445            ),
446            &routes,
447        )
448        .unwrap_err();
449        assert!(
450            err.to_string().contains("region 4398046511106"),
451            "unexpected error: {err}"
452        );
453    }
454
455    #[test]
456    fn test_unrelated_columns_are_skipped() {
457        let host_bound =
458            r#"{"Expr":{"lhs":{"Column":"host"},"op":"Eq","rhs":{"Value":{"String":"a"}}}}"#;
459        // The changed column is not referenced by this bound.
460        validate_region_partition_bounds(
461            &changed(
462                "ts",
463                ConcreteDataType::timestamp_millisecond_datatype(),
464                ConcreteDataType::timestamp_nanosecond_datatype(),
465            ),
466            &routes(&[host_bound]),
467        )
468        .unwrap();
469        // The referenced column did not change.
470        validate_region_partition_bounds(
471            &changed(
472                "host",
473                ConcreteDataType::int32_datatype(),
474                ConcreteDataType::string_datatype(),
475            ),
476            &routes(&[&ts_bound(32_503_680_000_000)]),
477        )
478        .unwrap();
479    }
480
481    #[test]
482    fn test_nested_expression_is_validated() {
483        let expr = r#"{"Expr":{"lhs":{"Expr":{"lhs":{"Column":"ts"},"op":"GtEq","rhs":{"Value":{"Timestamp":{"value":0,"unit":"Millisecond"}}}}},"op":"And","rhs":{"Expr":{"lhs":{"Column":"ts"},"op":"Lt","rhs":{"Value":{"Timestamp":{"value":32503680000000,"unit":"Millisecond"}}}}}}}"#.to_string();
484        let err = validate_region_partition_bounds(
485            &changed(
486                "ts",
487                ConcreteDataType::timestamp_millisecond_datatype(),
488                ConcreteDataType::timestamp_nanosecond_datatype(),
489            ),
490            &routes(&[&expr]),
491        )
492        .unwrap_err();
493        assert!(err.to_string().contains("'ts'"), "unexpected error: {err}");
494    }
495
496    #[test]
497    fn test_deprecated_and_empty_bounds_are_skipped() {
498        validate_region_partition_bounds(
499            &changed(
500                "ts",
501                ConcreteDataType::timestamp_millisecond_datatype(),
502                ConcreteDataType::timestamp_nanosecond_datatype(),
503            ),
504            &routes(&["", r#""MaxValue""#, r#"{"Value":{"UInt32":1}}"#, "not-json"]),
505        )
506        .unwrap();
507    }
508
509    #[test]
510    fn test_field_narrowing_is_rejected_for_persisted_bounds() {
511        // BIGINT bound outside the INT range with the column narrowed to INT: the
512        // scan would coerce both sides to Int64, but the region's write path
513        // compares the persisted Int64 bound against Int32 rows directly and
514        // panics, so the ALTER must be rejected.
515        let bound =
516            r#"{"Expr":{"lhs":{"Column":"v"},"op":"Lt","rhs":{"Value":{"Int64":3000000000}}}}"#;
517        let err = validate_region_partition_bounds(
518            &changed(
519                "v",
520                ConcreteDataType::int64_datatype(),
521                ConcreteDataType::int32_datatype(),
522            ),
523            &routes(&[bound]),
524        )
525        .unwrap_err();
526        assert!(err.to_string().contains("'v'"), "unexpected error: {err}");
527    }
528
529    #[test]
530    fn test_non_timestamp_widening_is_rejected_for_persisted_bounds() {
531        // Widening INT to BIGINT still changes the variant rows carry: the
532        // region's write path compares the persisted Int32 bound against Int64
533        // rows directly and panics, so the ALTER must be rejected as well.
534        let bound = r#"{"Expr":{"lhs":{"Column":"v"},"op":"Lt","rhs":{"Value":{"Int32":10}}}}"#;
535        let err = validate_region_partition_bounds(
536            &changed(
537                "v",
538                ConcreteDataType::int32_datatype(),
539                ConcreteDataType::int64_datatype(),
540            ),
541            &routes(&[bound]),
542        )
543        .unwrap_err();
544        assert!(err.to_string().contains("'v'"), "unexpected error: {err}");
545    }
546
547    #[test]
548    fn test_bound_without_common_comparison_type_is_rejected() {
549        // A boolean bound has no comparison coercion with a timestamp column, so
550        // even a widening whose bounds are normally representable fails every
551        // predicate build; the ALTER must be rejected.
552        let bound =
553            r#"{"Expr":{"lhs":{"Column":"ts"},"op":"Lt","rhs":{"Value":{"Boolean":true}}}}"#;
554        let err = validate_region_partition_bounds(
555            &changed(
556                "ts",
557                ConcreteDataType::timestamp_millisecond_datatype(),
558                ConcreteDataType::timestamp_nanosecond_datatype(),
559            ),
560            &routes(&[bound]),
561        )
562        .unwrap_err();
563        assert!(err.to_string().contains("'ts'"), "unexpected error: {err}");
564    }
565}