1use crate::{
2 lakehouse::async_parquet_writer::AsyncParquetWriter, response_writer::Logger, time::TimeRange,
3};
4use anyhow::{Context, Result};
5use chrono::{DateTime, TimeDelta, Utc};
6use datafusion::{
7 arrow::{array::RecordBatch, datatypes::Schema},
8 parquet::{
9 arrow::AsyncArrowWriter,
10 basic::Compression,
11 file::properties::{WriterProperties, WriterVersion},
12 },
13};
14use micromegas_ingestion::data_lake_connection::DataLakeConnection;
15use micromegas_tracing::prelude::*;
16use object_store::ObjectStoreExt;
17use object_store::buffered::BufWriter;
18use sqlx::Row;
19use std::collections::hash_map::DefaultHasher;
20use std::hash::{Hash, Hasher};
21use std::sync::{Arc, atomic::AtomicI64};
22use tokio::sync::mpsc::Receiver;
23
24use super::{partition::Partition, partition_source_data, view::ViewMetadata};
25
26pub async fn add_file_for_cleanup(
31 transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
32 file_path: &str,
33 file_size: i64,
34) -> Result<()> {
35 let expiration = Utc::now()
36 + TimeDelta::try_hours(1)
37 .with_context(|| "calculating expiration time for temporary file")?;
38
39 instrument_named!(
40 sqlx::query("INSERT INTO temporary_files VALUES ($1, $2, $3)")
41 .bind(file_path)
42 .bind(file_size)
43 .bind(expiration)
44 .execute(&mut **transaction),
45 "sql_insert_temporary_file"
46 )
47 .await
48 .with_context(|| format!("adding file {file_path} to temporary files for cleanup"))?;
49
50 Ok(())
51}
52
53pub struct PartitionRowSet {
55 pub rows_time_range: TimeRange,
56 pub rows: RecordBatch,
57}
58
59impl PartitionRowSet {
60 pub fn new(rows_time_range: TimeRange, rows: RecordBatch) -> Self {
61 Self {
62 rows_time_range,
63 rows,
64 }
65 }
66}
67
68#[span_fn]
69async fn retire_expired_partitions_batch(
70 lake: &DataLakeConnection,
71 expiration: DateTime<Utc>,
72) -> Result<bool> {
73 let batch_size: i32 = 1000;
74 let mut transaction = lake.db_pool.begin().await?;
75 let rows = instrument_named!(
76 sqlx::query(
77 "DELETE FROM lakehouse_partitions
78 WHERE (view_set_name, view_instance_id, begin_insert_time, end_insert_time) IN (
79 SELECT view_set_name, view_instance_id, begin_insert_time, end_insert_time
80 FROM lakehouse_partitions
81 WHERE end_insert_time < $1
82 LIMIT $2
83 )
84 RETURNING file_path, file_size;",
85 )
86 .bind(expiration)
87 .bind(batch_size)
88 .fetch_all(&mut *transaction),
89 "sql_delete_expired_partitions_batch"
90 )
91 .await
92 .with_context(|| "deleting expired partitions batch")?;
93
94 if rows.is_empty() {
95 return Ok(false);
96 }
97 let count = rows.len();
98 for row in &rows {
99 let file_path: Option<String> = row.try_get("file_path")?;
100 let file_size: i64 = row.try_get("file_size")?;
101 if let Some(path) = file_path {
102 debug!("retiring expired partition file {path} ({file_size} bytes)");
103 add_file_for_cleanup(&mut transaction, &path, file_size).await?;
104 }
105 }
106 transaction.commit().await.with_context(|| "commit")?;
107 info!("retired {count} expired partitions");
108 Ok(count == batch_size as usize)
109}
110
111#[span_fn]
112pub async fn retire_expired_partitions(
113 lake: &DataLakeConnection,
114 expiration: DateTime<Utc>,
115) -> Result<()> {
116 while retire_expired_partitions_batch(lake, expiration).await? {}
117 Ok(())
118}
119
120pub async fn retire_partitions(
123 transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
124 view_set_name: &str,
125 view_instance_id: &str,
126 begin_insert_time: DateTime<Utc>,
127 end_insert_time: DateTime<Utc>,
128 logger: Arc<dyn Logger>,
129) -> Result<()> {
130 let old_partitions = if begin_insert_time == end_insert_time {
136 instrument_named!(
138 sqlx::query(
139 "SELECT file_path, file_size
140 FROM lakehouse_partitions
141 WHERE view_set_name = $1
142 AND view_instance_id = $2
143 AND begin_insert_time = $3
144 AND end_insert_time = $3
145 ;",
146 )
147 .bind(view_set_name)
148 .bind(view_instance_id)
149 .bind(begin_insert_time)
150 .fetch_all(&mut **transaction),
151 "sql_select_old_partitions"
152 )
153 .await
154 .with_context(|| "listing old partitions (exact match)")?
155 } else {
156 instrument_named!(
158 sqlx::query(
159 "SELECT file_path, file_size
160 FROM lakehouse_partitions
161 WHERE view_set_name = $1
162 AND view_instance_id = $2
163 AND begin_insert_time >= $3
164 AND end_insert_time <= $4
165 ;",
166 )
167 .bind(view_set_name)
168 .bind(view_instance_id)
169 .bind(begin_insert_time)
170 .bind(end_insert_time)
171 .fetch_all(&mut **transaction),
172 "sql_select_old_partitions"
173 )
174 .await
175 .with_context(|| "listing old partitions (range)")?
176 };
177
178 if !old_partitions.is_empty() {
180 logger
181 .write_log_entry(format!(
182 "[RETIRE_FOUND] view={}/{} time_range=[{}, {}] found_partitions={}",
183 view_set_name,
184 view_instance_id,
185 begin_insert_time,
186 end_insert_time,
187 old_partitions.len()
188 ))
189 .await?;
190 }
191
192 let mut file_paths = Vec::new();
193 for old_part in &old_partitions {
194 let file_path: Option<String> = old_part.try_get("file_path")?;
195 let file_size: i64 = old_part.try_get("file_size")?;
196 if let Some(path) = file_path {
197 logger
198 .write_log_entry(format!(
199 "adding out of date partition {path} to temporary files to be deleted"
200 ))
201 .await?;
202 add_file_for_cleanup(transaction, &path, file_size).await?;
203 file_paths.push(path);
204 }
205 }
206
207 if begin_insert_time == end_insert_time {
208 instrument_named!(
210 sqlx::query(
211 "DELETE from lakehouse_partitions
212 WHERE view_set_name = $1
213 AND view_instance_id = $2
214 AND begin_insert_time = $3
215 AND end_insert_time = $3
216 ;",
217 )
218 .bind(view_set_name)
219 .bind(view_instance_id)
220 .bind(begin_insert_time)
221 .execute(&mut **transaction),
222 "sql_delete_old_partitions"
223 )
224 .await
225 .with_context(|| "deleting out of date partitions (exact match)")?
226 } else {
227 instrument_named!(
229 sqlx::query(
230 "DELETE from lakehouse_partitions
231 WHERE view_set_name = $1
232 AND view_instance_id = $2
233 AND begin_insert_time >= $3
234 AND end_insert_time <= $4
235 ;",
236 )
237 .bind(view_set_name)
238 .bind(view_instance_id)
239 .bind(begin_insert_time)
240 .bind(end_insert_time)
241 .execute(&mut **transaction),
242 "sql_delete_old_partitions"
243 )
244 .await
245 .with_context(|| "deleting out of date partitions (range)")?
246 };
247 Ok(())
248}
249
250fn generate_partition_lock_key(
252 view_set_name: &str,
253 view_instance_id: &str,
254 begin_insert_time: DateTime<Utc>,
255 end_insert_time: DateTime<Utc>,
256) -> i64 {
257 let mut hasher = DefaultHasher::new();
258 view_set_name.hash(&mut hasher);
259 view_instance_id.hash(&mut hasher);
260 begin_insert_time.hash(&mut hasher);
261 end_insert_time.hash(&mut hasher);
262 hasher.finish() as i64
263}
264
265async fn delete_if_orphan(lake: &DataLakeConnection, file_path: &str) -> Result<()> {
272 let referenced = instrument_named!(
273 sqlx::query("SELECT 1 FROM lakehouse_partitions WHERE file_path = $1 LIMIT 1;")
274 .bind(file_path)
275 .fetch_optional(&lake.db_pool),
276 "sql_select_partition_file_referenced"
277 )
278 .await
279 .with_context(|| "checking whether partition file is referenced")?
280 .is_some();
281 if !referenced {
282 let path = object_store::path::Path::from(file_path);
283 lake.blob_storage
284 .inner()
285 .delete(&path)
286 .await
287 .with_context(|| format!("deleting orphaned partition file {file_path}"))?;
288 }
289 Ok(())
290}
291
292async fn insert_partition(
293 lake: &DataLakeConnection,
294 partition: &Partition,
295 logger: Arc<dyn Logger>,
296) -> Result<()> {
297 let result = insert_partition_transaction(lake, partition, logger).await;
298 if result.is_err()
299 && let Some(file_path) = &partition.file_path
300 {
301 if let Err(cleanup_err) = delete_if_orphan(lake, file_path).await {
305 warn!("delete_if_orphan failed for {file_path}: {cleanup_err}");
306 }
307 }
308 result
309}
310
311async fn insert_partition_transaction(
312 lake: &DataLakeConnection,
313 partition: &Partition,
314 logger: Arc<dyn Logger>,
315) -> Result<()> {
316 let lock_key = generate_partition_lock_key(
318 &partition.view_metadata.view_set_name,
319 &partition.view_metadata.view_instance_id,
320 partition.begin_insert_time(),
321 partition.end_insert_time(),
322 );
323
324 let mut transaction = lake.db_pool.begin().await?;
325
326 debug!(
327 "[PARTITION_LOCK] view={}/{} time_range=[{}, {}] lock_key={} - acquiring advisory lock",
328 &partition.view_metadata.view_set_name,
329 &partition.view_metadata.view_instance_id,
330 partition.begin_insert_time(),
331 partition.end_insert_time(),
332 lock_key
333 );
334
335 instrument_named!(
341 sqlx::query("SELECT pg_advisory_xact_lock($1);")
342 .bind(lock_key)
343 .execute(&mut *transaction),
344 "sql_advisory_lock"
345 )
346 .await
347 .with_context(|| "acquiring advisory lock")?;
348
349 let source_row_count = partition_source_data::hash_to_object_count(&partition.source_data_hash)
351 .with_context(|| "decoding source_data_hash to row count")?;
352
353 logger
355 .write_log_entry(format!(
356 "[PARTITION_WRITE_START] view={}/{} time_range=[{}, {}] source_rows={} - lock acquired",
357 partition.view_metadata.view_set_name,
358 partition.view_metadata.view_instance_id,
359 partition.begin_insert_time(),
360 partition.end_insert_time(),
361 source_row_count
362 ))
363 .await?;
364
365 retire_partitions(
368 &mut transaction,
369 &partition.view_metadata.view_set_name,
370 &partition.view_metadata.view_instance_id,
371 partition.begin_insert_time(),
372 partition.end_insert_time(),
373 logger.clone(),
374 )
375 .await
376 .with_context(|| "retire_partitions")?;
377
378 debug!(
379 "[PARTITION_INSERT_ATTEMPT] view={}/{} time_range=[{}, {}] source_rows={} file_path={:?}",
380 &partition.view_metadata.view_set_name,
381 &partition.view_metadata.view_instance_id,
382 partition.begin_insert_time(),
383 partition.end_insert_time(),
384 source_row_count,
385 partition.file_path
386 );
387
388 let insert_result = instrument_named!(
390 sqlx::query(
391 "INSERT INTO lakehouse_partitions VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, 2, $13);",
392 )
393 .bind(&*partition.view_metadata.view_set_name)
394 .bind(&*partition.view_metadata.view_instance_id)
395 .bind(partition.begin_insert_time())
396 .bind(partition.end_insert_time())
397 .bind(partition.min_event_time())
398 .bind(partition.max_event_time())
399 .bind(partition.updated)
400 .bind(&partition.file_path)
401 .bind(partition.file_size)
402 .bind(&partition.view_metadata.file_schema_hash)
403 .bind(&partition.source_data_hash)
404 .bind(partition.num_rows)
405 .bind(&partition.sort_order)
406 .execute(&mut *transaction),
407 "sql_insert_partition"
408 )
409 .await;
410
411 match insert_result {
412 Ok(_) => {
413 debug!(
414 "[PARTITION_INSERT_SUCCESS] view={}/{} time_range=[{}, {}] source_rows={}",
415 &partition.view_metadata.view_set_name,
416 &partition.view_metadata.view_instance_id,
417 partition.begin_insert_time(),
418 partition.end_insert_time(),
419 source_row_count
420 );
421 }
422 Err(ref e) => {
423 logger
424 .write_log_entry(format!(
425 "[PARTITION_INSERT_ERROR] view={}/{} time_range=[{}, {}] source_rows={} error={}",
426 partition.view_metadata.view_set_name,
427 partition.view_metadata.view_instance_id,
428 partition.begin_insert_time(),
429 partition.end_insert_time(),
430 source_row_count,
431 e
432 ))
433 .await?;
434 let overlap_detail = e.as_database_error().and_then(|db_err| {
439 (db_err.constraint() == Some("lakehouse_partitions_no_overlap"))
440 .then(|| db_err.to_string())
441 });
442 if let Some(detail) = overlap_detail {
443 anyhow::bail!(
444 "new partition {}/{} [{}, {}] overlaps an existing partition it does not \
445 fully contain, so this write cannot replace it (likely a concurrent \
446 materialization or merge). Retire the conflicting partition (e.g. \
447 retire_partition_by_metadata) or align the requested range/delta, then \
448 retry. Postgres detail: {detail}",
449 partition.view_metadata.view_set_name,
450 partition.view_metadata.view_instance_id,
451 partition.begin_insert_time().to_rfc3339(),
452 partition.end_insert_time().to_rfc3339(),
453 );
454 }
455 return Err(insert_result.unwrap_err().into());
456 }
457 };
458
459 transaction.commit().await.with_context(|| "commit")?;
462
463 info!(
464 "[PARTITION_WRITE_COMMIT] view={}/{} time_range=[{}, {}] file_path={:?} - lock released",
465 &partition.view_metadata.view_set_name,
466 &partition.view_metadata.view_instance_id,
467 partition.begin_insert_time(),
468 partition.end_insert_time(),
469 partition.file_path
470 );
471 Ok(())
472}
473
474struct PartitionWriteResult {
476 num_rows: i64,
477 file_path: Option<String>,
478 file_size: i64,
479 event_time_range: Option<TimeRange>,
480}
481
482pub async fn write_rows_and_track_times(
484 rb_stream: &mut Receiver<Result<PartitionRowSet, anyhow::Error>>,
485 arrow_writer: &mut AsyncArrowWriter<AsyncParquetWriter>,
486 logger: &Arc<dyn Logger>,
487 desc: &str,
488) -> Result<Option<TimeRange>> {
489 let mut min_event_time: Option<DateTime<Utc>> = None;
490 let mut max_event_time: Option<DateTime<Utc>> = None;
491 let mut write_progression = 0;
492
493 while let Some(msg) = rb_stream.recv().await {
494 let row_set = msg?;
495 min_event_time = Some(
496 min_event_time
497 .unwrap_or(row_set.rows_time_range.begin)
498 .min(row_set.rows_time_range.begin),
499 );
500 max_event_time = Some(
501 max_event_time
502 .unwrap_or(row_set.rows_time_range.end)
503 .max(row_set.rows_time_range.end),
504 );
505 arrow_writer
506 .write(&row_set.rows)
507 .await
508 .with_context(|| "arrow_writer.write")?;
509 if arrow_writer.in_progress_size() > 100 * 1024 * 1024 {
510 arrow_writer
511 .flush()
512 .await
513 .with_context(|| "arrow_writer.flush")?;
514 }
515
516 let progression = arrow_writer.bytes_written() / (10 * 1024 * 1024);
518 if progression != write_progression {
519 write_progression = progression;
520 let written = arrow_writer.bytes_written();
521 logger
522 .write_log_entry(format!("{desc}: written {written} bytes"))
523 .await
524 .with_context(|| "writing log entry")?;
525 }
526 }
527
528 Ok(match (min_event_time, max_event_time) {
529 (Some(begin), Some(end)) => Some(TimeRange { begin, end }),
530 _ => None,
531 })
532}
533
534async fn finalize_partition_write(
536 event_time_range: Option<TimeRange>,
537 arrow_writer: AsyncArrowWriter<AsyncParquetWriter>,
538 file_path: String,
539 byte_counter: &Arc<AtomicI64>,
540 logger: &Arc<dyn Logger>,
541 desc: &str,
542 object_store: Arc<dyn object_store::ObjectStore>,
543) -> Result<PartitionWriteResult> {
544 if let Some(event_time_range) = event_time_range {
545 let close_result = arrow_writer.close().await;
547
548 match close_result {
549 Ok(parquet_metadata) => {
550 let num_rows = parquet_metadata.file_metadata().num_rows();
551
552 if num_rows == 0 {
555 logger
557 .write_log_entry(format!(
558 "created 0-row file, treating as empty partition for {desc}"
559 ))
560 .await
561 .with_context(|| "writing log entry")?;
562
563 let path = object_store::path::Path::from(file_path.as_str());
565 if let Err(delete_err) = object_store.delete(&path).await {
566 warn!("failed to delete empty file {}: {}", file_path, delete_err);
567 }
568
569 return Ok(PartitionWriteResult {
570 num_rows: 0,
571 file_path: None,
572 file_size: 0,
573 event_time_range: None,
574 });
575 }
576
577 debug!(
579 "wrote nb_rows={} size={} path={file_path}",
580 num_rows,
581 byte_counter.load(std::sync::atomic::Ordering::Relaxed)
582 );
583 let file_size = byte_counter.load(std::sync::atomic::Ordering::Relaxed);
584 Ok(PartitionWriteResult {
585 num_rows,
586 file_path: Some(file_path),
587 file_size,
588 event_time_range: Some(event_time_range),
589 })
590 }
591 Err(e) => {
592 warn!(
594 "arrow_writer.close failed, attempting to delete partial file: {}",
595 file_path
596 );
597 let path = object_store::path::Path::from(file_path.as_str());
598 if let Err(delete_err) = object_store.delete(&path).await {
599 warn!(
600 "failed to delete partial file {}: {}",
601 file_path, delete_err
602 );
603 }
604 Err(e).with_context(|| "arrow_writer.close")
605 }
606 }
607 } else {
608 drop(arrow_writer);
611
612 logger
613 .write_log_entry(format!("creating empty partition record for {desc}"))
614 .await
615 .with_context(|| "writing log entry")?;
616
617 let path = object_store::path::Path::from(file_path.as_str());
620 let _ = object_store.delete(&path).await;
621
622 Ok(PartitionWriteResult {
623 num_rows: 0,
624 file_path: None,
625 file_size: 0,
626 event_time_range: None,
627 })
628 }
629}
630
631#[expect(clippy::too_many_arguments)]
636pub async fn write_partition_from_rows(
637 lake: Arc<DataLakeConnection>,
638 view_metadata: ViewMetadata,
639 file_schema: Arc<Schema>,
640 insert_range: TimeRange,
641 source_data_hash: Vec<u8>,
642 sort_order: Option<Vec<String>>,
643 mut rb_stream: Receiver<Result<PartitionRowSet, anyhow::Error>>,
644 logger: Arc<dyn Logger>,
645) -> Result<()> {
646 let file_id = uuid::Uuid::new_v4();
647 let file_path = format!(
648 "views/{}/{}/{}/{}_{file_id}.parquet",
649 view_metadata.view_set_name,
650 view_metadata.view_instance_id,
651 insert_range.begin.format("%Y-%m-%d"),
652 insert_range.begin.format("%H-%M-%S")
653 );
654 let byte_counter = Arc::new(AtomicI64::new(0));
655 let object_store_writer = AsyncParquetWriter::new(
656 BufWriter::new(
657 lake.blob_storage.inner(),
658 object_store::path::Path::parse(&file_path).with_context(|| "parsing path")?,
659 )
660 .with_max_concurrency(2),
661 byte_counter.clone(),
662 );
663
664 let props = WriterProperties::builder()
667 .set_writer_version(WriterVersion::PARQUET_2_0)
668 .set_compression(Compression::LZ4_RAW)
669 .set_statistics_enabled(parquet::file::properties::EnabledStatistics::Page)
672 .build();
673 let mut arrow_writer =
674 AsyncArrowWriter::try_new(object_store_writer, file_schema.clone(), Some(props))
675 .with_context(|| "allocating async arrow writer")?;
676
677 let desc = format!(
678 "[{}, {}] {} {}",
679 view_metadata.view_set_name,
680 view_metadata.view_instance_id,
681 insert_range.begin.to_rfc3339(),
682 insert_range.end.to_rfc3339()
683 );
684
685 let event_time_range =
687 match write_rows_and_track_times(&mut rb_stream, &mut arrow_writer, &logger, &desc).await {
688 Ok(range) => range,
689 Err(e) => {
690 drop(arrow_writer);
694 warn!(
695 "write_rows_and_track_times failed, attempting to delete partial file: {}",
696 file_path
697 );
698 let path = object_store::path::Path::from(file_path.as_str());
699 if let Err(delete_err) = lake.blob_storage.inner().delete(&path).await {
700 warn!(
701 "failed to delete partial file {}: {}",
702 file_path, delete_err
703 );
704 }
705 return Err(e).with_context(|| "write_rows_and_track_times");
706 }
707 };
708
709 let result = finalize_partition_write(
711 event_time_range,
712 arrow_writer,
713 file_path,
714 &byte_counter,
715 &logger,
716 &desc,
717 lake.blob_storage.inner(),
718 )
719 .await?;
720
721 let warm_file_path = result.file_path.clone();
723 insert_partition(
724 &lake,
725 &Partition {
726 view_metadata,
727 insert_time_range: insert_range,
728 event_time_range: result.event_time_range,
729 updated: sqlx::types::chrono::Utc::now(),
730 file_path: result.file_path,
731 file_size: result.file_size,
732 source_data_hash,
733 num_rows: result.num_rows,
734 sort_order,
735 },
736 logger,
737 )
738 .await
739 .with_context(|| "insert_partition")?;
740
741 if let Some(file_path) = &warm_file_path {
746 lake.warm_object(file_path, result.file_size);
747 }
748 Ok(())
749}