Skip to main content

frontend/instance/otlp/
trace_ingest.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
15use std::collections::{BTreeMap, HashMap, HashSet};
16use std::mem;
17use std::sync::Arc;
18
19use api::helper::ColumnDataTypeWrapper;
20use api::v1::alter_table_expr::Kind;
21use api::v1::{
22    AlterTableExpr, ColumnDataType, ColumnSchema, ModifyColumnType, ModifyColumnTypes,
23    RowInsertRequest, RowInsertRequests, Rows, Value,
24};
25use client::Output;
26use common_batcher::request_limiter::RequestLimiter;
27use common_error::ext::{BoxedError, ErrorExt};
28use common_error::status_code::StatusCode;
29use common_meta::rpc::ddl::TriggerReason;
30use common_query::WriteCompletion;
31use common_telemetry::{debug, warn};
32use datatypes::prelude::ConcreteDataType;
33use moka::sync::Cache;
34use opentelemetry_proto::tonic::collector::trace::v1::ExportTraceServiceRequest;
35use pipeline::PipelineWay;
36use prometheus::IntCounter;
37use servers::error::{self, Result as ServerResult};
38use servers::otlp;
39use servers::otlp::coerce::{coerce_value_data, is_supported_trace_coercion, trace_value_datatype};
40use servers::otlp::trace::span::{TraceSpan, TraceSpanGroup};
41use servers::otlp::trace::v1::{TraceBatchSchema, TraceBinaryType, TraceRetryColumns};
42use servers::otlp::trace::{SERVICE_NAME_COLUMN, TraceAuxData};
43use servers::query_handler::TraceIngestOutcome;
44use session::context::QueryContextRef;
45use snafu::{IntoError, ResultExt};
46use table::requests::{
47    SEMANTIC_ENTITY_SERVICE_ID, SEMANTIC_PIPELINE, SEMANTIC_SIGNAL_TYPE, SEMANTIC_SOURCE,
48    SEMANTIC_TRACE_CONVENTIONS, SEMANTIC_VALUE_MIXED, SEMANTIC_VALUE_UNKNOWN, SIGNAL_TYPE_TRACE,
49    SOURCE_OPENTELEMETRY, TABLE_DATA_MODEL_TRACE_V1, TABLE_DATA_MODEL_TRACE_V2,
50};
51use table::table_name::TableName;
52use tokio::sync::OwnedSemaphorePermit;
53
54use crate::instance::Instance;
55use crate::instance::otlp::trace_semconv::trace_semconv_fixed_type;
56use crate::instance::otlp::trace_types::{
57    PendingTraceColumnRewrite, PreparedTraceColumnRewrites, TraceColumnRewriteError,
58    choose_trace_reconcile_decision, enrich_trace_reconcile_error,
59    is_trace_reconcile_candidate_type, prepare_trace_column_rewrites, push_observed_trace_type,
60    truncate_for_diagnostics,
61};
62use crate::metrics::{
63    OTLP_TRACE_AUX_CACHE_HIT, OTLP_TRACE_AUX_CACHE_MISS, OTLP_TRACES_FAILURE_COUNT,
64    OTLP_TRACES_ROWS,
65};
66
67/// Confirmed auxiliary writes, shared across requests and instance clones.
68///
69/// ponytail: these tables are ingestion-managed; add invalidation if external
70/// mutations must be supported without restarting every serving frontend.
71#[derive(Clone)]
72pub(crate) struct TraceAuxCache {
73    entries: Cache<TraceAuxCacheKey, ()>,
74}
75
76#[derive(Clone, Hash, PartialEq, Eq)]
77struct TraceAuxCacheKey {
78    table: Arc<TableName>,
79    entry: TraceAuxEntry,
80}
81
82impl TraceAuxCacheKey {
83    /// Estimated key memory, excluding cache and allocator overhead.
84    fn size(&self) -> usize {
85        let entry_size = match &self.entry {
86            TraceAuxEntry::Service(service) => service.capacity(),
87            TraceAuxEntry::Operation(service, span, kind) => {
88                service.capacity() + span.capacity() + kind.capacity()
89            }
90        };
91        // Charge the shared table name per entry to keep the estimate conservative.
92        mem::size_of::<Self>()
93            + mem::size_of::<TableName>()
94            + self.table.catalog_name.capacity()
95            + self.table.schema_name.capacity()
96            + self.table.table_name.capacity()
97            + entry_size
98    }
99}
100
101#[derive(Clone, Hash, PartialEq, Eq)]
102enum TraceAuxEntry {
103    Service(String),
104    Operation(String, String, String),
105}
106
107impl TraceAuxCache {
108    /// Creates a cache with an estimated byte budget; zero disables caching.
109    pub(crate) fn new(max_size: u64) -> Self {
110        Self {
111            entries: Cache::builder()
112                .max_capacity(max_size)
113                .weigher(|key: &TraceAuxCacheKey, _: &()| {
114                    u32::try_from(key.size()).unwrap_or(u32::MAX)
115                })
116                .build(),
117        }
118    }
119
120    /// Removes confirmed writes and returns keys to remember after insertion.
121    /// Only the first cache check in a request supplies metrics.
122    fn filter(
123        &self,
124        table: TableName,
125        aux_data: &mut TraceAuxData,
126        metrics: Option<(&IntCounter, &IntCounter)>,
127    ) -> Vec<TraceAuxCacheKey> {
128        let lookups = aux_data.services.len() + aux_data.operations.len();
129        let table = Arc::new(table);
130        let mut pending = Vec::new();
131        let mut retain = |entry| {
132            let key = TraceAuxCacheKey {
133                table: table.clone(),
134                entry,
135            };
136            if self.entries.get(&key).is_some() {
137                false
138            } else {
139                pending.push(key);
140                true
141            }
142        };
143        aux_data
144            .services
145            .retain(|service| retain(TraceAuxEntry::Service(service.clone())));
146        aux_data.operations.retain(|(service, span, kind)| {
147            retain(TraceAuxEntry::Operation(
148                service.clone(),
149                span.clone(),
150                kind.clone(),
151            ))
152        });
153        if let Some((hits, misses)) = metrics {
154            hits.inc_by((lookups - pending.len()) as u64);
155            misses.inc_by(pending.len() as u64);
156        }
157        pending
158    }
159
160    fn record(&self, keys: Vec<TraceAuxCacheKey>) {
161        for key in keys {
162            self.entries.insert(key, ());
163        }
164    }
165
166    /// Reserves background capacity only for misses, without holding a cache lock.
167    async fn acquire_for_misses(
168        &self,
169        limiter: &RequestLimiter,
170        table: TableName,
171        mut aux_data: TraceAuxData,
172        metrics: (&IntCounter, &IntCounter),
173    ) -> ServerResult<Option<Arc<OwnedSemaphorePermit>>> {
174        self.filter(table.clone(), &mut aux_data, Some(metrics));
175        if aux_data.is_empty() {
176            return Ok(None);
177        }
178        let permit = limiter
179            .acquire()
180            .await
181            .map_err(|_| error::BatcherChannelClosedSnafu.build())?;
182        // Another request may have confirmed these entries while admission waited.
183        self.filter(table, &mut aux_data, None);
184        Ok((!aux_data.is_empty()).then_some(permit))
185    }
186}
187
188/// Merge converted V2 rows only when their fixed schemas agree.
189fn merge_trace_v2_rows(rows: &mut Rows, batch: Rows) -> ServerResult<()> {
190    if rows.schema.is_empty() {
191        rows.schema = batch.schema;
192    } else {
193        snafu::ensure!(
194            rows.schema == batch.schema,
195            error::InternalSnafu {
196                err_msg: "Trace V2 schema mismatch while merging spans",
197            }
198        );
199    }
200    rows.rows.extend(batch.rows);
201    Ok(())
202}
203
204const TRACE_FAILURE_MESSAGE_LIMIT: usize = 4;
205
206/// Maximum characters of a failure cause echoed to the client and the log.
207const TRACE_FAILURE_CAUSE_LIMIT: usize = 256;
208
209/// Determines how trace ingestion responds to a failure before a write is dispatched.
210#[derive(Debug, Clone, Copy, PartialEq, Eq)]
211enum ChunkFailureReaction {
212    RetryPerSpan,
213    DiscardChunk,
214    Propagate,
215}
216
217impl ChunkFailureReaction {
218    fn as_metric_label(self) -> &'static str {
219        match self {
220            Self::RetryPerSpan => "retry_per_span",
221            Self::DiscardChunk => "discard_chunk",
222            Self::Propagate => "propagate_failure",
223        }
224    }
225}
226
227/// Shared dependencies and request metadata used while ingesting trace chunks.
228struct TraceChunkIngestContext<'a> {
229    pipeline: &'a PipelineWay,
230    table_name: &'a str,
231}
232
233/// Accumulates trace outcomes, auxiliary rows, and bounded failure details.
234struct TraceIngestState {
235    needs_aux_write: bool,
236    aux_data: TraceAuxData,
237    pending_aux_data: Vec<(TraceAuxData, Vec<WriteCompletion>)>,
238    outcome: TraceIngestOutcome,
239    failure_messages: TraceFailureMessages,
240}
241
242impl TraceIngestState {
243    fn add_aux_data(&mut self, aux_data: TraceAuxData, completions: Vec<WriteCompletion>) {
244        if completions.is_empty() {
245            self.aux_data.services.extend(aux_data.services);
246            self.aux_data.operations.extend(aux_data.operations);
247        } else if !aux_data.is_empty() {
248            self.pending_aux_data.push((aux_data, completions));
249        }
250    }
251}
252
253/// Keeps confirmed chunks even if another chunk fails or loses its completion sender.
254async fn confirmed_trace_aux_data(
255    pending: Vec<(TraceAuxData, Vec<WriteCompletion>)>,
256) -> TraceAuxData {
257    let results = futures::future::join_all(pending.into_iter().map(
258        |(aux_data, completions)| async move {
259            for completion in completions {
260                completion.wait().await?;
261            }
262            Ok::<_, BoxedError>(aux_data)
263        },
264    ))
265    .await;
266    let mut confirmed = TraceAuxData::default();
267    for result in results {
268        match result {
269            Ok(aux_data) => {
270                confirmed.services.extend(aux_data.services);
271                confirmed.operations.extend(aux_data.operations);
272            }
273            Err(err) => {
274                warn!(err; "Skipping auxiliary trace rows after asynchronous main write failure")
275            }
276        }
277    }
278    confirmed
279}
280
281/// Bounded, deduplicated failure details for one trace request.
282///
283/// Occurrences past [`TRACE_FAILURE_MESSAGE_LIMIT`] distinct failures are
284/// counted but their keys are dropped: retaining them would let this state grow
285/// with the number of distinct bad values in a request.
286#[derive(Debug, Default)]
287struct TraceFailureMessages {
288    entries: Vec<TraceFailureEntry>,
289    suppressed_occurrences: usize,
290}
291
292#[derive(Debug)]
293struct TraceFailureEntry {
294    label: &'static str,
295    /// Untruncated: two causes can agree on a truncated prefix and differ
296    /// exactly where the actionable detail is.
297    key: String,
298    message: String,
299    occurrences: usize,
300}
301
302impl TraceFailureMessages {
303    fn is_empty(&self) -> bool {
304        self.entries.is_empty()
305    }
306}
307
308/// How a v1 chunk should be reconciled when it is written.
309#[derive(Debug, Clone, Copy, PartialEq, Eq)]
310enum TraceChunkSchemaState {
311    Prepared,
312    ReconcilePerChunk,
313}
314
315impl TraceChunkSchemaState {
316    fn mark_for_reconcile(&mut self) {
317        if matches!(self, Self::Prepared) {
318            *self = Self::ReconcilePerChunk;
319        }
320    }
321}
322
323/// Sparse representation retained for a v1 trace chunk.
324struct TraceChunkRetry {
325    table_name: String,
326    schema: Vec<ColumnSchema>,
327    rows: Vec<TraceRetryRow>,
328}
329
330/// The projected retry values and identifying metadata for one span.
331struct TraceRetryRow {
332    span_metadata: TraceSpanMetadata,
333    values: Vec<TraceRetryValue>,
334}
335
336/// One retry cell and the logical type to restore before insertion.
337struct TraceRetryValue {
338    column_index: usize,
339    value: Value,
340    schema_type: Option<TraceRetrySchemaType>,
341}
342
343/// The scalar or binary logical type recorded for a retry value.
344enum TraceRetrySchemaType {
345    Scalar(ColumnDataType),
346    Binary(TraceBinaryType),
347}
348
349/// Span identity and operation fields retained after request conversion.
350struct TraceSpanMetadata {
351    trace_id: String,
352    span_id: String,
353    operation: Option<(String, String, String)>,
354}
355
356impl From<&TraceSpan> for TraceSpanMetadata {
357    fn from(span: &TraceSpan) -> Self {
358        Self {
359            trace_id: span.trace_id.clone(),
360            span_id: span.span_id.clone(),
361            operation: span.service_name.as_ref().map(|service_name| {
362                (
363                    service_name.clone(),
364                    span.span_name.clone(),
365                    span.span_kind.clone(),
366                )
367            }),
368        }
369    }
370}
371
372impl TraceSpanMetadata {
373    fn add_to_aux_data(self, aux_data: &mut TraceAuxData) {
374        if let Some((service_name, span_name, span_kind)) = self.operation {
375            aux_data.services.insert(service_name.clone());
376            aux_data
377                .operations
378                .insert((service_name, span_name, span_kind));
379        }
380    }
381}
382
383impl TraceChunkRetry {
384    fn try_new(
385        table_name: String,
386        rows: Rows,
387        span_metadata: Vec<TraceSpanMetadata>,
388        retry_columns: TraceRetryColumns,
389    ) -> ServerResult<Self> {
390        let Rows { schema, rows } = rows;
391        let row_count = rows.len();
392        if row_count != span_metadata.len() {
393            return error::InternalSnafu {
394                err_msg: format!(
395                    "trace row count {} does not match span metadata count {}",
396                    row_count,
397                    span_metadata.len()
398                ),
399            }
400            .fail();
401        }
402
403        let retry_columns_by_index = schema
404            .iter()
405            .map(|column| retry_columns.get(&column.column_name))
406            .collect::<Vec<_>>();
407        let mut fixed_columns = Vec::with_capacity(schema.len());
408        let mut sparse_columns = vec![Vec::new(); rows.len()];
409        for (column_index, retry_column) in retry_columns_by_index.iter().enumerate() {
410            let Some(retry_column) = retry_column else {
411                fixed_columns.push(column_index);
412                continue;
413            };
414            for present_row in &retry_column.present_rows {
415                if *present_row < sparse_columns.len() {
416                    sparse_columns[*present_row].push(column_index);
417                }
418            }
419        }
420
421        let mut retry_rows = Vec::with_capacity(rows.len());
422        for (row_index, (mut row, span_metadata)) in rows.into_iter().zip(span_metadata).enumerate()
423        {
424            if row.values.len() > schema.len() {
425                return error::InternalSnafu {
426                    err_msg: format!(
427                        "trace row column count {} exceeds schema column count {}",
428                        row.values.len(),
429                        schema.len()
430                    ),
431                }
432                .fail();
433            }
434            let sparse_row = &mut sparse_columns[row_index];
435            let mut selected_columns = Vec::with_capacity(fixed_columns.len() + sparse_row.len());
436            selected_columns.extend_from_slice(&fixed_columns);
437            selected_columns.append(sparse_row);
438            selected_columns.sort_unstable();
439            selected_columns.dedup();
440
441            let mut values = Vec::with_capacity(selected_columns.len());
442            for column_index in selected_columns {
443                let column = &schema[column_index];
444                let value = row
445                    .values
446                    .get_mut(column_index)
447                    .map(std::mem::take)
448                    .unwrap_or_default();
449                let value_type = value.value_data.as_ref().and_then(trace_value_datatype);
450                let binary_type = if value_type == Some(ColumnDataType::Binary) {
451                    retry_columns_by_index[column_index].and_then(|retry_column| {
452                        retry_column
453                            .binary_types
454                            .binary_search_by_key(&row_index, |(row_index, _)| *row_index)
455                            .ok()
456                            .map(|index| retry_column.binary_types[index].1)
457                    })
458                } else {
459                    None
460                };
461                let schema_type = binary_type.map(TraceRetrySchemaType::Binary).or_else(|| {
462                    value_type
463                        .filter(|datatype| {
464                            is_trace_reconcile_candidate_type(*datatype)
465                                && ColumnDataType::try_from(column.datatype).ok() != Some(*datatype)
466                        })
467                        .map(TraceRetrySchemaType::Scalar)
468                });
469                values.push(TraceRetryValue {
470                    column_index,
471                    value,
472                    schema_type,
473                });
474            }
475            retry_rows.push(TraceRetryRow {
476                span_metadata,
477                values,
478            });
479        }
480
481        Ok(Self {
482            table_name,
483            schema,
484            rows: retry_rows,
485        })
486    }
487
488    fn to_request(&self) -> ServerResult<RowInsertRequest> {
489        let mut rows = Vec::with_capacity(self.rows.len());
490        for retry_row in &self.rows {
491            let mut values = vec![Value::default(); self.schema.len()];
492            for retry_value in &retry_row.values {
493                let Some(value) = values.get_mut(retry_value.column_index) else {
494                    return error::InternalSnafu {
495                        err_msg: format!(
496                            "trace retry column index {} is out of bounds",
497                            retry_value.column_index
498                        ),
499                    }
500                    .fail();
501                };
502                *value = retry_value.value.clone();
503            }
504            rows.push(api::v1::Row { values });
505        }
506
507        Ok(RowInsertRequest {
508            table_name: self.table_name.clone(),
509            rows: Some(Rows {
510                schema: self.schema.clone(),
511                rows,
512            }),
513        })
514    }
515
516    fn into_single_span_chunks(self) -> ServerResult<Vec<Self>> {
517        let Self {
518            table_name,
519            schema,
520            rows,
521        } = self;
522        rows.into_iter()
523            .map(|row| {
524                let (span_metadata, rows_for_insert) = row.into_rows(&schema)?;
525                Self::try_new(
526                    table_name.clone(),
527                    rows_for_insert,
528                    vec![span_metadata],
529                    TraceRetryColumns::default(),
530                )
531            })
532            .collect()
533    }
534
535    fn add_to_aux_data(self, aux_data: &mut TraceAuxData) {
536        for row in self.rows {
537            row.span_metadata.add_to_aux_data(aux_data);
538        }
539    }
540}
541
542impl TraceRetryRow {
543    fn into_rows(self, schema: &[ColumnSchema]) -> ServerResult<(TraceSpanMetadata, Rows)> {
544        let mut projected_schema = Vec::with_capacity(self.values.len());
545        let mut projected_values = Vec::with_capacity(self.values.len());
546        for retry_value in self.values {
547            let Some(mut column) = schema.get(retry_value.column_index).cloned() else {
548                return error::InternalSnafu {
549                    err_msg: format!(
550                        "trace fallback column index {} is out of bounds",
551                        retry_value.column_index
552                    ),
553                }
554                .fail();
555            };
556            match retry_value.schema_type {
557                Some(TraceRetrySchemaType::Scalar(datatype)) => {
558                    column.datatype = datatype as i32;
559                    column.datatype_extension = None;
560                }
561                Some(TraceRetrySchemaType::Binary(binary_type)) => {
562                    binary_type.apply_to_schema(&mut column);
563                }
564                None => {}
565            }
566            projected_schema.push(column);
567            projected_values.push(retry_value.value);
568        }
569
570        Ok((
571            self.span_metadata,
572            Rows {
573                schema: projected_schema,
574                rows: vec![api::v1::Row {
575                    values: projected_values,
576                }],
577            },
578        ))
579    }
580}
581
582/// Request-wide schema observations collected across all v1 trace chunks.
583#[derive(Default)]
584struct TraceRequestSchema {
585    columns: Vec<TraceColumnRequestSchema>,
586    column_indexes: HashMap<String, usize>,
587}
588
589/// Per-column observations and the resolved target type for a trace request.
590struct TraceColumnRequestSchema {
591    schema: ColumnSchema,
592    batches: BTreeMap<usize, TraceBatchColumnObservation>,
593    target_type: Option<ColumnDataType>,
594}
595
596/// The schema and value types observed for one column in one chunk.
597struct TraceBatchColumnObservation {
598    value_types: Vec<ColumnDataType>,
599    schema_type: ColumnDataType,
600    concrete_type: ConcreteDataType,
601}
602
603/// Maps incompatible columns to the chunk indexes that must use fallback.
604type TraceSchemaExclusions = HashMap<String, HashSet<usize>>;
605
606/// Table columns to add or widen before inserting the prepared chunks.
607struct TraceTablePreAlter {
608    ensure_columns: Vec<ColumnSchema>,
609    modify_float64_columns: Vec<String>,
610    alter_existing: bool,
611}
612
613/// Either a ready table-alter plan or the observations that cannot be unified.
614enum TraceRequestSchemaPlan {
615    Ready(TraceTablePreAlter),
616    IncompatibleObservations(TraceSchemaExclusions),
617}
618
619impl TraceTablePreAlter {
620    fn requires_ddl(&self) -> bool {
621        !self.ensure_columns.is_empty() || !self.modify_float64_columns.is_empty()
622    }
623}
624
625impl TraceRequestSchema {
626    fn observe_batch_schema(
627        &mut self,
628        batch_index: usize,
629        schema: &[ColumnSchema],
630        batch_schema: &TraceBatchSchema,
631    ) {
632        for column in schema {
633            if batch_schema.has_incompatible_logical_types_for(&column.column_name) {
634                continue;
635            }
636            let observed_types = batch_schema.value_types(&column.column_name);
637            self.observe_trace_column(batch_index, column, None);
638            if let Some(observed_types) = observed_types {
639                for value_type in observed_types {
640                    self.observe_trace_column(batch_index, column, Some(*value_type));
641                }
642            }
643        }
644    }
645
646    fn observe_retry_chunk(
647        &mut self,
648        batch_index: usize,
649        chunk: &TraceChunkRetry,
650    ) -> ServerResult<()> {
651        // Split chunks already carry row-projected schemas; untouched chunks keep
652        // their original batch schema while observations stay sparse.
653        for row in &chunk.rows {
654            for retry_value in &row.values {
655                let Some(column) = chunk.schema.get(retry_value.column_index) else {
656                    return error::InternalSnafu {
657                        err_msg: format!(
658                            "trace retry column index {} is out of bounds",
659                            retry_value.column_index
660                        ),
661                    }
662                    .fail();
663                };
664                let value_type = retry_value
665                    .value
666                    .value_data
667                    .as_ref()
668                    .and_then(trace_value_datatype);
669                self.observe_trace_column(batch_index, column, value_type);
670            }
671        }
672        Ok(())
673    }
674
675    fn observe_trace_column(
676        &mut self,
677        batch_index: usize,
678        schema: &ColumnSchema,
679        value_type: Option<ColumnDataType>,
680    ) {
681        let column_schema = self.observe_column(schema);
682        if let Ok(current_type) = ColumnDataType::try_from(schema.datatype) {
683            let observation = column_schema.batches.entry(batch_index).or_insert_with(|| {
684                let wrapper =
685                    ColumnDataTypeWrapper::new(current_type, schema.datatype_extension.clone());
686                TraceBatchColumnObservation {
687                    value_types: Vec::new(),
688                    schema_type: current_type,
689                    concrete_type: ConcreteDataType::from(wrapper),
690                }
691            });
692            push_observed_trace_type(&mut observation.value_types, current_type);
693        }
694        if let Some(value_type) = value_type {
695            column_schema.observe_type(batch_index, value_type);
696        }
697    }
698
699    fn incompatible_schema_observations(
700        &self,
701        table_schema: Option<&datatypes::schema::Schema>,
702    ) -> TraceSchemaExclusions {
703        let mut exclusions = HashMap::new();
704        for column in &self.columns {
705            let existing_type = table_schema
706                .and_then(|schema| schema.column_schema_by_name(&column.schema.column_name))
707                .and_then(|table_col| {
708                    ColumnDataTypeWrapper::try_from(table_col.data_type.clone())
709                        .ok()
710                        .map(|wrapper| {
711                            let datatype = wrapper.datatype();
712                            (datatype, ConcreteDataType::from(wrapper))
713                        })
714                });
715            let incompatible_batches = column.incompatible_schema_batches(existing_type);
716            if !incompatible_batches.is_empty() {
717                exclusions.insert(column.schema.column_name.clone(), incompatible_batches);
718            }
719        }
720        exclusions
721    }
722
723    fn resolve_table_schema(
724        &mut self,
725        table_schema: Option<&datatypes::schema::Schema>,
726    ) -> TraceRequestSchemaPlan {
727        let mut pre_alter = TraceTablePreAlter {
728            ensure_columns: Vec::new(),
729            modify_float64_columns: Vec::new(),
730            alter_existing: table_schema.is_some(),
731        };
732
733        for column in &mut self.columns {
734            let Some(current_type) = ColumnDataType::try_from(column.schema.datatype).ok() else {
735                continue;
736            };
737            let observed_types = column.observed_types();
738
739            let existing_type = table_schema
740                .and_then(|schema| schema.column_schema_by_name(&column.schema.column_name))
741                .and_then(|table_col| {
742                    ColumnDataTypeWrapper::try_from(table_col.data_type.clone())
743                        .ok()
744                        .map(|wrapper| wrapper.datatype())
745                });
746            let fixed_type = trace_semconv_fixed_type(&column.schema.column_name);
747
748            let needs_reconcile = observed_types
749                .iter()
750                .copied()
751                .any(is_trace_reconcile_candidate_type)
752                || existing_type
753                    .map(is_trace_reconcile_candidate_type)
754                    .unwrap_or(false)
755                || fixed_type.is_some();
756
757            let target_type = if needs_reconcile {
758                let decision = match choose_trace_reconcile_decision(
759                    &column.schema.column_name,
760                    &observed_types,
761                    existing_type,
762                ) {
763                    Ok(decision) => decision,
764                    Err(_) => {
765                        return TraceRequestSchemaPlan::IncompatibleObservations(HashMap::from([
766                            (
767                                column.schema.column_name.clone(),
768                                column.batches.keys().copied().collect(),
769                            ),
770                        ]));
771                    }
772                };
773                decision
774                    .map(|decision| {
775                        if decision.requires_alter() {
776                            pre_alter
777                                .modify_float64_columns
778                                .push(column.schema.column_name.clone());
779                        }
780                        decision.target_type()
781                    })
782                    .unwrap_or(current_type)
783            } else {
784                current_type
785            };
786
787            column.target_type = Some(target_type);
788
789            if table_schema
790                .map(|schema| {
791                    schema
792                        .column_schema_by_name(&column.schema.column_name)
793                        .is_none()
794                })
795                .unwrap_or(false)
796            {
797                pre_alter.ensure_columns.push(column.target_schema());
798            }
799        }
800
801        if table_schema.is_none() {
802            pre_alter.ensure_columns = self
803                .columns
804                .iter()
805                .map(TraceColumnRequestSchema::target_schema)
806                .collect();
807        }
808
809        TraceRequestSchemaPlan::Ready(pre_alter)
810    }
811
812    fn prepare_request_rewrite(
813        &self,
814        batch_index: usize,
815        request: &RowInsertRequest,
816    ) -> Result<Option<PreparedTraceColumnRewrites>, TraceColumnRewriteError> {
817        let Some(rows) = request.rows.as_ref() else {
818            return Ok(None);
819        };
820        let pending_rewrites = self.pending_batch_rewrites(batch_index, rows);
821        if pending_rewrites.is_empty() {
822            return Ok(None);
823        }
824        prepare_trace_column_rewrites(&rows.rows, pending_rewrites, &request.table_name).map(Some)
825    }
826
827    fn apply_request_rewrite(
828        request: &mut RowInsertRequest,
829        prepared: PreparedTraceColumnRewrites,
830    ) {
831        if let Some(rows) = request.rows.as_mut() {
832            prepared.apply(rows);
833        }
834    }
835
836    fn pending_batch_rewrites(
837        &self,
838        batch_index: usize,
839        rows: &api::v1::Rows,
840    ) -> Vec<PendingTraceColumnRewrite> {
841        let mut pending_rewrites = Vec::new();
842        for (col_idx, col_schema) in rows.schema.iter().enumerate() {
843            let Some(global_idx) = self.column_indexes.get(&col_schema.column_name).copied() else {
844                continue;
845            };
846            let global_column = &self.columns[global_idx];
847            let Some(target_type) = global_column.target_type() else {
848                continue;
849            };
850            if !global_column
851                .batches
852                .get(&batch_index)
853                .is_some_and(|observation| {
854                    observation
855                        .value_types
856                        .iter()
857                        .any(|datatype| *datatype != target_type)
858                })
859            {
860                continue;
861            }
862            pending_rewrites.push(PendingTraceColumnRewrite {
863                col_idx,
864                target_type,
865                column_name: global_column.schema.column_name.clone(),
866            });
867        }
868
869        pending_rewrites
870    }
871
872    #[cfg(test)]
873    fn remove_batches(&mut self, batch_indexes: &HashSet<usize>) {
874        for column in &mut self.columns {
875            column
876                .batches
877                .retain(|batch_index, _| !batch_indexes.contains(batch_index));
878            column.target_type = None;
879        }
880        self.columns.retain(|column| !column.batches.is_empty());
881
882        self.column_indexes.clear();
883        for (index, column) in self.columns.iter().enumerate() {
884            self.column_indexes
885                .insert(column.schema.column_name.clone(), index);
886        }
887    }
888
889    #[cfg(test)]
890    fn resolved_target_types(&self) -> Vec<Option<ColumnDataType>> {
891        self.columns
892            .iter()
893            .map(|column| column.target_type)
894            .collect()
895    }
896
897    fn observe_column(&mut self, schema: &ColumnSchema) -> &mut TraceColumnRequestSchema {
898        if let Some(index) = self.column_indexes.get(&schema.column_name).copied() {
899            return &mut self.columns[index];
900        }
901
902        let index = self.columns.len();
903        self.columns.push(TraceColumnRequestSchema {
904            schema: schema.clone(),
905            batches: BTreeMap::new(),
906            target_type: None,
907        });
908        self.column_indexes
909            .insert(schema.column_name.clone(), index);
910        &mut self.columns[index]
911    }
912}
913
914impl TraceColumnRequestSchema {
915    fn observe_type(&mut self, batch_index: usize, datatype: ColumnDataType) {
916        if let Some(observation) = self.batches.get_mut(&batch_index) {
917            push_observed_trace_type(&mut observation.value_types, datatype);
918        }
919    }
920
921    fn observed_types(&self) -> Vec<ColumnDataType> {
922        let mut observed_types = Vec::new();
923        for datatype in self
924            .batches
925            .values()
926            .flat_map(|observation| &observation.value_types)
927        {
928            push_observed_trace_type(&mut observed_types, *datatype);
929        }
930        observed_types
931    }
932
933    fn incompatible_schema_batches(
934        &self,
935        existing_type: Option<(ColumnDataType, ConcreteDataType)>,
936    ) -> HashSet<usize> {
937        let mut incompatible_batches = HashSet::new();
938        if let Some((existing_datatype, existing_concrete_type)) = existing_type {
939            for (batch_index, observation) in &self.batches {
940                if trace_logical_types_incompatible(
941                    observation.schema_type,
942                    &observation.concrete_type,
943                    existing_datatype,
944                    &existing_concrete_type,
945                ) {
946                    incompatible_batches.insert(*batch_index);
947                }
948            }
949            return incompatible_batches;
950        }
951
952        let mut reconcile_candidates = Vec::<&TraceBatchColumnObservation>::new();
953        for observation in self
954            .batches
955            .values()
956            .filter(|observation| is_trace_reconcile_candidate_type(observation.schema_type))
957        {
958            if !reconcile_candidates.iter().any(|candidate| {
959                candidate.schema_type == observation.schema_type
960                    && candidate.concrete_type == observation.concrete_type
961            }) {
962                reconcile_candidates.push(observation);
963            }
964        }
965        if !reconcile_candidates.is_empty() {
966            for (batch_index, observation) in &self.batches {
967                if reconcile_candidates.iter().any(|candidate| {
968                    trace_logical_types_incompatible(
969                        observation.schema_type,
970                        &observation.concrete_type,
971                        candidate.schema_type,
972                        &candidate.concrete_type,
973                    )
974                }) {
975                    incompatible_batches.insert(*batch_index);
976                }
977            }
978            return incompatible_batches;
979        }
980
981        let mut schemas = Vec::<(ColumnDataType, ConcreteDataType, bool)>::new();
982        for observation in self.batches.values() {
983            if schemas.iter().any(|(observed_datatype, observed_type, _)| {
984                *observed_datatype == observation.schema_type
985                    && observed_type == &observation.concrete_type
986            }) {
987                continue;
988            }
989
990            let mut conflicts = false;
991            for (observed_datatype, observed_type, observed_conflicts) in &mut schemas {
992                if trace_logical_types_incompatible(
993                    *observed_datatype,
994                    observed_type,
995                    observation.schema_type,
996                    &observation.concrete_type,
997                ) {
998                    *observed_conflicts = true;
999                    conflicts = true;
1000                }
1001            }
1002            schemas.push((
1003                observation.schema_type,
1004                observation.concrete_type.clone(),
1005                conflicts,
1006            ));
1007        }
1008
1009        for (batch_index, observation) in &self.batches {
1010            if schemas
1011                .iter()
1012                .any(|(observed_datatype, observed_type, conflict)| {
1013                    *observed_datatype == observation.schema_type
1014                        && observed_type == &observation.concrete_type
1015                        && *conflict
1016                })
1017            {
1018                incompatible_batches.insert(*batch_index);
1019            }
1020        }
1021
1022        incompatible_batches
1023    }
1024
1025    fn target_type(&self) -> Option<ColumnDataType> {
1026        self.target_type
1027            .or_else(|| ColumnDataType::try_from(self.schema.datatype).ok())
1028    }
1029
1030    fn target_schema(&self) -> ColumnSchema {
1031        let mut schema = self.schema.clone();
1032        if let Some(target_type) = self.target_type() {
1033            schema.datatype = target_type as i32;
1034        }
1035        schema
1036    }
1037}
1038
1039impl Instance {
1040    /// Ingest OTLP trace spans with chunk-level writes and span-level fallback on
1041    /// deterministic chunk failures.
1042    pub(super) async fn ingest_trace_spans(
1043        &self,
1044        pipeline: &PipelineWay,
1045        table_name: String,
1046        groups: Vec<TraceSpanGroup>,
1047        conventions: &str,
1048        ctx: QueryContextRef,
1049    ) -> ServerResult<TraceIngestOutcome> {
1050        let aux_limiter = self
1051            .trace_aux_limiter
1052            .as_ref()
1053            .filter(|_| ctx.batching_enabled());
1054        // Reserve once per request, before any main chunks are submitted. Main
1055        // batcher permits remain independent so multi-chunk requests can progress.
1056        let aux_permit = if let Some(limiter) = aux_limiter {
1057            let mut aux_data = TraceAuxData::default();
1058            for span in groups.iter().flat_map(|group| &group.spans) {
1059                aux_data.observe_span(span);
1060            }
1061            self.trace_aux_cache
1062                .acquire_for_misses(
1063                    limiter,
1064                    TableName::new(ctx.current_catalog(), ctx.current_schema(), &table_name),
1065                    aux_data,
1066                    (&OTLP_TRACE_AUX_CACHE_HIT, &OTLP_TRACE_AUX_CACHE_MISS),
1067                )
1068                .await?
1069        } else {
1070            None
1071        };
1072        // Requests without auxiliary admission first check the cache when preparing writes.
1073        let aux_cache_metrics = aux_limiter
1074            .is_none()
1075            .then_some((&*OTLP_TRACE_AUX_CACHE_HIT, &*OTLP_TRACE_AUX_CACHE_MISS));
1076        let is_trace_v1_model = matches!(pipeline, PipelineWay::OtlpTraceDirectV1);
1077        let data_model = match pipeline {
1078            PipelineWay::OtlpTraceDirectV1 => Some(TABLE_DATA_MODEL_TRACE_V1),
1079            PipelineWay::OtlpTraceDirectV2 => Some(TABLE_DATA_MODEL_TRACE_V2),
1080            _ => None,
1081        };
1082
1083        // Only the main span table gets the identity; the derived `_services` /
1084        // `_operations` lookup tables keep the unstamped `ctx`.
1085        let main_ctx = {
1086            let mut c = (*ctx).clone();
1087            c.set_extension(SEMANTIC_SIGNAL_TYPE, SIGNAL_TYPE_TRACE);
1088            c.set_extension(SEMANTIC_SOURCE, SOURCE_OPENTELEMETRY);
1089            // `service_name` is a tag column in both trace models, so the main span
1090            // table declares the logical `service` entity (Layer 1 auto-stamp).
1091            c.set_extension(SEMANTIC_ENTITY_SERVICE_ID, SERVICE_NAME_COLUMN);
1092            if let Some(data_model) = data_model {
1093                c.set_extension(SEMANTIC_PIPELINE, data_model);
1094                c.set_extension(SEMANTIC_TRACE_CONVENTIONS, conventions);
1095            }
1096            Arc::new(c)
1097        };
1098
1099        if data_model.is_some()
1100            && let Some(table) = self
1101                .catalog_manager
1102                .table(
1103                    ctx.current_catalog(),
1104                    &ctx.current_schema(),
1105                    &table_name,
1106                    None,
1107                )
1108                .await?
1109        {
1110            operator::insert::validate_trace_table_model(&table.table_info(), &main_ctx)?;
1111        }
1112
1113        let ingest_ctx = TraceChunkIngestContext {
1114            pipeline,
1115            table_name: &table_name,
1116        };
1117        let mut ingest_state = TraceIngestState {
1118            // Confirmed hits stay satisfied for this request even if evicted
1119            // later: cache eviction does not delete the stored lookup rows.
1120            needs_aux_write: aux_limiter.is_none() || aux_permit.is_some(),
1121            aux_data: TraceAuxData::default(),
1122            pending_aux_data: Vec::new(),
1123            outcome: TraceIngestOutcome::default(),
1124            failure_messages: TraceFailureMessages::default(),
1125        };
1126
1127        let main_result: ServerResult<()> = async {
1128            if is_trace_v1_model {
1129                let mut request_schema = TraceRequestSchema::default();
1130                let mut chunks = Vec::new();
1131                let mut chunk_states = Vec::new();
1132                // Convert each chunk once, then retain only its sparse values while
1133                // request-wide schema planning runs.
1134                for chunk in chunk_spans_across_groups(groups, self.trace_ingest_chunk_size) {
1135                    let batch_index = chunks.len();
1136                    let span_metadata = chunk.iter().map(TraceSpanMetadata::from).collect();
1137                    let (table_data, batch_schema) =
1138                        otlp::trace::v1::v1_to_main_table_data_with_schema(chunk)?;
1139                    let (schema, rows) = table_data.into_schema_and_rows();
1140                    let has_incompatible_logical_types =
1141                        batch_schema.has_incompatible_logical_types();
1142                    if !has_incompatible_logical_types {
1143                        request_schema.observe_batch_schema(batch_index, &schema, &batch_schema);
1144                    }
1145                    let retry = TraceChunkRetry::try_new(
1146                        ingest_ctx.table_name.to_string(),
1147                        Rows { schema, rows },
1148                        span_metadata,
1149                        batch_schema.into_retry_columns(),
1150                    )?;
1151                    if has_incompatible_logical_types {
1152                        // A chunk schema cannot hold raw bytes and JSONB in one column.
1153                        // Plan each span on its own so the conflict only affects the
1154                        // spans that carry it, not the other columns of the chunk.
1155                        for retry in retry.into_single_span_chunks()? {
1156                            request_schema.observe_retry_chunk(chunks.len(), &retry)?;
1157                            chunks.push(retry);
1158                            chunk_states.push(TraceChunkSchemaState::Prepared);
1159                        }
1160                    } else {
1161                        chunks.push(retry);
1162                        chunk_states.push(TraceChunkSchemaState::Prepared);
1163                    }
1164                }
1165
1166                if !chunks.is_empty() {
1167                    self.ingest_trace_v1_prepared_chunks(
1168                        &ingest_ctx,
1169                        request_schema,
1170                        chunks,
1171                        chunk_states,
1172                        main_ctx.clone(),
1173                        &mut ingest_state,
1174                    )
1175                    .await?;
1176                }
1177            } else {
1178                for chunk in chunk_spans_across_groups(groups, self.trace_ingest_chunk_size) {
1179                    self.ingest_trace_chunk(
1180                        &ingest_ctx,
1181                        chunk,
1182                        main_ctx.clone(),
1183                        &mut ingest_state,
1184                    )
1185                    .await?;
1186                }
1187            }
1188
1189            Ok(())
1190        }
1191        .await;
1192
1193        OTLP_TRACES_ROWS.inc_by(ingest_state.outcome.accepted_spans as u64);
1194
1195        if !ingest_state.pending_aux_data.is_empty() {
1196            let pending = std::mem::take(&mut ingest_state.pending_aux_data);
1197            let instance = self.clone();
1198            let pipeline = match pipeline {
1199                PipelineWay::OtlpTraceDirectV1 => PipelineWay::OtlpTraceDirectV1,
1200                PipelineWay::OtlpTraceDirectV2 => PipelineWay::OtlpTraceDirectV2,
1201                _ => PipelineWay::OtlpTraceDirectV0,
1202            };
1203            let table_name = table_name.clone();
1204            let ctx = ctx.clone();
1205            common_runtime::spawn_ingest(async move {
1206                let _permit = aux_permit;
1207                let aux_data = confirmed_trace_aux_data(pending).await;
1208                let result: ServerResult<()> = async {
1209                    let (requests, keys) = instance.prepare_trace_aux_write(
1210                        aux_data,
1211                        &pipeline,
1212                        &table_name,
1213                        &ctx,
1214                        aux_cache_metrics,
1215                    )?;
1216                    if !requests.inserts.is_empty() {
1217                        instance
1218                            .insert_trace_aux_requests(requests, keys, &pipeline, ctx)
1219                            .await?;
1220                    }
1221                    Ok(())
1222                }
1223                .await;
1224                if let Err(err) = result {
1225                    OTLP_TRACES_FAILURE_COUNT
1226                        .with_label_values(&["aux_table_update_failed"])
1227                        .inc();
1228                    warn!(table_name, err = ?err, "Failed to write auxiliary trace rows after asynchronous main write");
1229                }
1230            });
1231        }
1232
1233        if !ingest_state.aux_data.is_empty() {
1234            // Auxiliary trace tables are derived from accepted spans, so they
1235            // never create new accepted spans or affect rejected span counts.
1236            let aux_requests = self.prepare_trace_aux_write(
1237                std::mem::take(&mut ingest_state.aux_data),
1238                ingest_ctx.pipeline,
1239                ingest_ctx.table_name,
1240                &ctx,
1241                aux_cache_metrics,
1242            );
1243
1244            match aux_requests {
1245                Ok((aux_requests, pending_aux_keys)) if !aux_requests.inserts.is_empty() => {
1246                    match self
1247                        .insert_trace_aux_requests(
1248                            aux_requests,
1249                            pending_aux_keys,
1250                            ingest_ctx.pipeline,
1251                            ctx,
1252                        )
1253                        .await
1254                    {
1255                        Ok(output) => {
1256                            Self::add_trace_write_cost(&mut ingest_state.outcome, output.meta.cost);
1257                        }
1258                        Err(err) => {
1259                            Self::push_trace_failure_message(
1260                                &mut ingest_state.failure_messages,
1261                                "aux_table_update_failed",
1262                                err.status_code().as_ref(),
1263                                format!(
1264                                    "Auxiliary trace tables were not fully updated ({})",
1265                                    err.status_code().as_ref()
1266                                ),
1267                            );
1268                        }
1269                    }
1270                }
1271                // Preserve the existing conversion-error behavior after a
1272                // successful main write, but never mask an earlier main error.
1273                Err(err) if main_result.is_ok() => return Err(err),
1274                Err(err) => Self::push_trace_failure_message(
1275                    &mut ingest_state.failure_messages,
1276                    "aux_table_update_failed",
1277                    err.status_code().as_ref(),
1278                    format!(
1279                        "Auxiliary trace tables were not fully updated ({})",
1280                        err.status_code().as_ref()
1281                    ),
1282                ),
1283                Ok(_) => {}
1284            }
1285        }
1286
1287        main_result?;
1288
1289        ingest_state.outcome.error_message = Self::finish_trace_failure_message(
1290            ingest_state.outcome.accepted_spans,
1291            ingest_state.outcome.rejected_spans,
1292            ingest_state.failure_messages,
1293        );
1294
1295        if let Some(error_message) = &ingest_state.outcome.error_message {
1296            let accepted_spans = ingest_state.outcome.accepted_spans;
1297            let rejected_spans = ingest_state.outcome.rejected_spans;
1298            // A partial success repeats every export interval while the sender
1299            // keeps emitting the bad value, so only a fully rejected request is
1300            // worth a warning. Either way the detail reaches the sender: as an
1301            // OTLP partial success, or as the status message of a 400.
1302            //
1303            // The detail embeds attribute keys verbatim, so it goes out as a
1304            // Debug field; interpolating it would let a newline in an attribute
1305            // key forge log lines.
1306            if accepted_spans == 0 && rejected_spans > 0 {
1307                warn!(
1308                    table_name = ingest_ctx.table_name,
1309                    rejected_spans,
1310                    error_message = ?error_message,
1311                    "OTLP trace ingest rejected every span"
1312                );
1313            } else {
1314                debug!(
1315                    table_name = ingest_ctx.table_name,
1316                    accepted_spans,
1317                    rejected_spans,
1318                    error_message = ?error_message,
1319                    "OTLP trace ingest reported failures"
1320                );
1321            }
1322        }
1323
1324        Ok(ingest_state.outcome)
1325    }
1326
1327    fn prepare_trace_aux_write(
1328        &self,
1329        mut aux_data: TraceAuxData,
1330        pipeline: &PipelineWay,
1331        table_name: &str,
1332        ctx: &QueryContextRef,
1333        metrics: Option<(&IntCounter, &IntCounter)>,
1334    ) -> ServerResult<(RowInsertRequests, Vec<TraceAuxCacheKey>)> {
1335        let keys = self.trace_aux_cache.filter(
1336            TableName::new(ctx.current_catalog(), ctx.current_schema(), table_name),
1337            &mut aux_data,
1338            metrics,
1339        );
1340        if aux_data.is_empty() {
1341            return Ok((RowInsertRequests::default(), keys));
1342        }
1343        let (mut requests, _) =
1344            otlp::trace::to_grpc_insert_requests_for_aux_tables(aux_data, pipeline, table_name)?;
1345        // Remove fully cached tables before v1 reconciliation performs catalog lookups.
1346        requests.inserts.retain(|request| {
1347            request
1348                .rows
1349                .as_ref()
1350                .is_some_and(|rows| !rows.rows.is_empty())
1351        });
1352        Ok((requests, keys))
1353    }
1354
1355    /// Checks deferred cache population without confusing visible rows with completion.
1356    #[cfg(feature = "testing")]
1357    pub fn trace_aux_data_cached_for_test(
1358        &self,
1359        table_name: &str,
1360        mut aux_data: TraceAuxData,
1361        ctx: &QueryContextRef,
1362    ) -> bool {
1363        self.trace_aux_cache
1364            .filter(
1365                TableName::new(ctx.current_catalog(), ctx.current_schema(), table_name),
1366                &mut aux_data,
1367                None,
1368            )
1369            .is_empty()
1370    }
1371
1372    async fn insert_trace_aux_requests(
1373        &self,
1374        requests: RowInsertRequests,
1375        keys: Vec<TraceAuxCacheKey>,
1376        pipeline: &PipelineWay,
1377        ctx: QueryContextRef,
1378    ) -> ServerResult<Output> {
1379        // Cache entries require a completed write, even when main spans use
1380        // batching with early acknowledgement.
1381        let mut aux_ctx = ctx.fork();
1382        aux_ctx.set_batching_enabled(false);
1383        let output = self
1384            .insert_trace_requests(requests, pipeline, Arc::new(aux_ctx))
1385            .await?;
1386        // ponytail: cache skip_wal writes too. After datanode data loss, lookup
1387        // rows may stay missing until eviction/restart; require durable writes
1388        // before caching if that limitation becomes unacceptable.
1389        self.trace_aux_cache.record(keys);
1390        Ok(output)
1391    }
1392
1393    /// Ingest one owned trace chunk so successful spans can be moved into the
1394    /// accepted set without extra cloning.
1395    async fn ingest_trace_chunk(
1396        &self,
1397        ingest_ctx: &TraceChunkIngestContext<'_>,
1398        mut chunk: Vec<TraceSpan>,
1399        ctx: QueryContextRef,
1400        ingest_state: &mut TraceIngestState,
1401    ) -> ServerResult<()> {
1402        // Try the fast path first so healthy batches keep their original
1403        // throughput and write amplification stays low.
1404        let convert = |spans: &[TraceSpan]| {
1405            otlp::trace::to_grpc_insert_requests_from_spans(
1406                spans,
1407                ingest_ctx.pipeline,
1408                ingest_ctx.table_name,
1409            )
1410        };
1411        let (requests, chunk_rows) = match convert(&chunk) {
1412            Ok(x) => x,
1413            Err(_) if matches!(ingest_ctx.pipeline, PipelineWay::OtlpTraceDirectV2) => {
1414                let mut rows = Rows {
1415                    rows: Vec::with_capacity(chunk.len()),
1416                    ..Default::default()
1417                };
1418
1419                for span in std::mem::take(&mut chunk) {
1420                    let result = convert(std::slice::from_ref(&span));
1421                    match result {
1422                        Ok((requests, _)) => {
1423                            // V2 must always emit the same table and fixed column order.
1424                            for batch in requests.inserts.into_iter().filter_map(|r| r.rows) {
1425                                merge_trace_v2_rows(&mut rows, batch)?;
1426                            }
1427                            chunk.push(span);
1428                        }
1429                        Err(err) => {
1430                            ingest_state.outcome.rejected_spans += 1;
1431
1432                            let (cause, shown_cause) = Self::trace_failure_cause(&err);
1433                            Self::push_trace_failure_message(
1434                                &mut ingest_state.failure_messages,
1435                                "span_rejected",
1436                                &cause,
1437                                format!(
1438                                    "Rejected span {}:{}: {}",
1439                                    span.trace_id, span.span_id, shown_cause
1440                                ),
1441                            );
1442                        }
1443                    }
1444                }
1445
1446                if rows.rows.is_empty() {
1447                    return Ok(());
1448                }
1449                let chunk_rows = rows.rows.len();
1450                let requests = RowInsertRequests {
1451                    inserts: vec![RowInsertRequest {
1452                        table_name: ingest_ctx.table_name.to_string(),
1453                        rows: Some(rows),
1454                    }],
1455                };
1456                (requests, chunk_rows)
1457            }
1458            Err(e) => return Err(e),
1459        };
1460
1461        let output = match self
1462            .insert_trace_requests(requests, ingest_ctx.pipeline, ctx)
1463            .await
1464        {
1465            Ok(output) => output,
1466            Err(err) => {
1467                // A distributed insert can partially commit before returning an
1468                // error, so retrying or rejecting the whole chunk is unsafe.
1469                Self::push_trace_failure_message(
1470                    &mut ingest_state.failure_messages,
1471                    ChunkFailureReaction::Propagate.as_metric_label(),
1472                    err.status_code().as_ref(),
1473                    format!(
1474                        "Propagating chunk write failure ({})",
1475                        err.status_code().as_ref()
1476                    ),
1477                );
1478                return Err(err);
1479            }
1480        };
1481
1482        Self::add_trace_write_cost(&mut ingest_state.outcome, output.meta.cost);
1483        ingest_state.outcome.accepted_spans += chunk_rows;
1484        if ingest_state.needs_aux_write {
1485            let mut aux_data = TraceAuxData::default();
1486            for span in &chunk {
1487                aux_data.observe_span(span);
1488            }
1489            ingest_state.add_aux_data(aux_data, output.meta.write_completions);
1490        }
1491
1492        Ok(())
1493    }
1494
1495    async fn ingest_trace_v1_prepared_chunks(
1496        &self,
1497        ingest_ctx: &TraceChunkIngestContext<'_>,
1498        mut request_schema: TraceRequestSchema,
1499        mut chunks: Vec<TraceChunkRetry>,
1500        mut chunk_states: Vec<TraceChunkSchemaState>,
1501        ctx: QueryContextRef,
1502        ingest_state: &mut TraceIngestState,
1503    ) -> ServerResult<()> {
1504        if let Err(err) = self
1505            .prepare_trace_v1_request_schema(
1506                ingest_ctx.table_name,
1507                &mut request_schema,
1508                &mut chunks,
1509                &mut chunk_states,
1510                &ctx,
1511            )
1512            .await
1513        {
1514            if matches!(
1515                Self::classify_trace_prewrite_failure(err.status_code(), err.is_retryable()),
1516                ChunkFailureReaction::Propagate
1517            ) {
1518                return Err(err);
1519            }
1520
1521            // Span-specific conversion failures were already isolated during
1522            // prevalidation. An error escaping request-wide schema preparation
1523            // applies to every remaining span, so repeating it per span cannot
1524            // recover valid data.
1525            let span_count = chunks.iter().map(|chunk| chunk.rows.len()).sum::<usize>();
1526            ingest_state.outcome.rejected_spans += span_count;
1527            let (cause, shown_cause) = Self::trace_failure_cause(&err);
1528            Self::push_trace_failure_message(
1529                &mut ingest_state.failure_messages,
1530                ChunkFailureReaction::DiscardChunk.as_metric_label(),
1531                // Merging discards of different sizes would make the reported
1532                // count times its occurrences wrong.
1533                &format!("{span_count}:{cause}"),
1534                format!(
1535                    "Discarded {} spans after pre-write request failure ({}): {}",
1536                    span_count,
1537                    err.status_code().as_ref(),
1538                    shown_cause
1539                ),
1540            );
1541            return Ok(());
1542        }
1543
1544        for (batch_index, (retry, schema_state)) in chunks.into_iter().zip(chunk_states).enumerate()
1545        {
1546            self.ingest_trace_v1_chunk(
1547                &request_schema,
1548                batch_index,
1549                retry,
1550                schema_state,
1551                ctx.clone(),
1552                ingest_state,
1553            )
1554            .await?;
1555        }
1556
1557        Ok(())
1558    }
1559
1560    async fn ingest_trace_v1_chunk(
1561        &self,
1562        request_schema: &TraceRequestSchema,
1563        batch_index: usize,
1564        retry: TraceChunkRetry,
1565        mut schema_state: TraceChunkSchemaState,
1566        ctx: QueryContextRef,
1567        ingest_state: &mut TraceIngestState,
1568    ) -> ServerResult<()> {
1569        let mut request = retry.to_request()?;
1570        let _ = Self::prepare_trace_v1_chunk_rewrite(
1571            request_schema,
1572            batch_index,
1573            &mut request,
1574            &mut schema_state,
1575        )?;
1576        let span_count = retry.rows.len();
1577        let mut requests = RowInsertRequests {
1578            inserts: vec![request],
1579        };
1580        if schema_state == TraceChunkSchemaState::ReconcilePerChunk
1581            && let Err(err) = self.reconcile_trace_column_types(&mut requests, &ctx).await
1582        {
1583            match Self::classify_trace_prewrite_failure(err.status_code(), err.is_retryable()) {
1584                ChunkFailureReaction::RetryPerSpan => {
1585                    Self::push_trace_failure_message(
1586                        &mut ingest_state.failure_messages,
1587                        ChunkFailureReaction::RetryPerSpan.as_metric_label(),
1588                        err.status_code().as_ref(),
1589                        format!("Chunk fallback triggered by {}", err.status_code().as_ref()),
1590                    );
1591                    return self
1592                        .ingest_trace_v1_rows_span_by_span(retry, ctx, ingest_state)
1593                        .await;
1594                }
1595                ChunkFailureReaction::DiscardChunk => {
1596                    ingest_state.outcome.rejected_spans += span_count;
1597                    let (cause, shown_cause) = Self::trace_failure_cause(&err);
1598                    Self::push_trace_failure_message(
1599                        &mut ingest_state.failure_messages,
1600                        ChunkFailureReaction::DiscardChunk.as_metric_label(),
1601                        // Chunks discarded for one cause differ in size; merging
1602                        // them would make the reported count times its
1603                        // occurrences wrong.
1604                        &format!("{span_count}:{cause}"),
1605                        format!(
1606                            "Discarded {} spans after pre-write chunk failure ({}): {}",
1607                            span_count,
1608                            err.status_code().as_ref(),
1609                            shown_cause
1610                        ),
1611                    );
1612                    return Ok(());
1613                }
1614                ChunkFailureReaction::Propagate => {
1615                    Self::push_trace_failure_message(
1616                        &mut ingest_state.failure_messages,
1617                        ChunkFailureReaction::Propagate.as_metric_label(),
1618                        err.status_code().as_ref(),
1619                        format!(
1620                            "Propagating pre-write chunk failure ({})",
1621                            err.status_code().as_ref()
1622                        ),
1623                    );
1624                    return Err(err);
1625                }
1626            }
1627        }
1628
1629        let output = match self
1630            .handle_trace_inserts(requests, ctx)
1631            .await
1632            .map_err(BoxedError::new)
1633            .context(error::ExecuteGrpcQuerySnafu)
1634        {
1635            Ok(output) => output,
1636            Err(err) => {
1637                // Do not retry after dispatch: writes to other peers may already
1638                // have committed even when the aggregate request returns an error.
1639                Self::push_trace_failure_message(
1640                    &mut ingest_state.failure_messages,
1641                    ChunkFailureReaction::Propagate.as_metric_label(),
1642                    err.status_code().as_ref(),
1643                    format!(
1644                        "Propagating chunk write failure ({})",
1645                        err.status_code().as_ref()
1646                    ),
1647                );
1648                return Err(err);
1649            }
1650        };
1651
1652        Self::add_trace_write_cost(&mut ingest_state.outcome, output.meta.cost);
1653        ingest_state.outcome.accepted_spans += span_count;
1654        if ingest_state.needs_aux_write {
1655            let mut aux_data = TraceAuxData::default();
1656            retry.add_to_aux_data(&mut aux_data);
1657            ingest_state.add_aux_data(aux_data, output.meta.write_completions);
1658        }
1659
1660        Ok(())
1661    }
1662
1663    async fn ingest_trace_v1_rows_span_by_span(
1664        &self,
1665        retry: TraceChunkRetry,
1666        ctx: QueryContextRef,
1667        ingest_state: &mut TraceIngestState,
1668    ) -> ServerResult<()> {
1669        for row in retry.rows {
1670            let (span, rows) = row.into_rows(&retry.schema)?;
1671            let mut requests = RowInsertRequests {
1672                inserts: vec![RowInsertRequest {
1673                    table_name: retry.table_name.clone(),
1674                    rows: Some(rows),
1675                }],
1676            };
1677
1678            if let Err(err) = self.reconcile_trace_column_types(&mut requests, &ctx).await {
1679                if matches!(
1680                    Self::classify_trace_prewrite_failure(err.status_code(), err.is_retryable()),
1681                    ChunkFailureReaction::Propagate
1682                ) {
1683                    Self::push_trace_failure_message(
1684                        &mut ingest_state.failure_messages,
1685                        ChunkFailureReaction::Propagate.as_metric_label(),
1686                        err.status_code().as_ref(),
1687                        format!(
1688                            "Propagating pre-write span failure for {}:{} ({})",
1689                            span.trace_id,
1690                            span.span_id,
1691                            err.status_code().as_ref()
1692                        ),
1693                    );
1694                    return Err(err);
1695                }
1696
1697                ingest_state.outcome.rejected_spans += 1;
1698                // Dedup on the cause, not the span id: one bad column rejects
1699                // every span and would otherwise fill the entry list.
1700                let (cause, shown_cause) = Self::trace_failure_cause(&err);
1701                Self::push_trace_failure_message(
1702                    &mut ingest_state.failure_messages,
1703                    "span_rejected",
1704                    &cause,
1705                    format!(
1706                        "Rejected span {}:{} ({}): {}",
1707                        span.trace_id,
1708                        span.span_id,
1709                        err.status_code().as_ref(),
1710                        shown_cause
1711                    ),
1712                );
1713                continue;
1714            }
1715
1716            let output = match self
1717                .handle_trace_inserts(requests, ctx.clone())
1718                .await
1719                .map_err(BoxedError::new)
1720                .context(error::ExecuteGrpcQuerySnafu)
1721            {
1722                Ok(output) => output,
1723                Err(err) => {
1724                    Self::push_trace_failure_message(
1725                        &mut ingest_state.failure_messages,
1726                        ChunkFailureReaction::Propagate.as_metric_label(),
1727                        err.status_code().as_ref(),
1728                        format!(
1729                            "Propagating span write failure for {}:{} ({})",
1730                            span.trace_id,
1731                            span.span_id,
1732                            err.status_code().as_ref()
1733                        ),
1734                    );
1735                    return Err(err);
1736                }
1737            };
1738
1739            Self::add_trace_write_cost(&mut ingest_state.outcome, output.meta.cost);
1740            ingest_state.outcome.accepted_spans += 1;
1741            if ingest_state.needs_aux_write {
1742                let mut aux_data = TraceAuxData::default();
1743                span.add_to_aux_data(&mut aux_data);
1744                ingest_state.add_aux_data(aux_data, output.meta.write_completions);
1745            }
1746        }
1747
1748        Ok(())
1749    }
1750
1751    /// Reconcile and insert one trace request batch.
1752    async fn insert_trace_requests(
1753        &self,
1754        mut requests: RowInsertRequests,
1755        pipeline: &PipelineWay,
1756        ctx: QueryContextRef,
1757    ) -> ServerResult<Output> {
1758        if matches!(pipeline, PipelineWay::OtlpTraceDirectV1) {
1759            self.reconcile_trace_column_types(&mut requests, &ctx)
1760                .await?;
1761        }
1762        if matches!(
1763            pipeline,
1764            PipelineWay::OtlpTraceDirectV1 | PipelineWay::OtlpTraceDirectV2
1765        ) {
1766            self.handle_trace_inserts(requests, ctx)
1767                .await
1768                .map_err(BoxedError::new)
1769                .context(error::ExecuteGrpcQuerySnafu)
1770        } else {
1771            self.handle_log_inserts(requests, ctx)
1772                .await
1773                .map_err(BoxedError::new)
1774                .context(error::ExecuteGrpcQuerySnafu)
1775        }
1776    }
1777
1778    async fn plan_trace_v1_request_schema(
1779        &self,
1780        table_name: &str,
1781        request_schema: &mut TraceRequestSchema,
1782        ctx: &QueryContextRef,
1783    ) -> ServerResult<TraceRequestSchemaPlan> {
1784        let catalog = ctx.current_catalog();
1785        let schema = ctx.current_schema();
1786        let table = self
1787            .catalog_manager
1788            .table(catalog, &schema, table_name, None)
1789            .await?;
1790        if let Some(table) = &table {
1791            operator::insert::validate_trace_table_model(&table.table_info(), ctx)?;
1792        }
1793        let table_schema = table.as_ref().map(|table| table.schema());
1794        let exclusions = request_schema.incompatible_schema_observations(table_schema.as_deref());
1795        if !exclusions.is_empty() {
1796            return Ok(TraceRequestSchemaPlan::IncompatibleObservations(exclusions));
1797        }
1798
1799        Ok(request_schema.resolve_table_schema(table_schema.as_deref()))
1800    }
1801
1802    async fn prepare_trace_v1_request_schema(
1803        &self,
1804        table_name: &str,
1805        request_schema: &mut TraceRequestSchema,
1806        chunks: &mut Vec<TraceChunkRetry>,
1807        chunk_states: &mut Vec<TraceChunkSchemaState>,
1808        ctx: &QueryContextRef,
1809    ) -> ServerResult<()> {
1810        loop {
1811            // Fixed semconv targets are known before schema planning. Isolate invalid
1812            // values before they can affect conflict resolution or DDL.
1813            let exclusions = Self::prevalidate_trace_v1_fixed_columns(chunks, chunk_states)?;
1814            if !exclusions.is_empty() {
1815                Self::exclude_trace_v1_schema_observations(
1816                    request_schema,
1817                    chunks,
1818                    chunk_states,
1819                    &exclusions,
1820                )?;
1821                continue;
1822            }
1823
1824            if request_schema.columns.is_empty() {
1825                return Ok(());
1826            }
1827
1828            let pre_alter = match self
1829                .plan_trace_v1_request_schema(table_name, request_schema, ctx)
1830                .await?
1831            {
1832                TraceRequestSchemaPlan::Ready(pre_alter) => pre_alter,
1833                TraceRequestSchemaPlan::IncompatibleObservations(exclusions) => {
1834                    Self::exclude_trace_v1_schema_observations(
1835                        request_schema,
1836                        chunks,
1837                        chunk_states,
1838                        &exclusions,
1839                    )?;
1840                    continue;
1841                }
1842            };
1843            if pre_alter.requires_ddl() {
1844                let exclusions = Self::prevalidate_trace_v1_chunk_rewrites(
1845                    request_schema,
1846                    chunks,
1847                    chunk_states,
1848                )?;
1849                if !exclusions.is_empty() {
1850                    Self::exclude_trace_v1_schema_observations(
1851                        request_schema,
1852                        chunks,
1853                        chunk_states,
1854                        &exclusions,
1855                    )?;
1856                    continue;
1857                }
1858
1859                self.apply_trace_v1_pre_alter(ctx, table_name, pre_alter)
1860                    .await?;
1861                let final_plan = self
1862                    .plan_trace_v1_request_schema(table_name, request_schema, ctx)
1863                    .await?;
1864                let schema_converged = matches!(
1865                    final_plan,
1866                    TraceRequestSchemaPlan::Ready(final_plan) if !final_plan.requires_ddl()
1867                );
1868                if !schema_converged {
1869                    for state in chunk_states.iter_mut() {
1870                        state.mark_for_reconcile();
1871                    }
1872                    return Ok(());
1873                }
1874
1875                return Ok(());
1876            }
1877
1878            return Ok(());
1879        }
1880    }
1881
1882    fn prevalidate_trace_v1_fixed_columns(
1883        chunks: &[TraceChunkRetry],
1884        chunk_states: &[TraceChunkSchemaState],
1885    ) -> ServerResult<TraceSchemaExclusions> {
1886        let mut exclusions = HashMap::<String, HashSet<usize>>::new();
1887        for (batch_index, (chunk, state)) in chunks.iter().zip(chunk_states).enumerate() {
1888            if *state != TraceChunkSchemaState::Prepared {
1889                continue;
1890            }
1891
1892            'rows: for row in &chunk.rows {
1893                for retry_value in &row.values {
1894                    let Some(column) = chunk.schema.get(retry_value.column_index) else {
1895                        return error::InternalSnafu {
1896                            err_msg: format!(
1897                                "trace retry column index {} is out of bounds",
1898                                retry_value.column_index
1899                            ),
1900                        }
1901                        .fail();
1902                    };
1903                    let Some(target_type) = trace_semconv_fixed_type(&column.column_name) else {
1904                        continue;
1905                    };
1906                    let Some(request_type) = retry_value
1907                        .value
1908                        .value_data
1909                        .as_ref()
1910                        .and_then(trace_value_datatype)
1911                    else {
1912                        continue;
1913                    };
1914                    if request_type != target_type
1915                        && coerce_value_data(
1916                            &retry_value.value.value_data,
1917                            target_type,
1918                            request_type,
1919                        )
1920                        .is_err()
1921                    {
1922                        exclusions
1923                            .entry(column.column_name.clone())
1924                            .or_default()
1925                            .insert(batch_index);
1926                        break 'rows;
1927                    }
1928                }
1929            }
1930        }
1931        Ok(exclusions)
1932    }
1933
1934    fn prevalidate_trace_v1_chunk_rewrites(
1935        request_schema: &TraceRequestSchema,
1936        chunks: &[TraceChunkRetry],
1937        chunk_states: &mut [TraceChunkSchemaState],
1938    ) -> ServerResult<TraceSchemaExclusions> {
1939        let mut exclusions = HashMap::<String, HashSet<usize>>::new();
1940        for (batch_index, (chunk, state)) in chunks.iter().zip(chunk_states).enumerate() {
1941            if *state != TraceChunkSchemaState::Prepared {
1942                continue;
1943            }
1944            let mut request = chunk.to_request()?;
1945            if let Some(column_name) = Self::prepare_trace_v1_chunk_rewrite(
1946                request_schema,
1947                batch_index,
1948                &mut request,
1949                state,
1950            )? {
1951                exclusions
1952                    .entry(column_name)
1953                    .or_default()
1954                    .insert(batch_index);
1955            }
1956        }
1957        Ok(exclusions)
1958    }
1959
1960    fn prepare_trace_v1_chunk_rewrite(
1961        request_schema: &TraceRequestSchema,
1962        batch_index: usize,
1963        request: &mut RowInsertRequest,
1964        state: &mut TraceChunkSchemaState,
1965    ) -> ServerResult<Option<String>> {
1966        if *state != TraceChunkSchemaState::Prepared {
1967            return Ok(None);
1968        }
1969
1970        match request_schema.prepare_request_rewrite(batch_index, request) {
1971            Ok(Some(rewrite)) => {
1972                TraceRequestSchema::apply_request_rewrite(request, rewrite);
1973                Ok(None)
1974            }
1975            Ok(None) => Ok(None),
1976            Err(failure) => {
1977                if !matches!(
1978                    Self::classify_trace_prewrite_failure(
1979                        failure.error.status_code(),
1980                        failure.error.is_retryable(),
1981                    ),
1982                    ChunkFailureReaction::RetryPerSpan
1983                ) {
1984                    return Err(failure.error);
1985                }
1986                state.mark_for_reconcile();
1987                Ok(Some(failure.column_name))
1988            }
1989        }
1990    }
1991
1992    fn exclude_trace_v1_schema_observations(
1993        request_schema: &mut TraceRequestSchema,
1994        chunks: &mut Vec<TraceChunkRetry>,
1995        chunk_states: &mut Vec<TraceChunkSchemaState>,
1996        exclusions: &TraceSchemaExclusions,
1997    ) -> ServerResult<()> {
1998        if chunks.len() != chunk_states.len() {
1999            return error::InternalSnafu {
2000                err_msg: format!(
2001                    "trace chunk count {} does not match state count {}",
2002                    chunks.len(),
2003                    chunk_states.len()
2004                ),
2005            }
2006            .fail();
2007        }
2008
2009        let batch_indexes = exclusions
2010            .values()
2011            .flatten()
2012            .copied()
2013            .collect::<HashSet<_>>();
2014        let previous_chunks = std::mem::take(chunks);
2015        let previous_states = std::mem::take(chunk_states);
2016        for (batch_index, (chunk, mut state)) in
2017            previous_chunks.into_iter().zip(previous_states).enumerate()
2018        {
2019            if !batch_indexes.contains(&batch_index) {
2020                chunks.push(chunk);
2021                chunk_states.push(state);
2022            } else if chunk.rows.len() <= 1 {
2023                state.mark_for_reconcile();
2024                chunks.push(chunk);
2025                chunk_states.push(state);
2026            } else {
2027                let split_chunks = chunk.into_single_span_chunks()?;
2028                chunk_states.extend(std::iter::repeat_n(
2029                    TraceChunkSchemaState::Prepared,
2030                    split_chunks.len(),
2031                ));
2032                chunks.extend(split_chunks);
2033            }
2034        }
2035
2036        *request_schema = TraceRequestSchema::default();
2037        for (batch_index, (chunk, state)) in chunks.iter().zip(chunk_states.iter()).enumerate() {
2038            if *state == TraceChunkSchemaState::Prepared {
2039                request_schema.observe_retry_chunk(batch_index, chunk)?;
2040            }
2041        }
2042
2043        Ok(())
2044    }
2045
2046    async fn apply_trace_v1_pre_alter(
2047        &self,
2048        ctx: &QueryContextRef,
2049        table_name: &str,
2050        pre_alter: TraceTablePreAlter,
2051    ) -> ServerResult<()> {
2052        let TraceTablePreAlter {
2053            ensure_columns,
2054            modify_float64_columns,
2055            alter_existing,
2056        } = pre_alter;
2057
2058        if !ensure_columns.is_empty() {
2059            self.inserter
2060                .ensure_trace_table_on_demand(
2061                    table_name,
2062                    ensure_columns,
2063                    alter_existing,
2064                    ctx,
2065                    &self.statement_executor,
2066                )
2067                .await
2068                .map_err(BoxedError::new)
2069                .context(error::ExecuteGrpcQuerySnafu)?;
2070        }
2071
2072        if !modify_float64_columns.is_empty() {
2073            self.alter_trace_table_columns_to_float64(ctx, table_name, &modify_float64_columns)
2074                .await?;
2075        }
2076
2077        Ok(())
2078    }
2079
2080    fn classify_trace_prewrite_failure(
2081        status: StatusCode,
2082        retryable: bool,
2083    ) -> ChunkFailureReaction {
2084        if retryable {
2085            return ChunkFailureReaction::Propagate;
2086        }
2087
2088        match status {
2089            StatusCode::InvalidArguments
2090            | StatusCode::InvalidSyntax
2091            | StatusCode::Unsupported
2092            | StatusCode::TableNotFound
2093            | StatusCode::TableColumnNotFound => ChunkFailureReaction::RetryPerSpan,
2094            StatusCode::DatabaseNotFound => ChunkFailureReaction::DiscardChunk,
2095            _ => ChunkFailureReaction::Propagate,
2096        }
2097    }
2098
2099    fn add_trace_write_cost(outcome: &mut TraceIngestOutcome, cost: usize) {
2100        outcome.write_cost += cost;
2101    }
2102
2103    /// Returns the full cause of a pre-write failure and its display form.
2104    ///
2105    /// `output_msg` masks internal errors and unwraps the root cause. Dedup must
2106    /// key on the full text: two causes can agree on a truncated prefix and
2107    /// differ exactly where the actionable detail is.
2108    fn trace_failure_cause(err: &error::Error) -> (String, String) {
2109        let cause = err.output_msg();
2110        let display = truncate_for_diagnostics(&cause, TRACE_FAILURE_CAUSE_LIMIT);
2111        (cause, display)
2112    }
2113
2114    /// Records one failure, merging repeats of `(label, key)` into a count.
2115    fn push_trace_failure_message(
2116        messages: &mut TraceFailureMessages,
2117        label: &'static str,
2118        key: &str,
2119        message: String,
2120    ) {
2121        OTLP_TRACES_FAILURE_COUNT.with_label_values(&[label]).inc();
2122
2123        if let Some(entry) = messages
2124            .entries
2125            .iter_mut()
2126            .find(|entry| entry.label == label && entry.key == key)
2127        {
2128            entry.occurrences += 1;
2129            return;
2130        }
2131
2132        if messages.entries.len() >= TRACE_FAILURE_MESSAGE_LIMIT {
2133            messages.suppressed_occurrences += 1;
2134            return;
2135        }
2136
2137        messages.entries.push(TraceFailureEntry {
2138            label,
2139            key: key.to_string(),
2140            message,
2141            occurrences: 1,
2142        });
2143    }
2144
2145    fn finish_trace_failure_message(
2146        accepted_spans: usize,
2147        rejected_spans: usize,
2148        messages: TraceFailureMessages,
2149    ) -> Option<String> {
2150        if rejected_spans == 0 && messages.is_empty() {
2151            return None;
2152        }
2153
2154        let mut summary = format!(
2155            "Accepted {} spans, rejected {} spans",
2156            accepted_spans, rejected_spans
2157        );
2158
2159        if !messages.is_empty() {
2160            let details = messages
2161                .entries
2162                .into_iter()
2163                .map(|entry| {
2164                    if entry.occurrences > 1 {
2165                        format!("{} (x{})", entry.message, entry.occurrences)
2166                    } else {
2167                        entry.message
2168                    }
2169                })
2170                .collect::<Vec<_>>()
2171                .join("; ");
2172            summary.push_str(": ");
2173            summary.push_str(&details);
2174        }
2175
2176        if messages.suppressed_occurrences > 0 {
2177            summary.push_str(&format!(
2178                "; {} additional failures suppressed",
2179                messages.suppressed_occurrences
2180            ));
2181        }
2182
2183        Some(summary)
2184    }
2185
2186    /// Widen existing trace table columns to Float64 before request rewrite.
2187    async fn alter_trace_table_columns_to_float64(
2188        &self,
2189        ctx: &QueryContextRef,
2190        table_name: &str,
2191        column_names: &[String],
2192    ) -> ServerResult<()> {
2193        let catalog_name = ctx.current_catalog().to_string();
2194        let schema_name = ctx.current_schema();
2195        let alter_expr = AlterTableExpr {
2196            catalog_name: catalog_name.clone(),
2197            schema_name: schema_name.clone(),
2198            table_name: table_name.to_string(),
2199            kind: Some(Kind::ModifyColumnTypes(ModifyColumnTypes {
2200                modify_column_types: column_names
2201                    .iter()
2202                    .map(|column_name| ModifyColumnType {
2203                        column_name: column_name.clone(),
2204                        target_type: ColumnDataType::Float64 as i32,
2205                        target_type_extension: None,
2206                    })
2207                    .collect(),
2208            })),
2209        };
2210
2211        if let Err(err) = self
2212            .statement_executor
2213            .alter_table_inner(alter_expr, ctx.clone(), TriggerReason::AutoAlter)
2214            .await
2215        {
2216            let table = self
2217                .catalog_manager
2218                .table(&catalog_name, &schema_name, table_name, None)
2219                .await
2220                .map_err(servers::error::Error::from)?;
2221            let alter_already_applied = table
2222                .map(|table| {
2223                    let table_schema = table.schema();
2224                    column_names.iter().all(|column_name| {
2225                        table_schema
2226                            .column_schema_by_name(column_name)
2227                            .and_then(|table_col| {
2228                                ColumnDataTypeWrapper::try_from(table_col.data_type.clone())
2229                                    .ok()
2230                                    .map(|wrapper| wrapper.datatype())
2231                            })
2232                            == Some(ColumnDataType::Float64)
2233                    })
2234                })
2235                .unwrap_or(false);
2236
2237            if alter_already_applied {
2238                return Ok(());
2239            }
2240
2241            warn!(
2242                table_name,
2243                columns = ?column_names,
2244                error = %err,
2245                "failed to widen trace columns before insert"
2246            );
2247
2248            return Err(wrap_trace_alter_failure(err));
2249        }
2250
2251        Ok(())
2252    }
2253
2254    /// Coerce request column types and values to match the existing table schema
2255    /// for compatible type pairs. Existing table schema wins when present;
2256    /// otherwise the full request batch decides a stable target type.
2257    async fn reconcile_trace_column_types(
2258        &self,
2259        requests: &mut RowInsertRequests,
2260        ctx: &QueryContextRef,
2261    ) -> ServerResult<()> {
2262        let catalog = ctx.current_catalog();
2263        let schema = ctx.current_schema();
2264
2265        for req in &mut requests.inserts {
2266            let table = self
2267                .catalog_manager
2268                .table(catalog, &schema, &req.table_name, None)
2269                .await?;
2270
2271            let Some(rows) = req.rows.as_mut() else {
2272                continue;
2273            };
2274
2275            let table_schema = table.map(|table| table.schema());
2276            let mut pending_rewrites = Vec::new();
2277            let mut pending_alter_columns = Vec::new();
2278
2279            for (col_idx, col_schema) in rows.schema.iter().enumerate() {
2280                let Some(current_type) = ColumnDataType::try_from(col_schema.datatype).ok() else {
2281                    continue;
2282                };
2283
2284                let mut observed_types = Vec::new();
2285                push_observed_trace_type(&mut observed_types, current_type);
2286
2287                // Scan the full request first so the final type decision is not affected
2288                // by row order inside the batch.
2289                for row in &rows.rows {
2290                    let Some(value) = row
2291                        .values
2292                        .get(col_idx)
2293                        .and_then(|value| value.value_data.as_ref())
2294                    else {
2295                        continue;
2296                    };
2297
2298                    let Some(value_type) = trace_value_datatype(value) else {
2299                        continue;
2300                    };
2301                    push_observed_trace_type(&mut observed_types, value_type);
2302                }
2303
2304                let existing_schema_type = table_schema
2305                    .as_ref()
2306                    .and_then(|schema| schema.column_schema_by_name(&col_schema.column_name))
2307                    .and_then(|table_col| {
2308                        ColumnDataTypeWrapper::try_from(table_col.data_type.clone())
2309                            .ok()
2310                            .map(|wrapper| {
2311                                let datatype = wrapper.datatype();
2312                                (datatype, ConcreteDataType::from(wrapper))
2313                            })
2314                    });
2315                let request_concrete_type = ConcreteDataType::from(ColumnDataTypeWrapper::new(
2316                    current_type,
2317                    col_schema.datatype_extension.clone(),
2318                ));
2319                if let Some((existing_datatype, existing_concrete_type)) =
2320                    existing_schema_type.as_ref()
2321                    && trace_logical_types_incompatible(
2322                        current_type,
2323                        &request_concrete_type,
2324                        *existing_datatype,
2325                        existing_concrete_type,
2326                    )
2327                {
2328                    return error::InvalidParameterSnafu {
2329                        reason: format!(
2330                            "incompatible logical types for trace column '{}' in table '{}'",
2331                            col_schema.column_name, req.table_name
2332                        ),
2333                    }
2334                    .fail();
2335                }
2336                let existing_type = existing_schema_type.map(|(datatype, _)| datatype);
2337                let fixed_type = trace_semconv_fixed_type(&col_schema.column_name);
2338
2339                if !observed_types
2340                    .iter()
2341                    .copied()
2342                    .any(is_trace_reconcile_candidate_type)
2343                    && existing_type
2344                        .map(|datatype| !is_trace_reconcile_candidate_type(datatype))
2345                        .unwrap_or(true)
2346                    && fixed_type.is_none()
2347                {
2348                    continue;
2349                }
2350
2351                // Decide the final type once per column, then rewrite all affected cells
2352                // together in one row pass below.
2353                let Some(decision) = choose_trace_reconcile_decision(
2354                    &col_schema.column_name,
2355                    &observed_types,
2356                    existing_type,
2357                )
2358                .map_err(|_| {
2359                    enrich_trace_reconcile_error(
2360                        &req.table_name,
2361                        &col_schema.column_name,
2362                        &observed_types,
2363                        existing_type,
2364                        fixed_type,
2365                    )
2366                })?
2367                else {
2368                    continue;
2369                };
2370                let target_type = decision.target_type();
2371
2372                if !decision.requires_alter()
2373                    && observed_types
2374                        .iter()
2375                        .all(|observed| *observed == target_type)
2376                    && col_schema.datatype == target_type as i32
2377                {
2378                    continue;
2379                }
2380
2381                if decision.requires_alter()
2382                    && !pending_alter_columns.contains(&col_schema.column_name)
2383                {
2384                    pending_alter_columns.push(col_schema.column_name.clone());
2385                }
2386
2387                pending_rewrites.push(PendingTraceColumnRewrite {
2388                    col_idx,
2389                    target_type,
2390                    column_name: col_schema.column_name.clone(),
2391                });
2392            }
2393
2394            if pending_rewrites.is_empty() {
2395                continue;
2396            }
2397
2398            let prepared_rewrites =
2399                prepare_trace_column_rewrites(&rows.rows, pending_rewrites, &req.table_name)
2400                    .map_err(|failure| failure.error)?;
2401
2402            if !pending_alter_columns.is_empty() {
2403                self.alter_trace_table_columns_to_float64(
2404                    ctx,
2405                    &req.table_name,
2406                    &pending_alter_columns,
2407                )
2408                .await?;
2409            }
2410
2411            prepared_rewrites.apply(rows);
2412        }
2413
2414        Ok(())
2415    }
2416}
2417
2418fn trace_logical_types_incompatible(
2419    left_datatype: ColumnDataType,
2420    left_concrete_type: &ConcreteDataType,
2421    right_datatype: ColumnDataType,
2422    right_concrete_type: &ConcreteDataType,
2423) -> bool {
2424    if left_concrete_type == right_concrete_type {
2425        return false;
2426    }
2427    // A supported coercion in either direction means the two types can be
2428    // reconciled, so they are not logically incompatible. This lets a signed
2429    // request reach the coercion path instead of being excluded up front when
2430    // the existing column is unsigned (e.g. trace `duration_nano` written as
2431    // Int64 into an existing UInt64 column during the unsigned -> signed
2432    // transition).
2433    if is_supported_trace_coercion(left_datatype, right_datatype)
2434        || is_supported_trace_coercion(right_datatype, left_datatype)
2435    {
2436        return false;
2437    }
2438    left_datatype == right_datatype
2439        || !is_trace_reconcile_candidate_type(left_datatype)
2440        || !is_trace_reconcile_candidate_type(right_datatype)
2441}
2442
2443/// Chunks the spans of all resource/scope groups together. Every span carries
2444/// its own resource and scope attributes, so a chunk may mix groups; chunking per
2445/// group instead would turn a collector batch of many small groups into many
2446/// tiny inserts, each fanned out to every trace partition.
2447fn chunk_spans_across_groups(
2448    groups: Vec<TraceSpanGroup>,
2449    chunk_size: usize,
2450) -> Vec<Vec<TraceSpan>> {
2451    chunk_owned(
2452        groups.into_iter().flat_map(|group| group.spans).collect(),
2453        chunk_size,
2454    )
2455}
2456
2457fn chunk_owned<T>(items: Vec<T>, chunk_size: usize) -> Vec<Vec<T>> {
2458    if items.is_empty() {
2459        return Vec::new();
2460    }
2461
2462    if chunk_size == 0 {
2463        return vec![items];
2464    }
2465
2466    let mut chunks = Vec::with_capacity(items.len().div_ceil(chunk_size));
2467    let mut iter = items.into_iter();
2468    while iter.len() > 0 {
2469        chunks.push(iter.by_ref().take(chunk_size).collect());
2470    }
2471    chunks
2472}
2473
2474/// Preserve the original alter failure status so chunk retry behavior stays correct.
2475fn wrap_trace_alter_failure<E>(err: E) -> servers::error::Error
2476where
2477    E: ErrorExt + Send + Sync + 'static,
2478{
2479    error::ExecuteGrpcQuerySnafu.into_error(BoxedError::new(err))
2480}
2481
2482/// Derives `trace.conventions` from the request's resource/scope `schema_url`s.
2483/// A single distinct non-empty value is concrete; multiple distinct values are
2484/// `mixed`; none is `unknown`. `schema_url` is row-level in OTLP, so the
2485/// table-level value is best-effort per the RFC conflict rule.
2486pub(super) fn trace_conventions(request: &ExportTraceServiceRequest) -> String {
2487    let mut seen: Option<&str> = None;
2488    let mut mixed = false;
2489
2490    for resource_spans in &request.resource_spans {
2491        let urls = std::iter::once(resource_spans.schema_url.as_str()).chain(
2492            resource_spans
2493                .scope_spans
2494                .iter()
2495                .map(|s| s.schema_url.as_str()),
2496        );
2497        for url in urls {
2498            if url.is_empty() {
2499                continue;
2500            }
2501            match seen {
2502                None => seen = Some(url),
2503                Some(prev) if prev == url => {}
2504                Some(_) => {
2505                    mixed = true;
2506                    break;
2507                }
2508            }
2509        }
2510        if mixed {
2511            break;
2512        }
2513    }
2514
2515    if mixed {
2516        SEMANTIC_VALUE_MIXED.to_string()
2517    } else {
2518        seen.map(str::to_string)
2519            .unwrap_or_else(|| SEMANTIC_VALUE_UNKNOWN.to_string())
2520    }
2521}
2522
2523#[cfg(test)]
2524mod tests;