Skip to main content

servers/postgres/copy_in/
mod.rs

1// Copyright 2023 Greptime Team
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! PostgreSQL `COPY ... FROM STDIN` (copy-in) support.
16//!
17//! The wire handshake is driven by pgwire: returning [`Response::CopyIn`]
18//! from a query handler makes the server send a `CopyInResponse` and enter
19//! the copy-in sub-protocol; subsequent `CopyData` messages from the client
20//! are dispatched to [`CopyHandler`], whose implementation below parses
21//! the data stream incrementally and flushes it into the query handler as
22//! [`RowInsertRequests`] batches.
23//!
24//! # Formats
25//!
26//! The supported formats are the explicit [`codec::CopyFormat`] enum —
27//! `csv` (table-bound, incremental row parsing) and `influxdb`
28//! (table-less, one bounded whole-payload request). Each format states
29//! its own target rule and ingestion shape in the match arms of the
30//! copy lifecycle below; adding a format is a new variant plus its
31//! arms. Builders resolve a `FORMAT` name (and its aliases) through
32//! [`REGISTERED_FORMATS`]. See the `codec` module docs.
33
34mod codec;
35mod csv;
36mod influxdb;
37mod options;
38mod otlp;
39mod parse;
40
41use std::fmt::Debug;
42use std::sync::Arc;
43
44use crate::influxdb::InfluxdbRequest;
45use crate::query_handler::PipelineHandlerRef;
46use api::helper::ColumnDataTypeWrapper;
47use api::v1::column_def::options_from_column_schema;
48use api::v1::{
49    Row as GrpcRow, RowInsertRequest, RowInsertRequests, Rows as GrpcRows, SemanticType,
50};
51use async_trait::async_trait;
52use codec::{CopyFormat, RecordParser};
53use common_catalog::consts::{TRACE_TABLE_NAME, TRACE_TABLE_NAME_SESSION_KEY};
54use common_grpc::precision::Precision;
55use common_query::OutputData;
56use common_telemetry::{info, warn};
57use csv::CsvCodec;
58use datafusion::sql::sqlparser::ast::{CopyTarget, Statement as SqlParserStatement};
59use datatypes::data_type::DataType;
60use datatypes::schema::{ColumnSchema, SchemaRef};
61use futures::{Sink, SinkExt, stream};
62use influxdb::InfluxdbOptions;
63use otel_arrow_rust::proto::opentelemetry::collector::metrics::v1::ExportMetricsServiceRequest;
64use otlp::{OtlpOptions, OtlpSignal};
65use pgwire::api::copy::CopyHandler;
66use pgwire::api::results::{CopyResponse, Response, Tag};
67use pgwire::api::{ClientInfo, PgWireConnectionState};
68use pgwire::error::{ErrorInfo, PgWireError, PgWireResult};
69use pgwire::messages::PgWireBackendMessage;
70use pgwire::messages::copy::{CopyData, CopyDone, CopyFail};
71use prost::Message;
72use session::context::QueryContextRef;
73use table::metadata::TableInfo;
74
75use crate::metrics::METRIC_POSTGRES_COPY_IN_ROWS;
76use crate::postgres::PostgresServerHandlerInner;
77use crate::postgres::types::PgErrorCode;
78use crate::postgres::utils::convert_err;
79
80/// Number of parsed rows buffered before flushing a batch to the handler.
81///
82/// Each flush runs the full insert pipeline (catalog lookup, on-demand
83/// schema check, partition split, region write) and, without the
84/// pending-rows batcher, waits for it — so larger batches mean fewer
85/// round trips at the cost of linearly more memory per connection
86/// (decoder batch + rows buffer). The constant doubles as the arrow
87/// decoder's batch size. High-rate COPY users should enable the
88/// pending-rows batcher (`global-table-batch` / `BatcherOptions.protocols`
89/// containing `Postgres`), which turns flushes into enqueues and makes
90/// the per-batch fixed costs negligible.
91pub(crate) const COPY_IN_FLUSH_ROWS: usize = 32768;
92
93/// Upper bound of a table-less COPY FROM STDIN payload (influxdb line
94/// protocol, OTLP). One COPY statement is one bounded ingestion request
95/// (the whole payload is buffered like one HTTP request body); a larger
96/// import is multiple COPY statements.
97pub(crate) const COPY_IN_MAX_PAYLOAD_BYTES: usize = 64 * 1024 * 1024;
98
99/// Builds a format from the format-neutral option set.
100type FormatBuilder = fn(options::CopyOptionSet) -> PgWireResult<CopyFormat>;
101
102/// The registered COPY FROM STDIN formats: accepted `FORMAT` name to
103/// builder. `csv` is the only implemented format; the others are
104/// registered so they report a clear "not supported yet" instead of an
105/// unknown-format error.
106const REGISTERED_FORMATS: &[(&str, FormatBuilder)] = &[
107    ("csv", csv::build_csv_codec),
108    ("influxdb", influxdb::build_influxdb_codec),
109    ("influxdb_line", influxdb::build_influxdb_codec),
110    ("otlp", otlp::build_otlp_codec),
111    ("otlp_metrics", otlp::build_otlp_codec),
112    ("text", build_unimplemented_text_codec),
113    ("txt", build_unimplemented_text_codec),
114    ("binary", build_unimplemented_binary_codec),
115];
116
117fn build_unimplemented_text_codec(_options: options::CopyOptionSet) -> PgWireResult<CopyFormat> {
118    Err(unsupported(
119        "the text format is not supported yet; use WITH (FORMAT csv)",
120    ))
121}
122
123fn build_unimplemented_binary_codec(_options: options::CopyOptionSet) -> PgWireResult<CopyFormat> {
124    Err(unsupported(
125        "COPY FROM STDIN WITH (FORMAT binary) is not supported yet",
126    ))
127}
128
129/// Resolves the format from the parsed options.
130fn resolve_format(options: &mut options::CopyOptionSet) -> PgWireResult<CopyFormat> {
131    let name = options.take_format()?.ok_or_else(|| {
132        unsupported(
133            "COPY FROM STDIN requires an explicit format in this version; use WITH (FORMAT csv)",
134        )
135    })?;
136    let builder = REGISTERED_FORMATS
137        .iter()
138        .find(|(registered, _)| *registered == name)
139        .map(|(_, builder)| *builder)
140        .ok_or_else(|| bad_copy_data(format!("unknown COPY format \"{}\"", name)))?;
141    builder(std::mem::take(options))
142}
143
144/// The parsed `COPY tbl [(columns)] FROM STDIN [(options)]` statement.
145#[derive(Debug, Clone)]
146pub(crate) struct CopyFromStdin {
147    /// Table name parts as written (`t`, `schema.t`, `catalog.schema.t`);
148    /// quoted names keep their case.
149    table: Vec<String>,
150    /// Requested column list; empty means all columns in table order.
151    columns: Vec<String>,
152    /// The resolved format with its validated options.
153    format: CopyFormat,
154}
155
156fn copy_in_error(code: PgErrorCode, message: impl Into<String>) -> PgWireError {
157    PgWireError::UserError(Box::new(code.to_err_info(message.into())))
158}
159
160pub(super) fn unsupported(message: impl Into<String>) -> PgWireError {
161    copy_in_error(PgErrorCode::Ec0A000, message)
162}
163
164fn bad_copy_data(message: impl Into<String>) -> PgWireError {
165    copy_in_error(PgErrorCode::Ec22P04, message)
166}
167
168fn invalid_text_repr(message: impl Into<String>) -> PgWireError {
169    copy_in_error(PgErrorCode::Ec22P02, message)
170}
171
172pub(crate) fn copy_in_must_run_alone_error() -> PgWireError {
173    PgWireError::UserError(Box::new(ErrorInfo::new(
174        "ERROR".to_string(),
175        PgErrorCode::Ec0A000.code(),
176        "COPY FROM STDIN must be executed alone".to_string(),
177    )))
178}
179
180/// Parses a leading `COPY ... FROM STDIN` statement from SQL text.
181///
182/// The statement is parsed textually — not through sqlparser — because the
183/// `WITH (...)` option list is open: formats may accept options beyond
184/// sqlparser's COPY grammar, and unknown options must reach the format
185/// codec instead of failing statement parsing. Returns `Ok(None)` when
186/// the string does not lead with a copy-in statement.
187pub(crate) fn copy_from_stdin_from_sql(sql: &str) -> PgWireResult<Option<CopyFromStdin>> {
188    let raw = match parse::scan_copy_from_stdin(sql)? {
189        parse::CopyInScan::NotCopyIn => return Ok(None),
190        parse::CopyInScan::CopyIn(raw) => raw,
191    };
192    let mut option_set = raw.options;
193    let format = resolve_format(&mut option_set)?;
194    Ok(Some(CopyFromStdin {
195        table: raw.table,
196        columns: raw.columns,
197        format,
198    }))
199}
200
201/// Whether an already-parsed statement is a `COPY ... FROM STDIN`; used to
202/// reject copy-in statements trailing other statements, where the textual
203/// scanner (which only sees statement-leading COPYs) does not see them.
204pub(crate) fn is_copy_from_stdin(statement: &SqlParserStatement) -> bool {
205    matches!(
206        statement,
207        SqlParserStatement::Copy {
208            to: false,
209            target: CopyTarget::Stdin,
210            ..
211        }
212    )
213}
214
215/// Resolves table name parts against the query context defaults.
216fn resolve_table_name(
217    query_ctx: &QueryContextRef,
218    parts: &[String],
219) -> PgWireResult<(String, String, String)> {
220    match parts {
221        [table] => Ok((
222            query_ctx.current_catalog().to_string(),
223            query_ctx.current_schema(),
224            table.clone(),
225        )),
226        [schema, table] => Ok((
227            query_ctx.current_catalog().to_string(),
228            schema.clone(),
229            table.clone(),
230        )),
231        [catalog, schema, table] => Ok((catalog.clone(), schema.clone(), table.clone())),
232        _ => Err(unsupported(format!(
233            "invalid table name for COPY: {}",
234            parts.join(".")
235        ))),
236    }
237}
238
239/// Per-connection state of an in-progress COPY FROM STDIN.
240///
241/// The payload shape follows the codec: table-bound formats (csv) parse
242/// records into rows against the targeted table's columns; table-less
243/// formats (influxdb line protocol) split the stream into complete
244/// lines that each route to their own measurement, and flush through
245/// the handler's line-protocol pipeline — the same one the HTTP write
246/// endpoint uses.
247pub(crate) struct CopyInState {
248    query_ctx: QueryContextRef,
249    payload: Payload,
250    /// The resolved codec's name, for logging.
251    codec_name: &'static str,
252    /// The wire format code of the resolved codec.
253    codec_format_code: i8,
254    rows_written: u64,
255}
256
257enum Payload {
258    /// Table-bound rows: parsed against `grpc_schema` and flushed to
259    /// `table_name` via `copy_in_insert`. The schema is cloned into each
260    /// flushed batch: `Rows.schema` is an owned `Vec` in the proto
261    /// request, so one copy per request is inherent.
262    Table {
263        grpc_schema: Vec<api::v1::ColumnSchema>,
264        table_name: String,
265        parser: Box<dyn RecordParser + Send>,
266        /// Parsed rows not yet flushed to the handler.
267        rows: Vec<GrpcRow>,
268    },
269    /// The whole buffered influxdb line-protocol payload with its
270    /// request precision. One COPY statement is one bounded ingestion
271    /// request, buffered until `CopyDone` — exactly like one HTTP write
272    /// body — and record boundaries belong to the line-protocol parser,
273    /// never to the COPY layer.
274    Influxdb {
275        data: Vec<u8>,
276        precision: Option<Precision>,
277    },
278    /// The whole buffered OTLP payload with its validated options,
279    /// decoded and completed as one request at `CopyDone` through the
280    /// matching OTLP pipeline.
281    Otlp { data: Vec<u8>, options: OtlpOptions },
282}
283
284impl CopyInState {
285    fn new(
286        codec: &CsvCodec,
287        columns: Vec<String>,
288        table_info: Arc<TableInfo>,
289        query_ctx: QueryContextRef,
290        table_name: String,
291    ) -> PgWireResult<Self> {
292        let schema = table_info.meta.schema.clone();
293        let timestamp_index = schema.timestamp_index();
294
295        let columns = resolve_columns(codec, columns, &schema, &table_info)?;
296        if let Some(ts_index) = timestamp_index {
297            let ts_column = &schema.column_schemas()[ts_index];
298            if !columns.iter().any(|column| column.name == ts_column.name) {
299                return Err(unsupported(format!(
300                    "COPY FROM STDIN requires the time index column \"{}\" in the column list",
301                    ts_column.name
302                )));
303            }
304        }
305        let parser = codec.create_parser(&columns, query_ctx.timezone().to_string().as_str())?;
306
307        let grpc_schema = columns
308            .iter()
309            .map(|column| build_grpc_column_schema(column, &table_info))
310            .collect::<PgWireResult<Vec<_>>>()?;
311
312        Ok(Self {
313            query_ctx,
314            payload: Payload::Table {
315                grpc_schema,
316                table_name,
317                parser,
318                rows: Vec::with_capacity(COPY_IN_FLUSH_ROWS),
319            },
320            codec_name: "csv",
321            codec_format_code: 0,
322            rows_written: 0,
323        })
324    }
325
326    /// State for the influxdb format: the whole payload is buffered and
327    /// completed once at `CopyDone` through the line-protocol pipeline.
328    fn new_influxdb(options: &InfluxdbOptions, query_ctx: QueryContextRef) -> Self {
329        Self {
330            query_ctx,
331            payload: Payload::Influxdb {
332                data: Vec::new(),
333                precision: options.precision,
334            },
335            codec_name: "influxdb",
336            codec_format_code: 0,
337            rows_written: 0,
338        }
339    }
340
341    /// State for the OTLP format: the whole binary payload is buffered
342    /// and completed once at `CopyDone` through the matching OTLP
343    /// pipeline. Binary: the advertised wire format code is 1.
344    fn new_otlp(options: &OtlpOptions, query_ctx: QueryContextRef) -> Self {
345        Self {
346            query_ctx,
347            payload: Payload::Otlp {
348                data: Vec::new(),
349                options: options.clone(),
350            },
351            codec_name: "otlp",
352            codec_format_code: 1,
353            rows_written: 0,
354        }
355    }
356
357    /// The wire format code advertised to the client.
358    pub(crate) fn format_code(&self) -> i8 {
359        self.codec_format_code
360    }
361
362    /// Feeds a `CopyData` chunk into the parser and buffers parsed rows
363    /// (csv) or payload bytes (influxdb).
364    fn feed(&mut self, data: &[u8]) -> PgWireResult<()> {
365        match &mut self.payload {
366            Payload::Table { parser, rows, .. } => rows.extend(parser.feed(data)?),
367            Payload::Influxdb { data: buffered, .. } | Payload::Otlp { data: buffered, .. } => {
368                if buffered.len() + data.len() > COPY_IN_MAX_PAYLOAD_BYTES {
369                    return Err(bad_copy_data(format!(
370                        "COPY FROM STDIN payload exceeds the {} limit; split the import into multiple COPY statements",
371                        COPY_IN_MAX_PAYLOAD_BYTES
372                    )));
373                }
374                buffered.extend_from_slice(data);
375            }
376        }
377        Ok(())
378    }
379
380    /// Parses the buffered tail at `CopyDone`; a final record without a
381    /// trailing newline is accepted.
382    fn finish(&mut self) -> PgWireResult<()> {
383        match &mut self.payload {
384            Payload::Table { parser, rows, .. } => rows.extend(parser.finish()?),
385            // The whole payload is one request body; nothing to finalize.
386            Payload::Influxdb { .. } | Payload::Otlp { .. } => {}
387        }
388        Ok(())
389    }
390
391    fn should_flush(&self) -> bool {
392        match &self.payload {
393            Payload::Table { rows, .. } => rows.len() >= COPY_IN_FLUSH_ROWS,
394            // Completed once at CopyDone, not mid-stream.
395            Payload::Influxdb { .. } | Payload::Otlp { .. } => false,
396        }
397    }
398
399    /// Takes the buffered rows as an insert request, if any.
400    fn take_requests(&mut self) -> Option<RowInsertRequests> {
401        let Payload::Table {
402            grpc_schema,
403            table_name,
404            rows,
405            ..
406        } = &mut self.payload
407        else {
408            return None;
409        };
410        if rows.is_empty() {
411            return None;
412        }
413        let rows = GrpcRows {
414            schema: grpc_schema.clone(),
415            rows: std::mem::take(rows),
416        };
417        Some(RowInsertRequests {
418            inserts: vec![RowInsertRequest {
419                table_name: table_name.clone(),
420                rows: Some(rows),
421            }],
422        })
423    }
424
425    /// Takes the buffered OTLP payload and its options, if any.
426    fn take_otlp(&mut self) -> Option<(OtlpOptions, Vec<u8>)> {
427        let Payload::Otlp { options, data } = &mut self.payload else {
428            return None;
429        };
430        if data.is_empty() {
431            return None;
432        }
433        Some((options.clone(), std::mem::take(data)))
434    }
435
436    /// Takes the buffered payload as one line-protocol request body, if
437    /// any — the same bytes an HTTP write of the same records would
438    /// carry.
439    fn take_lines(&mut self) -> PgWireResult<Option<InfluxdbRequest>> {
440        let Payload::Influxdb { data, precision } = &mut self.payload else {
441            return Ok(None);
442        };
443        if data.is_empty() {
444            return Ok(None);
445        }
446        let lines = String::from_utf8(std::mem::take(data))
447            .map_err(|e| bad_copy_data(format!("COPY FROM STDIN data is not valid UTF-8: {e}")))?;
448        Ok(Some(InfluxdbRequest {
449            precision: *precision,
450            lines,
451        }))
452    }
453}
454
455fn resolve_columns(
456    codec: &CsvCodec,
457    columns: Vec<String>,
458    schema: &SchemaRef,
459    table_info: &Arc<TableInfo>,
460) -> PgWireResult<Vec<ColumnSchema>> {
461    let table_name = &table_info.name;
462    let selected: Vec<ColumnSchema> = if columns.is_empty() {
463        schema.column_schemas().to_vec()
464    } else {
465        let mut selected = Vec::with_capacity(columns.len());
466        for name in &columns {
467            let column = schema
468                .column_schema_by_name(name)
469                .ok_or_else(|| {
470                    copy_in_error(
471                        PgErrorCode::Ec42703,
472                        format!(
473                            "column \"{}\" of relation \"{}\" does not exist",
474                            name, table_name
475                        ),
476                    )
477                })?
478                .clone();
479            if selected.iter().any(|c: &ColumnSchema| &c.name == name) {
480                return Err(bad_copy_data(format!(
481                    "column \"{}\" specified more than once",
482                    name
483                )));
484            }
485            selected.push(column);
486        }
487        selected
488    };
489
490    for column in &selected {
491        codec.check_column_type(&column.data_type)?;
492    }
493
494    Ok(selected)
495}
496
497/// Derives a column's semantic type the same way the flow adapter does
498/// (`flow::adapter::util::column_schemas_to_proto`): the time-index flag
499/// for timestamps, primary-key membership for tags.
500fn semantic_type_of(table_info: &TableInfo, column: &ColumnSchema) -> PgWireResult<SemanticType> {
501    let meta = &table_info.meta;
502    if column.is_time_index() {
503        Ok(SemanticType::Timestamp)
504    } else if meta
505        .primary_key_indices
506        .iter()
507        .any(|&index| meta.schema.column_schemas()[index].name == column.name)
508    {
509        Ok(SemanticType::Tag)
510    } else {
511        Ok(SemanticType::Field)
512    }
513}
514
515fn build_grpc_column_schema(
516    column: &ColumnSchema,
517    table_info: &Arc<TableInfo>,
518) -> PgWireResult<api::v1::ColumnSchema> {
519    let (datatype, datatype_extension) = ColumnDataTypeWrapper::try_from(column.data_type.clone())
520        .map_err(|_| {
521            unsupported(format!(
522                "column type \"{}\" is not supported for COPY FROM STDIN yet",
523                column.data_type.name()
524            ))
525        })?
526        .to_parts();
527    Ok(api::v1::ColumnSchema {
528        column_name: column.name.clone(),
529        datatype: datatype as i32,
530        semantic_type: semantic_type_of(table_info, column)? as i32,
531        datatype_extension,
532        options: options_from_column_schema(column),
533    })
534}
535
536// ---------------------------------------------------------------------------
537// pgwire handlers
538// ---------------------------------------------------------------------------
539
540impl PostgresServerHandlerInner {
541    /// Prepares a COPY FROM STDIN: resolves the target table, validates
542    /// columns and stashes the per-connection copy state.
543    pub(crate) async fn begin_copy_in(&self, stmt: CopyFromStdin) -> PgWireResult<Response> {
544        // The batching-aware context routes copy-in flushes through the
545        // pending-rows batcher like SQL INSERT when it is enabled for
546        // this connection.
547        let query_ctx = self.new_query_context();
548
549        // Each format states its own target rule and ingestion shape
550        // explicitly.
551        let state = match &stmt.format {
552            // csv requires a target table: records are parsed against
553            // its columns and inserted in batches.
554            CopyFormat::Csv(codec) => {
555                if stmt.table.is_empty() {
556                    return Err(unsupported(
557                        "format csv requires a table: COPY tbl [(cols)] FROM STDIN WITH (FORMAT csv)",
558                    ));
559                }
560                let (catalog, schema, table) = resolve_table_name(&query_ctx, &stmt.table)?;
561
562                let Some(table_info) = self
563                    .copy_in_handler
564                    .copy_in_table(&catalog, &schema, &table, query_ctx.clone())
565                    .await
566                    .map_err(convert_err)?
567                else {
568                    return Err(copy_in_error(
569                        PgErrorCode::Ec42P01,
570                        format!("relation \"{}\" does not exist", stmt.table.join(".")),
571                    ));
572                };
573
574                // The insert path resolves the bare table name against the
575                // query context, so point the context at the resolved
576                // catalog and schema when the COPY target is qualified.
577                let query_ctx = adjust_query_context(query_ctx, &catalog, &schema);
578
579                CopyInState::new(codec, stmt.columns, table_info, query_ctx, table)?
580            }
581            // influxdb forbids a target table — each line names its own
582            // measurement — and completes through the line-protocol
583            // pipeline the HTTP write endpoints use; the server must be
584            // wired for it.
585            CopyFormat::Influxdb(options) => {
586                if !stmt.table.is_empty() {
587                    return Err(unsupported(format!(
588                        "format influxdb routes each record to its own table; \"COPY {} FROM STDIN\" must not name one",
589                        stmt.table.join(".")
590                    )));
591                }
592                if self.influxdb_handler.is_none() {
593                    return Err(unsupported(
594                        "this server is not wired for the influxdb line protocol format",
595                    ));
596                }
597                CopyInState::new_influxdb(options, query_ctx)
598            }
599            // OTLP forbids a target table too — the payload's resource
600            // metrics route to their own tables — and completes through
601            // the OTLP metrics pipeline; the server must be wired.
602            CopyFormat::Otlp(options) => {
603                if !stmt.table.is_empty() {
604                    return Err(unsupported(format!(
605                        "format otlp routes each record to its own table; \"COPY {} FROM STDIN\" must not name one",
606                        stmt.table.join(".")
607                    )));
608                }
609                if self.otlp_handler.is_none() {
610                    return Err(unsupported("this server is not wired for the otlp format"));
611                }
612                CopyInState::new_otlp(options, query_ctx)
613            }
614        };
615        let num_columns = match &state.payload {
616            Payload::Table { grpc_schema, .. } => grpc_schema.len(),
617            // No fixed column set: the format routes per record.
618            Payload::Influxdb { .. } | Payload::Otlp { .. } => 0,
619        };
620        let format_code = state.format_code();
621
622        *self.copy_in_state.lock().await = Some(state);
623
624        Ok(Response::CopyIn(CopyResponse::new(
625            format_code,
626            num_columns,
627            stream::empty(),
628        )))
629    }
630
631    /// Flushes the buffered payload of an in-progress copy. Returns the
632    /// number of rows written by the flushed batch.
633    async fn flush_copy_in(&self, state: &mut CopyInState) -> PgWireResult<u64> {
634        let query_ctx = state.query_ctx.clone();
635        // The buffered influxdb payload is completed through the
636        // line-protocol pipeline — the same handler the HTTP write
637        // endpoints use, so conversion, permissions, interceptors,
638        // timestamp alignment and inserts are identical to an HTTP
639        // write of the same bytes. The context stays this connection's
640        // postgres context.
641        if let Some((options, bytes)) = state.take_otlp() {
642            let Some(otlp_handler) = &self.otlp_handler else {
643                return Err(unsupported("this server is not wired for the otlp format"));
644            };
645            return self
646                .flush_otlp(otlp_handler, options, bytes, query_ctx)
647                .await;
648        }
649        if let Some(request) = state.take_lines()? {
650            let Some(influxdb_handler) = &self.influxdb_handler else {
651                return Err(unsupported(
652                    "this server is not wired for the influxdb line protocol format",
653                ));
654            };
655            let output = influxdb_handler
656                .exec(request, query_ctx)
657                .await
658                .map_err(convert_err)?;
659            return match output.data {
660                OutputData::AffectedRows(rows) => Ok(rows as u64),
661                OutputData::RecordBatches(_) | OutputData::Stream(_) => Ok(0),
662            };
663        }
664        let Some(requests) = state.take_requests() else {
665            return Ok(0);
666        };
667        let output = self
668            .copy_in_handler
669            .copy_in_insert(requests, query_ctx)
670            .await
671            .map_err(convert_err)?;
672        match output.data {
673            OutputData::AffectedRows(rows) => Ok(rows as u64),
674            OutputData::RecordBatches(_) | OutputData::Stream(_) => {
675                warn!("unexpected output from COPY FROM STDIN insert");
676                Ok(0)
677            }
678        }
679    }
680
681    /// Completes an OTLP copy through the pipeline the matching
682    /// OTLP/HTTP endpoint uses — the payload is decoded with the same
683    /// protobuf types and handed to the same handler methods with the
684    /// same context preparation, so both ingestion routes are
685    /// identical. The context stays this connection's postgres context
686    /// apart from the signal-specific preparation below.
687    async fn flush_otlp(
688        &self,
689        otlp_handler: &crate::query_handler::OpenTelemetryProtocolHandlerRef,
690        options: OtlpOptions,
691        bytes: Vec<u8>,
692        query_ctx: QueryContextRef,
693    ) -> PgWireResult<u64> {
694        use opentelemetry_proto::tonic::collector::logs::v1::ExportLogsServiceRequest;
695        use opentelemetry_proto::tonic::collector::trace::v1::ExportTraceServiceRequest;
696        use pipeline::{GreptimePipelineParams, PipelineWay, SelectInfo};
697        use session::protocol_ctx::{MetricType, OtlpMetricCtx, ProtocolCtx};
698
699        // The HTTP routes derive the pipeline handler from the same
700        // protocol handler via trait upcasting; so does the copy.
701        let pipeline_handler: PipelineHandlerRef = otlp_handler.clone();
702        match options.signal {
703            OtlpSignal::Metrics {
704                promote_all_resource_attrs,
705                resource_attrs,
706                promote_scope_attrs,
707                translation_strategy,
708            } => {
709                // Same context preparation as the HTTP metrics route.
710                let mut ctx = (*query_ctx).clone();
711                ctx.set_protocol_ctx(ProtocolCtx::OtlpMetric(OtlpMetricCtx {
712                    promote_all_resource_attrs,
713                    resource_attrs,
714                    promote_scope_attrs,
715                    with_metric_engine: self.otlp_with_metric_engine,
716                    // Set by the frontend from its config.
717                    resource_info: false,
718                    metric_type: MetricType::Init,
719                    metric_translation_strategy: translation_strategy,
720                }));
721                let request = ExportMetricsServiceRequest::decode(bytes.as_slice())
722                    .map_err(|e| bad_copy_data(format!("invalid OTLP payload: {e}")))?;
723                let outcome = otlp_handler
724                    .metrics(
725                        request,
726                        Arc::new(ctx),
727                        Some((self.memory_limiter.clone(), 0)),
728                    )
729                    .await
730                    .map_err(convert_err)?;
731                // Same failure semantics as the HTTP route: a payload
732                // whose points were all rejected is an error, not a
733                // successful `COPY 0`.
734                if outcome.accepted_data_points == 0 && outcome.rejected_data_points > 0 {
735                    return Err(all_rejected_error(
736                        "data points",
737                        outcome.rejected_data_points,
738                        outcome.error_message.as_deref(),
739                    ));
740                }
741                // Rows are the accepted data points.
742                Ok(outcome.accepted_data_points.max(0) as u64)
743            }
744            OtlpSignal::Traces {
745                table_name,
746                pipeline_name,
747                pipeline_version,
748                pipeline_params,
749            } => {
750                let table_name = table_name.unwrap_or_else(|| TRACE_TABLE_NAME.to_string());
751                // Same context preparation as the HTTP traces route.
752                let mut ctx = (*query_ctx).clone();
753                ctx.set_extension(TRACE_TABLE_NAME_SESSION_KEY, &table_name);
754                let pipeline = PipelineWay::from_name_and_default(
755                    pipeline_name.as_deref(),
756                    pipeline_version.as_deref(),
757                    None,
758                )
759                .map_err(convert_err)?;
760                let request = ExportTraceServiceRequest::decode(bytes.as_slice())
761                    .map_err(|e| bad_copy_data(format!("invalid OTLP payload: {e}")))?;
762                let outcome = otlp_handler
763                    .traces(
764                        pipeline_handler,
765                        request,
766                        pipeline,
767                        GreptimePipelineParams::from_params(pipeline_params.as_deref()),
768                        table_name,
769                        Arc::new(ctx),
770                    )
771                    .await
772                    .map_err(convert_err)?;
773                // Same failure semantics as the HTTP route.
774                if outcome.accepted_spans == 0 && outcome.rejected_spans > 0 {
775                    return Err(all_rejected_error(
776                        "spans",
777                        outcome.rejected_spans as i64,
778                        outcome.error_message.as_deref(),
779                    ));
780                }
781                // Rows are the accepted spans.
782                Ok(outcome.accepted_spans as u64)
783            }
784            OtlpSignal::Logs {
785                table_name,
786                pipeline_name,
787                pipeline_version,
788                pipeline_params,
789                extract_keys,
790            } => {
791                let table_name = table_name.unwrap_or_else(|| "opentelemetry_logs".to_string());
792                // Same select semantics as the HTTP extract-keys header.
793                let select_info = match extract_keys.as_deref() {
794                    Some(keys) if !keys.is_empty() => SelectInfo::from(keys.to_string()),
795                    _ => Default::default(),
796                };
797                let pipeline = PipelineWay::from_name_and_default(
798                    pipeline_name.as_deref(),
799                    pipeline_version.as_deref(),
800                    Some(PipelineWay::OtlpLogDirect(Box::new(select_info))),
801                )
802                .map_err(convert_err)?;
803                let request = ExportLogsServiceRequest::decode(bytes.as_slice())
804                    .map_err(|e| bad_copy_data(format!("invalid OTLP payload: {e}")))?;
805                let outputs = otlp_handler
806                    .logs(
807                        pipeline_handler,
808                        request,
809                        pipeline,
810                        GreptimePipelineParams::from_params(pipeline_params.as_deref()),
811                        table_name,
812                        query_ctx,
813                    )
814                    .await
815                    .map_err(convert_err)?;
816                // Rows are the affected rows across the outputs.
817                Ok(outputs
818                    .iter()
819                    .map(|o| match &o.data {
820                        OutputData::AffectedRows(rows) => *rows as u64,
821                        _ => 0,
822                    })
823                    .sum())
824            }
825        }
826    }
827}
828
829/// The error for an OTLP payload whose points/spans were all rejected,
830/// carrying the pipeline's reason when it provided one.
831fn all_rejected_error(kind: &str, rejected: i64, reason: Option<&str>) -> PgWireError {
832    bad_copy_data(match reason {
833        Some(reason) => format!("OTLP ingestion rejected all {rejected} {kind}: {reason}"),
834        None => format!("OTLP ingestion rejected all {rejected} {kind}"),
835    })
836}
837
838fn adjust_query_context(
839    query_ctx: QueryContextRef,
840    catalog: &str,
841    schema: &str,
842) -> QueryContextRef {
843    if query_ctx.current_catalog() == catalog && query_ctx.current_schema() == schema {
844        return query_ctx;
845    }
846    let mut forked = query_ctx.fork();
847    forked.set_current_catalog(catalog);
848    forked.set_current_schema(schema);
849    Arc::new(forked)
850}
851
852#[async_trait]
853impl CopyHandler for PostgresServerHandlerInner {
854    async fn on_copy_data<C>(&self, _client: &mut C, copy_data: CopyData) -> PgWireResult<()>
855    where
856        C: ClientInfo + Sink<PgWireBackendMessage> + Unpin + Send + Sync,
857        C::Error: Debug,
858        PgWireError: From<<C as Sink<PgWireBackendMessage>>::Error>,
859    {
860        let mut guard = self.copy_in_state.lock().await;
861        let Some(mut state) = guard.take() else {
862            return Err(copy_in_error(
863                PgErrorCode::Ec08P01,
864                "CopyData message received outside COPY FROM STDIN",
865            ));
866        };
867        // The state is owned here: any parse, conversion or insert error
868        // below aborts the whole copy and drops the buffered data —
869        // otherwise a pooled connection would retain failed-import
870        // buffers until its next COPY. On success it is put back.
871        state.feed(&copy_data.data)?;
872        if state.should_flush() {
873            let rows = self.flush_copy_in(&mut state).await?;
874            state.rows_written += rows;
875            METRIC_POSTGRES_COPY_IN_ROWS.inc_by(rows);
876        }
877        *guard = Some(state);
878        Ok(())
879    }
880
881    async fn on_copy_done<C>(&self, client: &mut C, _done: CopyDone) -> PgWireResult<()>
882    where
883        C: ClientInfo + Sink<PgWireBackendMessage> + Unpin + Send + Sync,
884        C::Error: Debug,
885        PgWireError: From<<C as Sink<PgWireBackendMessage>>::Error>,
886    {
887        let mut guard = self.copy_in_state.lock().await;
888        // Take ownership up front: even finalization failures (parse
889        // errors in the trailing record, insert errors) drop the state
890        // instead of retaining it on a long-lived connection.
891        let Some(mut state) = guard.take() else {
892            return Err(copy_in_error(
893                PgErrorCode::Ec08P01,
894                "CopyDone message received outside COPY FROM STDIN",
895            ));
896        };
897        state.finish()?;
898        let rows = self.flush_copy_in(&mut state).await?;
899        state.rows_written += rows;
900        METRIC_POSTGRES_COPY_IN_ROWS.inc_by(rows);
901
902        let rows_written = state.rows_written;
903        let codec_name = state.codec_name;
904        info!(
905            "PostgreSQL COPY FROM STDIN (format {}) finished: {} rows written",
906            codec_name, rows_written
907        );
908        client
909            .send(PgWireBackendMessage::CommandComplete(
910                Tag::new("COPY").with_rows(rows_written as usize).into(),
911            ))
912            .await?;
913
914        // After a successful extended-protocol copy-in, pgwire leaves the
915        // connection in `CopyInProgress(true)` and swallows the client's
916        // terminating `Sync` message, so `ReadyForQuery` would never be
917        // sent and the connection would deadlock. Route the state to
918        // `AwaitingSync` so the `Sync` is dispatched to `on_sync`, which
919        // completes the round trip.
920        if let PgWireConnectionState::CopyInProgress(true) = client.state() {
921            client.set_state(PgWireConnectionState::AwaitingSync);
922        }
923        Ok(())
924    }
925
926    async fn on_copy_fail<C>(&self, _client: &mut C, fail: CopyFail) -> PgWireError
927    where
928        C: ClientInfo + Sink<PgWireBackendMessage> + Unpin + Send + Sync,
929        C::Error: Debug,
930        PgWireError: From<<C as Sink<PgWireBackendMessage>>::Error>,
931    {
932        // Drop the buffered state; partial data of a failed copy is not
933        // written.
934        self.copy_in_state.lock().await.take();
935        copy_in_error(
936            PgErrorCode::Ec57014,
937            format!("COPY FROM STDIN mode terminated by user: {}", fail.message),
938        )
939    }
940}
941
942#[cfg(test)]
943mod tests {
944    use super::*;
945
946    #[test]
947    fn test_parse_copy_from_stdin_requires_format() {
948        let err = copy_from_stdin_from_sql("COPY t FROM STDIN").unwrap_err();
949        assert!(
950            err.to_string()
951                .contains("requires an explicit format in this version"),
952            "{err}"
953        );
954
955        let copy = copy_from_stdin_from_sql("COPY t FROM STDIN WITH (FORMAT csv)")
956            .unwrap()
957            .unwrap();
958        assert_eq!(copy.table, vec!["t"]);
959        assert!(copy.columns.is_empty());
960        assert!(matches!(copy.format, CopyFormat::Csv(_)));
961        // Both formats are textual: format code 0.
962    }
963
964    #[test]
965    fn test_parse_copy_from_stdin_csv() {
966        let copy = copy_from_stdin_from_sql(
967            "COPY t (a, b) FROM STDIN WITH (FORMAT csv, HEADER, DELIMITER ';')",
968        )
969        .unwrap()
970        .unwrap();
971        assert_eq!(copy.columns, vec!["a", "b"]);
972        assert!(matches!(copy.format, CopyFormat::Csv(_)));
973    }
974
975    #[test]
976    fn test_parse_copy_from_stdin_text_unsupported() {
977        for sql in [
978            "COPY t FROM STDIN WITH (FORMAT text)",
979            "COPY t FROM STDIN WITH (FORMAT txt)",
980        ] {
981            let err = copy_from_stdin_from_sql(sql).unwrap_err();
982            assert!(
983                err.to_string().contains("text format is not supported yet"),
984                "{err}"
985            );
986        }
987    }
988
989    #[test]
990    fn test_parse_copy_from_stdin_binary_unsupported() {
991        let err = copy_from_stdin_from_sql("COPY t FROM STDIN WITH (FORMAT binary)").unwrap_err();
992        assert!(err.to_string().contains("not supported"));
993    }
994
995    #[test]
996    fn test_parse_copy_from_stdin_unknown_format() {
997        let err = copy_from_stdin_from_sql("COPY t FROM STDIN WITH (FORMAT parquet)").unwrap_err();
998        assert!(err.to_string().contains("unknown COPY format"));
999    }
1000
1001    #[test]
1002    fn test_parse_copy_from_stdin_not_copy_in() {
1003        assert!(
1004            copy_from_stdin_from_sql("COPY (SELECT 1) TO STDOUT")
1005                .unwrap()
1006                .is_none()
1007        );
1008        assert!(
1009            copy_from_stdin_from_sql("COPY t TO '/tmp/x.csv'")
1010                .unwrap()
1011                .is_none()
1012        );
1013        // GreptimeDB's own file-based COPY FROM.
1014        assert!(
1015            copy_from_stdin_from_sql("COPY t FROM '/tmp/x.csv' WITH (FORMAT csv)")
1016                .unwrap()
1017                .is_none()
1018        );
1019    }
1020
1021    #[test]
1022    fn test_redundant_options() {
1023        let err = copy_from_stdin_from_sql("COPY t FROM STDIN WITH (FORMAT csv, FORMAT text)")
1024            .unwrap_err();
1025        assert!(err.to_string().contains("redundant FORMAT"));
1026
1027        let err = copy_from_stdin_from_sql("COPY t FROM STDIN WITH (DELIMITER ',', DELIMITER ';')")
1028            .unwrap_err();
1029        assert!(err.to_string().contains("redundant DELIMITER"));
1030    }
1031
1032    #[test]
1033    fn test_format_specific_options_rejected() {
1034        // The open WITH section parses anything, but the format rejects
1035        // the options it does not understand.
1036        let err =
1037            copy_from_stdin_from_sql("COPY t FROM STDIN WITH (FORMAT csv, PRECISION 'ns', FREEZE)")
1038                .unwrap_err();
1039        assert!(
1040            err.to_string().contains("not supported by format csv"),
1041            "{err}"
1042        );
1043    }
1044
1045    #[test]
1046    fn test_multi_statement_rejected() {
1047        let err = copy_from_stdin_from_sql("COPY t FROM STDIN; SELECT 1").unwrap_err();
1048        assert!(err.to_string().contains("alone"));
1049        // Trailing separators and quoted semicolons are fine.
1050        assert!(
1051            copy_from_stdin_from_sql("COPY t FROM STDIN WITH (FORMAT csv);")
1052                .unwrap()
1053                .is_some()
1054        );
1055        assert!(
1056            copy_from_stdin_from_sql("COPY t FROM STDIN WITH (FORMAT csv, DELIMITER ';')")
1057                .unwrap()
1058                .is_some()
1059        );
1060    }
1061}