Skip to main content

micromegas_analytics/lakehouse/
partition_cache.rs

1use crate::time::TimeRange;
2
3use super::{partition::Partition, view::ViewMetadata};
4use anyhow::{Context, Result};
5use async_trait::async_trait;
6use chrono::{DateTime, Utc};
7use micromegas_tracing::prelude::*;
8use sqlx::{PgPool, Row};
9use std::{fmt, sync::Arc};
10
11/// A trait for providing queryable partitions.
12#[async_trait]
13pub trait QueryPartitionProvider: std::fmt::Display + Send + Sync + std::fmt::Debug {
14    /// Fetches partitions based on the provided criteria.
15    async fn fetch(
16        &self,
17        view_set_name: &str,
18        view_instance_id: &str,
19        query_range: Option<TimeRange>,
20        file_schema_hash: Vec<u8>,
21    ) -> Result<Vec<Partition>>;
22}
23
24/// PartitionCache allows to query partitions based on the insert_time range
25#[derive(Debug)]
26pub struct PartitionCache {
27    pub partitions: Vec<Partition>,
28    insert_range: TimeRange,
29}
30
31impl fmt::Display for PartitionCache {
32    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
33        write!(f, "{self:?}")
34    }
35}
36
37impl PartitionCache {
38    /// Builds an empty cache for `insert_range`, without querying Postgres. Used by
39    /// offline/no-DB tests that need a `PartitionCache` handle to satisfy an API (e.g. session
40    /// context registration) but have no other view's partitions to supply.
41    pub fn empty(insert_range: TimeRange) -> Self {
42        Self {
43            partitions: vec![],
44            insert_range,
45        }
46    }
47
48    pub fn len(&self) -> usize {
49        self.partitions.len()
50    }
51
52    pub fn is_empty(&self) -> bool {
53        self.partitions.is_empty()
54    }
55
56    /// fetches the partitions of all views matching the specified insert range
57    //todo: this should be limited to global instances
58    //todo: ask for a list of view sets (which would be provided by the views using a get_dependencies() api entry)
59    #[span_fn]
60    pub async fn fetch_overlapping_insert_range(
61        pool: &sqlx::PgPool,
62        insert_range: TimeRange,
63    ) -> Result<Self> {
64        let rows = instrument_named!(
65            sqlx::query(
66                "SELECT view_set_name,
67                    view_instance_id,
68                    begin_insert_time,
69                    end_insert_time,
70                    min_event_time,
71                    max_event_time,
72                    updated,
73                    file_path,
74                    file_size,
75                    file_schema_hash,
76                    source_data_hash,
77                    num_rows,
78                    sort_order,
79                    max_sort_key_time
80             FROM lakehouse_partitions
81             WHERE begin_insert_time < $1
82             AND end_insert_time > $2
83             ORDER BY begin_insert_time, file_path
84             ;",
85            )
86            .bind(insert_range.end)
87            .bind(insert_range.begin)
88            .fetch_all(pool),
89            "sql_select_overlapping_partitions"
90        )
91        .await
92        .with_context(|| "fetching partitions")?;
93        let mut partitions = vec![];
94        for r in rows {
95            let view_metadata = ViewMetadata {
96                view_set_name: Arc::new(r.try_get("view_set_name")?),
97                view_instance_id: Arc::new(r.try_get("view_instance_id")?),
98                file_schema_hash: r.try_get("file_schema_hash")?,
99            };
100            // file_metadata will be loaded on-demand when needed
101            let insert_time_range = TimeRange {
102                begin: r.try_get("begin_insert_time")?,
103                end: r.try_get("end_insert_time")?,
104            };
105            let event_time_range = match (
106                r.try_get::<DateTime<Utc>, _>("min_event_time").ok(),
107                r.try_get::<DateTime<Utc>, _>("max_event_time").ok(),
108            ) {
109                (Some(begin), Some(end)) => Some(TimeRange { begin, end }),
110                (None, None) => None, // Empty partition - both NULL
111                (Some(_), None) | (None, Some(_)) => {
112                    anyhow::bail!(
113                        "Corrupt partition record: only one of min/max_event_time is NULL"
114                    );
115                }
116            };
117            let partition = Partition {
118                view_metadata,
119                insert_time_range,
120                event_time_range,
121                updated: r.try_get("updated")?,
122                file_path: r.try_get::<String, _>("file_path").ok(),
123                file_size: r.try_get("file_size")?,
124                source_data_hash: r.try_get("source_data_hash")?,
125                num_rows: r.try_get("num_rows")?,
126                sort_order: r.try_get("sort_order")?,
127                max_sort_key_time: r.try_get("max_sort_key_time")?,
128            };
129            partition
130                .validate()
131                .with_context(|| "validating partition from database")?;
132            partitions.push(partition);
133        }
134        Ok(Self {
135            partitions,
136            insert_range,
137        })
138    }
139
140    /// fetches the partitions of a single view instance matching the specified insert range
141    #[span_fn]
142    pub async fn fetch_overlapping_insert_range_for_view(
143        pool: &sqlx::PgPool,
144        view_set_name: Arc<String>,
145        view_instance_id: Arc<String>,
146        insert_range: TimeRange,
147    ) -> Result<Self> {
148        let rows = instrument_named!(
149            sqlx::query(
150                "SELECT begin_insert_time,
151                    end_insert_time,
152                    min_event_time,
153                    max_event_time,
154                    updated,
155                    file_path,
156                    file_size,
157                    file_schema_hash,
158                    source_data_hash,
159                    num_rows,
160                    sort_order,
161                    max_sort_key_time
162             FROM lakehouse_partitions
163             WHERE begin_insert_time < $1
164             AND end_insert_time > $2
165             AND view_set_name = $3
166             AND view_instance_id = $4
167             ORDER BY begin_insert_time, file_path
168             ;",
169            )
170            .bind(insert_range.end)
171            .bind(insert_range.begin)
172            .bind(&*view_set_name)
173            .bind(&*view_instance_id)
174            .fetch_all(pool),
175            "sql_select_overlapping_partitions_for_view"
176        )
177        .await
178        .with_context(|| "fetching partitions")?;
179        let mut partitions = vec![];
180        for r in rows {
181            let view_metadata = ViewMetadata {
182                view_set_name: view_set_name.clone(),
183                view_instance_id: view_instance_id.clone(),
184                file_schema_hash: r.try_get("file_schema_hash")?,
185            };
186            // file_metadata will be loaded on-demand when needed
187            let insert_time_range = TimeRange {
188                begin: r.try_get("begin_insert_time")?,
189                end: r.try_get("end_insert_time")?,
190            };
191            let event_time_range = match (
192                r.try_get::<DateTime<Utc>, _>("min_event_time").ok(),
193                r.try_get::<DateTime<Utc>, _>("max_event_time").ok(),
194            ) {
195                (Some(begin), Some(end)) => Some(TimeRange { begin, end }),
196                (None, None) => None, // Empty partition - both NULL
197                (Some(_), None) | (None, Some(_)) => {
198                    anyhow::bail!(
199                        "Corrupt partition record: only one of min/max_event_time is NULL"
200                    );
201                }
202            };
203            let partition = Partition {
204                view_metadata,
205                insert_time_range,
206                event_time_range,
207                updated: r.try_get("updated")?,
208                file_path: r.try_get::<String, _>("file_path").ok(),
209                file_size: r.try_get("file_size")?,
210                source_data_hash: r.try_get("source_data_hash")?,
211                num_rows: r.try_get("num_rows")?,
212                sort_order: r.try_get("sort_order")?,
213                max_sort_key_time: r.try_get("max_sort_key_time")?,
214            };
215            partition
216                .validate()
217                .with_context(|| "validating partition from database")?;
218            partitions.push(partition);
219        }
220        Ok(Self {
221            partitions,
222            insert_range,
223        })
224    }
225
226    // overlap test for a specific view
227    pub fn filter(
228        &self,
229        view_set_name: &str,
230        view_instance_id: &str,
231        file_schema_hash: &[u8],
232        insert_range: TimeRange,
233    ) -> Self {
234        let mut partitions = vec![];
235        for part in &self.partitions {
236            if *part.view_metadata.view_set_name == view_set_name
237                && *part.view_metadata.view_instance_id == view_instance_id
238                && part.view_metadata.file_schema_hash == file_schema_hash
239                && part.begin_insert_time() < insert_range.end
240                && part.end_insert_time() > insert_range.begin
241            {
242                partitions.push(part.clone());
243            }
244        }
245        Self {
246            partitions,
247            insert_range,
248        }
249    }
250
251    // overlap test for a all views
252    pub fn filter_insert_range(&self, insert_range: TimeRange) -> Self {
253        let mut partitions = vec![];
254        for part in &self.partitions {
255            if part.begin_insert_time() < insert_range.end
256                && part.end_insert_time() > insert_range.begin
257            {
258                partitions.push(part.clone());
259            }
260        }
261        Self {
262            partitions,
263            insert_range,
264        }
265    }
266
267    // single view that fits completely in the specified range
268    pub fn filter_inside_range(
269        &self,
270        view_set_name: &str,
271        view_instance_id: &str,
272        insert_range: TimeRange,
273    ) -> Self {
274        let mut partitions = vec![];
275        for part in &self.partitions {
276            if *part.view_metadata.view_set_name == view_set_name
277                && *part.view_metadata.view_instance_id == view_instance_id
278                && part.begin_insert_time() >= insert_range.begin
279                && part.end_insert_time() <= insert_range.end
280            {
281                partitions.push(part.clone());
282            }
283        }
284        Self {
285            partitions,
286            insert_range,
287        }
288    }
289}
290
291#[async_trait]
292impl QueryPartitionProvider for PartitionCache {
293    /// unlike LivePartitionProvider, the query_range is tested against the insertion time, not the event time
294    #[span_fn]
295    async fn fetch(
296        &self,
297        view_set_name: &str,
298        view_instance_id: &str,
299        query_range: Option<TimeRange>,
300        file_schema_hash: Vec<u8>,
301    ) -> Result<Vec<Partition>> {
302        let mut partitions = vec![];
303        if let Some(range) = query_range {
304            if range.begin < self.insert_range.begin || range.end > self.insert_range.end {
305                anyhow::bail!("filtering from a result set that's not large enough");
306            }
307            for part in &self.partitions {
308                if *part.view_metadata.view_set_name == view_set_name
309                    && *part.view_metadata.view_instance_id == view_instance_id
310                    && part.begin_insert_time() < range.end
311                    && part.end_insert_time() > range.begin
312                    && part.view_metadata.file_schema_hash == file_schema_hash
313                {
314                    partitions.push(part.clone());
315                }
316            }
317        } else {
318            for part in &self.partitions {
319                if *part.view_metadata.view_set_name == view_set_name
320                    && *part.view_metadata.view_instance_id == view_instance_id
321                    && part.view_metadata.file_schema_hash == file_schema_hash
322                {
323                    partitions.push(part.clone());
324                }
325            }
326        }
327        Ok(partitions)
328    }
329}
330
331/// A `QueryPartitionProvider` that fetches partitions directly from the database.
332#[derive(Debug)]
333pub struct LivePartitionProvider {
334    db_pool: PgPool,
335}
336
337impl fmt::Display for LivePartitionProvider {
338    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
339        write!(f, "{self:?}")
340    }
341}
342
343impl LivePartitionProvider {
344    pub fn new(db_pool: PgPool) -> Self {
345        Self { db_pool }
346    }
347}
348
349#[async_trait]
350impl QueryPartitionProvider for LivePartitionProvider {
351    #[span_fn]
352    async fn fetch(
353        &self,
354        view_set_name: &str,
355        view_instance_id: &str,
356        query_range: Option<TimeRange>,
357        file_schema_hash: Vec<u8>,
358    ) -> Result<Vec<Partition>> {
359        let mut partitions = vec![];
360        let rows = if let Some(range) = query_range {
361            instrument_named!(
362                sqlx::query(
363                    "SELECT view_set_name,
364                    view_instance_id,
365                    begin_insert_time,
366                    end_insert_time,
367                    min_event_time,
368                    max_event_time,
369                    updated,
370                    file_path,
371                    file_size,
372                    file_schema_hash,
373                    source_data_hash,
374                    num_rows,
375                    sort_order,
376                    max_sort_key_time
377             FROM lakehouse_partitions
378             WHERE view_set_name = $1
379             AND view_instance_id = $2
380             -- Pruning must keep comparing max_event_time here, not max_sort_key_time: it's the
381             -- max span *end*, the only bound that conservatively covers a span whose `begin`
382             -- precedes the query range but whose `end` reaches into it. Narrowing this to
383             -- max_sort_key_time would silently prune partitions that hold matching rows.
384             AND min_event_time <= $3
385             AND max_event_time >= $4
386             AND file_schema_hash = $5
387             ORDER BY begin_insert_time, file_path
388             ;",
389                )
390                .bind(view_set_name)
391                .bind(view_instance_id)
392                .bind(range.end)
393                .bind(range.begin)
394                .bind(file_schema_hash)
395                .fetch_all(&self.db_pool),
396                "sql_select_live_partitions"
397            )
398            .await
399            .with_context(|| "listing lakehouse partitions")?
400        } else {
401            instrument_named!(
402                sqlx::query(
403                    "SELECT view_set_name,
404                    view_instance_id,
405                    begin_insert_time,
406                    end_insert_time,
407                    min_event_time,
408                    max_event_time,
409                    updated,
410                    file_path,
411                    file_size,
412                    file_schema_hash,
413                    source_data_hash,
414                    num_rows,
415                    sort_order,
416                    max_sort_key_time
417             FROM lakehouse_partitions
418             WHERE view_set_name = $1
419             AND view_instance_id = $2
420             AND file_schema_hash = $3
421             ORDER BY begin_insert_time, file_path
422             ;",
423                )
424                .bind(view_set_name)
425                .bind(view_instance_id)
426                .bind(file_schema_hash)
427                .fetch_all(&self.db_pool),
428                "sql_select_live_partitions"
429            )
430            .await
431            .with_context(|| "listing lakehouse partitions")?
432        };
433        for r in rows {
434            let view_metadata = ViewMetadata {
435                view_set_name: Arc::new(r.try_get("view_set_name")?),
436                view_instance_id: Arc::new(r.try_get("view_instance_id")?),
437                file_schema_hash: r.try_get("file_schema_hash")?,
438            };
439            // file_metadata will be loaded on-demand when needed
440            let insert_time_range = TimeRange {
441                begin: r.try_get("begin_insert_time")?,
442                end: r.try_get("end_insert_time")?,
443            };
444            let event_time_range = match (
445                r.try_get::<DateTime<Utc>, _>("min_event_time").ok(),
446                r.try_get::<DateTime<Utc>, _>("max_event_time").ok(),
447            ) {
448                (Some(begin), Some(end)) => Some(TimeRange { begin, end }),
449                (None, None) => None, // Empty partition - both NULL
450                (Some(_), None) | (None, Some(_)) => {
451                    anyhow::bail!(
452                        "Corrupt partition record: only one of min/max_event_time is NULL"
453                    );
454                }
455            };
456            let partition = Partition {
457                view_metadata,
458                insert_time_range,
459                event_time_range,
460                updated: r.try_get("updated")?,
461                file_path: r.try_get::<String, _>("file_path").ok(),
462                file_size: r.try_get("file_size")?,
463                source_data_hash: r.try_get("source_data_hash")?,
464                num_rows: r.try_get("num_rows")?,
465                sort_order: r.try_get("sort_order")?,
466                max_sort_key_time: r.try_get("max_sort_key_time")?,
467            };
468            partition
469                .validate()
470                .with_context(|| "validating partition from database")?;
471            partitions.push(partition);
472        }
473        Ok(partitions)
474    }
475}
476
477/// A `QueryPartitionProvider` that always returns an empty list of partitions.
478#[derive(Debug)]
479pub struct NullPartitionProvider {}
480
481impl fmt::Display for NullPartitionProvider {
482    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
483        write!(f, "{self:?}")
484    }
485}
486
487#[async_trait]
488impl QueryPartitionProvider for NullPartitionProvider {
489    async fn fetch(
490        &self,
491        _view_set_name: &str,
492        _view_instance_id: &str,
493        _query_range: Option<TimeRange>,
494        _file_schema_hash: Vec<u8>,
495    ) -> Result<Vec<Partition>> {
496        Ok(vec![])
497    }
498}