Skip to main content

micromegas_analytics/lakehouse/
write_partition.rs

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
26/// Adds a file to the temporary_files table for cleanup.
27///
28/// Files added to temporary_files will be automatically deleted by the cleanup process
29/// after the expiration time. The default expiration is 1 hour from now.
30pub 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
53/// A set of rows for a partition, along with their time range.
54pub 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
120/// Retires partitions from the active set.
121/// Overlap is determined by the insert_time of the telemetry.
122pub 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    // this is not an overlap test, we need to assume that we are not making a new smaller partition
131    // where a bigger one existed
132    // its gets tricky in the jit case where a partition can have only one block and begin_insert == end_insert
133
134    //todo: use DELETE+RETURNING
135    let old_partitions = if begin_insert_time == end_insert_time {
136        // For identical timestamps, look for exact matches to handle single-block partitions
137        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        // For time ranges, use inclusive inequalities
157        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    // LOG: Found partitions for retirement (only if any found)
179    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        // For identical timestamps, delete exact matches to handle single-block partitions
209        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        // For time ranges, use inclusive inequalities
228        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
250/// Generate a deterministic advisory lock key for a partition
251fn 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
265/// Deletes `file_path` from object storage unless a partition row references it.
266///
267/// A failed commit may still have been applied server-side, so the returned error alone can't
268/// tell us whether the file is orphaned. Check the authoritative state instead: the path carries
269/// a per-write UUID, so if no `lakehouse_partitions` row references it, nothing ever will and it
270/// is safe to delete.
271async 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        // The insert failed. A failed commit may still have been applied server-side, so we
302        // can't assume the file is unreferenced -- delete_if_orphan checks and deletes only if
303        // nothing references it. Best-effort: never mask the original error.
304        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    // Generate deterministic lock key for this partition
317    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    // Acquire advisory lock - this will block until we can proceed
336    // pg_advisory_xact_lock automatically releases when transaction ends.
337    // The lock only serializes writers of this exact (view, instance, range) key, avoiding
338    // duplicate work; correctness against overlapping writers of *different* ranges is enforced
339    // by the lakehouse_partitions_no_overlap exclusion constraint at insert time.
340    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    // Decode source_data_hash back to the row count (it's stored as i64 little-endian bytes)
350    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    // LOG: Lock acquired, starting partition write
354    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    // for jit partitions, we assume that the blocks were registered in order
366    // since they are built based on begin_ticks, not insert_time
367    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    // Insert the new partition with format version 2 (Arrow 57.0)
389    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            // Translate an exclusion-constraint violation (raw SQLSTATE 23P01) into a legible
435            // domain error: it means an existing partition overlaps this one without being
436            // contained by it, so the containment-based retire in this transaction did not
437            // replace it -- e.g. a concurrent maintenance merge committed a wider partition.
438            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    // Commit the transaction (this also releases the advisory lock). On failure the transaction
460    // rolls back and the caller's delete_if_orphan reclaims the now-unreferenced parquet file.
461    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
474/// Result of writing rows to a partition file.
475struct PartitionWriteResult {
476    num_rows: i64,
477    file_path: Option<String>,
478    file_size: i64,
479    event_time_range: Option<TimeRange>,
480}
481
482/// Writes rows from the stream and tracks event time ranges.
483pub 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        // Log progress every 10MB to avoid spamming and prevent idle timeout
517        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
534/// Finalizes the partition write, closing the file and creating metadata.
535async 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        // Potentially non-empty partition: close the file and get metadata
546        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                // Check if the file actually contains rows
553                // Even if we tracked event times, the file might be empty
554                if num_rows == 0 {
555                    // File contains no rows - treat as empty partition
556                    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                    // Delete the empty file
564                    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                // Non-empty file: keep it and return the result
578                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                // Close failed - try to delete any partial file that may have been written
593                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        // Empty partition: no data was written, but the arrow writer may have written
609        // a partial file header. Drop the writer and delete any partial file.
610        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        // Try to delete any partial file that may have been created
618        // (ignore errors - file may not exist if no header was written)
619        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/// Writes a partition to a Parquet file from a stream of `PartitionRowSet`s.
632///
633/// `sort_order` is recorded on the resulting `Partition` as-is (see
634/// `View::get_merged_partition_sort_order` and `MetadataPartitionSpec::sort_order`).
635#[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    // Configure writer with page-level statistics enabled (default in Arrow 57.0+)
665    // This ensures ColumnIndex with proper null_pages field is written for DataFusion 51+ compatibility
666    let props = WriterProperties::builder()
667        .set_writer_version(WriterVersion::PARQUET_2_0)
668        .set_compression(Compression::LZ4_RAW)
669        // Explicitly enable page-level statistics for clarity (this is the default in Arrow 57.0+)
670        // This generates ColumnIndex structures with proper null_pages field
671        .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    // Write rows and track event time ranges
686    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                // The writer is dropped without close/abort on this error path, which can
691                // leave already-uploaded multipart data orphaned in object storage. Delete
692                // any partial file before propagating the error (mirror finalize cleanup).
693                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    // Finalize the write (close file or create empty metadata)
710    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    // On failure insert_partition reclaims the now-unreferenced parquet file via delete_if_orphan.
722    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    // The file is now durable in S3 and registered in PostgreSQL: warm the
742    // object cache with its key so the follow-up query's first read is a
743    // cache hit instead of a cold origin GET. Fire-and-forget: this must
744    // never delay or fail the write/materialization path.
745    if let Some(file_path) = &warm_file_path {
746        lake.warm_object(file_path, result.file_size);
747    }
748    Ok(())
749}