Skip to main content

micromegas_analytics/lakehouse/
retire_partition_by_metadata_udf.rs

1use anyhow::{Context, Result};
2use async_trait::async_trait;
3use chrono::{DateTime, Utc};
4use datafusion::{
5    arrow::{
6        array::{Array, BinaryArray, StringArray, StringBuilder, TimestampNanosecondArray},
7        datatypes::{DataType, TimeUnit},
8    },
9    common::internal_err,
10    error::DataFusionError,
11    logical_expr::{
12        ColumnarValue, ScalarFunctionArgs, ScalarUDFImpl, Signature, Volatility,
13        async_udf::AsyncScalarUDFImpl,
14    },
15};
16use micromegas_ingestion::data_lake_connection::DataLakeConnection;
17use micromegas_tracing::prelude::*;
18use sqlx::Row;
19use std::sync::Arc;
20
21use super::write_partition::add_file_for_cleanup;
22
23/// A scalar UDF that retires a single partition by its metadata.
24///
25/// This function retires partitions by their metadata identifiers (view_set_name,
26/// view_instance_id, begin_insert_time, end_insert_time, file_schema_hash). This works for both
27/// empty partitions (file_path=NULL) and non-empty partitions.
28///
29/// This is the preferred method for retiring partitions as it uses the partition's
30/// natural identifiers rather than relying on file paths.
31#[derive(Debug)]
32pub struct RetirePartitionByMetadata {
33    signature: Signature,
34    lake: Arc<DataLakeConnection>,
35}
36
37impl PartialEq for RetirePartitionByMetadata {
38    fn eq(&self, other: &Self) -> bool {
39        self.signature == other.signature
40    }
41}
42
43impl Eq for RetirePartitionByMetadata {}
44
45impl std::hash::Hash for RetirePartitionByMetadata {
46    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
47        self.signature.hash(state);
48    }
49}
50
51impl RetirePartitionByMetadata {
52    pub fn new(lake: Arc<DataLakeConnection>) -> Self {
53        Self {
54            signature: Signature::exact(
55                vec![
56                    DataType::Utf8,
57                    DataType::Utf8,
58                    DataType::Timestamp(TimeUnit::Nanosecond, Some("+00:00".into())),
59                    DataType::Timestamp(TimeUnit::Nanosecond, Some("+00:00".into())),
60                    DataType::Binary,
61                ],
62                Volatility::Volatile,
63            ),
64            lake,
65        }
66    }
67
68    /// Retires a single partition by its metadata within an existing transaction.
69    ///
70    /// # Arguments
71    /// * `transaction` - Database transaction to use
72    /// * `view_set_name` - The name of the view set
73    /// * `view_instance_id` - The instance ID (e.g., process_id or 'global')
74    /// * `begin_insert_time` - Begin insert time timestamp
75    /// * `end_insert_time` - End insert time timestamp
76    /// * `file_schema_hash` - The `file_schema_hash` of the partition to retire, disambiguating
77    ///   the target when an old-schema and a new-schema partition legally coexist over the same
78    ///   metadata range (see `migration.rs`'s `lakehouse_partitions_no_overlap` exclusion
79    ///   constraint, which is scoped by `file_schema_hash`)
80    ///
81    /// # Returns
82    /// * `Ok(())` on successful retirement
83    /// * `Err(anyhow::Error)` with descriptive message for any failure
84    async fn retire_partition_in_transaction(
85        &self,
86        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
87        view_set_name: &str,
88        view_instance_id: &str,
89        begin_insert_time: DateTime<Utc>,
90        end_insert_time: DateTime<Utc>,
91        file_schema_hash: &[u8],
92    ) -> Result<()> {
93        let schema_hash_hex = hex::encode(file_schema_hash);
94
95        // First, check if the partition exists and get its details
96        let partition_rows = instrument_named!(
97            sqlx::query(
98                "SELECT file_path, file_size FROM lakehouse_partitions
99             WHERE view_set_name = $1
100               AND view_instance_id = $2
101               AND begin_insert_time = $3
102               AND end_insert_time = $4
103               AND file_schema_hash = $5",
104            )
105            .bind(view_set_name)
106            .bind(view_instance_id)
107            .bind(begin_insert_time)
108            .bind(end_insert_time)
109            .bind(file_schema_hash)
110            .fetch_all(&mut **transaction),
111            "sql_select_partition_by_metadata"
112        )
113        .await
114        .with_context(|| {
115            format!(
116                "querying partition {view_set_name}/{view_instance_id} [{begin_insert_time}, {end_insert_time}) schema_hash={schema_hash_hex}"
117            )
118        })?;
119
120        if partition_rows.is_empty() {
121            anyhow::bail!(
122                "Partition not found: {view_set_name}/{view_instance_id} [{begin_insert_time}, {end_insert_time}) schema_hash={schema_hash_hex}"
123            );
124        }
125
126        if partition_rows.len() > 1 {
127            let mut file_paths = Vec::with_capacity(partition_rows.len());
128            for row in &partition_rows {
129                let file_path: Option<String> = row.try_get("file_path")?;
130                file_paths.push(file_path.unwrap_or_else(|| "NULL".to_string()));
131            }
132            anyhow::bail!(
133                "Ambiguous match: {} rows for {view_set_name}/{view_instance_id} [{begin_insert_time}, {end_insert_time}) schema_hash={schema_hash_hex}, file_paths=[{}]; use retire_partition_by_file when file_paths are distinct and non-NULL",
134                partition_rows.len(),
135                file_paths.join(", ")
136            );
137        }
138
139        let partition_row = &partition_rows[0];
140
141        // Handle file cleanup if file_path is not NULL
142        let file_path_opt: Option<String> = partition_row.try_get("file_path")?;
143        if let Some(file_path) = file_path_opt {
144            let file_size: i64 = partition_row.try_get("file_size")?;
145            // Add to temporary files for cleanup (expires in 1 hour)
146            add_file_for_cleanup(transaction, &file_path, file_size).await?;
147        }
148
149        // Remove from active partitions
150        let delete_result = instrument_named!(
151            sqlx::query(
152                "DELETE FROM lakehouse_partitions
153             WHERE view_set_name = $1
154               AND view_instance_id = $2
155               AND begin_insert_time = $3
156               AND end_insert_time = $4
157               AND file_schema_hash = $5",
158            )
159            .bind(view_set_name)
160            .bind(view_instance_id)
161            .bind(begin_insert_time)
162            .bind(end_insert_time)
163            .bind(file_schema_hash)
164            .execute(&mut **transaction),
165            "sql_delete_partition_by_metadata"
166        )
167        .await
168        .with_context(|| {
169            format!(
170                "deleting partition {view_set_name}/{view_instance_id} [{begin_insert_time}, {end_insert_time}) schema_hash={schema_hash_hex}"
171            )
172        })?;
173
174        if delete_result.rows_affected() != 1 {
175            // A concurrent writer raced the SELECT above: fail closed instead of reporting
176            // SUCCESS on a delete that didn't remove exactly the row we resolved.
177            anyhow::bail!(
178                "Partition not found during deletion: {view_set_name}/{view_instance_id} [{begin_insert_time}, {end_insert_time}) schema_hash={schema_hash_hex}"
179            );
180        }
181
182        info!(
183            "Successfully retired partition: {}/{} [{}, {}) schema_hash={}",
184            view_set_name, view_instance_id, begin_insert_time, end_insert_time, schema_hash_hex
185        );
186        Ok(())
187    }
188}
189
190impl ScalarUDFImpl for RetirePartitionByMetadata {
191    fn name(&self) -> &str {
192        "retire_partition_by_metadata"
193    }
194
195    fn signature(&self) -> &Signature {
196        &self.signature
197    }
198
199    fn return_type(&self, _arg_types: &[DataType]) -> datafusion::error::Result<DataType> {
200        Ok(DataType::Utf8)
201    }
202
203    fn invoke_with_args(
204        &self,
205        _args: ScalarFunctionArgs,
206    ) -> datafusion::error::Result<ColumnarValue> {
207        Err(DataFusionError::NotImplemented(
208            "retire_partition_by_metadata can only be called from async contexts".into(),
209        ))
210    }
211}
212
213#[async_trait]
214impl AsyncScalarUDFImpl for RetirePartitionByMetadata {
215    async fn invoke_async_with_args(
216        &self,
217        args: ScalarFunctionArgs,
218    ) -> datafusion::error::Result<ColumnarValue> {
219        let args = ColumnarValue::values_to_arrays(&args.args)?;
220        if args.len() != 5 {
221            return internal_err!(
222                "retire_partition_by_metadata expects exactly 5 arguments: view_set_name, view_instance_id, begin_insert_time, end_insert_time, file_schema_hash"
223            );
224        }
225
226        let view_set_names: &StringArray =
227            args[0].as_any().downcast_ref::<_>().ok_or_else(|| {
228                DataFusionError::Internal(
229                    "error casting view_set_name argument as StringArray".into(),
230                )
231            })?;
232
233        let view_instance_ids: &StringArray =
234            args[1].as_any().downcast_ref::<_>().ok_or_else(|| {
235                DataFusionError::Internal(
236                    "error casting view_instance_id argument as StringArray".into(),
237                )
238            })?;
239
240        let begin_insert_times: &TimestampNanosecondArray =
241            args[2].as_any().downcast_ref::<_>().ok_or_else(|| {
242                DataFusionError::Internal(
243                    "error casting begin_insert_time argument as TimestampNanosecondArray".into(),
244                )
245            })?;
246
247        let end_insert_times: &TimestampNanosecondArray =
248            args[3].as_any().downcast_ref::<_>().ok_or_else(|| {
249                DataFusionError::Internal(
250                    "error casting end_insert_time argument as TimestampNanosecondArray".into(),
251                )
252            })?;
253
254        let file_schema_hashes: &BinaryArray =
255            args[4].as_any().downcast_ref::<_>().ok_or_else(|| {
256                DataFusionError::Internal(
257                    "error casting file_schema_hash argument as BinaryArray".into(),
258                )
259            })?;
260
261        let mut builder = StringBuilder::with_capacity(view_set_names.len(), 64);
262
263        // Use a single transaction for the entire batch
264        let mut transaction =
265            self.lake.db_pool.begin().await.map_err(|e| {
266                DataFusionError::Internal(format!("Failed to begin transaction: {e}"))
267            })?;
268
269        let mut success_count = 0;
270        let mut has_errors = false;
271
272        // Process each partition in the batch within the same transaction
273        for index in 0..view_set_names.len() {
274            if view_set_names.is_null(index)
275                || view_instance_ids.is_null(index)
276                || begin_insert_times.is_null(index)
277                || end_insert_times.is_null(index)
278                || file_schema_hashes.is_null(index)
279            {
280                builder.append_value("ERROR: all arguments must be non-null");
281                has_errors = true;
282                continue;
283            }
284
285            let view_set_name = view_set_names.value(index);
286            let view_instance_id = view_instance_ids.value(index);
287            let begin_insert_time_nanos = begin_insert_times.value(index);
288            let end_insert_time_nanos = end_insert_times.value(index);
289            let file_schema_hash = file_schema_hashes.value(index);
290            let schema_hash_hex = hex::encode(file_schema_hash);
291
292            // Convert nanoseconds to DateTime<Utc> for proper sqlx binding
293            let begin_insert_time = DateTime::from_timestamp_nanos(begin_insert_time_nanos);
294            let end_insert_time = DateTime::from_timestamp_nanos(end_insert_time_nanos);
295
296            match self
297                .retire_partition_in_transaction(
298                    &mut transaction,
299                    view_set_name,
300                    view_instance_id,
301                    begin_insert_time,
302                    end_insert_time,
303                    file_schema_hash,
304                )
305                .await
306            {
307                Ok(()) => {
308                    success_count += 1;
309                    builder.append_value(format!(
310                        "SUCCESS: Retired partition {view_set_name}/{view_instance_id} [{begin_insert_time}, {end_insert_time}) schema_hash={schema_hash_hex}"
311                    ));
312                }
313                Err(e) => {
314                    error!(
315                        "Failed to retire partition {}/{} [{}, {}) schema_hash={}: {:?}",
316                        view_set_name,
317                        view_instance_id,
318                        begin_insert_time,
319                        end_insert_time,
320                        schema_hash_hex,
321                        e
322                    );
323                    builder.append_value(format!("ERROR: {e:?}"));
324                    has_errors = true;
325                }
326            }
327        }
328
329        // Commit the transaction only if there were no errors
330        if has_errors {
331            if let Err(e) = transaction.rollback().await {
332                error!("Failed to rollback transaction after errors: {:?}", e);
333            }
334            info!("Rolled back transaction due to errors in batch retirement");
335            builder.append_value(format!(
336                "ROLLED_BACK: All {} previous changes were reverted due to errors in batch",
337                success_count
338            ));
339        } else {
340            transaction.commit().await.map_err(|e| {
341                DataFusionError::Internal(format!("Failed to commit transaction: {e}"))
342            })?;
343            info!("Successfully retired {} partitions in batch", success_count);
344        }
345
346        Ok(ColumnarValue::Array(Arc::new(builder.finish())))
347    }
348}
349
350/// Creates a user-defined function to retire a single partition by its metadata.
351///
352/// This function retires partitions by their metadata identifiers rather than file path,
353/// making it suitable for both empty partitions (file_path=NULL) and non-empty partitions.
354///
355/// # Usage
356/// ```sql
357/// SELECT retire_partition_by_metadata(
358///     'log_entries',
359///     'process_123',
360///     TIMESTAMP '2024-01-01 00:00:00',
361///     TIMESTAMP '2024-01-01 01:00:00',
362///     decode('04', 'hex')
363/// ) as result;
364/// ```
365///
366/// # Returns
367/// A string message indicating success or failure:
368/// - "SUCCESS: Retired partition \<view_set\>/\<instance\> [\<begin\>, \<end\>) schema_hash=\<hex\>" on successful retirement
369/// - "ERROR: Partition not found: \<view_set\>/\<instance\> [\<begin\>, \<end\>) schema_hash=\<hex\>" if the partition doesn't exist
370/// - "ERROR: Ambiguous match: \<n\> rows for \<view_set\>/\<instance\> [\<begin\>, \<end\>) schema_hash=\<hex\>, file_paths=[...]" if more than one row matches
371/// - "ERROR: Database error: \<details\>" for any database-related failures
372pub fn make_retire_partition_by_metadata_udf(
373    lake: Arc<DataLakeConnection>,
374) -> datafusion::logical_expr::async_udf::AsyncScalarUDF {
375    datafusion::logical_expr::async_udf::AsyncScalarUDF::new(Arc::new(
376        RetirePartitionByMetadata::new(lake),
377    ))
378}