Skip to main content

micromegas_analytics/lakehouse/
thread_spans_view.rs

1use super::{
2    blocks_view::BlocksView,
3    dataframe_time_bounds::{DataFrameTimeBounds, NamedColumnsTimeBounds},
4    jit_partitions::{
5        BlockOrder, JitPartitionConfig, blocks_insert_time_range, generate_stream_jit_partitions,
6        group_contiguous_block_chains, is_jit_partition_up_to_date,
7    },
8    lakehouse_context::LakehouseContext,
9    partition_cache::PartitionCache,
10    partition_source_data::{SourceDataBlocksInMemory, hash_to_object_count},
11    partitioned_execution_plan::{OrderingBounds, ScanOrdering},
12    view::{PartitionSpec, ScanSortColumn, View, ViewMetadata},
13    view_factory::{ViewFactory, ViewMaker},
14};
15use crate::{
16    call_tree::make_call_tree,
17    dfext::typed_column::typed_column_by_name,
18    lakehouse::write_partition::{PartitionRowSet, RetireMatch, write_partition_from_rows},
19    metadata::{find_process_with_latest_timing, find_stream_from_view},
20    response_writer::ResponseWriter,
21    span_table::{SpanRecordBuilder, get_spans_schema},
22    time::{ConvertTicks, TimeRange, datetime_to_scalar, make_time_converter_from_latest_timing},
23};
24use anyhow::{Context, Result};
25use async_trait::async_trait;
26use chrono::{DateTime, Utc};
27use datafusion::arrow::array::TimestampNanosecondArray;
28use datafusion::arrow::record_batch::RecordBatch;
29use datafusion::logical_expr::{BinaryExpr, Expr, Operator};
30use datafusion::{arrow::datatypes::Schema, logical_expr::expr_fn::col};
31use micromegas_ingestion::data_lake_connection::DataLakeConnection;
32use micromegas_telemetry::{blob_storage::BlobStorage, types::block::BlockMetadata};
33use micromegas_tracing::prelude::*;
34use std::sync::Arc;
35use uuid::Uuid;
36
37const VIEW_SET_NAME: &str = "thread_spans";
38const SCHEMA_VERSION: u8 = 3;
39lazy_static::lazy_static! {
40    static ref MIN_TIME_COLUMN: Arc<String> = Arc::new( String::from("begin"));
41    static ref MAX_TIME_COLUMN: Arc<String> = Arc::new( String::from("end"));
42}
43
44/// A `ViewMaker` for creating `ThreadSpansView` instances.
45#[derive(Debug)]
46pub struct ThreadSpansViewMaker {
47    view_factory: Arc<ViewFactory>,
48}
49
50impl ThreadSpansViewMaker {
51    pub fn new(view_factory: Arc<ViewFactory>) -> Self {
52        Self { view_factory }
53    }
54}
55
56impl ViewMaker for ThreadSpansViewMaker {
57    fn make_view(&self, stream_id: &str) -> Result<Arc<dyn View>> {
58        Ok(Arc::new(ThreadSpansView::new(
59            stream_id,
60            self.view_factory.clone(),
61        )?))
62    }
63
64    fn get_schema_hash(&self) -> Vec<u8> {
65        vec![SCHEMA_VERSION]
66    }
67
68    fn get_schema(&self) -> Arc<Schema> {
69        Arc::new(get_spans_schema())
70    }
71}
72
73/// A view of thread spans.
74#[derive(Debug)]
75pub struct ThreadSpansView {
76    view_set_name: Arc<String>,
77    view_instance_id: Arc<String>,
78    stream_id: sqlx::types::Uuid,
79    view_factory: Arc<ViewFactory>,
80}
81
82impl ThreadSpansView {
83    pub fn new(view_instance_id: &str, view_factory: Arc<ViewFactory>) -> Result<Self> {
84        if view_instance_id == "global" {
85            anyhow::bail!("the global view is not implemented for thread spans");
86        }
87
88        Ok(Self {
89            view_set_name: Arc::new(String::from(VIEW_SET_NAME)),
90            view_instance_id: Arc::new(String::from(view_instance_id)),
91            stream_id: Uuid::parse_str(view_instance_id).with_context(|| "Uuid::parse_str")?,
92            view_factory,
93        })
94    }
95}
96
97#[span_fn]
98async fn append_call_tree(
99    record_builder: &mut SpanRecordBuilder,
100    convert_ticks: &ConvertTicks,
101    blocks: &[BlockMetadata],
102    blob_storage: Arc<BlobStorage>,
103    stream: &crate::metadata::StreamMetadata,
104) -> Result<()> {
105    let call_tree = make_call_tree(
106        blocks,
107        convert_ticks.delta_ticks_to_ns(blocks[0].begin_ticks),
108        convert_ticks.delta_ticks_to_ns(blocks[blocks.len() - 1].end_ticks),
109        None,
110        blob_storage,
111        convert_ticks.clone(),
112        stream,
113    )
114    .await
115    .with_context(|| "make_call_tree")?;
116    record_builder
117        .append_call_tree(&call_tree)
118        .with_context(|| "adding call tree to span record builder")?;
119    Ok(())
120}
121
122/// Verifies that `batch`'s `begin` column is non-decreasing, naming `stream_id` and the offending
123/// row/values in both the returned error and (unlike a plain `anyhow::ensure!`) an `error!` log
124/// line emitted immediately before it: nothing on `ensure_begin_non_decreasing`'s callers' error
125/// propagation path (`MaterializedView::scan` -> DataFusion planning -> the flight SQL service)
126/// logs a planning-time error at error level, so the check logs itself instead of relying on that
127/// propagation.
128///
129/// `pub`, not inlined into `write_partition`, so `rust/analytics/tests/` (an external integration
130/// crate that can only reach `pub` items) can call it directly with a hand-built batch.
131pub fn ensure_begin_non_decreasing(stream_id: &str, batch: &RecordBatch) -> Result<()> {
132    let begins: &TimestampNanosecondArray = typed_column_by_name(batch, "begin")?;
133    let mut previous: Option<i64> = None;
134    for i in 0..begins.len() {
135        let begin = begins.value(i);
136        if let Some(prev) = previous
137            && begin < prev
138        {
139            let msg = format!(
140                "thread_spans stream {stream_id}: begin regressed at row {i}: {begin} < {prev}"
141            );
142            error!("{msg}");
143            anyhow::bail!(msg);
144        }
145        previous = Some(begin);
146    }
147    Ok(())
148}
149
150/// Writes a partition from a set of blocks.
151#[span_fn]
152async fn write_partition(
153    lake: Arc<DataLakeConnection>,
154    view_meta: ViewMetadata,
155    schema: Arc<Schema>,
156    convert_ticks: &ConvertTicks,
157    spec: &SourceDataBlocksInMemory,
158    same_run_ranges: &[TimeRange],
159) -> Result<()> {
160    let nb_events = hash_to_object_count(&spec.block_ids_hash)? as usize;
161    info!("nb_events: {nb_events}");
162    if spec.blocks.is_empty() {
163        anyhow::bail!("empty partition spec");
164    }
165    // JIT partitions here are grouped under BlockOrder::EventTime (see jit_update below), so
166    // spec.blocks is event-time ordered, not insert-time ordered -- blocks_insert_time_range computes
167    // the real min/max rather than reading list endpoints.
168    let stream_id = spec.blocks[0].stream.stream_id.to_string();
169    let insert_range =
170        blocks_insert_time_range(&spec.blocks).with_context(|| "blocks_insert_time_range")?;
171
172    let (tx, rx) = tokio::sync::mpsc::channel(1);
173    let null_response_writer = Arc::new(ResponseWriter::new(None));
174    let join_handle = spawn_with_context(write_partition_from_rows(
175        lake.clone(),
176        view_meta,
177        schema,
178        insert_range,
179        spec.block_ids_hash.clone(),
180        None,
181        RetireMatch::Overlap,
182        same_run_ranges.to_vec(),
183        rx,
184        null_response_writer,
185    ));
186
187    let build_result: Result<PartitionRowSet> = async {
188        let mut record_builder = SpanRecordBuilder::with_capacity(nb_events / 2);
189        // One call tree per unbroken chain of blocks, so a span opened in one block and closed in
190        // the next is reconstructed whole; see `group_contiguous_block_chains`.
191        for chain in group_contiguous_block_chains(&spec.blocks) {
192            append_call_tree(
193                &mut record_builder,
194                convert_ticks,
195                &chain,
196                lake.blob_storage.clone(),
197                &spec.blocks[0].stream,
198            )
199            .await?;
200        }
201        let min_ticks = spec
202            .blocks
203            .iter()
204            .map(|b| b.block.begin_ticks)
205            .min()
206            .with_context(|| "empty partition spec")?;
207        let max_ticks = spec
208            .blocks
209            .iter()
210            .map(|b| b.block.end_ticks)
211            .max()
212            .with_context(|| "empty partition spec")?;
213        let min_time_row = convert_ticks.delta_ticks_to_time(min_ticks);
214        let max_time_row = convert_ticks.delta_ticks_to_time(max_ticks);
215        let rows = record_builder
216            .finish()
217            .with_context(|| "record_builder.finish()")?;
218        ensure_begin_non_decreasing(&stream_id, &rows)
219            .with_context(|| "ensure_begin_non_decreasing")?;
220        info!("writing {} rows", rows.num_rows());
221        // The true max `begin` is simply the last row's value: rows are verified
222        // non-decreasing on `begin` above, and one SpanRecordBuilder accumulates every chain
223        // into exactly one RecordBatch (see SpanRecordBuilder::finish), so "the last row" is the
224        // partition-global max with no per-chain scoping hole. Zero rows is reachable in
225        // practice (e.g. every event filtered out by the chain range, or a block carrying only
226        // async events), not just theoretically -- guard it rather than indexing an empty column.
227        let max_sort_key_time = if rows.num_rows() == 0 {
228            None
229        } else {
230            let begins: &TimestampNanosecondArray = typed_column_by_name(&rows, "begin")?;
231            Some(DateTime::from_timestamp_nanos(
232                begins.value(begins.len() - 1),
233            ))
234        };
235        Ok(PartitionRowSet {
236            rows_time_range: TimeRange::new(min_time_row, max_time_row),
237            rows,
238            max_sort_key_time,
239        })
240    }
241    .await;
242
243    match build_result {
244        Ok(row_set) => {
245            tx.send(Ok(row_set)).await?;
246            drop(tx);
247            join_handle.await??;
248            Ok(())
249        }
250        Err(e) => {
251            warn!(
252                "aborting thread-spans partition write for block {:?}: {e:?}",
253                spec.block_ids_hash
254            );
255            let _ = tx
256                .send(Err(anyhow::anyhow!("thread-spans build aborted")))
257                .await;
258            drop(tx);
259            match join_handle.await {
260                Ok(Ok(())) => {}
261                Ok(Err(writer_err)) => {
262                    debug!("thread-spans writer task error during abort: {writer_err:?}");
263                }
264                Err(join_err) => {
265                    warn!("thread-spans writer task panicked during abort: {join_err:?}");
266                }
267            }
268            Err(e)
269        }
270    }
271}
272/// Rebuilds the partition if it's missing or out of date.
273///
274/// `same_run_ranges` accumulates the insert ranges every partition this `jit_update` run has already
275/// handled -- written, or found already up to date -- earlier in its loop. This call reads it (to
276/// pass to `retire_partitions` via `RetireMatch::Overlap`, protecting those earlier partitions from
277/// this write) and appends `spec`'s own range before returning.
278///
279/// `pub` only so `thread_spans_ordering_db_test.rs` can write a single JIT partition directly, with
280/// exact boundaries `jit_update`'s loop wouldn't expose; `rust/analytics/tests/` compiles as an
281/// external crate and can only reach `pub` items. Not intended as API.
282/// (`net_spans_view::update_partition` stays private because its tests drive `retire_partitions`
283/// directly instead -- there is no Rust producer of `net` streams to push blocks through.)
284#[span_fn]
285pub async fn update_partition(
286    lake: Arc<DataLakeConnection>,
287    view_meta: ViewMetadata,
288    schema: Arc<Schema>,
289    convert_ticks: &ConvertTicks,
290    spec: &SourceDataBlocksInMemory,
291    same_run_ranges: &mut Vec<TimeRange>,
292) -> Result<()> {
293    let insert_range =
294        blocks_insert_time_range(&spec.blocks).with_context(|| "blocks_insert_time_range")?;
295    if is_jit_partition_up_to_date(
296        &lake.db_pool,
297        view_meta.clone(),
298        spec,
299        BlockOrder::EventTime,
300    )
301    .await?
302    {
303        same_run_ranges.push(insert_range);
304        return Ok(());
305    }
306    write_partition(
307        lake,
308        view_meta,
309        schema,
310        convert_ticks,
311        spec,
312        same_run_ranges.as_slice(),
313    )
314    .await
315    .with_context(|| "write_partition")?;
316    same_run_ranges.push(insert_range);
317
318    Ok(())
319}
320
321#[async_trait]
322impl View for ThreadSpansView {
323    fn get_view_set_name(&self) -> Arc<String> {
324        self.view_set_name.clone()
325    }
326
327    fn get_view_instance_id(&self) -> Arc<String> {
328        self.view_instance_id.clone()
329    }
330
331    async fn make_batch_partition_spec(
332        &self,
333        _lakehouse: Arc<LakehouseContext>,
334        _existing_partitions: Arc<PartitionCache>,
335        _insert_range: TimeRange,
336    ) -> Result<Arc<dyn PartitionSpec>> {
337        anyhow::bail!("not implemented")
338    }
339
340    fn get_file_schema_hash(&self) -> Vec<u8> {
341        vec![SCHEMA_VERSION]
342    }
343
344    fn get_file_schema(&self) -> Arc<Schema> {
345        Arc::new(get_spans_schema())
346    }
347
348    #[span_fn]
349    async fn jit_update(
350        &self,
351        lakehouse: Arc<LakehouseContext>,
352        query_range: Option<TimeRange>,
353    ) -> Result<()> {
354        let Some(query_range) = query_range else {
355            anyhow::bail!("query range mandatory for thread spans view");
356        };
357        let stream = Arc::new(
358            find_stream_from_view(
359                lakehouse.clone(),
360                self.view_factory.clone(),
361                &self.stream_id,
362                None,
363            )
364            .await
365            .with_context(|| "find_stream_from_view")?,
366        );
367        let (process, last_block_end_ticks, last_block_end_time) = find_process_with_latest_timing(
368            lakehouse.clone(),
369            self.view_factory.clone(),
370            &stream.process_id,
371            None,
372        )
373        .await
374        .with_context(|| "find_process_with_latest_timing")?;
375        let process = Arc::new(process);
376        let convert_ticks = make_time_converter_from_latest_timing(
377            &process,
378            last_block_end_ticks,
379            last_block_end_time,
380        )
381        .with_context(|| "make_time_converter_from_latest_timing")?;
382        let blocks_view = BlocksView::new(lakehouse.default_audience())?;
383        // ThreadSpansView builds cross-block call trees and declares ScanOrdering::Concatenated
384        // over `begin` (see get_scan_output_ordering below), so its JIT partitions must be
385        // event-time ordered, not insert-time ordered -- see BlockOrder::EventTime's docs.
386        let config = JitPartitionConfig {
387            block_order: BlockOrder::EventTime,
388            ..Default::default()
389        };
390        let partitions = generate_stream_jit_partitions(
391            &config,
392            lakehouse.clone(),
393            &blocks_view,
394            &query_range,
395            stream.clone(),
396            process.clone(),
397        )
398        .await
399        .with_context(|| "generate_stream_jit_partitions")?;
400        // Accumulates this run's own already-handled insert ranges across the loop, so a later
401        // partition's retire step never retires an earlier one from this same run -- see
402        // `update_partition`'s and `RetireMatch::Overlap`'s docs.
403        let mut same_run_ranges: Vec<TimeRange> = Vec::new();
404        for part in &partitions {
405            update_partition(
406                lakehouse.lake().clone(),
407                ViewMetadata {
408                    view_set_name: self.get_view_set_name(),
409                    view_instance_id: self.get_view_instance_id(),
410                    file_schema_hash: self.get_file_schema_hash(),
411                },
412                self.get_file_schema(),
413                &convert_ticks,
414                part,
415                &mut same_run_ranges,
416            )
417            .await
418            .with_context(|| "update_partition")?;
419        }
420        Ok(())
421    }
422
423    fn make_time_filter(&self, begin: DateTime<Utc>, end: DateTime<Utc>) -> Result<Vec<Expr>> {
424        Ok(vec![
425            Expr::BinaryExpr(BinaryExpr::new(
426                col("begin").into(),
427                Operator::LtEq,
428                Expr::Literal(datetime_to_scalar(end), None).into(),
429            )),
430            Expr::BinaryExpr(BinaryExpr::new(
431                col("end").into(),
432                Operator::GtEq,
433                Expr::Literal(datetime_to_scalar(begin), None).into(),
434            )),
435        ])
436    }
437
438    fn get_time_bounds(&self) -> Arc<dyn DataFrameTimeBounds> {
439        Arc::new(NamedColumnsTimeBounds::new(
440            MIN_TIME_COLUMN.clone(),
441            MAX_TIME_COLUMN.clone(),
442        ))
443    }
444
445    fn get_update_group(&self) -> Option<i32> {
446        None
447    }
448
449    fn get_scan_output_ordering(&self) -> ScanOrdering {
450        ScanOrdering::Concatenated {
451            columns: vec![ScanSortColumn {
452                column: MIN_TIME_COLUMN.clone(),
453                descending: false,
454            }],
455            bounds: OrderingBounds::EventTime,
456        }
457    }
458}