Skip to main content

mz_adapter/coord/sequencer/inner/
copy_from.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::str::FromStr;
11use std::sync::Arc;
12
13use mz_adapter_types::connection::ConnectionId;
14use mz_expr::Eval;
15use mz_ore::cast::CastInto;
16use mz_persist_client::Diagnostics;
17use mz_persist_client::batch::ProtoBatch;
18use mz_persist_types::codec_impls::UnitSchema;
19use mz_pgcopy::CopyFormatParams;
20use mz_repr::{CatalogItemId, ColumnIndex, Datum, RelationDesc, Row, RowArena};
21use mz_sql::catalog::SessionCatalog;
22use mz_sql::plan::{self, CopyFromFilter, CopyFromSource, HirScalarExpr};
23use mz_sql::session::metadata::SessionMetadata;
24use mz_storage_client::client::TableData;
25use mz_storage_types::StorageDiff;
26use mz_storage_types::oneshot_sources::{ContentShape, OneshotIngestionRequest};
27use mz_storage_types::sources::SourceData;
28use smallvec::SmallVec;
29use timely::progress::Antichain;
30use tokio::sync::{mpsc, oneshot};
31use url::Url;
32use uuid::Uuid;
33
34use crate::command::CopyFromStdinWriter;
35use crate::coord::sequencer::inner::return_if_err;
36use crate::coord::{ActiveCopyFrom, Coordinator, TargetCluster};
37use crate::optimize;
38use crate::optimize::dataflows::{EvalTime, ExprPrep, ExprPrepOneShot};
39use crate::session::{Session, TransactionOps, WriteOp};
40use crate::{AdapterError, ExecuteContext, ExecuteResponse};
41
42/// Finalize persist batches periodically during COPY FROM STDIN to avoid
43/// unbounded in-memory growth in a single giant batch.
44const COPY_FROM_STDIN_MAX_BATCH_BYTES: usize = 32 * 1024 * 1024;
45
46/// Cap on the number of parallel decode workers spawned per COPY FROM STDIN.
47/// A single network-bound stream sees marginal gains past a handful of
48/// decoders, and capping bounds how much of the blocking pool any one COPY can
49/// occupy while actively decoding.
50const COPY_FROM_STDIN_MAX_WORKERS: usize = 8;
51
52impl Coordinator {
53    pub(crate) async fn sequence_copy_from(
54        &mut self,
55        ctx: ExecuteContext,
56        plan: plan::CopyFromPlan,
57        target_cluster: TargetCluster,
58    ) {
59        if ctx
60            .session()
61            .vars()
62            .transaction_isolation()
63            .is_bounded_staleness()
64        {
65            return ctx.retire(Err(AdapterError::BoundedStalenessReadOnly));
66        }
67
68        // STDIN is sequenced by handing control back to pgwire, which drives the
69        // CopyData/CopyDone exchange. URL/S3 sources stage a one-shot ingestion
70        // server-side and fall through to the rest of this function.
71        if let CopyFromSource::Stdin = plan.source {
72            let (tx, _, session, ctx_extra, response_barriers) = ctx.into_parts();
73            let response = Ok(ExecuteResponse::CopyFrom {
74                target_id: plan.target_id,
75                target_name: plan.target_name,
76                columns: plan.columns,
77                params: plan.params,
78                ctx_extra,
79            });
80            if response_barriers.is_empty() {
81                tx.send(response, session);
82            } else {
83                mz_ore::task::spawn(|| "copy_from_stdin_after_response_barriers", async move {
84                    for barrier in response_barriers {
85                        barrier.await;
86                    }
87                    tx.send(response, session);
88                });
89            }
90            return;
91        }
92
93        let plan::CopyFromPlan {
94            target_name: _,
95            target_id,
96            source,
97            columns: _,
98            source_desc,
99            mfp,
100            params,
101            filter,
102        } = plan;
103
104        let eval_uri = |from: HirScalarExpr| -> Result<String, AdapterError> {
105            let style = ExprPrepOneShot {
106                logical_time: EvalTime::NotAvailable,
107                session: ctx.session(),
108                catalog_state: self.catalog().state(),
109            };
110            let mut from = from.lower_uncorrelated(self.catalog().state().system_config())?;
111            style.prep_scalar_expr(&mut from)?;
112
113            // TODO(cf3): Add structured errors for the below uses of `coord_bail!`
114            // and AdapterError::Unstructured.
115            let temp_storage = RowArena::new();
116            let eval_result = from.eval(&[], &temp_storage)?;
117            let eval_string = match eval_result {
118                Datum::Null => coord_bail!("COPY FROM target value cannot be NULL"),
119                Datum::String(url_str) => url_str,
120                other => coord_bail!("programming error! COPY FROM target cannot be {other}"),
121            };
122
123            Ok(eval_string.to_string())
124        };
125
126        // We check in planning that we're copying into a Table, but be defensive.
127        let Some(entry) = self.catalog().try_get_entry(&target_id) else {
128            return ctx.retire(Err(AdapterError::ConcurrentDependencyDrop {
129                dependency_kind: "table",
130                dependency_id: target_id.to_string(),
131            }));
132        };
133        let Some(dest_table) = entry.table() else {
134            let typ = entry.item().typ();
135            let msg = format!("programming error: expected a Table found {typ:?}");
136            return ctx.retire(Err(AdapterError::Unstructured(anyhow::anyhow!(msg))));
137        };
138
139        // Generate a unique UUID for our ingestion.
140        let ingestion_id = Uuid::new_v4();
141        let collection_id = dest_table.global_id_writes();
142
143        let format = match params {
144            CopyFormatParams::Csv(csv) => {
145                mz_storage_types::oneshot_sources::ContentFormat::Csv(csv.to_owned())
146            }
147            CopyFormatParams::Parquet => mz_storage_types::oneshot_sources::ContentFormat::Parquet,
148            CopyFormatParams::Text(_) | CopyFormatParams::Binary => {
149                mz_ore::soft_panic_or_log!("unsupported formats should be rejected in planning");
150                ctx.retire(Err(AdapterError::Unsupported("COPY FROM URL/S3 format")));
151                return;
152            }
153        };
154
155        let source = match source {
156            CopyFromSource::Url(from_expr) => {
157                let url = return_if_err!(eval_uri(from_expr), ctx);
158                // TODO(cf2): Structured errors.
159                let result = Url::parse(&url)
160                    .map_err(|err| AdapterError::Unstructured(anyhow::anyhow!("{err}")));
161                let url = return_if_err!(result, ctx);
162
163                // Only allow http(s) schemes. Technically we would fail later, as the current
164                // crate (reqwest) doesn't support other schemes.
165                // Prefer to fail early and explicitly in case the downstream ever changes.
166                // DNS resolution for hostnames is performed at execution time to avoid stalls
167                // during sequencing; IP-literal hosts are validated here because reqwest's
168                // custom DNS resolver is only invoked for hostnames.
169                match url.scheme() {
170                    "http" | "https" => {}
171                    other => {
172                        return ctx.retire(Err(AdapterError::Unstructured(anyhow::anyhow!(
173                            "only 'http://' and 'https://' urls are supported as COPY FROM \
174                             target, got '{other}://'"
175                        ))));
176                    }
177                }
178                let enforce_external_addresses =
179                    mz_storage_types::dyncfgs::ENFORCE_EXTERNAL_ADDRESSES
180                        .get(self.controller.storage.config().config_set());
181                if enforce_external_addresses {
182                    if let Err(err) = mz_ore::netio::ensure_url_ip_global(&url) {
183                        return ctx
184                            .retire(Err(AdapterError::Unstructured(anyhow::anyhow!("{err}"))));
185                    }
186                }
187                mz_storage_types::oneshot_sources::ContentSource::Http {
188                    url: mz_ore::url::SensitiveUrl(url),
189                }
190            }
191            CopyFromSource::AwsS3 {
192                uri,
193                connection,
194                connection_id,
195            } => {
196                let uri = return_if_err!(eval_uri(uri), ctx);
197
198                // Validate the URI is an S3 URI, with a bucket name. We rely on validating here
199                // and expect it in clusterd.
200                //
201                // TODO(cf2): Structured errors.
202                let result = http::Uri::from_str(&uri)
203                    .map_err(|err| {
204                        AdapterError::Unstructured(anyhow::anyhow!("expected S3 uri: {err}"))
205                    })
206                    .and_then(|uri| {
207                        if uri.scheme_str() != Some("s3") && uri.scheme_str() != Some("gs") {
208                            coord_bail!("only 's3://...' and 'gs://...' urls are supported as COPY FROM target");
209                        }
210                        Ok(uri)
211                    })
212                    .and_then(|uri| {
213                        if uri.host().is_none() {
214                            coord_bail!("missing bucket name from 's3://...' url");
215                        }
216                        Ok(uri)
217                    });
218                let uri = return_if_err!(result, ctx);
219
220                mz_storage_types::oneshot_sources::ContentSource::AwsS3 {
221                    connection,
222                    connection_id,
223                    uri: uri.to_string(),
224                }
225            }
226            CopyFromSource::Stdin => {
227                unreachable!("STDIN handled by the early return above")
228            }
229        };
230
231        let filter = match filter {
232            None => mz_storage_types::oneshot_sources::ContentFilter::None,
233            Some(CopyFromFilter::Files(files)) => {
234                mz_storage_types::oneshot_sources::ContentFilter::Files(files)
235            }
236            Some(CopyFromFilter::Pattern(pattern)) => {
237                mz_storage_types::oneshot_sources::ContentFilter::Pattern(pattern)
238            }
239        };
240
241        let source_mfp = mfp
242            .into_plan()
243            .map_err(|s| AdapterError::internal("copy_from", s))
244            .and_then(|mfp| {
245                mfp.into_nontemporal().map_err(|_| {
246                    AdapterError::internal("copy_from", "temporal MFP not allowed in copy from")
247                })
248            });
249        let source_mfp = return_if_err!(source_mfp, ctx);
250
251        let shape = ContentShape {
252            source_desc,
253            source_mfp,
254        };
255
256        let request = OneshotIngestionRequest {
257            source,
258            format,
259            filter,
260            shape,
261        };
262
263        let target_cluster = match self
264            .catalog()
265            .resolve_target_cluster(target_cluster, ctx.session())
266        {
267            Ok(cluster) => cluster,
268            Err(err) => {
269                return ctx.retire(Err(err));
270            }
271        };
272        let cluster_id = target_cluster.id;
273
274        // When we finish staging the Batches in Persist, we'll send a command
275        // to the Coordinator.
276        let command_tx = self.internal_cmd_tx.clone();
277        let conn_id = ctx.session().conn_id().clone();
278        let closure = Box::new(move |batches| {
279            let _ = command_tx.send(crate::coord::Message::StagedBatches {
280                conn_id,
281                table_id: target_id,
282                batches,
283            });
284        });
285        // Stash the execute context so we can cancel the COPY.
286        let conn_id = ctx.session().conn_id().clone();
287        self.active_copies.insert(
288            conn_id,
289            ActiveCopyFrom {
290                ingestion_id,
291                cluster_id,
292                table_id: target_id,
293                ctx,
294            },
295        );
296
297        let _result = self
298            .controller
299            .storage
300            .create_oneshot_ingestion(ingestion_id, collection_id, cluster_id, request, closure)
301            .await;
302    }
303
304    /// Sets up a streaming COPY FROM STDIN operation.
305    ///
306    /// Spawns N parallel background batch builder tasks that each receive
307    /// raw byte chunks, decode them, apply column defaults/reordering,
308    /// and build persist batches. Returns a [`CopyFromStdinWriter`] for
309    /// pgwire to distribute raw byte chunks across the workers.
310    pub(crate) fn setup_copy_from_stdin(
311        &self,
312        session: &Session,
313        target_id: CatalogItemId,
314        target_name: String,
315        columns: Vec<ColumnIndex>,
316        row_desc: RelationDesc,
317        params: CopyFormatParams<'static>,
318    ) -> Result<CopyFromStdinWriter, AdapterError> {
319        // Look up the table and its persist shard metadata.
320        let Some(entry) = self.catalog().try_get_entry(&target_id) else {
321            return Err(AdapterError::ConcurrentDependencyDrop {
322                dependency_kind: "table",
323                dependency_id: target_id.to_string(),
324            });
325        };
326        let Some(dest_table) = entry.table() else {
327            let typ = entry.item().typ();
328            return Err(AdapterError::Unstructured(anyhow::anyhow!(
329                "programming error: expected a Table found {typ:?}"
330            )));
331        };
332        let collection_id = dest_table.global_id_writes();
333
334        let collection_meta = self
335            .controller
336            .storage
337            .collection_metadata(collection_id)
338            .map_err(|e| AdapterError::Unstructured(anyhow::anyhow!("{e}")))?;
339        let shard_id = collection_meta.data_shard;
340        let collection_desc = collection_meta.relation_desc.clone();
341
342        // Pre-compute the column transformation.
343        let pcx = session.pcx().clone();
344        let session_meta = session.meta();
345        let catalog = self.catalog().clone();
346        let conn_catalog = catalog.for_session(session);
347        let catalog_state = conn_catalog.state();
348        let optimizer_config = optimize::OptimizerConfig::from(conn_catalog.system_vars());
349
350        // Determine if we need column rewriting (defaults/reordering).
351        let target_desc = catalog
352            .try_get_entry(&target_id)
353            .expect("table must exist")
354            .relation_desc_latest()
355            .expect("table has desc")
356            .into_owned();
357        let all_columns_in_order = columns.len() == target_desc.arity()
358            && columns.iter().enumerate().all(|(i, c)| c.to_raw() == i);
359
360        // If we need column rewriting, pre-compute the transform by running
361        // plan_copy_from with a single dummy row through the optimizer.
362        let column_transform = if all_columns_in_order {
363            None
364        } else {
365            let dummy_datums: Vec<Datum> = columns.iter().map(|_| Datum::Null).collect();
366            let dummy_row = Row::pack(&dummy_datums);
367
368            let prep = ExprPrepOneShot {
369                logical_time: EvalTime::NotAvailable,
370                session: &session_meta,
371                catalog_state,
372            };
373            let mut optimizer = optimize::view::Optimizer::new_with_prep_no_limit(
374                optimizer_config.clone(),
375                None,
376                prep,
377            );
378
379            let hir = mz_sql::plan::plan_copy_from(
380                &pcx,
381                &conn_catalog,
382                target_id,
383                target_name.clone(),
384                columns.clone(),
385                vec![dummy_row],
386            )?;
387            let mir = optimize::Optimize::optimize(&mut optimizer, hir)?;
388            let mir_expr = mir.into_inner();
389            let (result_ref, _) = mir_expr
390                .as_const()
391                .expect("optimizer should produce constant");
392            let result_rows = result_ref
393                .clone()
394                .map_err(|e| AdapterError::Unstructured(anyhow::anyhow!("eval error: {e}")))?;
395
396            let (full_row, _) = result_rows.into_iter().next().expect("should have one row");
397            let full_datums: Vec<Datum> = full_row.unpack();
398
399            let col_to_source: std::collections::BTreeMap<ColumnIndex, usize> =
400                columns.iter().enumerate().map(|(a, b)| (*b, a)).collect();
401
402            let mut sources: Vec<ColumnSource> = Vec::with_capacity(target_desc.arity());
403            let mut default_datums: Vec<Datum> = Vec::new();
404
405            for i in 0..target_desc.arity() {
406                let col_idx = ColumnIndex::from_raw(i);
407                if let Some(&src_idx) = col_to_source.get(&col_idx) {
408                    sources.push(ColumnSource::Input(src_idx));
409                } else {
410                    sources.push(ColumnSource::Default(default_datums.len()));
411                    default_datums.push(full_datums[i]);
412                }
413            }
414
415            let defaults_row = Row::pack(&default_datums);
416
417            Some(ColumnTransform {
418                sources,
419                defaults_row,
420            })
421        };
422
423        // Compute column types for decoding (same logic as pgwire used to do).
424        let column_types: Arc<[mz_pgrepr::Type]> = row_desc
425            .typ()
426            .column_types
427            .iter()
428            .map(|x| &x.scalar_type)
429            .map(mz_pgrepr::Type::from)
430            .collect::<Vec<_>>()
431            .into();
432
433        // Determine number of parallel workers, capped so that a single COPY
434        // cannot reserve an unbounded share of the shared blocking pool.
435        let num_workers = std::cmp::min(
436            std::thread::available_parallelism()
437                .map(|n| n.get())
438                .unwrap_or(1),
439            COPY_FROM_STDIN_MAX_WORKERS,
440        );
441        tracing::info!(
442            %target_id, num_workers,
443            "starting parallel COPY FROM STDIN batch builders"
444        );
445
446        // Shared state across workers.
447        let column_transform = Arc::new(column_transform);
448        let target_desc = Arc::new(target_desc);
449        let collection_desc = Arc::new(collection_desc);
450        let persist_client = self.persist_client.clone();
451
452        // Create per-worker channels and spawn one async task per worker. Each
453        // worker offloads the CPU-intensive processing of a chunk (decode plus
454        // the per-row transform/constraint-check/columnar encode) to the
455        // blocking pool for the duration of that chunk (see
456        // `copy_from_stdin_batch_builder`), so workers run in parallel while
457        // doing CPU work but hold no thread while idle between chunks.
458        let mut batch_txs = Vec::with_capacity(num_workers);
459        let mut worker_handles = Vec::with_capacity(num_workers);
460
461        // When COPY FROM uses CSV with HEADER, only the very first chunk in
462        // the stream contains the real header line. The pgwire handler splits
463        // data into ~32MB chunks distributed round-robin across workers, so
464        // subsequent chunks' first rows are data, not headers. We must only
465        // skip the header on the first chunk of worker 0.
466        let first_chunk_has_header = params.requires_header();
467        let mut worker_params = params;
468        if let CopyFormatParams::Csv(ref mut csv) = worker_params {
469            csv.header = false;
470        }
471
472        for worker_id in 0..num_workers {
473            // Keep in-flight buffering tight: at most one chunk queued per
474            // worker in addition to the currently-processed chunk.
475            let (batch_tx, batch_rx) = mpsc::channel::<Vec<u8>>(1);
476            batch_txs.push(batch_tx);
477
478            let persist_client = persist_client.clone();
479            let column_types = Arc::clone(&column_types);
480            let column_transform = Arc::clone(&column_transform);
481            let target_desc = Arc::clone(&target_desc);
482            let collection_desc = Arc::clone(&collection_desc);
483            let params = worker_params.clone();
484            // Only worker 0 receives the first chunk (round-robin), so only
485            // it needs to skip the CSV header on its first chunk.
486            let skip_header_on_first_chunk = worker_id == 0 && first_chunk_has_header;
487
488            let handle = mz_ore::task::spawn(
489                || format!("copy_from_stdin_worker:{target_id}:{worker_id}"),
490                Self::copy_from_stdin_batch_builder(
491                    persist_client,
492                    shard_id,
493                    collection_id,
494                    collection_desc,
495                    target_desc,
496                    column_transform,
497                    column_types,
498                    params,
499                    skip_header_on_first_chunk,
500                    batch_rx,
501                ),
502            );
503            worker_handles.push(handle);
504        }
505
506        // Spawn a collector task that waits for all workers.
507        let (completion_tx, completion_rx) = oneshot::channel();
508        mz_ore::task::spawn(
509            || format!("copy_from_stdin_collector:{target_id}"),
510            async move {
511                let mut all_batches = Vec::with_capacity(num_workers);
512                let mut total_rows: u64 = 0;
513
514                for handle in worker_handles {
515                    match handle.await {
516                        Ok((proto_batches, count)) => {
517                            all_batches.extend(proto_batches);
518                            total_rows += count;
519                        }
520                        Err(e) => {
521                            let _ = completion_tx.send(Err(e));
522                            return;
523                        }
524                    }
525                }
526
527                let _ = completion_tx.send(Ok((all_batches, total_rows)));
528            },
529        );
530
531        Ok(CopyFromStdinWriter {
532            batch_txs,
533            completion_rx,
534        })
535    }
536
537    /// Background task: receives raw byte chunks, decodes rows, and builds
538    /// persist batches. One instance runs per parallel worker.
539    async fn copy_from_stdin_batch_builder(
540        persist_client: mz_persist_client::PersistClient,
541        shard_id: mz_persist_client::ShardId,
542        collection_id: mz_repr::GlobalId,
543        collection_desc: Arc<RelationDesc>,
544        target_desc: Arc<RelationDesc>,
545        column_transform: Arc<Option<ColumnTransform>>,
546        column_types: Arc<[mz_pgrepr::Type]>,
547        params: CopyFormatParams<'static>,
548        skip_header_on_first_chunk: bool,
549        mut batch_rx: mpsc::Receiver<Vec<u8>>,
550    ) -> Result<(Vec<ProtoBatch>, u64), AdapterError> {
551        let persist_diagnostics = Diagnostics {
552            shard_name: collection_id.to_string(),
553            handle_purpose: "CopyFromStdin::batch_builder".to_string(),
554        };
555        let write_handle = persist_client
556            .open_writer::<SourceData, (), mz_repr::Timestamp, StorageDiff>(
557                shard_id,
558                collection_desc,
559                Arc::new(UnitSchema),
560                persist_diagnostics,
561            )
562            .await
563            .map_err(|e| AdapterError::Unstructured(anyhow::anyhow!("persist open: {e}")))?;
564
565        // Build a batch at the minimum timestamp. The coordinator will
566        // re-timestamp it during commit.
567        let lower = mz_repr::Timestamp::MIN;
568        let upper = Antichain::from_elem(lower.step_forward());
569        let mut batch_builder = write_handle.builder(Antichain::from_elem(lower));
570        let mut row_count: u64 = 0;
571        let mut row_count_in_batch: u64 = 0;
572        let mut batch_bytes: usize = 0;
573        let mut proto_batches = Vec::new();
574
575        let rt = tokio::runtime::Handle::current();
576        let mut is_first_chunk = true;
577        while let Some(raw_bytes) = batch_rx.recv().await {
578            // For the first chunk of worker 0, re-enable header skipping so the
579            // real CSV header line is skipped.
580            let chunk_params = if is_first_chunk && skip_header_on_first_chunk {
581                let mut p = params.clone();
582                if let CopyFormatParams::Csv(ref mut csv) = p {
583                    csv.header = true;
584                }
585                p
586            } else {
587                params.clone()
588            };
589            is_first_chunk = false;
590            let raw_len = raw_bytes.len();
591
592            // Offload the entire CPU-bound per-chunk pipeline -- decode, column
593            // transform, constraint checks, and the columnar persist encode
594            // (`BatchBuilder::add` -> `PartBuilder::push`) -- to the blocking
595            // pool. There is no yield point in the row loop until a batch fills
596            // (`add` only awaits `flush_part`, and only once an *encoded* part
597            // reaches `blob_target_size`, far beyond the 32 MiB *raw* batch
598            // boundary), so left on the async runtime each chunk's rows would
599            // run as one uninterrupted burst on a shared runtime worker thread,
600            // starving other connections. The blocking thread is held only
601            // while a chunk is in flight and released back to the pool between
602            // chunks (during `recv().await`), so idle workers still hold no
603            // thread. `block_on` is invoked once per chunk -- not per row -- to
604            // drive the row loop and the rare `flush_part` it may await.
605            let chunk_column_types = Arc::clone(&column_types);
606            let chunk_transform = Arc::clone(&column_transform);
607            let chunk_target_desc = Arc::clone(&target_desc);
608            let chunk_rt = rt.clone();
609            let (returned_builder, added_rows) = mz_ore::task::spawn_blocking(
610                || "copy_from_stdin_process_chunk",
611                move || {
612                    let rows = mz_pgcopy::decode_copy_format(
613                        &raw_bytes,
614                        &chunk_column_types,
615                        chunk_params,
616                    )
617                    .map_err(|e| AdapterError::CopyFormatError(e.to_string()))?;
618
619                    chunk_rt.block_on(async move {
620                        let mut added: u64 = 0;
621                        for row in rows {
622                            // Apply column transform if needed (add defaults, reorder).
623                            let full_row = if let Some(ref transform) = *chunk_transform {
624                                transform.apply(&row)
625                            } else {
626                                row
627                            };
628
629                            // Check constraints.
630                            for (i, datum) in full_row.iter().enumerate() {
631                                chunk_target_desc.constraints_met(i, &datum).map_err(|e| {
632                                    AdapterError::Unstructured(anyhow::anyhow!(
633                                        "constraint violation: {e}"
634                                    ))
635                                })?;
636                            }
637
638                            let data = SourceData(Ok(full_row));
639                            batch_builder
640                                .add(&data, &(), &lower, &1)
641                                .await
642                                .map_err(|e| {
643                                    AdapterError::Unstructured(anyhow::anyhow!("persist add: {e}"))
644                                })?;
645                            added += 1;
646                        }
647                        Ok::<_, AdapterError>((batch_builder, added))
648                    })
649                },
650            )
651            .await?;
652            batch_builder = returned_builder;
653            row_count += added_rows;
654            row_count_in_batch += added_rows;
655
656            batch_bytes = batch_bytes.saturating_add(raw_len);
657            if batch_bytes >= COPY_FROM_STDIN_MAX_BATCH_BYTES {
658                let batch = batch_builder.finish(upper.clone()).await.map_err(|e| {
659                    AdapterError::Unstructured(anyhow::anyhow!("persist finish: {e}"))
660                })?;
661                proto_batches.push(batch.into_transmittable_batch());
662
663                batch_builder = write_handle.builder(Antichain::from_elem(lower));
664                row_count_in_batch = 0;
665                batch_bytes = 0;
666            }
667        }
668
669        if row_count_in_batch > 0 || proto_batches.is_empty() {
670            let batch = batch_builder
671                .finish(upper)
672                .await
673                .map_err(|e| AdapterError::Unstructured(anyhow::anyhow!("persist finish: {e}")))?;
674            proto_batches.push(batch.into_transmittable_batch());
675        }
676
677        Ok((proto_batches, row_count))
678    }
679
680    pub(crate) fn commit_staged_batches(
681        &mut self,
682        conn_id: ConnectionId,
683        table_id: CatalogItemId,
684        batches: Vec<Result<ProtoBatch, String>>,
685    ) {
686        let Some(active_copy) = self.active_copies.remove(&conn_id) else {
687            // Getting a successful response for a cancel COPY FROM is unexpected.
688            tracing::warn!(%conn_id, ?batches, "got response for canceled COPY FROM");
689            return;
690        };
691
692        let ActiveCopyFrom {
693            ingestion_id,
694            cluster_id: _,
695            table_id: _,
696            mut ctx,
697        } = active_copy;
698        tracing::info!(%ingestion_id, num_batches = ?batches.len(), "received batches to append");
699
700        let mut all_batches = SmallVec::with_capacity(batches.len());
701        let mut all_errors = SmallVec::<[String; 1]>::with_capacity(batches.len());
702        let mut row_count = 0u64;
703
704        for maybe_batch in batches {
705            match maybe_batch {
706                Ok(batch) => {
707                    let count = batch.batch.as_ref().map(|b| b.len).unwrap_or(0);
708                    all_batches.push(batch);
709                    row_count = row_count.saturating_add(count);
710                }
711                Err(err) => all_errors.push(err),
712            }
713        }
714
715        // If we got any errors we need to fail the whole operation.
716        if let Some(error) = all_errors.pop() {
717            tracing::warn!(?error, ?all_errors, "failed COPY FROM");
718
719            // TODO(cf1): Cleanup the existing ProtoBatches to prevent leaking them.
720            // TODO(cf2): Carry structured errors all the way through.
721
722            ctx.retire(Err(AdapterError::Unstructured(anyhow::anyhow!(
723                "COPY FROM: {error}"
724            ))));
725
726            return;
727        }
728
729        // Stage a WriteOp, then when the Session is retired we complete the
730        // transaction, which handles acquiring the write lock for `table_id`,
731        // advancing the timestamps of the staged batches, and waiting for
732        // everything to complete before sending a response to the client.
733        let stage_write = ctx
734            .session_mut()
735            .add_transaction_ops(TransactionOps::Writes(vec![WriteOp {
736                id: table_id,
737                rows: TableData::Batches(all_batches),
738            }]));
739
740        if let Err(err) = stage_write {
741            ctx.retire(Err(err));
742        } else {
743            ctx.retire(Ok(ExecuteResponse::Copied(row_count.cast_into())));
744        }
745    }
746
747    /// Cancel any active `COPY FROM` statements/oneshot ingestions.
748    #[mz_ore::instrument(level = "debug")]
749    pub(crate) fn cancel_pending_copy(&mut self, conn_id: &ConnectionId) {
750        if let Some(ActiveCopyFrom {
751            ingestion_id,
752            cluster_id: _,
753            table_id: _,
754            ctx,
755        }) = self.active_copies.remove(conn_id)
756        {
757            let cancel_result = self
758                .controller
759                .storage
760                .cancel_oneshot_ingestion(ingestion_id);
761            if let Err(err) = cancel_result {
762                tracing::error!(?err, "failed to cancel OneshotIngestion");
763            }
764
765            ctx.retire(Err(AdapterError::Canceled));
766        }
767    }
768}
769
770/// Describes how to transform a partial row (with only specified columns)
771/// into a full row matching the table schema.
772struct ColumnTransform {
773    /// For each column in the target table, where to get the value.
774    sources: Vec<ColumnSource>,
775    /// Pre-computed default values for columns not in the COPY column list.
776    /// Packed as a Row; indexed by the `Default(idx)` variant.
777    defaults_row: Row,
778}
779
780enum ColumnSource {
781    /// Take the value from the input row at this position.
782    Input(usize),
783    /// Use the pre-computed default at this index in `defaults_row`.
784    Default(usize),
785}
786
787impl ColumnTransform {
788    /// Apply the transform to produce a full row from a partial input row.
789    fn apply(&self, input: &Row) -> Row {
790        let input_datums: Vec<Datum> = input.unpack();
791        let default_datums: Vec<Datum> = self.defaults_row.unpack();
792        let mut output_datums = Vec::with_capacity(self.sources.len());
793        for source in &self.sources {
794            match source {
795                ColumnSource::Input(idx) => output_datums.push(input_datums[*idx]),
796                ColumnSource::Default(idx) => output_datums.push(default_datums[*idx]),
797            }
798        }
799        Row::pack(&output_datums)
800    }
801}