meterstore 0.2.0

Hot/cold tiered store for metering time series — PostgreSQL for the recent window, Apache Iceberg for history.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
//! Schema evolution, and the quarantine that stops an unsafe one.
//!
//! Metering schemas are regulator-defined and move on multi-year cycles, so this
//! is far smaller than in a general CDC framework — but it is not empty. New OBIS
//! channels, added deployment attributes, and widened decimal precision all
//! happen, and Iceberg's field-id resolution makes most of them free.
//!
//! # What the classification is for
//!
//! The store's configuration says what the schema *should* be. The cold table
//! says what it *is*. Those two drift for exactly two reasons, and they need
//! opposite responses:
//!
//! - **A deployment added a column.** The Iceberg table gains it with a fresh
//!   field id; every existing Parquet file still reads, with null for the new
//!   column. Nothing to do but proceed.
//! - **Something narrowed, retyped, or moved into the merge key.** Then rows
//!   already written mean something different from rows about to be written, and
//!   *no* amount of care downstream recovers that. The table halts.
//!
//! # Why halting is the right answer
//!
//! Quarantine is the honest response to a change that cannot be applied safely
//! (P6). A store that guessed would keep accepting writes and produce a table
//! where the same column carries two meanings, discoverable months later by
//! whoever reconciles a settlement. Halting one table while the others keep
//! running costs an operator an afternoon; not halting costs a restatement.
//!
//! The watermark freezes with it, which is the point: nothing is archived out of
//! PostgreSQL — where it can still be corrected — into a lake layout nobody has
//! agreed on.

use crate::arrow::datatypes::{DataType, Field, SchemaRef};
use crate::error::{Error, Result};

/// One difference between the configured schema and the stored one.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SchemaChange {
    /// A column the configuration declares and the table does not have.
    ///
    /// Safe when nullable: Iceberg adds it with a new field id and historical
    /// files read as null. A **non-nullable** addition is not safe, because every
    /// existing row would violate it — and every identity column is non-nullable,
    /// so this is also how a merge-key change arrives.
    Added {
        /// The declared column.
        field: Field,
        /// Whether it can be added without invalidating existing rows.
        safe: bool,
    },
    /// A column the table has and the configuration no longer declares.
    ///
    /// Safe. Iceberg keeps it for time travel, and dropping it from the write
    /// path does not invalidate a file that contains it.
    Dropped {
        /// The column's name.
        name: String,
    },
    /// A column whose type changed.
    Retyped {
        /// The column's name.
        name: String,
        /// What the table holds today.
        from: DataType,
        /// What the configuration now declares.
        to: DataType,
        /// Whether Iceberg can promote the old type to the new one.
        safe: bool,
    },
    /// A column that became nullable, or stopped being nullable.
    ///
    /// Widening to nullable is safe. Narrowing is not: existing rows may hold
    /// nulls the new declaration forbids, and no rewrite is available to find out.
    Nullability {
        /// The column's name.
        name: String,
        /// Whether the change widens rather than narrows.
        safe: bool,
    },
}

impl SchemaChange {
    /// Whether this change can be applied without invalidating stored rows.
    pub fn is_safe(&self) -> bool {
        match self {
            Self::Added { safe, .. } => *safe,
            Self::Dropped { .. } => true,
            Self::Retyped { safe, .. } => *safe,
            Self::Nullability { safe, .. } => *safe,
        }
    }

    /// A human-readable account, for the operator who has to resolve it.
    pub fn describe(&self) -> String {
        match self {
            Self::Added { field, safe: true } => {
                format!(
                    "column {:?} added (nullable — historical files read as null)",
                    field.name()
                )
            }
            Self::Added { field, safe: false } => format!(
                "column {:?} added as NOT NULL — every existing row would violate it; \
                 declare it nullable, or rewrite history out of band first",
                field.name()
            ),
            Self::Dropped { name } => {
                format!("column {name:?} no longer declared (retained for time travel)")
            }
            Self::Retyped {
                name,
                from,
                to,
                safe: true,
            } => format!("column {name:?} promoted {from:?} -> {to:?}"),
            Self::Retyped {
                name,
                from,
                to,
                safe: false,
            } => format!(
                "column {name:?} changed {from:?} -> {to:?}, which Iceberg cannot promote; \
                 stored values would be reinterpreted rather than converted"
            ),
            Self::Nullability { name, safe: true } => {
                format!("column {name:?} widened to nullable")
            }
            Self::Nullability { name, safe: false } => format!(
                "column {name:?} narrowed to NOT NULL, but stored rows may already hold nulls"
            ),
        }
    }
}

/// The verdict on a whole schema comparison.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Compatibility {
    /// Every difference found, in schema order.
    pub changes: Vec<SchemaChange>,
}

impl Compatibility {
    /// Whether the schemas already agree.
    pub fn is_identical(&self) -> bool {
        self.changes.is_empty()
    }

    /// Whether every change can be applied safely.
    pub fn is_safe(&self) -> bool {
        self.changes.iter().all(SchemaChange::is_safe)
    }

    /// The changes that cannot be applied.
    pub fn unsafe_changes(&self) -> Vec<&SchemaChange> {
        self.changes.iter().filter(|c| !c.is_safe()).collect()
    }

    /// Turn an unsafe comparison into the error that quarantines the table.
    ///
    /// `Ok(())` when every change is safe, including when there are none.
    pub fn require_safe(&self, table: &str) -> Result<()> {
        let unsafe_changes = self.unsafe_changes();
        if unsafe_changes.is_empty() {
            return Ok(());
        }
        Err(Error::Quarantined {
            table: table.to_string(),
            detail: unsafe_changes
                .iter()
                .map(|c| c.describe())
                .collect::<Vec<_>>()
                .join("; "),
        })
    }
}

/// Compare the schema a store would write against the one a table holds.
///
/// `configured` is the source of truth for intent; `stored` for reality. Columns
/// are matched by **name**, because that is what the encoder writes and what an
/// external engine reads — Iceberg matches by field id underneath, which is what
/// makes a rename free, but a rename is invisible from here and looks like a drop
/// plus an add. Both are safe, so the conservative reading costs nothing.
pub fn compare(configured: &SchemaRef, stored: &SchemaRef) -> Compatibility {
    let mut changes = Vec::new();

    for field in configured.fields() {
        match stored.field_with_name(field.name()) {
            Err(_) => changes.push(SchemaChange::Added {
                field: field.as_ref().clone(),
                // A new column has no values in existing files, so it can only
                // be added if null is a legal value for it.
                safe: field.is_nullable(),
            }),
            Ok(existing) => {
                if existing.data_type() != field.data_type() {
                    changes.push(SchemaChange::Retyped {
                        name: field.name().clone(),
                        from: existing.data_type().clone(),
                        to: field.data_type().clone(),
                        safe: is_promotable(existing.data_type(), field.data_type()),
                    });
                } else if existing.is_nullable() != field.is_nullable() {
                    changes.push(SchemaChange::Nullability {
                        name: field.name().clone(),
                        // Widening only. Narrowing would forbid nulls that may
                        // already be stored, and nothing here can prove they are
                        // not.
                        safe: field.is_nullable(),
                    });
                }
            }
        }
    }

    for field in stored.fields() {
        if configured.field_with_name(field.name()).is_err() {
            changes.push(SchemaChange::Dropped {
                name: field.name().clone(),
            });
        }
    }

    Compatibility { changes }
}

/// Whether Iceberg can promote `from` to `to` without rewriting data.
///
/// The allowed set is Iceberg's own type-promotion list, restricted to what this
/// schema can actually contain. Widening a decimal's **precision** is permitted
/// and its scale is not: precision adds representable digits to the left, while
/// changing scale reinterprets every stored integer by a factor of ten — a silent
/// factor-of-ten error in a settlement figure.
fn is_promotable(from: &DataType, to: &DataType) -> bool {
    match (from, to) {
        (a, b) if a == b => true,
        (DataType::Int32, DataType::Int64) => true,
        (DataType::Float32, DataType::Float64) => true,
        (
            DataType::Decimal128(from_precision, from_scale),
            DataType::Decimal128(to_precision, to_scale),
        ) => from_scale == to_scale && to_precision >= from_precision,
        // Iceberg spells a UTC timestamp `+00:00` where the canonical Arrow
        // schema says `UTC`; the same instant, a different string. Treating that
        // as a retype would quarantine every table on its first check.
        (DataType::Timestamp(a, Some(x)), DataType::Timestamp(b, Some(y))) => {
            a == b && is_utc(x) && is_utc(y)
        }
        _ => false,
    }
}

/// Whether a timezone string denotes UTC, in any of its accepted spellings.
fn is_utc(tz: &str) -> bool {
    matches!(tz, "UTC" | "utc" | "+00:00" | "Z" | "z" | "GMT" | "Etc/UTC")
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::arrow::datatypes::Schema;
    use std::sync::Arc;

    fn schema(fields: Vec<Field>) -> SchemaRef {
        Arc::new(Schema::new(fields))
    }

    fn base() -> SchemaRef {
        schema(vec![
            Field::new("malo_id", DataType::Utf8, false),
            Field::new("value", DataType::Decimal128(18, 6), false),
        ])
    }

    #[test]
    fn identical_schemas_have_nothing_to_report() {
        let c = compare(&base(), &base());
        assert!(c.is_identical());
        assert!(c.is_safe());
        assert!(c.require_safe("readings").is_ok());
    }

    #[test]
    fn a_nullable_addition_is_safe() {
        let configured = schema(vec![
            Field::new("malo_id", DataType::Utf8, false),
            Field::new("value", DataType::Decimal128(18, 6), false),
            Field::new("bilanzkreis", DataType::Utf8, true),
        ]);
        let c = compare(&configured, &base());
        assert_eq!(c.changes.len(), 1);
        assert!(c.is_safe());
        assert!(c.require_safe("readings").is_ok());
    }

    #[test]
    fn a_non_nullable_addition_quarantines() {
        // This is also how a merge-key change arrives: identity columns are
        // non-nullable by validation, so declaring a new one lands here.
        let configured = schema(vec![
            Field::new("malo_id", DataType::Utf8, false),
            Field::new("value", DataType::Decimal128(18, 6), false),
            Field::new("tenant", DataType::Utf8, false),
        ]);
        let c = compare(&configured, &base());
        assert!(!c.is_safe());
        let err = c.require_safe("readings").unwrap_err();
        assert!(matches!(err, Error::Quarantined { .. }));
        assert!(err.to_string().contains("tenant"), "{err}");
    }

    #[test]
    fn a_dropped_column_is_safe_because_iceberg_keeps_it() {
        let configured = schema(vec![Field::new("malo_id", DataType::Utf8, false)]);
        let c = compare(&configured, &base());
        assert_eq!(
            c.changes,
            vec![SchemaChange::Dropped {
                name: "value".into()
            }]
        );
        assert!(c.is_safe());
    }

    #[test]
    fn widening_decimal_precision_is_promotable() {
        let configured = schema(vec![
            Field::new("malo_id", DataType::Utf8, false),
            Field::new("value", DataType::Decimal128(20, 6), false),
        ]);
        assert!(compare(&configured, &base()).is_safe());
    }

    #[test]
    fn changing_decimal_scale_quarantines() {
        // A scale change reinterprets every stored integer by a factor of ten.
        // In a settlement figure that is a silent order-of-magnitude error.
        let configured = schema(vec![
            Field::new("malo_id", DataType::Utf8, false),
            Field::new("value", DataType::Decimal128(18, 3), false),
        ]);
        let c = compare(&configured, &base());
        assert!(!c.is_safe());
        assert!(c.require_safe("readings").is_err());
    }

    #[test]
    fn narrowing_decimal_precision_quarantines() {
        let configured = schema(vec![
            Field::new("malo_id", DataType::Utf8, false),
            Field::new("value", DataType::Decimal128(9, 6), false),
        ]);
        assert!(!compare(&configured, &base()).is_safe());
    }

    #[test]
    fn retyping_a_string_to_a_number_quarantines() {
        let configured = schema(vec![
            Field::new("malo_id", DataType::Int64, false),
            Field::new("value", DataType::Decimal128(18, 6), false),
        ]);
        assert!(!compare(&configured, &base()).is_safe());
    }

    #[test]
    fn widening_to_nullable_is_safe_and_narrowing_is_not() {
        let widened = schema(vec![
            Field::new("malo_id", DataType::Utf8, true),
            Field::new("value", DataType::Decimal128(18, 6), false),
        ]);
        assert!(compare(&widened, &base()).is_safe());
        assert!(!compare(&base(), &widened).is_safe());
    }

    #[test]
    fn the_two_utc_spellings_are_not_a_retype() {
        // Iceberg writes `+00:00`, the canonical Arrow schema says `UTC`. Same
        // instant. Treating it as a change would quarantine every table on its
        // very first check.
        let ours = schema(vec![Field::new(
            "from",
            DataType::Timestamp(
                crate::arrow::datatypes::TimeUnit::Microsecond,
                Some("UTC".into()),
            ),
            false,
        )]);
        let theirs = schema(vec![Field::new(
            "from",
            DataType::Timestamp(
                crate::arrow::datatypes::TimeUnit::Microsecond,
                Some("+00:00".into()),
            ),
            false,
        )]);
        assert!(compare(&ours, &theirs).is_safe());
        assert!(compare(&ours, &theirs).is_identical() || compare(&ours, &theirs).is_safe());
    }

    #[test]
    fn a_changed_timestamp_unit_is_not_promotable() {
        use crate::arrow::datatypes::TimeUnit;
        let micros = schema(vec![Field::new(
            "from",
            DataType::Timestamp(TimeUnit::Microsecond, Some("UTC".into())),
            false,
        )]);
        let nanos = schema(vec![Field::new(
            "from",
            DataType::Timestamp(TimeUnit::Nanosecond, Some("UTC".into())),
            false,
        )]);
        assert!(!compare(&nanos, &micros).is_safe());
    }

    #[test]
    fn the_quarantine_message_names_every_offending_column() {
        // The operator resolving this needs the whole list, not the first item.
        let configured = schema(vec![
            Field::new("malo_id", DataType::Utf8, false),
            Field::new("value", DataType::Decimal128(18, 3), false),
            Field::new("tenant", DataType::Utf8, false),
        ]);
        let err = compare(&configured, &base())
            .require_safe("readings")
            .unwrap_err()
            .to_string();
        assert!(err.contains("value"), "{err}");
        assert!(err.contains("tenant"), "{err}");
    }
}