Skip to main content

mz_interchange/avro/
encode.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10use std::collections::BTreeMap;
11use std::fmt;
12use std::sync::LazyLock;
13
14use anyhow::Ok;
15use byteorder::{NetworkEndian, WriteBytesExt};
16use chrono::Timelike;
17use itertools::Itertools;
18use mz_avro::Schema;
19use mz_avro::types::{DecimalValue, ToAvro, Value};
20use mz_ore::cast::CastFrom;
21use mz_repr::adt::jsonb::JsonbRef;
22use mz_repr::adt::numeric::{self, NUMERIC_AGG_MAX_PRECISION, NUMERIC_DATUM_MAX_PRECISION};
23use mz_repr::{CatalogItemId, ColumnName, Datum, RelationDesc, Row, SqlColumnType, SqlScalarType};
24use serde_json::json;
25use uuid::Uuid;
26
27use crate::encode::{Encode, TypedDatum, column_names_and_types};
28use crate::envelopes::{self, DBZ_ROW_TYPE_ID, ENVELOPE_CUSTOM_NAMES};
29use crate::json::{SchemaOptions, build_row_schema_json};
30
31// TODO(rkhaitan): this schema intentionally omits the data_collections field
32// that is typically present in Debezium transaction metadata topics. See
33// https://debezium.io/documentation/reference/connectors/postgresql.html#postgresql-transaction-metadata
34// for more information. We chose to omit this field because it is redundant
35// for sinks where each consistency topic corresponds to exactly one sink.
36// We will need to add it in order to be able to reingest sinked topics.
37static DEBEZIUM_TRANSACTION_SCHEMA: LazyLock<Schema> = LazyLock::new(|| {
38    Schema::parse(&json!({
39        "type": "record",
40        "name": "envelope",
41        "fields": [
42            {
43                "name": "id",
44                "type": "string"
45            },
46            {
47                "name": "status",
48                "type": "string"
49            },
50            {
51                "name": "event_count",
52                "type": [
53                  "null",
54                  "long"
55                ]
56            },
57            {
58                "name": "data_collections",
59                "type": [
60                    "null",
61                    {
62                        "type": "array",
63                        "items": {
64                            "type": "record",
65                            "name": "data_collection",
66                            "fields": [
67                                {
68                                    "name": "data_collection",
69                                    "type": "string"
70                                },
71                                {
72                                    "name": "event_count",
73                                    "type": "long"
74                                },
75                            ]
76                        }
77                    }
78                ],
79                "default": null,
80            },
81        ]
82    }))
83    .expect("valid schema constructed")
84});
85
86/// Identifies the schema-registry wire framing an [`AvroEncoder`] prepends to
87/// each record, carrying the registry-specific schema id.
88///
89/// The variant is fixed when the encoder is built and determines both the
90/// header written by [`Encode::encode_unchecked`] and the header stripped by
91/// [`Encode::hash`]. The two must always agree: hashing with the wrong stripper
92/// would hash a byte-shifted payload and scramble stable partitioning.
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum AvroSchemaId {
95    /// Confluent wire format: magic byte `0x00` then a 4-byte big-endian id.
96    Confluent(i32),
97    /// AWS Glue wire format: an 18-byte header carrying a schema-version UUID.
98    /// See [`crate::glue`].
99    Glue(Uuid),
100}
101
102fn encode_confluent_header(buf: &mut Vec<u8>, schema_id: i32) {
103    // The first byte is a magic byte (0) that indicates the Confluent
104    // serialization format version, and the next four bytes are a
105    // 32-bit schema ID.
106    //
107    // https://docs.confluent.io/platform/current/schema-registry/fundamentals/serdes-develop/index.html#wire-format
108    buf.write_u8(0).expect("writing to vec cannot fail");
109    buf.write_i32::<NetworkEndian>(schema_id)
110        .expect("writing to vec cannot fail");
111}
112
113fn encode_message_unchecked(
114    schema_id: AvroSchemaId,
115    row: Row,
116    schema: &Schema,
117    columns: &[(ColumnName, SqlColumnType)],
118) -> Vec<u8> {
119    let value = encode_datums_as_avro(row.iter(), columns);
120    match schema_id {
121        AvroSchemaId::Confluent(id) => {
122            let mut buf = vec![];
123            encode_confluent_header(&mut buf, id);
124            mz_avro::encode_unchecked(&value, schema, &mut buf);
125            buf
126        }
127        AvroSchemaId::Glue(id) => {
128            let mut buf = vec![];
129            crate::glue::write_avro_header(&mut buf, id);
130            mz_avro::encode_unchecked(&value, schema, &mut buf);
131            buf
132        }
133    }
134}
135
136#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone)]
137pub enum DocTarget {
138    Type(CatalogItemId),
139    Field {
140        object_id: CatalogItemId,
141        column_name: ColumnName,
142    },
143}
144
145impl DocTarget {
146    fn id(&self) -> CatalogItemId {
147        match self {
148            DocTarget::Type(object_id) => *object_id,
149            DocTarget::Field { object_id, .. } => *object_id,
150        }
151    }
152}
153
154/// Generates an Avro schema
155pub struct AvroSchemaGenerator {
156    columns: Vec<(ColumnName, SqlColumnType)>,
157    schema: Schema,
158}
159
160impl fmt::Debug for AvroSchemaGenerator {
161    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
162        f.debug_struct("SchemaGenerator")
163            .field("writer_schema", &self.schema())
164            .finish()
165    }
166}
167
168impl AvroSchemaGenerator {
169    pub fn new(
170        desc: RelationDesc,
171        debezium: bool,
172        mut doc_options: BTreeMap<DocTarget, String>,
173        avro_fullname: &str,
174        set_null_defaults: bool,
175        sink_from: Option<CatalogItemId>,
176        use_custom_envelope_names: bool,
177    ) -> Result<Self, anyhow::Error> {
178        let mut columns = column_names_and_types(desc);
179        if debezium {
180            columns = envelopes::dbz_envelope(columns);
181            // With DEBEZIUM envelope the message is wrapped into "before" and "after"
182            // with `DBZ_ROW_TYPE_ID` instead of `sink_from`.
183            // Replacing comments for the columns and type in `sink_from` to `DBZ_ROW_TYPE_ID`.
184            if let Some(sink_from_id) = sink_from {
185                let mut new_column_docs = BTreeMap::new();
186                doc_options.iter().for_each(|(k, v)| {
187                    if k.id() == sink_from_id {
188                        match k {
189                            DocTarget::Field { column_name, .. } => {
190                                new_column_docs.insert(
191                                    DocTarget::Field {
192                                        object_id: DBZ_ROW_TYPE_ID,
193                                        column_name: column_name.clone(),
194                                    },
195                                    v.clone(),
196                                );
197                            }
198                            DocTarget::Type(_) => {
199                                new_column_docs.insert(DocTarget::Type(DBZ_ROW_TYPE_ID), v.clone());
200                            }
201                        }
202                    }
203                });
204                doc_options.append(&mut new_column_docs);
205                doc_options.retain(|k, _v| k.id() != sink_from_id);
206            }
207        }
208        let custom_names = if use_custom_envelope_names {
209            &ENVELOPE_CUSTOM_NAMES
210        } else {
211            &BTreeMap::new()
212        };
213        let row_schema = build_row_schema_json(
214            &columns,
215            avro_fullname,
216            custom_names,
217            sink_from,
218            &SchemaOptions {
219                set_null_defaults,
220                doc_comments: doc_options,
221            },
222        )?;
223        let schema = Schema::parse(&row_schema).expect("valid schema constructed");
224        Ok(AvroSchemaGenerator { columns, schema })
225    }
226
227    pub fn schema(&self) -> &Schema {
228        &self.schema
229    }
230
231    pub fn columns(&self) -> &[(ColumnName, SqlColumnType)] {
232        &self.columns
233    }
234}
235
236/// Manages encoding of Avro-encoded bytes.
237pub struct AvroEncoder {
238    columns: Vec<(ColumnName, SqlColumnType)>,
239    schema: Schema,
240    schema_id: AvroSchemaId,
241}
242
243impl fmt::Debug for AvroEncoder {
244    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
245        f.debug_struct("AvroEncoder")
246            .field("writer_schema", &self.schema)
247            .finish()
248    }
249}
250
251impl AvroEncoder {
252    pub fn new(desc: RelationDesc, debezium: bool, schema: &str, schema_id: AvroSchemaId) -> Self {
253        let mut columns = column_names_and_types(desc);
254        if debezium {
255            columns = envelopes::dbz_envelope(columns);
256        };
257        AvroEncoder {
258            columns,
259            schema: Schema::parse(&serde_json::from_str(schema).expect("valid schema json"))
260                .expect("valid schema"),
261            schema_id,
262        }
263    }
264}
265
266impl Encode for AvroEncoder {
267    fn encode_unchecked(&self, row: Row) -> Vec<u8> {
268        encode_message_unchecked(self.schema_id, row, &self.schema, &self.columns)
269    }
270
271    fn hash(&self, buf: &[u8]) -> u64 {
272        // Compute a stable hash by ignoring the avro header, which carries a
273        // schema id that may vary run-to-run. Strip whichever framing this
274        // encoder wrote: hashing with the wrong stripper would hash a
275        // byte-shifted payload and scramble stable partitioning.
276        let payload = match self.schema_id {
277            AvroSchemaId::Confluent(_) => {
278                crate::confluent::extract_avro_header(buf)
279                    .expect("encode_unchecked wrote a Confluent header")
280                    .1
281            }
282            AvroSchemaId::Glue(_) => {
283                crate::glue::extract_avro_header(buf)
284                    .expect("encode_unchecked wrote a Glue header")
285                    .1
286            }
287        };
288        seahash::hash(payload)
289    }
290}
291
292/// Encodes a sequence of `Datum` as Avro (key and value), using supplied column names and types.
293pub fn encode_datums_as_avro<'a, I>(datums: I, names_types: &[(ColumnName, SqlColumnType)]) -> Value
294where
295    I: IntoIterator<Item = Datum<'a>>,
296{
297    let value_fields: Vec<(String, Value)> = names_types
298        .iter()
299        .zip_eq(datums)
300        .map(|((name, typ), datum)| {
301            let name = name.as_str().to_owned();
302            (name, TypedDatum::new(datum, typ).avro())
303        })
304        .collect();
305    let v = Value::Record(value_fields);
306    v
307}
308
309impl<'a> mz_avro::types::ToAvro for TypedDatum<'a> {
310    fn avro(self) -> Value {
311        let TypedDatum { datum, typ } = self;
312        if typ.nullable && datum.is_null() {
313            Value::Union {
314                index: 0,
315                inner: Box::new(Value::Null),
316                n_variants: 2,
317                null_variant: Some(0),
318            }
319        } else {
320            let mut val = match &typ.scalar_type {
321                SqlScalarType::AclItem => Value::String(datum.unwrap_acl_item().to_string()),
322                SqlScalarType::Bool => Value::Boolean(datum.unwrap_bool()),
323                SqlScalarType::PgLegacyChar => {
324                    Value::Fixed(1, datum.unwrap_uint8().to_le_bytes().into())
325                }
326                SqlScalarType::Int16 => Value::Int(i32::from(datum.unwrap_int16())),
327                SqlScalarType::Int32 => Value::Int(datum.unwrap_int32()),
328                SqlScalarType::Int64 => Value::Long(datum.unwrap_int64()),
329                SqlScalarType::UInt16 => {
330                    Value::Fixed(2, datum.unwrap_uint16().to_be_bytes().into())
331                }
332                SqlScalarType::UInt32 => {
333                    Value::Fixed(4, datum.unwrap_uint32().to_be_bytes().into())
334                }
335                SqlScalarType::UInt64 => {
336                    Value::Fixed(8, datum.unwrap_uint64().to_be_bytes().into())
337                }
338                SqlScalarType::Oid
339                | SqlScalarType::RegClass
340                | SqlScalarType::RegProc
341                | SqlScalarType::RegType => {
342                    Value::Fixed(4, datum.unwrap_uint32().to_be_bytes().into())
343                }
344                SqlScalarType::Float32 => Value::Float(datum.unwrap_float32()),
345                SqlScalarType::Float64 => Value::Double(datum.unwrap_float64()),
346                SqlScalarType::Numeric { max_scale } => {
347                    let mut d = datum.unwrap_numeric().0;
348                    let (unscaled, precision, scale) = match max_scale {
349                        Some(max_scale) => {
350                            // Values must be rescaled to resaturate trailing zeroes
351                            numeric::rescale(&mut d, max_scale.into_u8()).unwrap();
352                            (
353                                numeric::numeric_to_twos_complement_be(d).to_vec(),
354                                NUMERIC_DATUM_MAX_PRECISION,
355                                max_scale.into_u8(),
356                            )
357                        }
358                        // Decimals without specified scale must nonetheless be
359                        // expressed as a fixed scale, so we write everything as
360                        // a 78-digit number with a scale of 39, which
361                        // definitively expresses all valid numeric values.
362                        None => (
363                            numeric::numeric_to_twos_complement_wide(d).to_vec(),
364                            NUMERIC_AGG_MAX_PRECISION,
365                            NUMERIC_DATUM_MAX_PRECISION,
366                        ),
367                    };
368                    Value::Decimal(DecimalValue {
369                        unscaled,
370                        precision: usize::cast_from(precision),
371                        scale: usize::cast_from(scale),
372                    })
373                }
374                SqlScalarType::Date => Value::Date(datum.unwrap_date().unix_epoch_days()),
375                SqlScalarType::Time => Value::Long({
376                    let time = datum.unwrap_time();
377                    i64::from(time.num_seconds_from_midnight()) * 1_000_000
378                        + i64::from(time.nanosecond()) / 1_000
379                }),
380                SqlScalarType::Timestamp { .. } => {
381                    Value::Timestamp(datum.unwrap_timestamp().to_naive())
382                }
383                SqlScalarType::TimestampTz { .. } => {
384                    Value::Timestamp(datum.unwrap_timestamptz().to_naive())
385                }
386                // SQL intervals and Avro durations differ quite a lot (signed
387                // vs unsigned, different int sizes), so SQL intervals are their
388                // own bespoke type.
389                SqlScalarType::Interval => Value::Fixed(16, {
390                    let iv = datum.unwrap_interval();
391                    let mut buf = Vec::with_capacity(16);
392                    buf.extend(iv.months.to_le_bytes());
393                    buf.extend(iv.days.to_le_bytes());
394                    buf.extend(iv.micros.to_le_bytes());
395                    debug_assert_eq!(buf.len(), 16);
396                    buf
397                }),
398                SqlScalarType::Bytes => Value::Bytes(Vec::from(datum.unwrap_bytes())),
399                SqlScalarType::String
400                | SqlScalarType::VarChar { .. }
401                | SqlScalarType::PgLegacyName => Value::String(datum.unwrap_str().to_owned()),
402                SqlScalarType::Char { length } => {
403                    let s = mz_repr::adt::char::format_str_pad(datum.unwrap_str(), *length);
404                    Value::String(s)
405                }
406                SqlScalarType::Jsonb => Value::Json(JsonbRef::from_datum(datum).to_serde_json()),
407                SqlScalarType::Uuid => Value::Uuid(datum.unwrap_uuid()),
408                ty @ (SqlScalarType::Array(..)
409                | SqlScalarType::Int2Vector
410                | SqlScalarType::List { .. }) => {
411                    let list = match ty {
412                        SqlScalarType::Array(_) | SqlScalarType::Int2Vector => {
413                            datum.unwrap_array().elements()
414                        }
415                        SqlScalarType::List { .. } => datum.unwrap_list(),
416                        _ => unreachable!(),
417                    };
418
419                    let values = list
420                        .into_iter()
421                        .map(|datum| {
422                            TypedDatum::new(
423                                datum,
424                                &SqlColumnType {
425                                    nullable: true,
426                                    scalar_type: ty.unwrap_collection_element_type().clone(),
427                                },
428                            )
429                            .avro()
430                        })
431                        .collect();
432                    Value::Array(values)
433                }
434                SqlScalarType::Map { value_type, .. } => {
435                    let map = datum.unwrap_map();
436                    let elements = map
437                        .into_iter()
438                        .map(|(key, datum)| {
439                            let value = TypedDatum::new(
440                                datum,
441                                &SqlColumnType {
442                                    nullable: true,
443                                    scalar_type: (**value_type).clone(),
444                                },
445                            )
446                            .avro();
447                            (key.to_string(), value)
448                        })
449                        .collect();
450                    Value::Map(elements)
451                }
452                SqlScalarType::Record { fields, .. } => {
453                    let list = datum.unwrap_list();
454                    let fields = fields
455                        .iter()
456                        .zip_eq(list)
457                        .map(|((name, typ), datum)| {
458                            let name = name.to_string();
459                            let datum = TypedDatum::new(datum, typ);
460                            let value = datum.avro();
461                            (name, value)
462                        })
463                        .collect();
464                    Value::Record(fields)
465                }
466                SqlScalarType::MzTimestamp => {
467                    Value::String(datum.unwrap_mz_timestamp().to_string())
468                }
469                SqlScalarType::Range { .. } => Value::String(datum.unwrap_range().to_string()),
470                SqlScalarType::MzAclItem => Value::String(datum.unwrap_mz_acl_item().to_string()),
471            };
472            if typ.nullable {
473                val = Value::Union {
474                    index: 1,
475                    inner: Box::new(val),
476                    n_variants: 2,
477                    null_variant: Some(0),
478                };
479            }
480            val
481        }
482    }
483}
484
485pub fn get_debezium_transaction_schema() -> &'static Schema {
486    &DEBEZIUM_TRANSACTION_SCHEMA
487}
488
489pub fn encode_debezium_transaction_unchecked(
490    schema_id: i32,
491    collection: &str,
492    id: &str,
493    status: &str,
494    message_count: Option<i64>,
495) -> Vec<u8> {
496    let mut buf = Vec::new();
497    encode_confluent_header(&mut buf, schema_id);
498
499    let transaction_id = Value::String(id.to_owned());
500    let status = Value::String(status.to_owned());
501    let event_count = match message_count {
502        None => Value::Union {
503            index: 0,
504            inner: Box::new(Value::Null),
505            n_variants: 2,
506            null_variant: Some(0),
507        },
508        Some(count) => Value::Union {
509            index: 1,
510            inner: Box::new(Value::Long(count)),
511            n_variants: 2,
512            null_variant: Some(0),
513        },
514    };
515
516    let data_collections = if let Some(message_count) = message_count {
517        let collection = Value::Record(vec![
518            ("data_collection".into(), Value::String(collection.into())),
519            ("event_count".into(), Value::Long(message_count)),
520        ]);
521        Value::Union {
522            index: 1,
523            inner: Box::new(Value::Array(vec![collection])),
524            n_variants: 2,
525            null_variant: Some(0),
526        }
527    } else {
528        Value::Union {
529            index: 0,
530            inner: Box::new(Value::Null),
531            n_variants: 2,
532            null_variant: Some(0),
533        }
534    };
535
536    let record_contents = vec![
537        ("id".into(), transaction_id),
538        ("status".into(), status),
539        ("event_count".into(), event_count),
540        ("data_collections".into(), data_collections),
541    ];
542    let avro = Value::Record(record_contents);
543    debug_assert!(avro.validate(DEBEZIUM_TRANSACTION_SCHEMA.top_node()));
544    mz_avro::encode_unchecked(&avro, &DEBEZIUM_TRANSACTION_SCHEMA, &mut buf);
545    buf
546}
547
548#[cfg(test)]
549mod tests {
550    use mz_repr::{Datum, RelationDesc, Row, SqlScalarType};
551    use uuid::Uuid;
552
553    use super::*;
554    use crate::encode::Encode;
555
556    const SCHEMA: &str = r#"{"type":"record","name":"row","fields":[{"name":"a","type":"long"}]}"#;
557
558    fn encoder(schema_id: AvroSchemaId) -> AvroEncoder {
559        let desc = RelationDesc::builder()
560            .with_column("a", SqlScalarType::Int64.nullable(false))
561            .finish();
562        AvroEncoder::new(desc, false, SCHEMA, schema_id)
563    }
564
565    fn row() -> Row {
566        Row::pack_slice(&[Datum::Int64(42)])
567    }
568
569    #[mz_ore::test]
570    fn confluent_framing() {
571        let bytes = encoder(AvroSchemaId::Confluent(7)).encode_unchecked(row());
572        let (id, payload) = crate::confluent::extract_avro_header(&bytes).unwrap();
573        assert_eq!(bytes[0], 0x00, "confluent magic byte");
574        assert_eq!(id, 7);
575        assert!(!payload.is_empty());
576    }
577
578    #[mz_ore::test]
579    fn glue_framing() {
580        let uuid = Uuid::from_u128(0x1234_5678);
581        let bytes = encoder(AvroSchemaId::Glue(uuid)).encode_unchecked(row());
582        let (parsed, payload) = crate::glue::extract_avro_header(&bytes).unwrap();
583        assert_eq!(bytes[0], 0x03, "glue header version");
584        assert_eq!(parsed, uuid);
585        assert!(!payload.is_empty());
586    }
587
588    #[mz_ore::test]
589    fn hash_ignores_framing() {
590        // The same row under different framings must hash identically: `hash`
591        // strips the schema-id-bearing header before hashing, so framing (and
592        // the run-to-run-varying id it carries) cannot perturb partitioning.
593        let confluent = encoder(AvroSchemaId::Confluent(7));
594        let glue = encoder(AvroSchemaId::Glue(Uuid::from_u128(0x1234_5678)));
595        let cb = confluent.encode_unchecked(row());
596        let gb = glue.encode_unchecked(row());
597        assert_eq!(confluent.hash(&cb), glue.hash(&gb));
598    }
599}