Skip to main content

mz_storage/source/
postgres.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
10//! Code to render the ingestion dataflow of a [`PostgresSourceConnection`]. The dataflow consists
11//! of multiple operators in order to take advantage of all the available workers.
12//!
13//! # Snapshot
14//!
15//! One part of the dataflow deals with snapshotting the tables involved in the ingestion. Each
16//! table is partitioned across all workers using PostgreSQL's `ctid` column to identify row
17//! ranges. Each worker fetches its assigned range using a `COPY` query with ctid filtering,
18//! enabling parallel snapshotting of large tables.
19//!
20//! For all tables that ended up being snapshotted the snapshot reader also emits a rewind request
21//! to the replication reader which will ensure that the requested portion of the replication
22//! stream is subtracted from the snapshot.
23//!
24//! See the [snapshot] module for more information on the snapshot strategy.
25//!
26//! # Replication
27//!
28//! The other part of the dataflow deals with reading the logical replication slot, which must
29//! happen from a single worker. The minimum amount of processing is performed from that worker
30//! and the data is then distributed among all workers for decoding.
31//!
32//! See the [replication] module for more information on the replication strategy.
33//!
34//! # Error handling
35//!
36//! There are two kinds of errors that can happen during ingestion that are represented as two
37//! separate error types:
38//!
39//! [`DefiniteError`]s are errors that happen during processing of a specific
40//! collection record at a specific LSN. These are the only errors that can ever end up in the
41//! error collection of a subsource.
42//!
43//! Transient errors are any errors that can happen for reasons that are unrelated to the data
44//! itself. This could be authentication failures, connection failures, etc. The only operators
45//! that can emit such errors are the `TableReader` and the `ReplicationReader` operators, which
46//! are the ones that talk to the external world. Both of these operators are built with the
47//! `AsyncOperatorBuilder::build_fallible` method which allows transient errors to be propagated
48//! upwards with the standard `?` operator without risking downgrading the capability and producing
49//! bogus frontiers.
50//!
51//! The error streams from both of those operators are published to the source status and also
52//! trigger a restart of the dataflow.
53//!
54//! ```text
55//!    ┏━━━━━━━━━━━━━━┓
56//!    ┃    table     ┃
57//!    ┃    reader    ┃
58//!    ┗━┯━━━━━━━━━━┯━┛
59//!      │          │rewind
60//!      │          │requests
61//!      │          ╰────╮
62//!      │             ┏━v━━━━━━━━━━━┓
63//!      │             ┃ replication ┃
64//!      │             ┃   reader    ┃
65//!      │             ┗━┯━━━━━━━━━┯━┛
66//!  COPY│           slot│         │
67//!  data│           data│         │
68//! ┏━━━━v━━━━━┓ ┏━━━━━━━v━━━━━┓   │
69//! ┃  COPY    ┃ ┃ replication ┃   │
70//! ┃ decoder  ┃ ┃   decoder   ┃   │
71//! ┗━━━━┯━━━━━┛ ┗━━━━━┯━━━━━━━┛   │
72//!      │snapshot     │replication│
73//!      │updates      │updates    │
74//!      ╰────╮    ╭───╯           │
75//!          ╭┴────┴╮              │
76//!          │concat│              │
77//!          ╰──┬───╯              │
78//!             │ data             │progress
79//!             │ outputs          │output
80//!             │ (one per export) │
81//!             v                  v
82//! ```
83//!
84//! Both the snapshot and the replication operators emit one output stream per source export, and
85//! the streams of each export are concatenated pairwise.
86
87use std::collections::BTreeMap;
88use std::rc::Rc;
89use std::time::Duration;
90
91use itertools::Itertools as _;
92use mz_expr::EvalError;
93use mz_ore::error::ErrorExt;
94use mz_postgres_util::desc::PostgresTableDesc;
95use mz_postgres_util::schema_change::SchemaChangeError;
96use mz_postgres_util::{
97    Client, PostgresError, Sql, query_opt, simple_query, simple_query_opt, sql,
98};
99use mz_repr::{Datum, GlobalId, Row};
100use mz_storage_types::errors::{DataflowError, SourceError, SourceErrorDetails};
101use mz_storage_types::sources::casts::StorageScalarExpr;
102use mz_storage_types::sources::postgres::CastType;
103use mz_storage_types::sources::{
104    MzOffset, PostgresSourceConnection, SourceExport, SourceExportDetails, SourceTimestamp,
105};
106use mz_timely_util::builder_async::PressOnDropButton;
107use serde::{Deserialize, Serialize};
108use timely::dataflow::operators::Concat;
109use timely::dataflow::operators::vec::{Map, ToStream};
110use timely::dataflow::{Scope, StreamVec};
111use timely::progress::{Antichain, Timestamp};
112use tokio_postgres::SimpleQueryRow;
113use tokio_postgres::error::SqlState;
114use tokio_postgres::types::PgLsn;
115
116use crate::healthcheck::{HealthStatusMessage, HealthStatusUpdate, StatusNamespace};
117use crate::source::types::{Probe, ResumeUppers, SourceRender, StackedCollection};
118use crate::source::{RawSourceCreationConfig, SourceMessage};
119
120mod replication;
121mod snapshot;
122
123impl SourceRender for PostgresSourceConnection {
124    type Time = MzOffset;
125
126    const STATUS_NAMESPACE: StatusNamespace = StatusNamespace::Postgres;
127
128    /// Render the ingestion dataflow. This function only connects things together and contains no
129    /// actual processing logic.
130    fn render<'scope>(
131        self,
132        scope: Scope<'scope, MzOffset>,
133        config: &RawSourceCreationConfig,
134        resume_uppers: impl futures::Stream<Item = ResumeUppers<MzOffset>> + 'static,
135        _start_signal: impl std::future::Future<Output = ()> + 'static,
136    ) -> (
137        BTreeMap<
138            GlobalId,
139            StackedCollection<'scope, MzOffset, Result<SourceMessage, DataflowError>>,
140        >,
141        StreamVec<'scope, MzOffset, HealthStatusMessage>,
142        StreamVec<'scope, MzOffset, Probe<MzOffset>>,
143        Vec<PressOnDropButton>,
144    ) {
145        // Collect the source outputs that we will be exporting into a per-table map.
146        let mut table_info = BTreeMap::new();
147        for (idx, (id, export)) in config.source_exports.iter().enumerate() {
148            let SourceExport {
149                details,
150                storage_metadata: _,
151                data_config: _,
152            } = export;
153            let details = match details {
154                SourceExportDetails::Postgres(details) => details,
155                // This is an export that doesn't need any data output to it.
156                SourceExportDetails::None => continue,
157                _ => panic!("unexpected source export details: {:?}", details),
158            };
159            let desc = details.table.clone();
160            let casts = details.column_casts.clone();
161            let resume_upper = Antichain::from_iter(
162                config
163                    .source_resume_uppers
164                    .get(id)
165                    .expect("all source exports must be present in source resume uppers")
166                    .iter()
167                    .map(MzOffset::decode_row),
168            );
169            let output = SourceOutputInfo {
170                desc,
171                projection: None,
172                casts,
173                resume_upper,
174                export_id: id.clone(),
175                initial_lsn: details.initial_lsn.unwrap_or_else(MzOffset::minimum),
176            };
177            table_info
178                .entry(output.desc.oid)
179                .or_insert_with(BTreeMap::new)
180                .insert(idx, output);
181        }
182
183        let metrics = config.metrics.get_postgres_source_metrics(config.id);
184
185        let (snapshot_updates, rewinds, slot_ready, snapshot_err, snapshot_token) =
186            snapshot::render(
187                scope.clone(),
188                config.clone(),
189                self.clone(),
190                table_info.clone(),
191                metrics.snapshot_metrics.clone(),
192            );
193
194        let (repl_updates, probe_stream, repl_err, repl_token) = replication::render(
195            scope.clone(),
196            config.clone(),
197            self,
198            table_info,
199            rewinds,
200            slot_ready,
201            resume_uppers,
202            metrics,
203        );
204
205        // Both operators produce one stream per export, in output index order, which matches the
206        // iteration order of `source_exports`.
207        let mut data_collections = BTreeMap::new();
208        for ((id, snapshot_updates), repl_updates) in config
209            .source_exports
210            .keys()
211            .zip_eq(snapshot_updates)
212            .zip_eq(repl_updates)
213        {
214            data_collections.insert(*id, snapshot_updates.concat(repl_updates));
215        }
216
217        let export_ids = config.source_exports.keys().copied();
218        let health_init = export_ids
219            .map(Some)
220            .chain(std::iter::once(None))
221            .map(|id| HealthStatusMessage {
222                id,
223                namespace: Self::STATUS_NAMESPACE,
224                update: HealthStatusUpdate::Running,
225            })
226            .collect::<Vec<_>>()
227            .to_stream(scope);
228
229        // N.B. Note that we don't check ssh tunnel statuses here. We could, but immediately on
230        // restart we are going to set the status to an ssh error correctly, so we don't do this
231        // extra work.
232        let errs = snapshot_err.concat(repl_err).map(move |err| {
233            // This update will cause the dataflow to restart
234            let err_string = err.display_with_causes().to_string();
235            let hint = match &err {
236                ReplicationError::Definite(err) => err.hint(),
237                ReplicationError::Transient(_) => None,
238            };
239            let update = HealthStatusUpdate::halting(err_string.clone(), hint);
240
241            let namespace = match err {
242                ReplicationError::Transient(err)
243                    if matches!(
244                        &*err,
245                        TransientError::PostgresError(PostgresError::Ssh(_))
246                            | TransientError::PostgresError(PostgresError::SshIo(_))
247                    ) =>
248                {
249                    StatusNamespace::Ssh
250                }
251                _ => Self::STATUS_NAMESPACE,
252            };
253
254            HealthStatusMessage {
255                id: None,
256                namespace: namespace.clone(),
257                update,
258            }
259        });
260
261        let health = health_init.concat(errs);
262
263        (
264            data_collections,
265            health,
266            probe_stream,
267            vec![snapshot_token, repl_token],
268        )
269    }
270}
271
272#[derive(Clone, Debug)]
273struct SourceOutputInfo {
274    /// The expected upstream schema of this output.
275    desc: PostgresTableDesc,
276    /// A projection of the upstream columns into the columns expected by this output. This field
277    /// is recalculated every time we observe an upstream schema change. On dataflow initialization
278    /// this field is None since we haven't yet observed any schemas.
279    projection: Option<Vec<usize>>,
280    casts: Vec<(CastType, StorageScalarExpr)>,
281    resume_upper: Antichain<MzOffset>,
282    export_id: GlobalId,
283    /// An upper bound on the LSN whose upstream schema `desc` describes, read during purification
284    /// once `desc` was in hand. Outputs created before this was recorded fall back to
285    /// [`MzOffset::minimum`], which ignores nothing.
286    initial_lsn: MzOffset,
287}
288
289impl SourceOutputInfo {
290    /// Whether this output must skip replication messages committed at `commit_lsn`.
291    ///
292    /// Such messages fall outside the range this output describes. Its snapshot is taken at an LSN
293    /// at or after `initial_lsn`, so the rewind that subtracts the replication stream from that
294    /// snapshot stops at the same point. They also need not line up with `desc`, since the
295    /// upstream schema may have changed between them and `initial_lsn`.
296    ///
297    /// The comparison is strict because `initial_lsn` is an upper. Everything committed when it
298    /// was read is strictly below it, so a transaction landing exactly on it is one this output
299    /// has to ingest.
300    fn ignores(&self, commit_lsn: MzOffset) -> bool {
301        commit_lsn < self.initial_lsn
302    }
303}
304
305#[derive(Clone, Debug, thiserror::Error)]
306pub enum ReplicationError {
307    #[error(transparent)]
308    Transient(#[from] Rc<TransientError>),
309    #[error(transparent)]
310    Definite(#[from] Rc<DefiniteError>),
311}
312
313/// A transient error that never ends up in the collection of a specific table.
314#[derive(Debug, thiserror::Error)]
315pub enum TransientError {
316    #[error("replication slot mysteriously missing")]
317    MissingReplicationSlot,
318    #[error(
319        "slot overcompacted. Requested LSN {requested_lsn} but only LSNs >= {available_lsn} are available"
320    )]
321    OvercompactedReplicationSlot {
322        requested_lsn: MzOffset,
323        available_lsn: MzOffset,
324    },
325    #[error("replication slot already exists")]
326    ReplicationSlotAlreadyExists,
327    #[error(
328        "creating replication slot {slot} timed out after {timeout:?}. Slot creation waits for \
329         in-progress upstream transactions to end"
330    )]
331    ReplicationSlotCreationTimeout { slot: String, timeout: Duration },
332    #[error("stream ended prematurely")]
333    ReplicationEOF,
334    #[error("unexpected replication message")]
335    UnknownReplicationMessage,
336    #[error("unexpected logical replication message")]
337    UnknownLogicalReplicationMessage,
338    #[error("received replication event outside of transaction")]
339    BareTransactionEvent,
340    #[error("lsn mismatch between BEGIN and COMMIT")]
341    InvalidTransaction,
342    #[error("BEGIN within existing BEGIN stream")]
343    NestedTransaction,
344    #[error("recoverable errors should crash the process during snapshots")]
345    SyntheticError,
346    #[error("sql client error")]
347    SQLClient(#[from] tokio_postgres::Error),
348    #[error(transparent)]
349    PostgresError(#[from] PostgresError),
350    #[error(transparent)]
351    Generic(#[from] anyhow::Error),
352}
353
354/// A definite error that always ends up in the collection of a specific table.
355#[derive(Debug, Clone, Serialize, Deserialize, thiserror::Error)]
356pub enum DefiniteError {
357    #[error("slot compacted past snapshot point. snapshot consistent point={0} resume_lsn={1}")]
358    SlotCompactedPastResumePoint(MzOffset, MzOffset),
359    #[error("table was truncated")]
360    TableTruncated,
361    #[error("table was dropped")]
362    TableDropped,
363    #[error("publication {0:?} does not exist")]
364    PublicationDropped(String),
365    #[error("replication slot has been invalidated because it exceeded the maximum reserved size")]
366    InvalidReplicationSlot,
367    #[error("unexpected number of columns while parsing COPY output")]
368    MissingColumn,
369    #[error("failed to parse COPY protocol")]
370    InvalidCopyInput,
371    #[error(
372        "unsupported action: database restored from point-in-time backup. Expected timeline ID {expected} but got {actual}"
373    )]
374    InvalidTimelineId { expected: u64, actual: u64 },
375    #[error(
376        "unsupported action: upstream went back in time. Expected a snapshot at or after LSN {initial_lsn} but the snapshot was taken at {snapshot_lsn}"
377    )]
378    InvalidSnapshotLsn {
379        initial_lsn: MzOffset,
380        snapshot_lsn: MzOffset,
381    },
382    #[error(
383        "unsupported action: upstream physical replica status changed (e.g. a physical replica was promoted to a primary). Expected pg_is_in_recovery()={expected} but got {actual}"
384    )]
385    InvalidPhysicalReplica { expected: bool, actual: bool },
386    #[error(
387        "TOASTed value missing from old row. Did you forget to set REPLICA IDENTITY to FULL for your table?"
388    )]
389    MissingToast,
390    #[error(
391        "old row missing from replication stream. Did you forget to set REPLICA IDENTITY to FULL for your table?"
392    )]
393    DefaultReplicaIdentity,
394    #[error(transparent)]
395    IncompatibleSchema(SchemaChangeError),
396    #[error("invalid UTF8 string: {0:?}")]
397    InvalidUTF8(Vec<u8>),
398    #[error("failed to cast raw column: {0}")]
399    CastError(#[source] EvalError),
400    #[error("unexpected binary data in replication stream")]
401    UnexpectedBinaryData,
402}
403
404impl DefiniteError {
405    fn hint(&self) -> Option<String> {
406        match self {
407            DefiniteError::IncompatibleSchema(err) => err.hint(),
408            _ => None,
409        }
410    }
411}
412
413impl From<DefiniteError> for DataflowError {
414    fn from(err: DefiniteError) -> Self {
415        let m = err.to_string().into();
416        DataflowError::SourceError(Box::new(SourceError {
417            hint: err.hint().map(Into::into),
418            error: match &err {
419                DefiniteError::SlotCompactedPastResumePoint(_, _) => SourceErrorDetails::Other(m),
420                DefiniteError::TableTruncated => SourceErrorDetails::Other(m),
421                DefiniteError::TableDropped => SourceErrorDetails::Other(m),
422                DefiniteError::PublicationDropped(_) => SourceErrorDetails::Initialization(m),
423                DefiniteError::InvalidReplicationSlot => SourceErrorDetails::Initialization(m),
424                DefiniteError::MissingColumn => SourceErrorDetails::Other(m),
425                DefiniteError::InvalidCopyInput => SourceErrorDetails::Other(m),
426                DefiniteError::InvalidTimelineId { .. } => SourceErrorDetails::Initialization(m),
427                DefiniteError::InvalidSnapshotLsn { .. } => SourceErrorDetails::Initialization(m),
428                DefiniteError::InvalidPhysicalReplica { .. } => {
429                    SourceErrorDetails::Initialization(m)
430                }
431                DefiniteError::MissingToast => SourceErrorDetails::Other(m),
432                DefiniteError::DefaultReplicaIdentity => SourceErrorDetails::Other(m),
433                DefiniteError::IncompatibleSchema(_) => SourceErrorDetails::Other(m),
434                DefiniteError::InvalidUTF8(_) => SourceErrorDetails::Other(m),
435                DefiniteError::CastError(_) => SourceErrorDetails::Other(m),
436                DefiniteError::UnexpectedBinaryData => SourceErrorDetails::Other(m),
437            },
438        }))
439    }
440}
441
442async fn ensure_replication_slot(
443    client: &Client,
444    slot: &str,
445    timeout: Duration,
446) -> Result<(), TransientError> {
447    let query = sql!(
448        "CREATE_REPLICATION_SLOT {} LOGICAL \"pgoutput\" NOEXPORT_SNAPSHOT",
449        Sql::ident(slot)
450    );
451    match create_replication_slot(client, slot, query, timeout).await {
452        Ok(_) => Ok(()),
453        Err(TransientError::ReplicationSlotAlreadyExists) => {
454            tracing::trace!(%slot, "replication slot already existed");
455            Ok(())
456        }
457        Err(err) => Err(err),
458    }
459}
460
461/// Runs the `CREATE_REPLICATION_SLOT` command `query` for `slot`, failing with
462/// [`TransientError::ReplicationSlotCreationTimeout`] once the command has waited `timeout` for
463/// in-progress upstream transactions to end.
464///
465/// A failure inside a transaction leaves that transaction aborted.
466async fn create_replication_slot(
467    client: &Client,
468    slot: &str,
469    query: Sql,
470    timeout: Duration,
471) -> Result<Option<SimpleQueryRow>, TransientError> {
472    // Slot creation blocks on the transaction locks of in-progress transactions, which
473    // `lock_timeout` bounds. `statement_timeout` does not apply: the server arms it for SQL
474    // statements only, never for replication commands. Abandoning the command client-side does not
475    // help either: a walsender blocked on a transaction lock does not notice the closed connection
476    // and the `CREATE` command remains on the upstream.
477    //
478    // NOTE: `lock_timeout` only detects locks of concurrent sessions on the same server, not
479    // on upstream servers of physical replicas.
480    set_timeout(client, "lock_timeout", timeout).await?;
481    let result = simple_query_opt(client, query).await;
482    // Inside a transaction a failed command also fails the reset, and rolling the transaction back
483    // reverts the `SET`. The command's error is the one to report.
484    let reset = simple_query(client, sql!("RESET lock_timeout")).await;
485    let row = match result {
486        Ok(row) => row,
487        Err(PostgresError::Postgres(err)) if err.code() == Some(&SqlState::DUPLICATE_OBJECT) => {
488            return Err(TransientError::ReplicationSlotAlreadyExists);
489        }
490        Err(PostgresError::Postgres(err)) if err.code() == Some(&SqlState::LOCK_NOT_AVAILABLE) => {
491            return Err(TransientError::ReplicationSlotCreationTimeout {
492                slot: slot.to_owned(),
493                timeout,
494            });
495        }
496        Err(err) => return Err(err.into()),
497    };
498    reset?;
499    Ok(row)
500}
501
502/// Sets the session's timeout `parameter` (for example `statement_timeout`) to `timeout`.
503async fn set_timeout(
504    client: &Client,
505    parameter: &'static str,
506    timeout: Duration,
507) -> Result<(), TransientError> {
508    // A unitless value is interpreted as milliseconds, and the server rejects values above
509    // `i32::MAX` (about 24.8 days) rather than clamping them.
510    // https://www.postgresql.org/docs/current/runtime-config-client.html
511    let millis = i32::try_from(timeout.as_millis()).unwrap_or(i32::MAX);
512    let query = sql!("SET {} = {}", Sql::new(parameter), Sql::from(millis));
513    simple_query(client, query).await?;
514    Ok(())
515}
516
517/// The state of a replication slot.
518struct SlotMetadata {
519    /// The process ID of the session using this slot if the slot is currently actively being used.
520    /// None if inactive.
521    active_pid: Option<i32>,
522    /// The address (LSN) up to which the logical slot's consumer has confirmed receiving data.
523    /// Data corresponding to the transactions committed before this LSN is not available anymore.
524    confirmed_flush_lsn: MzOffset,
525    /// Whether the slot can no longer be read from, which PostgreSQL reports as a `wal_status` of
526    /// `lost`.
527    invalidated: bool,
528}
529
530/// Fetches the state of `slot`, waiting for it to finish initializing.
531async fn fetch_slot_metadata(
532    client: &Client,
533    slot: &str,
534    interval: Duration,
535) -> Result<SlotMetadata, TransientError> {
536    loop {
537        // `wal_status` exists on PostgreSQL 13 and later, which are also the only versions that
538        // invalidate slots. Reading it through `to_jsonb` yields NULL on earlier versions instead
539        // of failing the query.
540        let Some(row) = query_opt(
541            &**client,
542            sql!(
543                "SELECT active_pid, confirmed_flush_lsn, \
544                    to_jsonb(s) ->> 'wal_status' = 'lost' AS invalidated \
545                 FROM pg_replication_slots s WHERE slot_name = $1"
546            ),
547            &[&slot],
548        )
549        .await?
550        else {
551            return Err(TransientError::MissingReplicationSlot);
552        };
553
554        match row.get::<_, Option<PgLsn>>("confirmed_flush_lsn") {
555            // For postgres, `confirmed_flush_lsn` means that the slot is able to produce
556            // all transactions that happen at tx_lsn >= confirmed_flush_lsn. Therefore this value
557            // already has "upper" semantics.
558            Some(lsn) => {
559                return Ok(SlotMetadata {
560                    confirmed_flush_lsn: MzOffset::from(lsn),
561                    active_pid: row.get("active_pid"),
562                    invalidated: row.get::<_, Option<bool>>("invalidated").unwrap_or(false),
563                });
564            }
565            // It can happen that confirmed_flush_lsn is NULL as the slot initializes
566            // This could probably be a `tokio::time::interval`, but its only is called twice,
567            // so its fine like this.
568            None => tokio::time::sleep(interval).await,
569        };
570    }
571}
572
573// Ensures that the table with oid `oid` and expected schema `expected_schema` is still compatible
574// with the current upstream schema `upstream_info`.
575fn verify_schema(
576    oid: u32,
577    info: &SourceOutputInfo,
578    upstream_info: &BTreeMap<u32, PostgresTableDesc>,
579) -> Result<(), DefiniteError> {
580    let current_desc = upstream_info.get(&oid).ok_or(DefiniteError::TableDropped)?;
581
582    let allow_oids_to_change_by_col_num = info
583        .desc
584        .columns
585        .iter()
586        .zip_eq(info.casts.iter())
587        .flat_map(|(col, (cast_type, _))| match cast_type {
588            CastType::Text => Some(col.col_num),
589            CastType::Natural => None,
590        })
591        .collect();
592
593    match info
594        .desc
595        .determine_compatibility(current_desc, &allow_oids_to_change_by_col_num)
596    {
597        Ok(()) => Ok(()),
598        Err(err) => Err(DefiniteError::IncompatibleSchema(err)),
599    }
600}
601
602/// Casts a text row into the target types
603fn cast_row(
604    casts: &[(CastType, StorageScalarExpr)],
605    datums: &[Datum<'_>],
606    row: &mut Row,
607) -> Result<(), DefiniteError> {
608    let arena = mz_repr::RowArena::new();
609    let mut packer = row.packer();
610    for (_, column_cast) in casts {
611        let datum = column_cast
612            .eval(datums, &arena)
613            .map_err(DefiniteError::CastError)?;
614        packer.push(datum);
615    }
616    Ok(())
617}
618
619/// Converts raw bytes that are expected to be UTF8 encoded into a `Datum::String`
620fn decode_utf8_text(bytes: &[u8]) -> Result<Datum<'_>, DefiniteError> {
621    match std::str::from_utf8(bytes) {
622        Ok(text) => Ok(Datum::String(text)),
623        Err(_) => Err(DefiniteError::InvalidUTF8(bytes.to_vec())),
624    }
625}