micromegas_analytics/lakehouse/
retire_partition_by_metadata_udf.rs1use 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#[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 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 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 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_file_for_cleanup(transaction, &file_path, file_size).await?;
147 }
148
149 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 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 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 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 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 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
350pub 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}