Skip to main content

micromegas_analytics/lakehouse/
process_spans_table_function.rs

1use super::{
2    audience_guard::{AudienceGuard, Authorized, IdKind},
3    lakehouse_context::LakehouseContext,
4    partition_cache::QueryPartitionProvider,
5    process_streams::get_process_thread_list,
6    session_configurator::NoOpSessionConfigurator,
7    view_factory::ViewFactory,
8};
9use crate::{dfext::expressions::exp_to_string, span_table::get_spans_schema, time::TimeRange};
10use async_stream::try_stream;
11use datafusion::{
12    arrow::{
13        array::{ArrayRef, RecordBatch, StringDictionaryBuilder},
14        datatypes::{DataType, Field, Int16Type, Schema, SchemaRef},
15    },
16    catalog::{Session, TableFunctionArgs, TableFunctionImpl, TableProvider},
17    common::{Result as DFResult, plan_err},
18    error::DataFusionError,
19    execution::{SendableRecordBatchStream, TaskContext},
20    logical_expr::{Expr, TableType},
21    physical_expr::EquivalenceProperties,
22    physical_plan::{
23        DisplayAs, DisplayFormatType, ExecutionPlan, Partitioning, PlanProperties,
24        execution_plan::{Boundedness, EmissionType},
25        limit::GlobalLimitExec,
26        projection::ProjectionExec,
27        stream::RecordBatchStreamAdapter,
28    },
29};
30use futures::{StreamExt, TryStreamExt};
31use micromegas_tracing::prelude::*;
32use std::{
33    fmt::{self, Debug, Formatter},
34    sync::Arc,
35};
36use uuid::Uuid;
37
38/// Span types to include in the output
39#[derive(Debug, Clone, Copy)]
40pub enum SpanTypes {
41    Thread,
42    Async,
43    Both,
44}
45
46fn output_schema() -> SchemaRef {
47    let mut fields = vec![
48        Field::new(
49            "stream_id",
50            DataType::Dictionary(Box::new(DataType::Int16), Box::new(DataType::Utf8)),
51            false,
52        ),
53        Field::new(
54            "thread_name",
55            DataType::Dictionary(Box::new(DataType::Int16), Box::new(DataType::Utf8)),
56            false,
57        ),
58    ];
59    fields.extend(get_spans_schema().fields.iter().map(|f| f.as_ref().clone()));
60    Arc::new(Schema::new(fields))
61}
62
63fn augment_batch(
64    batch: &RecordBatch,
65    schema: SchemaRef,
66    stream_id: &str,
67    thread_name: &str,
68) -> DFResult<RecordBatch> {
69    let n = batch.num_rows();
70    let mut stream_id_builder = StringDictionaryBuilder::<Int16Type>::new();
71    let mut thread_name_builder = StringDictionaryBuilder::<Int16Type>::new();
72    stream_id_builder.append_values(stream_id, n);
73    thread_name_builder.append_values(thread_name, n);
74    let mut columns: Vec<ArrayRef> = vec![
75        Arc::new(stream_id_builder.finish()),
76        Arc::new(thread_name_builder.finish()),
77    ];
78    columns.extend(batch.columns().iter().cloned());
79    RecordBatch::try_new(schema, columns).map_err(Into::into)
80}
81
82// --- TableFunction ---
83
84#[derive(Debug)]
85pub struct ProcessSpansTableFunction {
86    lakehouse: Arc<LakehouseContext>,
87    view_factory: Arc<ViewFactory>,
88    part_provider: Arc<dyn QueryPartitionProvider>,
89    query_range: Option<TimeRange>,
90    guard: Arc<AudienceGuard>,
91}
92
93impl ProcessSpansTableFunction {
94    pub fn new(
95        lakehouse: Arc<LakehouseContext>,
96        view_factory: Arc<ViewFactory>,
97        part_provider: Arc<dyn QueryPartitionProvider>,
98        query_range: Option<TimeRange>,
99        guard: Arc<AudienceGuard>,
100    ) -> Self {
101        Self {
102            lakehouse,
103            view_factory,
104            part_provider,
105            query_range,
106            guard,
107        }
108    }
109}
110
111impl TableFunctionImpl for ProcessSpansTableFunction {
112    #[span_fn]
113    fn call_with_args(
114        &self,
115        args: TableFunctionArgs,
116    ) -> datafusion::error::Result<Arc<dyn TableProvider>> {
117        let exprs = args.exprs();
118        let arg1 = exprs.first().map(exp_to_string);
119        let Some(Ok(process_id_arg)) = arg1 else {
120            return plan_err!(
121                "First argument to process_spans must be a string (the process ID), given {:?}",
122                arg1
123            );
124        };
125        // Parsed as a `Uuid` at plan time -- a malformed id becomes a plan-time error instead of
126        // reaching the inner SQL these queries build for it (`process_streams.rs`). The canonical
127        // hyphenated rendering is what's actually stored in `process_id` columns, so it's kept
128        // (not the caller's original spelling) for the inner literal comparisons below to match.
129        let Ok(process_uuid) = Uuid::parse_str(&process_id_arg) else {
130            return plan_err!(
131                "First argument to process_spans must be a valid UUID (the process ID), given '{process_id_arg}'"
132            );
133        };
134        let process_id = process_uuid.hyphenated().to_string();
135
136        let arg2 = exprs.get(1).map(exp_to_string);
137        let Some(Ok(span_types_str)) = arg2 else {
138            return plan_err!(
139                "Second argument to process_spans must be a string ('thread', 'async', or 'both'), given {:?}",
140                arg2
141            );
142        };
143
144        let span_types = match span_types_str.as_str() {
145            "thread" => SpanTypes::Thread,
146            "async" => SpanTypes::Async,
147            "both" => SpanTypes::Both,
148            _ => {
149                return plan_err!(
150                    "span_types must be 'thread', 'async', or 'both', given: {span_types_str}"
151                );
152            }
153        };
154
155        let schema = output_schema();
156        let execution_plan = Arc::new(ProcessSpansExecutionPlan::new(
157            schema,
158            process_id,
159            process_uuid,
160            span_types,
161            self.query_range,
162            self.lakehouse.clone(),
163            self.view_factory.clone(),
164            self.part_provider.clone(),
165            self.guard.clone(),
166        ));
167
168        Ok(Arc::new(ProcessSpansTableProvider { execution_plan }))
169    }
170}
171
172// --- ExecutionPlan ---
173
174pub struct ProcessSpansExecutionPlan {
175    schema: SchemaRef,
176    process_id: String,
177    process_uuid: Uuid,
178    span_types: SpanTypes,
179    query_range: Option<TimeRange>,
180    lakehouse: Arc<LakehouseContext>,
181    view_factory: Arc<ViewFactory>,
182    part_provider: Arc<dyn QueryPartitionProvider>,
183    guard: Arc<AudienceGuard>,
184    /// Set only by [`ProcessSpansTableProvider::scan`], after `AudienceGuard::authorize`
185    /// succeeds -- `None` here means this plan never went through `scan`, so `execute` refuses
186    /// to run it: fail-closed by construction, not by convention. The inner session below also
187    /// runs under the witness's internal caller rather than the caller's own scope.
188    authorized: Option<Authorized>,
189    properties: Arc<PlanProperties>,
190}
191
192impl ProcessSpansExecutionPlan {
193    #[allow(clippy::too_many_arguments)]
194    fn new(
195        schema: SchemaRef,
196        process_id: String,
197        process_uuid: Uuid,
198        span_types: SpanTypes,
199        query_range: Option<TimeRange>,
200        lakehouse: Arc<LakehouseContext>,
201        view_factory: Arc<ViewFactory>,
202        part_provider: Arc<dyn QueryPartitionProvider>,
203        guard: Arc<AudienceGuard>,
204    ) -> Self {
205        let properties = PlanProperties::new(
206            EquivalenceProperties::new(schema.clone()),
207            Partitioning::UnknownPartitioning(1),
208            EmissionType::Final,
209            Boundedness::Bounded,
210        );
211        Self {
212            schema,
213            process_id,
214            process_uuid,
215            span_types,
216            query_range,
217            lakehouse,
218            view_factory,
219            part_provider,
220            guard,
221            authorized: None,
222            properties: Arc::new(properties),
223        }
224    }
225
226    /// Clones `self` with the witness set -- built by `scan` after `AudienceGuard::authorize`
227    /// succeeds. `ProcessSpansExecutionPlan` isn't `Clone` (its `Arc<Self>` handle can't be
228    /// mutated in place), so this is the explicit way to thread the witness from `scan` into a
229    /// fresh plan `execute` can trust.
230    fn with_authorized(&self, authorized: Authorized) -> Self {
231        Self {
232            schema: self.schema.clone(),
233            process_id: self.process_id.clone(),
234            process_uuid: self.process_uuid,
235            span_types: self.span_types,
236            query_range: self.query_range,
237            lakehouse: self.lakehouse.clone(),
238            view_factory: self.view_factory.clone(),
239            part_provider: self.part_provider.clone(),
240            guard: self.guard.clone(),
241            authorized: Some(authorized),
242            properties: self.properties.clone(),
243        }
244    }
245}
246
247impl Debug for ProcessSpansExecutionPlan {
248    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
249        f.debug_struct("ProcessSpansExecutionPlan")
250            .field("process_id", &self.process_id)
251            .field("span_types", &self.span_types)
252            .finish()
253    }
254}
255
256impl DisplayAs for ProcessSpansExecutionPlan {
257    fn fmt_as(&self, _t: DisplayFormatType, f: &mut fmt::Formatter) -> fmt::Result {
258        write!(
259            f,
260            "ProcessSpansExecutionPlan: process_id={}, span_types={:?}",
261            self.process_id, self.span_types
262        )
263    }
264}
265
266impl ExecutionPlan for ProcessSpansExecutionPlan {
267    fn name(&self) -> &str {
268        "ProcessSpansExecutionPlan"
269    }
270
271    fn schema(&self) -> SchemaRef {
272        self.schema.clone()
273    }
274
275    fn properties(&self) -> &Arc<PlanProperties> {
276        &self.properties
277    }
278
279    fn children(&self) -> Vec<&Arc<dyn ExecutionPlan>> {
280        vec![]
281    }
282
283    fn with_new_children(
284        self: Arc<Self>,
285        _children: Vec<Arc<dyn ExecutionPlan>>,
286    ) -> DFResult<Arc<dyn ExecutionPlan>> {
287        Ok(self)
288    }
289
290    #[span_fn]
291    fn execute(
292        &self,
293        _partition: usize,
294        _context: Arc<TaskContext>,
295    ) -> DFResult<SendableRecordBatchStream> {
296        let schema = self.schema.clone();
297        let stream_schema = schema.clone();
298        let process_id = self.process_id.clone();
299        let span_types = self.span_types;
300        let query_range = self.query_range;
301        let lakehouse = self.lakehouse.clone();
302        let view_factory = self.view_factory.clone();
303        let part_provider = self.part_provider.clone();
304        // No witness ⇒ this plan never went through `scan` (see `Self::authorized`'s doc
305        // comment) -- fail-closed by construction rather than by comment.
306        let Some(authorized) = &self.authorized else {
307            return Err(DataFusionError::Internal(
308                "process_spans: unauthorized plan (no witness from scan)".into(),
309            ));
310        };
311        let caller = authorized.internal_caller();
312
313        let record_batch_stream = try_stream! {
314            let schema = stream_schema;
315            // Runs under the witness's internal caller, not the caller's own scope: every SQL
316            // statement below is server-constructed and confined to the process id the guard
317            // just authorized (`process_streams::get_process_thread_list`, the `view_instance`
318            // calls further down) -- if that process is readable, everything these statements
319            // can reach is readable too. A deliberate deviation from naive scope inheritance.
320            let ctx = super::query::make_session_context(
321                lakehouse,
322                part_provider,
323                query_range,
324                view_factory,
325                Arc::new(NoOpSessionConfigurator),
326                caller,
327            )
328            .await
329            .map_err(|e| datafusion::error::DataFusionError::Internal(
330                format!("Failed to create session context: {e}"),
331            ))?;
332
333            // Thread spans
334            if matches!(span_types, SpanTypes::Thread | SpanTypes::Both) {
335                let threads = get_process_thread_list(&process_id, &ctx)
336                    .await
337                    .map_err(|e| datafusion::error::DataFusionError::Internal(
338                        format!("Failed to get thread list: {e}"),
339                    ))?;
340
341                let max_concurrent = std::thread::available_parallelism()
342                    .map(|n| n.get())
343                    .unwrap_or(4);
344
345                let queries: Vec<(String, String, String)> = threads
346                    .iter()
347                    .map(|(stream_id, _thread_id, display_name)| {
348                        let sql = format!(
349                            "SELECT * FROM view_instance('thread_spans', '{stream_id}')"
350                        );
351                        (stream_id.clone(), display_name.clone(), sql)
352                    })
353                    .collect();
354
355                let stream_results: Vec<(String, String, SendableRecordBatchStream)> =
356                    futures::stream::iter(queries)
357                        .map(|(stream_id, thread_name, sql)| {
358                            let ctx = ctx.clone();
359                            async move {
360                                spawn_with_context(async move {
361                                    let df = ctx.sql(&sql).await?;
362                                    let s = df.execute_stream().await?;
363                                    Ok::<_, anyhow::Error>((stream_id, thread_name, s))
364                                })
365                                .await?
366                            }
367                        })
368                        .buffered(max_concurrent)
369                        .try_collect()
370                        .await
371                        .map_err(|e| datafusion::error::DataFusionError::Internal(
372                            format!("Failed to query thread spans: {e}"),
373                        ))?;
374
375                for (stream_id, thread_name, mut data_stream) in stream_results {
376                    while let Some(batch) = data_stream.try_next().await? {
377                        let augmented = augment_batch(&batch, schema.clone(), &stream_id, &thread_name)?;
378                        yield augmented;
379                    }
380                }
381            }
382
383            // Async spans
384            if matches!(span_types, SpanTypes::Async | SpanTypes::Both) {
385                let async_sql = format!(
386                    "SELECT \
387                        b.span_id as id, \
388                        b.parent_span_id as parent, \
389                        b.depth, \
390                        b.hash, \
391                        b.time as \"begin\", \
392                        e.time as \"end\", \
393                        arrow_cast(e.time, 'Int64') - arrow_cast(b.time, 'Int64') as duration, \
394                        b.name, \
395                        b.target, \
396                        b.filename, \
397                        b.line \
398                    FROM (SELECT * FROM view_instance('async_events', '{process_id}') \
399                          WHERE event_type = 'begin') b \
400                    INNER JOIN (SELECT * FROM view_instance('async_events', '{process_id}') \
401                          WHERE event_type = 'end') e \
402                    ON b.span_id = e.span_id \
403                    WHERE b.time < e.time \
404                    ORDER BY b.time"
405                );
406
407                let df = ctx.sql(&async_sql).await
408                    .map_err(|e| datafusion::error::DataFusionError::Internal(
409                        format!("Failed to query async spans: {e}"),
410                    ))?;
411                let mut async_stream = df.execute_stream().await
412                    .map_err(|e| datafusion::error::DataFusionError::Internal(
413                        format!("Failed to execute async spans stream: {e}"),
414                    ))?;
415
416                while let Some(batch) = async_stream.try_next().await? {
417                    let augmented = augment_batch(&batch, schema.clone(), "", "async")?;
418                    yield augmented;
419                }
420            }
421        };
422
423        Ok(Box::pin(RecordBatchStreamAdapter::new(
424            schema,
425            record_batch_stream,
426        )))
427    }
428
429    fn apply_expressions(
430        &self,
431        _f: &mut dyn FnMut(
432            &Arc<dyn datafusion::physical_plan::PhysicalExpr>,
433        ) -> DFResult<datafusion::common::tree_node::TreeNodeRecursion>,
434    ) -> DFResult<datafusion::common::tree_node::TreeNodeRecursion> {
435        Ok(datafusion::common::tree_node::TreeNodeRecursion::Continue)
436    }
437}
438
439// --- TableProvider ---
440
441#[derive(Debug)]
442struct ProcessSpansTableProvider {
443    execution_plan: Arc<ProcessSpansExecutionPlan>,
444}
445
446#[async_trait::async_trait]
447impl TableProvider for ProcessSpansTableProvider {
448    fn schema(&self) -> SchemaRef {
449        self.execution_plan.schema()
450    }
451
452    fn table_type(&self) -> TableType {
453        TableType::Base
454    }
455
456    async fn scan(
457        &self,
458        _state: &dyn Session,
459        projection: Option<&Vec<usize>>,
460        _filters: &[Expr],
461        limit: Option<usize>,
462    ) -> DFResult<Arc<dyn ExecutionPlan>> {
463        let authorized = self
464            .execution_plan
465            .guard
466            .authorize(
467                self.execution_plan.process_uuid,
468                IdKind::Process,
469                "process_spans",
470            )
471            .await?;
472        let mut plan: Arc<dyn ExecutionPlan> =
473            Arc::new(self.execution_plan.with_authorized(authorized));
474        if let Some(projection) = projection {
475            let schema = plan.schema();
476            let projected_exprs: Vec<(Arc<dyn datafusion::physical_expr::PhysicalExpr>, String)> =
477                projection
478                    .iter()
479                    .map(|&i| {
480                        let name = schema.field(i).name().clone();
481                        let expr = Arc::new(datafusion::physical_expr::expressions::Column::new(
482                            &name, i,
483                        ))
484                            as Arc<dyn datafusion::physical_expr::PhysicalExpr>;
485                        (expr, name)
486                    })
487                    .collect();
488            plan = Arc::new(ProjectionExec::try_new(projected_exprs, plan)?);
489        }
490        if let Some(fetch) = limit {
491            plan = Arc::new(GlobalLimitExec::new(plan, 0, Some(fetch)));
492        }
493        Ok(plan)
494    }
495}