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    /// The true maximum value of the view's declared `Concatenated` leading sort column across
58    /// this row set (`begin`, for `thread_spans`). `None` for every sender except
59    /// `thread_spans_view.rs`, the only view that declares that ordering today; see
60    /// `Partition::max_sort_key_time`'s docs for the field it eventually feeds.
61    pub max_sort_key_time: Option<DateTime<Utc>>,
62}
63
64impl PartitionRowSet {
65    /// Takes `max_sort_key_time` as a required third argument (rather than defaulting it to
66    /// `None`) so every future caller has to make an explicit choice. `None` happens to be
67    /// correct for every caller today -- none of them declares a `Concatenated` event-time
68    /// ordering -- but a defaulted `new()` would hand that `None` out silently, and a future view
69    /// that does declare the ordering could be wired through `::new` and get a wrong bound with
70    /// no signal at all. The failure mode of a silent default here is a scan check that trusts a
71    /// bound nobody computed.
72    pub fn new(
73        rows_time_range: TimeRange,
74        rows: RecordBatch,
75        max_sort_key_time: Option<DateTime<Utc>>,
76    ) -> Self {
77        Self {
78            rows_time_range,
79            rows,
80            max_sort_key_time,
81        }
82    }
83}
84
85#[span_fn]
86async fn retire_expired_partitions_batch(
87    lake: &DataLakeConnection,
88    expiration: DateTime<Utc>,
89) -> Result<bool> {
90    let batch_size: i32 = 1000;
91    let mut transaction = lake.db_pool.begin().await?;
92    let rows = instrument_named!(
93        sqlx::query(
94            "DELETE FROM lakehouse_partitions
95         WHERE (view_set_name, view_instance_id, begin_insert_time, end_insert_time) IN (
96             SELECT view_set_name, view_instance_id, begin_insert_time, end_insert_time
97             FROM lakehouse_partitions
98             WHERE end_insert_time < $1
99             LIMIT $2
100         )
101         RETURNING file_path, file_size;",
102        )
103        .bind(expiration)
104        .bind(batch_size)
105        .fetch_all(&mut *transaction),
106        "sql_delete_expired_partitions_batch"
107    )
108    .await
109    .with_context(|| "deleting expired partitions batch")?;
110
111    if rows.is_empty() {
112        return Ok(false);
113    }
114    let count = rows.len();
115    for row in &rows {
116        let file_path: Option<String> = row.try_get("file_path")?;
117        let file_size: i64 = row.try_get("file_size")?;
118        if let Some(path) = file_path {
119            debug!("retiring expired partition file {path} ({file_size} bytes)");
120            add_file_for_cleanup(&mut transaction, &path, file_size).await?;
121        }
122    }
123    transaction.commit().await.with_context(|| "commit")?;
124    info!("retired {count} expired partitions");
125    Ok(count == batch_size as usize)
126}
127
128#[span_fn]
129pub async fn retire_expired_partitions(
130    lake: &DataLakeConnection,
131    expiration: DateTime<Utc>,
132) -> Result<()> {
133    while retire_expired_partitions_batch(lake, expiration).await? {}
134    Ok(())
135}
136
137/// Which SQL predicate `retire_partitions` uses to find partitions to replace.
138///
139/// There is no JIT registry to infer "this view regroups blocks across runs" from
140/// `view_set_name`/`view_instance_id` alone, so callers must say which rule applies to them.
141#[derive(Clone, Copy, Debug, PartialEq, Eq)]
142pub enum RetireMatch {
143    /// The existing partition is fully contained in the new
144    /// `[begin_insert_time, end_insert_time]` range (`begin_insert_time >= $3 AND end_insert_time
145    /// <= $4`, with an exact-match special case when the new range is degenerate). Correct
146    /// whenever a view's cut points are stable across materialization runs: a rewritten range
147    /// either exactly reproduces an old partition's range (matched by the exact-match case) or
148    /// strictly extends it (matched by containment). This holds for every JIT view under
149    /// `BlockOrder::InsertTime` and for every non-JIT (batch/merge) partition write. Used by
150    /// everything except the two `BlockOrder::EventTime` JIT views.
151    Containment,
152    /// An *inclusive* insert-range intersection test -- `tstzrange(begin, end, '[]') &&
153    /// tstzrange($3, $4, '[]')` -- with same-run siblings excluded by identity (see
154    /// `same_run_ranges` below). The `'[]'` bounds matter twice over: a partition's
155    /// `[begin_insert_time, end_insert_time]` is the inclusive min/max of its blocks' insert
156    /// times, so two partitions that merely *touch* at one instant can both contain a block at
157    /// that instant and must be considered intersecting; and an inclusive range is never empty,
158    /// so degenerate `[t, t]` partitions (on either side) intersect anything containing `t` --
159    /// with Postgres's default half-open ranges, `tstzrange(t, t)` is empty and `&&` is
160    /// vacuously false.
161    ///
162    /// Required by `thread_spans_view.rs` / `net_spans_view.rs`, whose `BlockOrder::EventTime`
163    /// grouping can move an *earlier* cut point between `jit_update` runs (see
164    /// `jit_partitions::group_blocks_into_partitions`), so a later run's narrower spec can leave
165    /// behind a stale, wider partition that merely *overlaps* the new range instead of being
166    /// contained by it -- `Containment` alone would never retire it, and the subsequent insert
167    /// would trip the `lakehouse_partitions_no_overlap` exclusion constraint.
168    ///
169    /// Tolerated gap: retiring the stale partition and inserting the new, narrower one are two
170    /// statements in one transaction, so a range the old partition covered but the new one does not
171    /// yet (a sibling later in the same `jit_update` loop covers it) is transiently missing until
172    /// that sibling is written -- or, if the loop fails, until the next successful `jit_update`.
173    /// Tolerable because JIT partitions are regenerated on demand from `blocks_view` and are not
174    /// the source of truth for their own data.
175    Overlap,
176}
177
178/// Retires partitions from the active set.
179///
180/// `retire_match` selects the SQL predicate (see `RetireMatch`): `Containment` is an insert-range
181/// containment test on the *existing* partition (with an exact-match special case for a
182/// degenerate new range), correct whenever a view's cut points are stable across runs.
183/// `Overlap` additionally matches (and retires) an existing partition that merely overlaps the new
184/// range without containing it, which `BlockOrder::EventTime` JIT views need because their cut
185/// points can move between runs.
186///
187/// `same_run_ranges` lists the exact `(begin_insert_time, end_insert_time)` pairs the calling
188/// `jit_update` run has already written (or found already up to date) earlier in its own loop; a
189/// row matching one of these pairs is never retired, even when the `retire_match` predicate
190/// would otherwise match it (see `RetireMatch::Overlap`'s docs for why this is identity-, not
191/// shape-, based). Only meaningful for `RetireMatch::Overlap` -- pass `&[]` for `Containment`.
192#[expect(clippy::too_many_arguments)]
193pub async fn retire_partitions(
194    transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
195    view_set_name: &str,
196    view_instance_id: &str,
197    begin_insert_time: DateTime<Utc>,
198    end_insert_time: DateTime<Utc>,
199    retire_match: RetireMatch,
200    same_run_ranges: &[TimeRange],
201    logger: Arc<dyn Logger>,
202) -> Result<()> {
203    // For RetireMatch::Containment: this is not an overlap test, we need to assume that we are
204    // not making a new smaller partition where a bigger one existed. It gets tricky in the jit
205    // case where a partition can have only one block and begin_insert == end_insert -- handled by
206    // the exact-match branch below. RetireMatch::Overlap (thread_spans/net_spans only) additionally
207    // matches a stale, wider partition that only overlaps the new range; see RetireMatch's docs.
208    let same_run_begins: Vec<DateTime<Utc>> = same_run_ranges.iter().map(|r| r.begin).collect();
209    let same_run_ends: Vec<DateTime<Utc>> = same_run_ranges.iter().map(|r| r.end).collect();
210
211    //todo: use DELETE+RETURNING
212    let old_partitions = match retire_match {
213        RetireMatch::Containment if begin_insert_time == end_insert_time => {
214            // For identical timestamps, look for exact matches to handle single-block partitions
215            instrument_named!(
216                sqlx::query(
217                    "SELECT file_path, file_size
218             FROM lakehouse_partitions
219             WHERE view_set_name = $1
220             AND view_instance_id = $2
221             AND begin_insert_time = $3
222             AND end_insert_time = $3
223             ;",
224                )
225                .bind(view_set_name)
226                .bind(view_instance_id)
227                .bind(begin_insert_time)
228                .fetch_all(&mut **transaction),
229                "sql_select_old_partitions"
230            )
231            .await
232            .with_context(|| "listing old partitions (exact match)")?
233        }
234        RetireMatch::Containment => {
235            // For time ranges, use inclusive inequalities
236            instrument_named!(
237                sqlx::query(
238                    "SELECT file_path, file_size
239             FROM lakehouse_partitions
240             WHERE view_set_name = $1
241             AND view_instance_id = $2
242             AND begin_insert_time >= $3
243             AND end_insert_time <= $4
244             ;",
245                )
246                .bind(view_set_name)
247                .bind(view_instance_id)
248                .bind(begin_insert_time)
249                .bind(end_insert_time)
250                .fetch_all(&mut **transaction),
251                "sql_select_old_partitions"
252            )
253            .await
254            .with_context(|| "listing old partitions (range)")?
255        }
256        RetireMatch::Overlap => {
257            // Inclusive-bounds intersection, with rows matching a same-run range excluded by
258            // identity; see RetireMatch::Overlap's docs for why the bounds must be '[]'.
259            instrument_named!(
260                sqlx::query(
261                    "SELECT file_path, file_size
262             FROM lakehouse_partitions
263             WHERE view_set_name = $1
264             AND view_instance_id = $2
265             AND tstzrange(begin_insert_time, end_insert_time, '[]') && tstzrange($3, $4, '[]')
266             AND NOT EXISTS (
267                 SELECT 1 FROM unnest($5::timestamptz[], $6::timestamptz[]) AS same_run(b, e)
268                 WHERE same_run.b = begin_insert_time AND same_run.e = end_insert_time
269             )
270             ;",
271                )
272                .bind(view_set_name)
273                .bind(view_instance_id)
274                .bind(begin_insert_time)
275                .bind(end_insert_time)
276                .bind(&same_run_begins)
277                .bind(&same_run_ends)
278                .fetch_all(&mut **transaction),
279                "sql_select_old_partitions"
280            )
281            .await
282            .with_context(|| "listing old partitions (overlap)")?
283        }
284    };
285
286    // LOG: Found partitions for retirement (only if any found)
287    if !old_partitions.is_empty() {
288        logger
289            .write_log_entry(format!(
290                "[RETIRE_FOUND] view={}/{} time_range=[{}, {}] found_partitions={}",
291                view_set_name,
292                view_instance_id,
293                begin_insert_time,
294                end_insert_time,
295                old_partitions.len()
296            ))
297            .await?;
298    }
299
300    let mut file_paths = Vec::new();
301    for old_part in &old_partitions {
302        let file_path: Option<String> = old_part.try_get("file_path")?;
303        let file_size: i64 = old_part.try_get("file_size")?;
304        if let Some(path) = file_path {
305            logger
306                .write_log_entry(format!(
307                    "adding out of date partition {path} to temporary files to be deleted"
308                ))
309                .await?;
310            add_file_for_cleanup(transaction, &path, file_size).await?;
311            file_paths.push(path);
312        }
313    }
314
315    match retire_match {
316        RetireMatch::Containment if begin_insert_time == end_insert_time => {
317            // For identical timestamps, delete exact matches to handle single-block partitions
318            instrument_named!(
319                sqlx::query(
320                    "DELETE from lakehouse_partitions
321             WHERE view_set_name = $1
322             AND view_instance_id = $2
323             AND begin_insert_time = $3
324             AND end_insert_time = $3
325             ;",
326                )
327                .bind(view_set_name)
328                .bind(view_instance_id)
329                .bind(begin_insert_time)
330                .execute(&mut **transaction),
331                "sql_delete_old_partitions"
332            )
333            .await
334            .with_context(|| "deleting out of date partitions (exact match)")?
335        }
336        RetireMatch::Containment => {
337            // For time ranges, use inclusive inequalities
338            instrument_named!(
339                sqlx::query(
340                    "DELETE from lakehouse_partitions
341             WHERE view_set_name = $1
342             AND view_instance_id = $2
343             AND begin_insert_time >= $3
344             AND end_insert_time <= $4
345             ;",
346                )
347                .bind(view_set_name)
348                .bind(view_instance_id)
349                .bind(begin_insert_time)
350                .bind(end_insert_time)
351                .execute(&mut **transaction),
352                "sql_delete_old_partitions"
353            )
354            .await
355            .with_context(|| "deleting out of date partitions (range)")?
356        }
357        RetireMatch::Overlap => {
358            // Same predicate (and same-run exclusion) as the SELECT above, so exactly the rows
359            // just listed (and scheduled for cleanup) are deleted.
360            instrument_named!(
361                sqlx::query(
362                    "DELETE from lakehouse_partitions
363             WHERE view_set_name = $1
364             AND view_instance_id = $2
365             AND tstzrange(begin_insert_time, end_insert_time, '[]') && tstzrange($3, $4, '[]')
366             AND NOT EXISTS (
367                 SELECT 1 FROM unnest($5::timestamptz[], $6::timestamptz[]) AS same_run(b, e)
368                 WHERE same_run.b = begin_insert_time AND same_run.e = end_insert_time
369             )
370             ;",
371                )
372                .bind(view_set_name)
373                .bind(view_instance_id)
374                .bind(begin_insert_time)
375                .bind(end_insert_time)
376                .bind(&same_run_begins)
377                .bind(&same_run_ends)
378                .execute(&mut **transaction),
379                "sql_delete_old_partitions"
380            )
381            .await
382            .with_context(|| "deleting out of date partitions (overlap)")?
383        }
384    };
385    Ok(())
386}
387
388/// Generate a deterministic advisory lock key for a partition
389fn generate_partition_lock_key(
390    view_set_name: &str,
391    view_instance_id: &str,
392    begin_insert_time: DateTime<Utc>,
393    end_insert_time: DateTime<Utc>,
394) -> i64 {
395    let mut hasher = DefaultHasher::new();
396    view_set_name.hash(&mut hasher);
397    view_instance_id.hash(&mut hasher);
398    begin_insert_time.hash(&mut hasher);
399    end_insert_time.hash(&mut hasher);
400    hasher.finish() as i64
401}
402
403/// Deletes `file_path` from object storage unless a partition row references it.
404///
405/// A failed commit may still have been applied server-side, so the returned error alone can't
406/// tell us whether the file is orphaned. Check the authoritative state instead: the path carries
407/// a per-write UUID, so if no `lakehouse_partitions` row references it, nothing ever will and it
408/// is safe to delete.
409async fn delete_if_orphan(lake: &DataLakeConnection, file_path: &str) -> Result<()> {
410    let referenced = instrument_named!(
411        sqlx::query("SELECT 1 FROM lakehouse_partitions WHERE file_path = $1 LIMIT 1;")
412            .bind(file_path)
413            .fetch_optional(&lake.db_pool),
414        "sql_select_partition_file_referenced"
415    )
416    .await
417    .with_context(|| "checking whether partition file is referenced")?
418    .is_some();
419    if !referenced {
420        let path = object_store::path::Path::from(file_path);
421        lake.blob_storage
422            .inner()
423            .delete(&path)
424            .await
425            .with_context(|| format!("deleting orphaned partition file {file_path}"))?;
426    }
427    Ok(())
428}
429
430/// Inserts a partition into `lakehouse_partitions`, retiring whatever `retire_match` says it
431/// replaces first.
432///
433/// Not intended as a general-purpose API: `write_partition_from_rows` is the production entry
434/// point that builds the `Partition` this function inserts. This is `pub` only so
435/// `rust/analytics/tests/` (an external crate) can drive the SQL plumbing directly in a
436/// persistence round-trip test, mirroring `thread_spans_view::update_partition`'s reason for
437/// being `pub`.
438pub async fn insert_partition(
439    lake: &DataLakeConnection,
440    partition: &Partition,
441    retire_match: RetireMatch,
442    same_run_ranges: &[TimeRange],
443    logger: Arc<dyn Logger>,
444) -> Result<()> {
445    let result =
446        insert_partition_transaction(lake, partition, retire_match, same_run_ranges, logger).await;
447    if result.is_err()
448        && let Some(file_path) = &partition.file_path
449    {
450        // The insert failed. A failed commit may still have been applied server-side, so we
451        // can't assume the file is unreferenced -- delete_if_orphan checks and deletes only if
452        // nothing references it. Best-effort: never mask the original error.
453        if let Err(cleanup_err) = delete_if_orphan(lake, file_path).await {
454            warn!("delete_if_orphan failed for {file_path}: {cleanup_err}");
455        }
456    }
457    result
458}
459
460async fn insert_partition_transaction(
461    lake: &DataLakeConnection,
462    partition: &Partition,
463    retire_match: RetireMatch,
464    same_run_ranges: &[TimeRange],
465    logger: Arc<dyn Logger>,
466) -> Result<()> {
467    // Generate deterministic lock key for this partition
468    let lock_key = generate_partition_lock_key(
469        &partition.view_metadata.view_set_name,
470        &partition.view_metadata.view_instance_id,
471        partition.begin_insert_time(),
472        partition.end_insert_time(),
473    );
474
475    let mut transaction = lake.db_pool.begin().await?;
476
477    debug!(
478        "[PARTITION_LOCK] view={}/{} time_range=[{}, {}] lock_key={} - acquiring advisory lock",
479        &partition.view_metadata.view_set_name,
480        &partition.view_metadata.view_instance_id,
481        partition.begin_insert_time(),
482        partition.end_insert_time(),
483        lock_key
484    );
485
486    // Acquire advisory lock - this will block until we can proceed
487    // pg_advisory_xact_lock automatically releases when transaction ends.
488    // The lock only serializes writers of this exact (view, instance, range) key, avoiding
489    // duplicate work. Correctness against overlapping writers of *different* ranges depends on
490    // retire_match: under RetireMatch::Containment (every caller except thread_spans/net_spans),
491    // it is enforced by the lakehouse_partitions_no_overlap exclusion constraint at insert time --
492    // a losing writer's insert fails loudly. Under RetireMatch::Overlap (thread_spans/net_spans),
493    // the Overlap retire step below routes around that constraint (it can delete a
494    // differently-ranged, already-committed partition before this insert), so two concurrent
495    // writers of different, overlapping ranges are instead reconciled by the next `jit_update`'s
496    // exact-equality `is_jit_partition_up_to_date` check, not by the exclusion constraint -- see
497    // `RetireMatch::Overlap`'s docs.
498    instrument_named!(
499        sqlx::query("SELECT pg_advisory_xact_lock($1);")
500            .bind(lock_key)
501            .execute(&mut *transaction),
502        "sql_advisory_lock"
503    )
504    .await
505    .with_context(|| "acquiring advisory lock")?;
506
507    // Decode source_data_hash back to the row count (it's stored as i64 little-endian bytes)
508    let source_row_count = partition_source_data::hash_to_object_count(&partition.source_data_hash)
509        .with_context(|| "decoding source_data_hash to row count")?;
510
511    // LOG: Lock acquired, starting partition write
512    logger
513        .write_log_entry(format!(
514            "[PARTITION_WRITE_START] view={}/{} time_range=[{}, {}] source_rows={} - lock acquired",
515            partition.view_metadata.view_set_name,
516            partition.view_metadata.view_instance_id,
517            partition.begin_insert_time(),
518            partition.end_insert_time(),
519            source_row_count
520        ))
521        .await?;
522
523    // Which partitions this call replaces depends on retire_match (see RetireMatch's docs):
524    // Containment only replaces partitions fully covered by this write's range; Overlap (the two
525    // BlockOrder::EventTime JIT views) also replaces a stale partition that merely overlaps it,
526    // since their grouping can move an earlier cut point between runs.
527    retire_partitions(
528        &mut transaction,
529        &partition.view_metadata.view_set_name,
530        &partition.view_metadata.view_instance_id,
531        partition.begin_insert_time(),
532        partition.end_insert_time(),
533        retire_match,
534        same_run_ranges,
535        logger.clone(),
536    )
537    .await
538    .with_context(|| "retire_partitions")?;
539
540    debug!(
541        "[PARTITION_INSERT_ATTEMPT] view={}/{} time_range=[{}, {}] source_rows={} file_path={:?}",
542        &partition.view_metadata.view_set_name,
543        &partition.view_metadata.view_instance_id,
544        partition.begin_insert_time(),
545        partition.end_insert_time(),
546        source_row_count,
547        partition.file_path
548    );
549
550    // Insert the new partition with format version 2. Explicit column list (rather
551    // than a bare positional VALUES(...)) so the statement's dependency on the table's shape is
552    // visible at the call site: Postgres's ALTER TABLE ... ADD COLUMN always appends, so a bare
553    // positional list cannot mis-bind from an ordinary migration, but it also can't catch a
554    // *missing* bind -- adding column 15 without touching this statement would compile, run, and
555    // silently store NULL forever. Naming every column (and partition_format_version's literal
556    // `2` by name rather than by ordinal) is what makes that omission catchable in review.
557    let insert_result = instrument_named!(
558        sqlx::query(
559            "INSERT INTO lakehouse_partitions (
560                 view_set_name,
561                 view_instance_id,
562                 begin_insert_time,
563                 end_insert_time,
564                 min_event_time,
565                 max_event_time,
566                 updated,
567                 file_path,
568                 file_size,
569                 file_schema_hash,
570                 source_data_hash,
571                 num_rows,
572                 partition_format_version,
573                 sort_order,
574                 max_sort_key_time
575             ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, 2, $13, $14);",
576        )
577        .bind(&*partition.view_metadata.view_set_name)
578        .bind(&*partition.view_metadata.view_instance_id)
579        .bind(partition.begin_insert_time())
580        .bind(partition.end_insert_time())
581        .bind(partition.min_event_time())
582        .bind(partition.max_event_time())
583        .bind(partition.updated)
584        .bind(&partition.file_path)
585        .bind(partition.file_size)
586        .bind(&partition.view_metadata.file_schema_hash)
587        .bind(&partition.source_data_hash)
588        .bind(partition.num_rows)
589        .bind(&partition.sort_order)
590        .bind(partition.max_sort_key_time)
591        .execute(&mut *transaction),
592        "sql_insert_partition"
593    )
594    .await;
595
596    match insert_result {
597        Ok(_) => {
598            debug!(
599                "[PARTITION_INSERT_SUCCESS] view={}/{} time_range=[{}, {}] source_rows={}",
600                &partition.view_metadata.view_set_name,
601                &partition.view_metadata.view_instance_id,
602                partition.begin_insert_time(),
603                partition.end_insert_time(),
604                source_row_count
605            );
606        }
607        Err(ref e) => {
608            logger
609                .write_log_entry(format!(
610                    "[PARTITION_INSERT_ERROR] view={}/{} time_range=[{}, {}] source_rows={} error={}",
611                    partition.view_metadata.view_set_name,
612                    partition.view_metadata.view_instance_id,
613                    partition.begin_insert_time(),
614                    partition.end_insert_time(),
615                    source_row_count,
616                    e
617                ))
618                .await?;
619            // Translate an exclusion-constraint violation (raw SQLSTATE 23P01) into a legible
620            // domain error: it means an existing partition overlaps this one without being
621            // matched by this transaction's retire step (RetireMatch::Containment: not fully
622            // contained by the new range; RetireMatch::Overlap: not matched by the intersection
623            // predicate either, e.g. still committed by a concurrent writer racing this one --
624            // see RetireMatch::Overlap's "Concurrent writers" docs) -- e.g. a concurrent
625            // maintenance merge committed a wider partition.
626            let overlap_detail = e.as_database_error().and_then(|db_err| {
627                (db_err.constraint() == Some("lakehouse_partitions_no_overlap"))
628                    .then(|| db_err.to_string())
629            });
630            if let Some(detail) = overlap_detail {
631                anyhow::bail!(
632                    "new partition {}/{} [{}, {}] overlaps an existing partition that this \
633                     write's retire step ({retire_match:?}) did not replace, so this write \
634                     cannot replace it (likely a concurrent materialization or merge). Retire \
635                     the conflicting partition (e.g. retire_partition_by_metadata) or align the \
636                     requested range/delta, then retry. Postgres detail: {detail}",
637                    partition.view_metadata.view_set_name,
638                    partition.view_metadata.view_instance_id,
639                    partition.begin_insert_time().to_rfc3339(),
640                    partition.end_insert_time().to_rfc3339(),
641                );
642            }
643            return Err(insert_result.unwrap_err().into());
644        }
645    };
646
647    // Commit the transaction (this also releases the advisory lock). On failure the transaction
648    // rolls back and the caller's delete_if_orphan reclaims the now-unreferenced parquet file.
649    transaction.commit().await.with_context(|| "commit")?;
650
651    info!(
652        "[PARTITION_WRITE_COMMIT] view={}/{} time_range=[{}, {}] file_path={:?} - lock released",
653        &partition.view_metadata.view_set_name,
654        &partition.view_metadata.view_instance_id,
655        partition.begin_insert_time(),
656        partition.end_insert_time(),
657        partition.file_path
658    );
659    Ok(())
660}
661
662/// Result of writing rows to a partition file.
663struct PartitionWriteResult {
664    num_rows: i64,
665    file_path: Option<String>,
666    file_size: i64,
667    event_time_range: Option<TimeRange>,
668    max_sort_key_time: Option<DateTime<Utc>>,
669}
670
671/// Result of `write_rows_and_track_times`: the partition's event time range, alongside the
672/// folded `max_sort_key_time`. `pub` because `write_rows_and_track_times` is itself `pub` and is
673/// called from `rust/analytics/tests/`, which compiles as an external crate.
674#[derive(Debug)]
675pub struct RowSetTrackingResult {
676    pub event_time_range: Option<TimeRange>,
677    pub max_sort_key_time: Option<DateTime<Utc>>,
678}
679
680/// Guards a declared non-nullable field against a `NULL` slipping through undetected.
681///
682/// The parquet writer does not enforce declared nullability itself: for a required top-level
683/// leaf, `ArrowLevels` has no definition levels, so `write_leaf` treats every index as non-null
684/// and a `NULL` in the batch is silently written as the type's default value (e.g. `""` for
685/// `Utf8`) rather than rejected. Builder-based views already catch this via
686/// `RecordBatch::try_new`'s own nullability check; what this guard adds is coverage for the two
687/// kinds of batch that bypass that check -- `blocks` (`rows_to_record_batch`, whose schema is
688/// inferred from Postgres and never compared to the declared one) and every DataFusion-produced
689/// batch (`SqlBatchView` transforms, every merge). One `null_count()` per declared non-nullable
690/// column per batch.
691fn check_non_nullable_columns(batch: &RecordBatch, file_schema: &Schema, desc: &str) -> Result<()> {
692    // Positional, not by-name: the parquet writer zips the declared schema's
693    // fields against the batch's columns by position with no name check, and a batch's column
694    // names need not match the declared schema's at all (e.g. `blocks`, whose batch is built
695    // from Postgres column aliases while the declared schema uses `processes.foo`-style names).
696    // A by-name lookup would therefore silently skip the check for exactly the view that needs
697    // it most.
698    for (i, field) in file_schema.fields().iter().enumerate() {
699        if field.is_nullable() {
700            continue;
701        }
702        let Some(column) = batch.columns().get(i) else {
703            continue;
704        };
705        if column.null_count() > 0 {
706            anyhow::bail!(
707                "writing partition {desc}: column {} ({:?}) is declared NOT NULL but the batch \
708                 carries {} null value(s)",
709                i,
710                field.name(),
711                column.null_count()
712            );
713        }
714    }
715    Ok(())
716}
717
718/// Writes rows from the stream and tracks event time ranges.
719///
720/// Also folds a running `max_sort_key_time` across every received `PartitionRowSet`: the
721/// partition-level value is `Some(max)` only if *every* received row set carried `Some` -- a
722/// single `None` poisons the whole partition to `None`. This must be a running `max`, never
723/// "last row set wins": this function is shared by four multi-row-set senders, and
724/// `BlockPartitionSpec` in particular streams row sets *out of order* via `buffer_unordered`
725/// (`block_partition_spec.rs`), where "last wins" would be actively wrong. `thread_spans_view.rs`
726/// sends exactly one row set today, so the distinction is invisible there, but do not let that
727/// fact simplify this fold away.
728pub async fn write_rows_and_track_times(
729    rb_stream: &mut Receiver<Result<PartitionRowSet, anyhow::Error>>,
730    arrow_writer: &mut AsyncArrowWriter<AsyncParquetWriter>,
731    file_schema: &Schema,
732    logger: &Arc<dyn Logger>,
733    desc: &str,
734) -> Result<RowSetTrackingResult> {
735    let mut min_event_time: Option<DateTime<Utc>> = None;
736    let mut max_event_time: Option<DateTime<Utc>> = None;
737    let mut max_sort_key_time: Option<DateTime<Utc>> = None;
738    let mut any_row_set = false;
739    let mut all_some_sort_key_time = true;
740    let mut write_progression = 0;
741
742    while let Some(msg) = rb_stream.recv().await {
743        let row_set = msg?;
744        any_row_set = true;
745        min_event_time = Some(
746            min_event_time
747                .unwrap_or(row_set.rows_time_range.begin)
748                .min(row_set.rows_time_range.begin),
749        );
750        max_event_time = Some(
751            max_event_time
752                .unwrap_or(row_set.rows_time_range.end)
753                .max(row_set.rows_time_range.end),
754        );
755        match row_set.max_sort_key_time {
756            Some(t) => {
757                max_sort_key_time = Some(max_sort_key_time.map_or(t, |running| running.max(t)));
758            }
759            None => {
760                all_some_sort_key_time = false;
761            }
762        }
763        check_non_nullable_columns(&row_set.rows, file_schema, desc)?;
764        arrow_writer
765            .write(&row_set.rows)
766            .await
767            .with_context(|| "arrow_writer.write")?;
768        if arrow_writer.in_progress_size() > 100 * 1024 * 1024 {
769            arrow_writer
770                .flush()
771                .await
772                .with_context(|| "arrow_writer.flush")?;
773        }
774
775        // Log progress every 10MB to avoid spamming and prevent idle timeout
776        let progression = arrow_writer.bytes_written() / (10 * 1024 * 1024);
777        if progression != write_progression {
778            write_progression = progression;
779            let written = arrow_writer.bytes_written();
780            logger
781                .write_log_entry(format!("{desc}: written {written} bytes"))
782                .await
783                .with_context(|| "writing log entry")?;
784        }
785    }
786
787    let event_time_range = match (min_event_time, max_event_time) {
788        (Some(begin), Some(end)) => Some(TimeRange { begin, end }),
789        _ => None,
790    };
791    let max_sort_key_time = if any_row_set && all_some_sort_key_time {
792        max_sort_key_time
793    } else {
794        None
795    };
796    Ok(RowSetTrackingResult {
797        event_time_range,
798        max_sort_key_time,
799    })
800}
801
802/// Finalizes the partition write, closing the file and creating metadata.
803#[expect(clippy::too_many_arguments)]
804async fn finalize_partition_write(
805    event_time_range: Option<TimeRange>,
806    max_sort_key_time: Option<DateTime<Utc>>,
807    arrow_writer: AsyncArrowWriter<AsyncParquetWriter>,
808    file_path: String,
809    byte_counter: &Arc<AtomicI64>,
810    logger: &Arc<dyn Logger>,
811    desc: &str,
812    object_store: Arc<dyn object_store::ObjectStore>,
813) -> Result<PartitionWriteResult> {
814    if let Some(event_time_range) = event_time_range {
815        // Potentially non-empty partition: close the file and get metadata
816        let close_result = arrow_writer.close().await;
817
818        match close_result {
819            Ok(parquet_metadata) => {
820                let num_rows = parquet_metadata.file_metadata().num_rows();
821
822                // Check if the file actually contains rows
823                // Even if we tracked event times, the file might be empty
824                if num_rows == 0 {
825                    // File contains no rows - treat as empty partition
826                    logger
827                        .write_log_entry(format!(
828                            "created 0-row file, treating as empty partition for {desc}"
829                        ))
830                        .await
831                        .with_context(|| "writing log entry")?;
832
833                    // Delete the empty file
834                    let path = object_store::path::Path::from(file_path.as_str());
835                    if let Err(delete_err) = object_store.delete(&path).await {
836                        warn!("failed to delete empty file {}: {}", file_path, delete_err);
837                    }
838
839                    return Ok(PartitionWriteResult {
840                        num_rows: 0,
841                        file_path: None,
842                        file_size: 0,
843                        event_time_range: None,
844                        max_sort_key_time: None,
845                    });
846                }
847
848                // Non-empty file: keep it and return the result
849                debug!(
850                    "wrote nb_rows={} size={} path={file_path}",
851                    num_rows,
852                    byte_counter.load(std::sync::atomic::Ordering::Relaxed)
853                );
854                let file_size = byte_counter.load(std::sync::atomic::Ordering::Relaxed);
855                Ok(PartitionWriteResult {
856                    num_rows,
857                    file_path: Some(file_path),
858                    file_size,
859                    event_time_range: Some(event_time_range),
860                    max_sort_key_time,
861                })
862            }
863            Err(e) => {
864                // Close failed - try to delete any partial file that may have been written
865                warn!(
866                    "arrow_writer.close failed, attempting to delete partial file: {}",
867                    file_path
868                );
869                let path = object_store::path::Path::from(file_path.as_str());
870                if let Err(delete_err) = object_store.delete(&path).await {
871                    warn!(
872                        "failed to delete partial file {}: {}",
873                        file_path, delete_err
874                    );
875                }
876                Err(e).with_context(|| "arrow_writer.close")
877            }
878        }
879    } else {
880        // Empty partition: no data was written, but the arrow writer may have written
881        // a partial file header. Drop the writer and delete any partial file.
882        drop(arrow_writer);
883
884        logger
885            .write_log_entry(format!("creating empty partition record for {desc}"))
886            .await
887            .with_context(|| "writing log entry")?;
888
889        // Try to delete any partial file that may have been created
890        // (ignore errors - file may not exist if no header was written)
891        let path = object_store::path::Path::from(file_path.as_str());
892        let _ = object_store.delete(&path).await;
893
894        Ok(PartitionWriteResult {
895            num_rows: 0,
896            file_path: None,
897            file_size: 0,
898            event_time_range: None,
899            max_sort_key_time: None,
900        })
901    }
902}
903
904/// Writes a partition to a Parquet file from a stream of `PartitionRowSet`s.
905///
906/// `sort_order` is recorded on the resulting `Partition` as-is (see
907/// `View::get_merged_partition_sort_order` and `MetadataPartitionSpec::sort_order`).
908///
909/// `retire_match` is forwarded to `retire_partitions` (see `RetireMatch`'s docs): pass
910/// `RetireMatch::Containment` unless this call is one of the two `BlockOrder::EventTime` JIT
911/// views (`thread_spans_view.rs` / `net_spans_view.rs`), which must pass `RetireMatch::Overlap`.
912///
913/// `same_run_ranges` is forwarded to `retire_partitions` as-is (see its docs and
914/// `RetireMatch::Overlap`'s); pass an empty `Vec` unless `retire_match` is `RetireMatch::Overlap`,
915/// in which case it must list every insert range the calling `jit_update` run has already written
916/// (or found already up to date) earlier in its own loop. Taken by value (not `&[TimeRange]`):
917/// this whole call is normally wrapped in `spawn_with_context`, whose `Future` bound requires
918/// `'static`, which a slice borrowed from the caller's loop-local accumulator cannot satisfy.
919#[expect(clippy::too_many_arguments)]
920pub async fn write_partition_from_rows(
921    lake: Arc<DataLakeConnection>,
922    view_metadata: ViewMetadata,
923    file_schema: Arc<Schema>,
924    insert_range: TimeRange,
925    source_data_hash: Vec<u8>,
926    sort_order: Option<Vec<String>>,
927    retire_match: RetireMatch,
928    same_run_ranges: Vec<TimeRange>,
929    mut rb_stream: Receiver<Result<PartitionRowSet, anyhow::Error>>,
930    logger: Arc<dyn Logger>,
931) -> Result<()> {
932    let file_id = uuid::Uuid::new_v4();
933    let file_path = format!(
934        "views/{}/{}/{}/{}_{file_id}.parquet",
935        view_metadata.view_set_name,
936        view_metadata.view_instance_id,
937        insert_range.begin.format("%Y-%m-%d"),
938        insert_range.begin.format("%H-%M-%S")
939    );
940    let byte_counter = Arc::new(AtomicI64::new(0));
941    let object_store_writer = AsyncParquetWriter::new(
942        BufWriter::new(
943            lake.blob_storage.inner(),
944            object_store::path::Path::parse(&file_path).with_context(|| "parsing path")?,
945        )
946        .with_max_concurrency(2),
947        byte_counter.clone(),
948    );
949
950    // Configure writer with page-level statistics enabled (default in Arrow 57.0+)
951    // This ensures ColumnIndex with proper null_pages field is written for DataFusion 51+ compatibility
952    let props = WriterProperties::builder()
953        .set_writer_version(WriterVersion::PARQUET_2_0)
954        .set_compression(Compression::LZ4_RAW)
955        // Explicitly enable page-level statistics for clarity (this is the default in Arrow 57.0+)
956        // This generates ColumnIndex structures with proper null_pages field
957        .set_statistics_enabled(parquet::file::properties::EnabledStatistics::Page)
958        // 8x finer than the 1 Mi default, so a clustered sort key actually pays off in row-group
959        // pruning; only affects newly written partitions.
960        .set_max_row_group_row_count(Some(128 * 1024))
961        .build();
962    let mut arrow_writer =
963        AsyncArrowWriter::try_new(object_store_writer, file_schema.clone(), Some(props))
964            .with_context(|| "allocating async arrow writer")?;
965
966    let desc = format!(
967        "[{}, {}] {} {}",
968        view_metadata.view_set_name,
969        view_metadata.view_instance_id,
970        insert_range.begin.to_rfc3339(),
971        insert_range.end.to_rfc3339()
972    );
973
974    // Write rows and track event time ranges
975    let RowSetTrackingResult {
976        event_time_range,
977        max_sort_key_time,
978    } = match write_rows_and_track_times(
979        &mut rb_stream,
980        &mut arrow_writer,
981        &file_schema,
982        &logger,
983        &desc,
984    )
985    .await
986    {
987        Ok(result) => result,
988        Err(e) => {
989            // The writer is dropped without close/abort on this error path, which can
990            // leave already-uploaded multipart data orphaned in object storage. Delete
991            // any partial file before propagating the error (mirror finalize cleanup).
992            drop(arrow_writer);
993            warn!(
994                "write_rows_and_track_times failed, attempting to delete partial file: {}",
995                file_path
996            );
997            let path = object_store::path::Path::from(file_path.as_str());
998            if let Err(delete_err) = lake.blob_storage.inner().delete(&path).await {
999                warn!(
1000                    "failed to delete partial file {}: {}",
1001                    file_path, delete_err
1002                );
1003            }
1004            return Err(e).with_context(|| "write_rows_and_track_times");
1005        }
1006    };
1007
1008    // Finalize the write (close file or create empty metadata)
1009    let result = finalize_partition_write(
1010        event_time_range,
1011        max_sort_key_time,
1012        arrow_writer,
1013        file_path,
1014        &byte_counter,
1015        &logger,
1016        &desc,
1017        lake.blob_storage.inner(),
1018    )
1019    .await?;
1020
1021    // On failure insert_partition reclaims the now-unreferenced parquet file via delete_if_orphan.
1022    let warm_file_path = result.file_path.clone();
1023    insert_partition(
1024        &lake,
1025        &Partition {
1026            view_metadata,
1027            insert_time_range: insert_range,
1028            event_time_range: result.event_time_range,
1029            updated: sqlx::types::chrono::Utc::now(),
1030            file_path: result.file_path,
1031            file_size: result.file_size,
1032            source_data_hash,
1033            num_rows: result.num_rows,
1034            sort_order,
1035            max_sort_key_time: result.max_sort_key_time,
1036        },
1037        retire_match,
1038        &same_run_ranges,
1039        logger,
1040    )
1041    .await
1042    .with_context(|| "insert_partition")?;
1043
1044    // The file is now durable in S3 and registered in PostgreSQL: warm the
1045    // object cache with its key so the follow-up query's first read is a
1046    // cache hit instead of a cold origin GET. Fire-and-forget: this must
1047    // never delay or fail the write/materialization path.
1048    if let Some(file_path) = &warm_file_path {
1049        lake.warm_object(file_path, result.file_size);
1050    }
1051    Ok(())
1052}