Skip to main content

micromegas_analytics/lakehouse/
perfetto_trace_execution_plan.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    read_scope::CallerContext,
7    session_configurator::NoOpSessionConfigurator,
8    view_factory::ViewFactory,
9};
10use crate::dfext::{
11    string_column_accessor::string_column_by_name, typed_column::typed_column_by_name,
12};
13use crate::time::TimeRange;
14use async_stream::stream;
15use datafusion::{
16    arrow::{
17        array::{RecordBatch, TimestampNanosecondArray, UInt32Array},
18        datatypes::SchemaRef,
19    },
20    catalog::{Session, TableProvider},
21    common::Result as DFResult,
22    error::DataFusionError,
23    execution::{SendableRecordBatchStream, TaskContext},
24    logical_expr::{Expr, TableType},
25    physical_expr::EquivalenceProperties,
26    physical_plan::{
27        DisplayAs, DisplayFormatType, ExecutionPlan, Partitioning, PlanProperties,
28        execution_plan::{Boundedness, EmissionType},
29        limit::GlobalLimitExec,
30        stream::RecordBatchStreamAdapter,
31    },
32};
33use futures::{StreamExt, TryStreamExt, stream};
34use micromegas_perfetto::{chunk_sender::ChunkSender, streaming_writer::PerfettoWriter};
35use micromegas_tracing::prelude::*;
36use std::{
37    fmt::{self, Debug, Formatter},
38    sync::Arc,
39};
40use uuid::Uuid;
41
42pub use super::process_spans_table_function::SpanTypes;
43
44/// Marker error for a process that could not be found. This is a caller error (the
45/// requested process id doesn't exist), not a server-side failure, so it must stay
46/// classified as `DataFusionError::Execution` while every other failure inside
47/// `generate_streaming_perfetto_trace` (session context, object store, DB, parquet
48/// reads, ...) is a genuine internal failure.
49#[derive(Debug, thiserror::Error)]
50#[error("Process {0} not found")]
51struct ProcessNotFoundError(String);
52
53/// Execution plan that generates Perfetto trace chunks
54pub struct PerfettoTraceExecutionPlan {
55    schema: SchemaRef,
56    process_id: String,
57    process_uuid: Uuid,
58    span_types: SpanTypes,
59    time_range: TimeRange,
60    lakehouse: Arc<LakehouseContext>,
61    view_factory: Arc<ViewFactory>,
62    part_provider: Arc<dyn QueryPartitionProvider>,
63    guard: Arc<AudienceGuard>,
64    /// Set only by [`PerfettoTraceTableProvider::scan`], after `AudienceGuard::authorize`
65    /// succeeds -- see `process_spans_table_function.rs`'s identical field for the full
66    /// rationale (fail-closed by construction, not by convention).
67    authorized: Option<Authorized>,
68    properties: Arc<PlanProperties>,
69}
70
71impl PerfettoTraceExecutionPlan {
72    #[allow(clippy::too_many_arguments)]
73    pub(crate) fn new(
74        schema: SchemaRef,
75        process_id: String,
76        process_uuid: Uuid,
77        span_types: SpanTypes,
78        time_range: TimeRange,
79        lakehouse: Arc<LakehouseContext>,
80        view_factory: Arc<ViewFactory>,
81        part_provider: Arc<dyn QueryPartitionProvider>,
82        guard: Arc<AudienceGuard>,
83    ) -> Self {
84        let properties = PlanProperties::new(
85            EquivalenceProperties::new(schema.clone()),
86            Partitioning::UnknownPartitioning(1),
87            EmissionType::Final,
88            Boundedness::Bounded,
89        );
90
91        Self {
92            schema,
93            process_id,
94            process_uuid,
95            span_types,
96            time_range,
97            lakehouse,
98            view_factory,
99            part_provider,
100            guard,
101            authorized: None,
102            properties: Arc::new(properties),
103        }
104    }
105
106    /// See `ProcessSpansExecutionPlan::with_authorized`'s identical doc comment.
107    fn with_authorized(&self, authorized: Authorized) -> Self {
108        Self {
109            schema: self.schema.clone(),
110            process_id: self.process_id.clone(),
111            process_uuid: self.process_uuid,
112            span_types: self.span_types,
113            time_range: self.time_range,
114            lakehouse: self.lakehouse.clone(),
115            view_factory: self.view_factory.clone(),
116            part_provider: self.part_provider.clone(),
117            guard: self.guard.clone(),
118            authorized: Some(authorized),
119            properties: self.properties.clone(),
120        }
121    }
122}
123
124impl Debug for PerfettoTraceExecutionPlan {
125    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
126        f.debug_struct("PerfettoTraceExecutionPlan")
127            .field("process_id", &self.process_id)
128            .field("span_types", &self.span_types)
129            .field("time_range", &self.time_range)
130            .finish()
131    }
132}
133
134impl DisplayAs for PerfettoTraceExecutionPlan {
135    fn fmt_as(&self, _t: DisplayFormatType, f: &mut fmt::Formatter) -> fmt::Result {
136        write!(
137            f,
138            "PerfettoTraceExecutionPlan: process_id={}, span_types={:?}, time_range={}..{}",
139            self.process_id, self.span_types, self.time_range.begin, self.time_range.end
140        )
141    }
142}
143
144impl ExecutionPlan for PerfettoTraceExecutionPlan {
145    fn name(&self) -> &str {
146        "PerfettoTraceExecutionPlan"
147    }
148
149    fn schema(&self) -> SchemaRef {
150        self.schema.clone()
151    }
152
153    fn properties(&self) -> &Arc<PlanProperties> {
154        &self.properties
155    }
156
157    fn children(&self) -> Vec<&Arc<dyn ExecutionPlan>> {
158        vec![]
159    }
160
161    fn with_new_children(
162        self: Arc<Self>,
163        _children: Vec<Arc<dyn ExecutionPlan>>,
164    ) -> DFResult<Arc<dyn ExecutionPlan>> {
165        Ok(self)
166    }
167
168    #[span_fn]
169    fn execute(
170        &self,
171        _partition: usize,
172        _context: Arc<TaskContext>,
173    ) -> DFResult<SendableRecordBatchStream> {
174        let schema = self.schema.clone();
175        let process_id = self.process_id.clone();
176        let span_types = self.span_types;
177        let time_range = self.time_range;
178        let lakehouse = self.lakehouse.clone();
179        let view_factory = self.view_factory.clone();
180        let part_provider = self.part_provider.clone();
181        // No witness ⇒ this plan never went through `scan` -- fail-closed by construction.
182        let Some(authorized) = &self.authorized else {
183            return Err(DataFusionError::Internal(
184                "perfetto_trace_chunks: unauthorized plan (no witness from scan)".into(),
185            ));
186        };
187        let caller = authorized.internal_caller();
188
189        // Create the stream directly without channels
190        let stream = generate_perfetto_trace_stream(
191            process_id,
192            span_types,
193            time_range,
194            lakehouse,
195            view_factory,
196            part_provider,
197            caller,
198        );
199
200        Ok(Box::pin(RecordBatchStreamAdapter::new(schema, stream)))
201    }
202
203    fn apply_expressions(
204        &self,
205        _f: &mut dyn FnMut(
206            &Arc<dyn datafusion::physical_plan::PhysicalExpr>,
207        ) -> DFResult<datafusion::common::tree_node::TreeNodeRecursion>,
208    ) -> DFResult<datafusion::common::tree_node::TreeNodeRecursion> {
209        Ok(datafusion::common::tree_node::TreeNodeRecursion::Continue)
210    }
211}
212
213/// Creates a stream of Perfetto trace chunks using streaming architecture
214#[span_fn]
215fn generate_perfetto_trace_stream(
216    process_id: String,
217    span_types: SpanTypes,
218    time_range: TimeRange,
219    lakehouse: Arc<LakehouseContext>,
220    view_factory: Arc<ViewFactory>,
221    part_provider: Arc<dyn QueryPartitionProvider>,
222    caller: CallerContext,
223) -> impl futures::Stream<Item = DFResult<RecordBatch>> {
224    stream! {
225        // Create channel for streaming chunks
226        const CHUNK_SIZE: usize = 8 * 1024; // 8KB chunks
227        let (chunk_sender, mut chunk_receiver) = tokio::sync::mpsc::channel(16);
228
229        // Create ChunkSender that will stream data through the channel
230        let chunk_sender_writer = ChunkSender::new(chunk_sender, CHUNK_SIZE);
231
232        // Spawn background task to generate trace
233        let generation_task = spawn_with_context(async move {
234            generate_streaming_perfetto_trace(
235                chunk_sender_writer,
236                process_id,
237                span_types,
238                time_range,
239                lakehouse,
240                view_factory,
241                part_provider,
242                caller,
243            ).await
244        });
245
246        // Stream chunks as they become available
247        while let Some(chunk_result) = chunk_receiver.recv().await {
248            match chunk_result {
249                Ok(batch) => yield Ok(batch),
250                Err(e) => {
251                    // ChunkSender only ever pushes `Ok(batch)` into this channel, so
252                    // reaching this branch means the channel itself misbehaved - an
253                    // internal failure, never a caller mistake.
254                    error!("Error in chunk generation: {:?}", e);
255                    yield Err(datafusion::error::DataFusionError::Internal(
256                        format!("Chunk generation failed: {}", e)
257                    ));
258                    return;
259                }
260            }
261        }
262
263        // Wait for generation task to complete and check for errors
264        match generation_task.await {
265            Ok(Ok(())) => {}, // Success
266            Ok(Err(e)) => {
267                if e.downcast_ref::<ProcessNotFoundError>().is_some() {
268                    // Caller asked for a process id that doesn't exist.
269                    warn!("Trace generation failed: {:?}", e);
270                    yield Err(datafusion::error::DataFusionError::Execution(
271                        format!("Trace generation failed: {}", e)
272                    ));
273                } else {
274                    // Everything else (session context, object store, DB, parquet
275                    // reads, ...) is a genuine server-side failure.
276                    error!("Trace generation failed: {:?}", e);
277                    yield Err(datafusion::error::DataFusionError::Internal(
278                        format!("Trace generation failed: {}", e)
279                    ));
280                }
281            }
282            Err(e) => {
283                error!("Task panicked: {:?}", e);
284                yield Err(datafusion::error::DataFusionError::Internal(
285                    format!("Task panicked: {}", e)
286                ));
287            }
288        }
289    }
290}
291
292/// Generate Perfetto trace using streaming architecture
293#[allow(clippy::too_many_arguments)]
294async fn generate_streaming_perfetto_trace(
295    chunk_sender: ChunkSender,
296    process_id: String,
297    span_types: SpanTypes,
298    time_range: TimeRange,
299    lakehouse: Arc<LakehouseContext>,
300    view_factory: Arc<ViewFactory>,
301    part_provider: Arc<dyn QueryPartitionProvider>,
302    caller: CallerContext,
303) -> anyhow::Result<()> {
304    info!(
305        "Generating streaming Perfetto trace for process {} with span types {:?} from {} to {}",
306        process_id, span_types, time_range.begin, time_range.end
307    );
308
309    // Runs under the witness's internal caller (`caller`, threaded in from `execute`), not the
310    // caller's own scope: every SQL statement below is server-constructed and confined to the
311    // process id the guard already authorized (`get_process_exe`, `get_process_thread_list`, the
312    // `view_instance` calls further down) -- if that process is readable, everything these
313    // statements can reach is readable too. A deliberate deviation from naive scope inheritance.
314    let ctx = super::query::make_session_context(
315        lakehouse,
316        part_provider,
317        Some(TimeRange {
318            begin: time_range.begin,
319            end: time_range.end,
320        }),
321        view_factory,
322        Arc::new(NoOpSessionConfigurator),
323        caller,
324    )
325    .await?;
326
327    // Use ChunkSender directly as the writer destination
328    let mut writer = PerfettoWriter::new(Box::new(chunk_sender), &process_id);
329
330    let process_exe = get_process_exe(&process_id, &ctx).await?;
331    writer.emit_process_descriptor(&process_exe).await?;
332    writer.flush().await?; // Forces chunk emission
333
334    let threads = get_process_thread_list(&process_id, &ctx).await?;
335    for (stream_id, thread_id, thread_name) in &threads {
336        writer
337            .emit_thread_descriptor(stream_id, *thread_id, thread_name)
338            .await?;
339    }
340    if !threads.is_empty() {
341        writer.flush().await?; // Forces chunk emission
342    }
343
344    if matches!(span_types, SpanTypes::Async | SpanTypes::Both) {
345        writer.emit_async_track_descriptor().await?;
346        writer.flush().await?; // Forces chunk emission
347    }
348
349    if matches!(span_types, SpanTypes::Thread | SpanTypes::Both) {
350        generate_thread_spans_with_writer(&mut writer, &ctx, &time_range, &threads).await?;
351    }
352
353    if matches!(span_types, SpanTypes::Async | SpanTypes::Both) {
354        generate_async_spans_with_writer(&mut writer, &process_id, &ctx, &time_range).await?;
355    }
356
357    writer.flush().await?; // Final chunk - this handles the chunk_sender.flush() internally
358    Ok(())
359}
360
361/// Get process executable name from the processes table
362async fn get_process_exe(
363    process_id: &str,
364    ctx: &datafusion::execution::context::SessionContext,
365) -> anyhow::Result<String> {
366    let sql = format!(
367        r#"
368        SELECT exe
369        FROM processes
370        WHERE process_id = '{}'
371        LIMIT 1
372        "#,
373        process_id
374    );
375
376    let df = ctx.sql(&sql).await?;
377    let batches = df.collect().await?;
378
379    if batches.is_empty() || batches[0].num_rows() == 0 {
380        return Err(ProcessNotFoundError(process_id.to_owned()).into());
381    }
382
383    let exes = string_column_by_name(&batches[0], "exe")?;
384    Ok(exes.value(0)?.to_owned())
385}
386
387/// Format the SQL query for thread spans
388fn format_thread_spans_query(stream_id: &str, time_range: &TimeRange) -> String {
389    format!(
390        r#"
391        SELECT "begin", "end", name, filename, target, line
392        FROM view_instance('thread_spans', '{}')
393        WHERE begin <= TIMESTAMP '{}'
394          AND end >= TIMESTAMP '{}'
395        ORDER BY begin
396        "#,
397        stream_id,
398        time_range.end.to_rfc3339(),
399        time_range.begin.to_rfc3339()
400    )
401}
402
403/// Generate thread spans with parallel JIT and sequential writing.
404///
405/// JIT partition locking is per-(view_set_name, view_instance_id), and each thread
406/// has a unique stream_id used as view_instance_id. This means different threads
407/// get different lock keys, making parallel JIT safe.
408///
409/// Strategy:
410/// - Spawn tasks with spawn_with_context() for true multi-threaded parallelism
411/// - Use buffered() to limit concurrent spawned tasks
412/// - Collect all streams preserving order
413/// - Consume streams sequentially to write each thread's spans together
414async fn generate_thread_spans_with_writer(
415    writer: &mut PerfettoWriter,
416    ctx: &datafusion::execution::context::SessionContext,
417    time_range: &TimeRange,
418    threads: &[(String, i32, String)],
419) -> anyhow::Result<()> {
420    let max_concurrent = std::thread::available_parallelism()
421        .map(|n| n.get())
422        .unwrap_or(4);
423
424    // Prepare query inputs upfront
425    let queries: Vec<(String, String)> = threads
426        .iter()
427        .map(|(stream_id, _, _)| {
428            (
429                stream_id.clone(),
430                format_thread_spans_query(stream_id, time_range),
431            )
432        })
433        .collect();
434
435    // Build streams in parallel using spawn for true multi-threading
436    let streams: Vec<(String, SendableRecordBatchStream)> = stream::iter(queries)
437        .map(|(stream_id, sql)| {
438            let ctx = ctx.clone();
439            async move {
440                spawn_with_context(async move {
441                    let df = ctx.sql(&sql).await?;
442                    let stream = df.execute_stream().await?;
443                    Ok::<_, anyhow::Error>((stream_id, stream))
444                })
445                .await?
446            }
447        })
448        .buffered(max_concurrent)
449        .try_collect()
450        .await?;
451
452    // Consume streams sequentially - each thread's spans written together
453    for (stream_id, data_stream) in streams {
454        write_thread_spans(writer, &stream_id, data_stream).await?;
455    }
456    Ok(())
457}
458
459/// Writes one thread's spans, in row order, from its query result stream to the Perfetto writer.
460///
461/// `begin` is only guaranteed sorted *within* a single thread's stream (different threads are
462/// independent timelines), so the monotonicity tracker below is local to this call. It is the
463/// runtime backstop for the ordering `ThreadSpansView::get_scan_output_ordering` declares to
464/// DataFusion: once the redundant `Sort` node is elided, DataFusion trusts that declared ordering
465/// and never re-validates it against the actual rows, so a violated invariant would otherwise
466/// silently mis-order the exported trace. Errors instead of writing a row whose `begin` regresses.
467pub async fn write_thread_spans(
468    writer: &mut PerfettoWriter,
469    stream_id: &str,
470    mut data_stream: SendableRecordBatchStream,
471) -> anyhow::Result<()> {
472    writer.set_current_thread(stream_id);
473
474    let mut previous_begin_ns: Option<i64> = None;
475    let mut span_count = 0;
476    while let Some(batch_result) = data_stream.next().await {
477        let batch = batch_result?;
478        let begin_times: &TimestampNanosecondArray = typed_column_by_name(&batch, "begin")?;
479        let end_times: &TimestampNanosecondArray = typed_column_by_name(&batch, "end")?;
480        let names = string_column_by_name(&batch, "name")?;
481        let filenames = string_column_by_name(&batch, "filename")?;
482        let targets = string_column_by_name(&batch, "target")?;
483        let lines: &UInt32Array = typed_column_by_name(&batch, "line")?;
484
485        for i in 0..batch.num_rows() {
486            let begin_time = begin_times.value(i);
487            if let Some(previous) = previous_begin_ns
488                && begin_time < previous
489            {
490                anyhow::bail!(
491                    "thread spans out of order for stream {stream_id}: begin {begin_time} follows {previous}"
492                );
493            }
494            previous_begin_ns = Some(begin_time);
495
496            let begin_ns = begin_time as u64;
497            let end_ns = end_times.value(i) as u64;
498            let name = names.value(i)?;
499            let filename = filenames.value(i)?;
500            let target = targets.value(i)?;
501            let line = lines.value(i);
502
503            writer
504                .emit_span(begin_ns, end_ns, name, target, filename, line)
505                .await?;
506
507            span_count += 1;
508            if span_count % 10 == 0 {
509                writer.flush().await?;
510            }
511        }
512    }
513    Ok(())
514}
515
516/// Generate async spans using the provided PerfettoWriter
517async fn generate_async_spans_with_writer(
518    writer: &mut PerfettoWriter,
519    process_id: &str,
520    ctx: &datafusion::execution::context::SessionContext,
521    time_range: &TimeRange,
522) -> anyhow::Result<()> {
523    let sql = format!(
524        r#"
525        WITH begin_events AS (
526            SELECT span_id, time as begin_time, name, filename, target, line
527            FROM view_instance('async_events', '{}')
528            WHERE time >= TIMESTAMP '{}'
529              AND time <= TIMESTAMP '{}'
530              AND event_type = 'begin'
531        ),
532        end_events AS (
533            SELECT span_id, time as end_time
534            FROM view_instance('async_events', '{}')
535            WHERE time >= TIMESTAMP '{}'
536              AND time <= TIMESTAMP '{}'
537              AND event_type = 'end'
538        )
539        SELECT 
540            b.span_id,
541            b.begin_time,
542            e.end_time,
543            b.name,
544            b.filename,
545            b.target,
546            b.line
547        FROM begin_events b
548        INNER JOIN end_events e ON b.span_id = e.span_id
549        ORDER BY b.begin_time
550        "#,
551        process_id,
552        time_range.begin.to_rfc3339(),
553        time_range.end.to_rfc3339(),
554        process_id,
555        time_range.begin.to_rfc3339(),
556        time_range.end.to_rfc3339(),
557    );
558
559    let df = ctx.sql(&sql).await?;
560    let mut stream = df.execute_stream().await?;
561
562    let mut span_count = 0;
563    while let Some(batch_result) = stream.next().await {
564        let batch = batch_result?;
565        let span_ids: &datafusion::arrow::array::Int64Array =
566            typed_column_by_name(&batch, "span_id")?;
567        let begin_times: &TimestampNanosecondArray = typed_column_by_name(&batch, "begin_time")?;
568        let end_times: &TimestampNanosecondArray = typed_column_by_name(&batch, "end_time")?;
569        let names = string_column_by_name(&batch, "name")?;
570        let filenames = string_column_by_name(&batch, "filename")?;
571        let targets = string_column_by_name(&batch, "target")?;
572        let lines: &UInt32Array = typed_column_by_name(&batch, "line")?;
573        for i in 0..batch.num_rows() {
574            let _span_id = span_ids.value(i);
575            let begin_ns = begin_times.value(i) as u64;
576            let end_ns = end_times.value(i) as u64;
577            let name = names.value(i)?;
578            let filename = filenames.value(i)?;
579            let target = targets.value(i)?;
580            let line = lines.value(i);
581
582            if begin_ns < end_ns {
583                // Emit async span begin and end events with single writer
584                writer
585                    .emit_async_span_begin(begin_ns, name, target, filename, line)
586                    .await?;
587                writer
588                    .emit_async_span_end(end_ns, name, target, filename, line)
589                    .await?;
590
591                span_count += 1;
592                // Flush every 10 async spans to create multiple chunks
593                if span_count % 10 == 0 {
594                    writer.flush().await?;
595                }
596            } else {
597                warn!("Skipping async span with invalid duration");
598            }
599        }
600    }
601
602    Ok(())
603}
604
605/// TableProvider wrapper for PerfettoTraceExecutionPlan
606#[derive(Debug)]
607pub struct PerfettoTraceTableProvider {
608    execution_plan: Arc<PerfettoTraceExecutionPlan>,
609}
610
611impl PerfettoTraceTableProvider {
612    /// `pub(crate)`, not `pub`: only ever called from `perfetto_trace_table_function.rs`, same
613    /// crate. So an external test crate can't build an un-authorized plan directly, matching
614    /// `process_spans`' existing module-private shape.
615    pub(crate) fn new(execution_plan: Arc<PerfettoTraceExecutionPlan>) -> Self {
616        Self { execution_plan }
617    }
618}
619
620#[async_trait::async_trait]
621impl TableProvider for PerfettoTraceTableProvider {
622    fn schema(&self) -> SchemaRef {
623        self.execution_plan.schema()
624    }
625
626    fn table_type(&self) -> TableType {
627        TableType::Base
628    }
629
630    async fn scan(
631        &self,
632        _state: &dyn Session,
633        _projection: Option<&Vec<usize>>,
634        _filters: &[Expr],
635        limit: Option<usize>,
636    ) -> DFResult<Arc<dyn ExecutionPlan>> {
637        let authorized = self
638            .execution_plan
639            .guard
640            .authorize(
641                self.execution_plan.process_uuid,
642                IdKind::Process,
643                "perfetto_trace_chunks",
644            )
645            .await?;
646        // Wrap the execution plan in a GlobalLimitExec if a limit is provided.
647        // DataFusion trusts us to apply the limit - if we ignore it, too many rows
648        // will be returned to the client.
649        let plan: Arc<dyn ExecutionPlan> =
650            Arc::new(self.execution_plan.with_authorized(authorized));
651        if let Some(fetch) = limit {
652            Ok(Arc::new(GlobalLimitExec::new(plan, 0, Some(fetch))))
653        } else {
654            Ok(plan)
655        }
656    }
657}