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