Skip to main content

micromegas_analytics/lakehouse/
jit_partitions.rs

1//! Just-in-time (JIT) partition generation groups a view's source blocks (queried from
2//! `blocks_view` in `ORDER BY insert_time, block_id` order) into `SourceDataBlocksInMemory`
3//! partitions no larger than `JitPartitionConfig::max_nb_objects`.
4//!
5//! `JitPartitionConfig::block_order` (see `BlockOrder`) picks between two orderings for the
6//! blocks a partition ends up holding: `InsertTime` (the SQL order, unchanged) for views that
7//! decode blocks independently, and `EventTime` for views that build cross-block trees or rely on
8//! event-time-sorted rows within a partition file. `group_blocks_into_partitions` is the single
9//! place both variants are cut, and it upholds one invariant regardless of ordering: every emitted
10//! partition's `[min_insert_time, max_insert_time]` range is non-overlapping with, and
11//! non-decreasing relative to, every other emitted partition's -- the precondition the
12//! `lakehouse_partitions_no_overlap` exclusion constraint enforces at insert time. See
13//! `group_blocks_into_partitions`'s own docs for how that invariant is upheld under
14//! `BlockOrder::EventTime`, where the natural size-based cut point is not always safe, and for
15//! the one (production-unreachable) shape it does not cover.
16//!
17//! Note that this is an *insert-time* invariant only. It says nothing about partitions'
18//! *event-time* ranges, which for the block-derived views are computed from block
19//! `begin_ticks`/`end_ticks`. By design both `micromegas_tracing` and the Unreal producer now stamp
20//! a single shared timestamp per flush, used for both the outgoing block's close and the
21//! replacement block's `begin`, so consecutive blocks touch exactly and adjacent partitions' event
22//! bounds do not overlap by construction. Data from producer versions predating that fix can still
23//! carry strictly-overlapping block ticks; `ThreadSpansView`'s recorded `max_sort_key_time` is what
24//! makes that harmless to the `Concatenated` scan check (`partitioned_execution_plan::partition_bounds`
25//! prefers it over the looser `max_event_time`) rather than requiring every source block to be
26//! re-ingested. See the ordering-invariant notes on `View::get_scan_output_ordering` and
27//! `tasks/completed/thread_spans_segment_boundary_overlap_plan.md`.
28
29use super::{
30    block_partition_spec::{BlockPartitionSpec, BlockProcessorMap},
31    blocks_view::BlocksView,
32    lakehouse_context::LakehouseContext,
33    partition_cache::{LivePartitionProvider, QueryPartitionProvider},
34    partition_source_data::{PartitionSourceBlock, SourceDataBlocksInMemory},
35    view::{View, ViewMetadata},
36};
37use crate::{
38    dfext::{
39        string_column_accessor::string_column_by_name,
40        typed_column::{get_single_row_primitive_value, typed_column_by_name},
41    },
42    lakehouse::{
43        partition_cache::PartitionCache, partition_source_data::hash_to_object_count,
44        query::query_partitions, view::PartitionSpec,
45    },
46    metadata::{
47        ProcessMetadata, StreamMetadata, block_from_batch_row, stream_metadata_from_batch_row,
48    },
49    response_writer::ResponseWriter,
50    time::TimeRange,
51};
52use anyhow::{Context, Result};
53use chrono::DurationRound;
54use chrono::{DateTime, TimeDelta, Utc};
55use datafusion::arrow::array::{Int64Array, TimestampNanosecondArray};
56use datafusion::arrow::datatypes::{Schema, TimestampNanosecondType};
57use micromegas_ingestion::data_lake_connection::DataLakeConnection;
58use micromegas_telemetry::types::block::BlockMetadata;
59use micromegas_tracing::prelude::*;
60use sqlx::Row;
61use std::collections::HashMap;
62use std::collections::VecDeque;
63use std::sync::Arc;
64use uuid::Uuid;
65
66/// How source blocks are ordered before being cut into JIT partitions.
67///
68/// The choice matters because `generate_stream_jit_partitions_segment` and the batched block
69/// queries driven by `generate_stream_jit_partitions` / `generate_process_jit_partitions` fetch
70/// blocks with `ORDER BY insert_time, block_id`, and that SQL order is what
71/// `group_blocks_into_partitions` starts from.
72#[derive(Clone, Copy, Debug, PartialEq, Eq)]
73pub enum BlockOrder {
74    /// Registration order (`insert_time`, `block_id`), i.e. the SQL order is kept as-is. Correct
75    /// for views that decode each block independently and declare no scan ordering: cutting an
76    /// insert-ordered list always yields insert-time ranges that are non-decreasing and
77    /// non-overlapping across partitions, by construction.
78    InsertTime,
79    /// Event order (`begin_ticks`, `end_ticks`). Required by views that build cross-block trees or
80    /// derive event-time bounds from the list endpoints, and by any view declaring
81    /// `ScanOrdering::Concatenated` over an event-time column. Sorting by event time can put blocks
82    /// with out-of-order `insert_time` inside one partition or straddling a cut point, so
83    /// `group_blocks_into_partitions` additionally enforces insert-safe cut points (see its docs)
84    /// to keep JIT partitions' insert-time ranges non-overlapping.
85    EventTime,
86}
87
88/// Configuration for Just-In-Time (JIT) partition generation.
89pub struct JitPartitionConfig {
90    pub max_nb_objects: i64,
91    pub max_insert_time_slice: TimeDelta,
92    pub block_order: BlockOrder,
93    /// Soft row-count target for a single batched block query (see `batch_windows`). Consecutive
94    /// insert-time buckets are packed into one query up to this many blocks, derived from a
95    /// per-bucket `COUNT(*)` run before the batch queries themselves. A config field rather
96    /// than a bare constant so DB-gated tests can lower it to force a run to split into more than
97    /// one batch.
98    pub target_rows_per_query: i64,
99}
100
101impl Default for JitPartitionConfig {
102    fn default() -> Self {
103        JitPartitionConfig {
104            max_nb_objects: 20 * 1024 * 1024,
105            max_insert_time_slice: TimeDelta::hours(1),
106            block_order: BlockOrder::InsertTime,
107            target_rows_per_query: 250_000,
108        }
109    }
110}
111
112/// Returns the real `[min, max]` `insert_time` range covered by `blocks`, independent of list
113/// order. Always use this instead of reading `blocks[0]`/`blocks[last]`: under
114/// `BlockOrder::EventTime` the list is sorted by event time, not insert time, so its endpoints are
115/// no longer the insert-time extremes.
116pub fn blocks_insert_time_range(blocks: &[Arc<PartitionSourceBlock>]) -> Result<TimeRange> {
117    let mut blocks_iter = blocks.iter();
118    let first = blocks_iter
119        .next()
120        .with_context(|| "blocks_insert_time_range: empty block list")?;
121    let mut min_insert_time = first.block.insert_time;
122    let mut max_insert_time = first.block.insert_time;
123    for block in blocks_iter {
124        min_insert_time = min_insert_time.min(block.block.insert_time);
125        max_insert_time = max_insert_time.max(block.block.insert_time);
126    }
127    Ok(TimeRange::new(min_insert_time, max_insert_time))
128}
129
130/// Splits an event-time-ordered block list into maximal chains whose tick coverage is unbroken, so
131/// each chain can be decoded into one cross-block tree (a call tree for `thread_spans`, a net span
132/// tree for `net_spans`).
133///
134/// A chain breaks only on a *gap* -- `begin_ticks` strictly after the running `end_ticks`, meaning
135/// blocks are missing in between and a tree built across the seam would be nonsense. An *overlap*
136/// (`begin_ticks` at or before the running end) still means unbroken coverage and keeps the chain
137/// open. The chain rule stays tolerant of overlap (not just equality) deliberately: by design both
138/// `micromegas_tracing::dispatch`'s flush paths and the Unreal producer now stamp one shared
139/// timestamp per flush, so consecutive blocks from a healthy producer touch exactly
140/// (`begin_ticks == running end_ticks`) -- but blocks from producer versions predating that fix
141/// (or any other producer stamping two separate timestamps) can still carry a real, small overlap,
142/// and those legacy blocks are exactly the ones this function must keep chaining correctly. An
143/// equality-only test would incorrectly break the chain on every such legacy seam.
144///
145/// The running end is a max, not just the previous block's `end_ticks`: a chain must not be broken
146/// by a short block fully contained in an earlier, longer one.
147pub fn group_contiguous_block_chains(
148    blocks: &[Arc<PartitionSourceBlock>],
149) -> Vec<Vec<BlockMetadata>> {
150    let mut chains: Vec<Vec<BlockMetadata>> = vec![];
151    let mut chain: Vec<BlockMetadata> = vec![];
152    let mut chain_end: Option<i64> = None;
153    for block in blocks {
154        match chain_end {
155            // A gap: close the chain and re-seed from this block.
156            Some(end) if block.block.begin_ticks > end => {
157                chains.push(std::mem::take(&mut chain));
158                chain_end = Some(block.block.end_ticks);
159            }
160            // Touching or overlapping: extend the chain.
161            Some(end) => chain_end = Some(end.max(block.block.end_ticks)),
162            None => chain_end = Some(block.block.end_ticks),
163        }
164        chain.push(block.block.clone());
165    }
166    if !chain.is_empty() {
167        chains.push(chain);
168    }
169    chains
170}
171
172/// Emits one partition (if it holds any objects) from `blocks[start..end]`, logging once if the
173/// window genuinely grew past `max_nb_objects` because no insert-safe cut point was available.
174fn emit_partition(
175    out: &mut Vec<SourceDataBlocksInMemory>,
176    blocks: &[Arc<PartitionSourceBlock>],
177    start: usize,
178    end: usize,
179    nb_objects: i64,
180    grown_past_limit: i64,
181    max_nb_objects: i64,
182) {
183    // A window whose blocks all report nb_objects == 0 is dropped rather than emitted as a
184    // rows-free partition. No *in-repo* producer emits a zero-object block --
185    // `micromegas_tracing::dispatch`'s flush paths skip empty streams, the Unreal sink does the
186    // same, and OTLP ingestion returns no block for a zero-record batch -- but nothing enforces it
187    // (`nb_objects` has no CHECK constraint and the ingestion API does not validate it). Dropping
188    // is safe: such blocks carry no rows, and each partition's DB range is derived per-spec from
189    // `blocks_insert_time_range`, not from full block coverage.
190    if nb_objects == 0 {
191        return;
192    }
193    if grown_past_limit > 0 {
194        // process_id only: under the process-level path (`generate_process_jit_partitions`) a
195        // partition can span several streams, and after the event-time sort `blocks[start]` is an
196        // arbitrary one of them.
197        //
198        // debug!, not warn!: grouping re-runs on every query over the view (jit_update is called
199        // from every scan, and the stable sort makes cut decisions deterministic), so a persistent
200        // inversion would re-emit the same message on every query forever.
201        let process_id = blocks[start].process.process_id;
202        debug!(
203            "group_blocks_into_partitions: process={process_id} emitted a partition of \
204             {nb_objects} objects (soft limit max_nb_objects={max_nb_objects}) after \
205             {grown_past_limit} cut(s) deferred by insert-time inversions with no insert-safe cut \
206             point available"
207        );
208    }
209    out.push(SourceDataBlocksInMemory {
210        blocks: blocks[start..end].to_vec(),
211        block_ids_hash: nb_objects.to_le_bytes().to_vec(),
212    });
213}
214
215/// Groups a segment's source blocks into JIT partitions.
216///
217/// Two invariants must both hold across the returned partitions:
218/// - **Size** -- `max_nb_objects` is a soft, whole-block-granularity limit (a block is never
219///   split, so a single oversized block already exceeds it).
220/// - **Insert-time non-overlap** -- the `lakehouse_partitions_no_overlap` exclusion constraint
221///   requires each partition's `[min_insert_time, max_insert_time]` to be non-overlapping with
222///   (and non-decreasing relative to) every other partition's. Adjacent partitions may *touch*
223///   (one's max equals the next's min): the constraint's `tstzrange` is half-open `[)`, so that
224///   is not an overlap. The one shape this does not cover is two or more partitions sharing an
225///   *identical degenerate* range (every block in both carrying the same `insert_time`), which
226///   requires more than `max_nb_objects` objects at a single microsecond and so does not occur in
227///   production; `tstzrange(t, t)` is empty, so the constraint stays quiet, but the
228///   `BlockOrder::EventTime` arm of `is_jit_partition_up_to_date` would see more than one row for
229///   that range and never report the partitions up to date.
230///
231/// Under `BlockOrder::InsertTime` both invariants hold for free: `blocks` is already sorted by
232/// `insert_time`, so every candidate cut point is insert-safe and this function cuts at exactly
233/// `max_nb_objects` -- identical to a naive greedy cut except that an all-zero-object window emits
234/// nothing rather than a rows-free partition (see `emit_partition`; unreachable in practice).
235///
236/// Under `BlockOrder::EventTime`, `blocks` is stable-sorted by `(begin_ticks, end_ticks)` first
237/// (ties keep the incoming, insert-ordered position, so grouping stays deterministic); a cut point
238/// is then only taken where it is *insert-safe*: every block already in the partition being closed
239/// must have an `insert_time` no later than every remaining block's, computed via a suffix-minimum
240/// over the event-time-sorted list. When the natural `max_nb_objects` cut point isn't safe, the cut
241/// looks back to the most recent safe index; when no safe index exists in the window, the window
242/// grows past the soft limit and a `debug!` logs it.
243pub fn group_blocks_into_partitions(
244    config: &JitPartitionConfig,
245    mut blocks: Vec<Arc<PartitionSourceBlock>>,
246) -> Vec<SourceDataBlocksInMemory> {
247    if config.block_order == BlockOrder::EventTime {
248        // Stable sort: ties keep the incoming (insert_time, block_id) order.
249        blocks.sort_by(|a, b| {
250            (a.block.begin_ticks, a.block.end_ticks).cmp(&(b.block.begin_ticks, b.block.end_ticks))
251        });
252    }
253    let n = blocks.len();
254    if n == 0 {
255        return vec![];
256    }
257
258    // suffix_min[i] = min(insert_time of blocks[i..n]) over the (possibly event-time-sorted)
259    // list; suffix_min[n] = DateTime::<Utc>::MAX_UTC so a cut at n (the tail) is always safe.
260    let mut suffix_min = vec![DateTime::<Utc>::MAX_UTC; n + 1];
261    for i in (0..n).rev() {
262        suffix_min[i] = suffix_min[i + 1].min(blocks[i].block.insert_time);
263    }
264
265    let mut out = vec![];
266    let mut start = 0usize;
267    let mut nb_objects: i64 = 0;
268    let mut prefix_max_insert = DateTime::<Utc>::MIN_UTC;
269    // (index, nb_objects of blocks[start..index]) of the most recent safe cut point seen since
270    // the current window started.
271    let mut last_safe: Option<(usize, i64)> = None;
272    // Counts only the "no safe cut point exists" (grow-past-limit) events below; the look-back
273    // branch logs about itself directly and does not feed this counter (its emitted prefix is
274    // provably <= max_nb_objects, so it must not carry the soft-limit wording).
275    let mut grown_past_limit: i64 = 0;
276
277    let mut i = 0usize;
278    while i < n {
279        // A cut at i is safe iff every block already accumulated (blocks[start..i]) was
280        // inserted no later than every remaining block (blocks[i..]): the emitted partition's
281        // insert range then cannot overlap any later partition's.
282        let safe_here = prefix_max_insert <= suffix_min[i];
283        if safe_here && i > start {
284            last_safe = Some((i, nb_objects));
285        }
286        let block_nb_objects = blocks[i].block.nb_objects as i64;
287        let full = nb_objects + block_nb_objects > config.max_nb_objects && i > start;
288        if full {
289            if safe_here {
290                // The natural cut point is insert-safe: cut here, <= max_nb_objects.
291                emit_partition(
292                    &mut out,
293                    &blocks,
294                    start,
295                    i,
296                    nb_objects,
297                    grown_past_limit,
298                    config.max_nb_objects,
299                );
300                start = i;
301                nb_objects = 0;
302                prefix_max_insert = DateTime::<Utc>::MIN_UTC;
303                last_safe = None;
304                grown_past_limit = 0;
305                continue;
306            } else if let Some((j, j_nb_objects)) = last_safe {
307                // Look back to the most recent safe index: emit the already-safe prefix (which is
308                // provably <= max_nb_objects, since `full` only trips once nb_objects already
309                // fits under the limit), then re-seed the window from j and replay blocks[j..i)
310                // to recompute running state before reprocessing block i (which may itself still
311                // be unsafe -- a straggler's window is not bounded by this rule, see the module
312                // docs).
313                // process_id only -- see the note in `emit_partition`. debug!, not warn!: this is
314                // the designed, insert-safe path and re-fires on every query over the view for as
315                // long as the inversion exists in the source blocks.
316                let process_id = blocks[start].process.process_id;
317                debug!(
318                    "group_blocks_into_partitions: process={process_id} cut moved back from index \
319                     {i} to {j} ({} block(s) looked back)",
320                    i - j
321                );
322                emit_partition(
323                    &mut out,
324                    &blocks,
325                    start,
326                    j,
327                    j_nb_objects,
328                    0,
329                    config.max_nb_objects,
330                );
331                start = j;
332                nb_objects = 0;
333                prefix_max_insert = DateTime::<Utc>::MIN_UTC;
334                last_safe = None;
335                grown_past_limit = 0;
336                // No last_safe recomputation while replaying: no index in (j, i] can be safe
337                // after the re-seed. j was the most recent safe index, so every block before j
338                // has insert_time <= suffix_min[j] <= suffix_min[m] for any m > j -- meaning a
339                // cut at m is safe in the re-seeded window iff it was safe in the original one,
340                // and the original pass already found all of (j, i] unsafe (otherwise last_safe
341                // would have pointed there instead of j).
342                for replayed in &blocks[start..i] {
343                    nb_objects += replayed.block.nb_objects as i64;
344                    prefix_max_insert = prefix_max_insert.max(replayed.block.insert_time);
345                }
346                continue;
347            } else {
348                // No safe cut point exists anywhere in this window: grow past the soft limit
349                // rather than emit a partition whose insert range could overlap a later one.
350                grown_past_limit += 1;
351            }
352        }
353        nb_objects += block_nb_objects;
354        prefix_max_insert = prefix_max_insert.max(blocks[i].block.insert_time);
355        i += 1;
356    }
357    emit_partition(
358        &mut out,
359        &blocks,
360        start,
361        n,
362        nb_objects,
363        grown_past_limit,
364        config.max_nb_objects,
365    );
366    out
367}
368
369/// Packs consecutive insert-time buckets of `[insert_range.begin, insert_range.end)` (stepping by
370/// `slice`, so every bucket edge is `slice`-aligned to `insert_range.begin`) into batch query
371/// windows, greedily closing a window just before it would exceed `target_rows_per_query`.
372///
373/// `bucket_counts` holds one `(bucket_start, nb_blocks)` pair per *non-empty* bucket, ascending;
374/// any bucket missing from it is treated as holding zero blocks (and so never forces a close on
375/// its own). A bucket whose own count already exceeds `target_rows_per_query` still forms one
376/// batch on its own -- the loop never splits a bucket.
377///
378/// Batch edges are always bucket-aligned and batches tile `insert_range` with no gaps or overlaps,
379/// so which width is picked cannot change the specs `group_blocks_into_partitions` later emits
380/// per bucket -- only how many SQL queries it takes to gather them.
381pub fn batch_windows(
382    insert_range: TimeRange,
383    slice: TimeDelta,
384    bucket_counts: &[(DateTime<Utc>, i64)],
385    target_rows_per_query: i64,
386) -> impl Iterator<Item = TimeRange> {
387    let mut windows = Vec::new();
388    let mut running: i64 = 0;
389    let mut batch_begin = insert_range.begin;
390    let mut bucket_begin = insert_range.begin;
391    let mut counts_idx = 0usize;
392    while bucket_begin < insert_range.end {
393        // bucket_counts is ascending and every bucket we walk here is >= the previous one, so a
394        // single forward-moving index suffices (no need to search backwards).
395        while counts_idx < bucket_counts.len() && bucket_counts[counts_idx].0 < bucket_begin {
396            counts_idx += 1;
397        }
398        let nb_blocks =
399            if counts_idx < bucket_counts.len() && bucket_counts[counts_idx].0 == bucket_begin {
400                let n = bucket_counts[counts_idx].1;
401                counts_idx += 1;
402                n
403            } else {
404                0
405            };
406        if running > 0 && running + nb_blocks > target_rows_per_query {
407            windows.push(TimeRange::new(batch_begin, bucket_begin));
408            running = 0;
409            batch_begin = bucket_begin;
410        }
411        running += nb_blocks;
412        bucket_begin += slice;
413    }
414    windows.push(TimeRange::new(batch_begin, insert_range.end));
415    windows.into_iter()
416}
417
418/// Renders a `TimeDelta` as arrow-parsable interval text (e.g. `"3600 seconds"`) for use with
419/// DataFusion's `date_bin`. `TimeDelta`'s own `Display` yields ISO-8601 (`PT3600S`), which
420/// `date_bin` cannot parse.
421fn interval_literal(slice: TimeDelta) -> String {
422    format!("{} seconds", slice.num_seconds())
423}
424
425/// Splits an insert-time-sorted block list into consecutive runs sharing the same
426/// `insert_time.duration_trunc(slice)` bucket. The SQL feeding this is `ORDER BY insert_time,
427/// block_id`, so buckets are contiguous runs in the list -- no sorting or grouping by key needed,
428/// just a scan that closes a run whenever the bucket changes.
429fn split_into_buckets(
430    blocks: Vec<Arc<PartitionSourceBlock>>,
431    slice: TimeDelta,
432) -> Result<Vec<Vec<Arc<PartitionSourceBlock>>>> {
433    let mut buckets = vec![];
434    let mut current: Vec<Arc<PartitionSourceBlock>> = vec![];
435    let mut current_bucket: Option<DateTime<Utc>> = None;
436    for block in blocks {
437        let bucket = block.block.insert_time.duration_trunc(slice)?;
438        if current_bucket != Some(bucket) {
439            if !current.is_empty() {
440                buckets.push(std::mem::take(&mut current));
441            }
442            current_bucket = Some(bucket);
443        }
444        current.push(block);
445    }
446    if !current.is_empty() {
447        buckets.push(current);
448    }
449    Ok(buckets)
450}
451
452/// Runs the per-bucket `COUNT(*) ... GROUP BY date_bin(slice, insert_time)` query over
453/// `insert_range`, under `identity_predicate` (the batch queries' own identity filter -- process +
454/// stream-tag, or stream id) so the returned counts match what the batch queries themselves will
455/// return -- the event-time predicate of the MIN/MAX pre-query would not do. Returns one
456/// `(bucket_start, nb_blocks)` pair per non-empty bucket, ascending.
457async fn fetch_bucket_counts(
458    lakehouse: Arc<LakehouseContext>,
459    blocks_view: &BlocksView,
460    partitions: &PartitionCache,
461    insert_range: TimeRange,
462    slice: TimeDelta,
463    identity_predicate: &str,
464) -> Result<Vec<(DateTime<Utc>, i64)>> {
465    let slice_literal = interval_literal(slice);
466    let begin_iso = insert_range.begin.to_rfc3339();
467    let end_iso = insert_range.end.to_rfc3339();
468    let sql = format!(
469        r#"SELECT date_bin('{slice_literal}', insert_time) as bucket, COUNT(*) as nb_blocks
470             FROM source
471             WHERE {identity_predicate}
472             AND insert_time >= '{begin_iso}'
473             AND insert_time < '{end_iso}'
474             GROUP BY bucket
475             ORDER BY bucket;"#
476    );
477    let filtered = partitions.filter_insert_range(insert_range).partitions;
478    let reader_factory = lakehouse.reader_factory().clone();
479    let df = instrument_named!(
480        query_partitions(
481            lakehouse.runtime().clone(),
482            reader_factory,
483            lakehouse.lake().blob_storage.inner(),
484            blocks_view.get_file_schema(),
485            Arc::new(filtered),
486            &sql,
487        ),
488        "query_partitions"
489    )
490    .await?;
491    let rbs = instrument_named!(df.collect(), "collect_bucket_counts").await?;
492    let mut counts = vec![];
493    for rb in &rbs {
494        let bucket_column: &TimestampNanosecondArray = typed_column_by_name(rb, "bucket")?;
495        let nb_blocks_column: &Int64Array = typed_column_by_name(rb, "nb_blocks")?;
496        for i in 0..rb.num_rows() {
497            counts.push((
498                DateTime::from_timestamp_nanos(bucket_column.value(i)),
499                nb_blocks_column.value(i),
500            ));
501        }
502    }
503    Ok(counts)
504}
505
506/// The process-variant batch query: block columns plus `stream_id` only -- **no stream-level
507/// column** (`streams.dependencies_metadata`/`objects_metadata`/`tags`/`properties`/`format`) may
508/// be added to the `SELECT` list. Re-adding one would reintroduce the per-row stream-blob-copy
509/// memory hazard the lean projection removes, with no test failing except the projection guard in
510/// `analytics/tests/jit_batch_windows_tests.rs`. `array_has("streams.tags", ...)` stays in the
511/// `WHERE` clause: filtering needs no projection.
512pub fn process_batch_sql(process_id: &Uuid, stream_tag: &str, range: &TimeRange) -> String {
513    let begin_iso = range.begin.to_rfc3339();
514    let end_iso = range.end.to_rfc3339();
515    format!(
516        r#"SELECT block_id, stream_id, process_id, begin_time, end_time, begin_ticks, end_ticks, nb_objects, object_offset, payload_size, insert_time
517             FROM source
518             WHERE process_id = '{process_id}'
519             AND array_has( "streams.tags", '{stream_tag}' )
520             AND insert_time >= '{begin_iso}'
521             AND insert_time < '{end_iso}'
522             ORDER BY insert_time, block_id;"#
523    )
524}
525
526/// Fetches and parses one batch window's worth of process-scoped blocks (`process_batch_sql`),
527/// looking up each block's stream metadata in `stream_metadata` (built once per call to
528/// `generate_process_jit_partitions` by `fetch_stream_metadata_map`) rather than rebuilding it per
529/// row -- this is the lean projection's fetch-and-parse half.
530///
531/// A `stream_id` missing from `stream_metadata` is a hard error: the metadata pre-query and this
532/// query share the same identity predicate and insert range, so it cannot happen unless something
533/// is wrong.
534pub async fn fetch_process_blocks(
535    lakehouse: Arc<LakehouseContext>,
536    blocks_view: &BlocksView,
537    partitions: &PartitionCache,
538    range: &TimeRange,
539    process: Arc<ProcessMetadata>,
540    stream_tag: &str,
541    stream_metadata: &HashMap<Uuid, (Arc<StreamMetadata>, String)>,
542) -> Result<Vec<Arc<PartitionSourceBlock>>> {
543    let filtered = partitions.filter_insert_range(*range).partitions;
544    let sql = process_batch_sql(&process.process_id, stream_tag, range);
545    let reader_factory = lakehouse.reader_factory().clone();
546    let df = instrument_named!(
547        query_partitions(
548            lakehouse.runtime().clone(),
549            reader_factory,
550            lakehouse.lake().blob_storage.inner(),
551            blocks_view.get_file_schema(),
552            Arc::new(filtered),
553            &sql,
554        ),
555        "query_partitions"
556    )
557    .await?;
558    let rbs = instrument_named!(df.collect(), "collect_partition_blocks").await?;
559    let mut blocks = vec![];
560    for rb in &rbs {
561        let stream_id_column = string_column_by_name(rb, "stream_id")?;
562        for ir in 0..rb.num_rows() {
563            let block = block_from_batch_row(rb, ir).with_context(|| "block_from_batch_row")?;
564            let stream_id = Uuid::parse_str(stream_id_column.value(ir)?)
565                .with_context(|| "parsing stream_id")?;
566            let (stream, format) = stream_metadata.get(&stream_id).with_context(|| {
567                format!(
568                    "fetch_process_blocks: missing stream metadata for stream {stream_id} \
569                     (same predicate, same range as the metadata pre-query -- this should not \
570                     happen)"
571                )
572            })?;
573            blocks.push(Arc::new(PartitionSourceBlock {
574                block,
575                stream: stream.clone(),
576                process: process.clone(),
577                format: format.clone(),
578            }));
579        }
580    }
581    Ok(blocks)
582}
583
584/// Pre-query 3 (process variant only): fetches every stream's metadata once for the whole insert
585/// range (stream metadata is immutable after registration), so the batched block queries can look
586/// it up per block instead of rebuilding it. Mirrors `streams_view.rs`'s transform query, and
587/// reads the result with the shared `stream_metadata_from_batch_row` helper -- `format` is kept
588/// alongside in the map since it is not part of `StreamMetadata`.
589async fn fetch_stream_metadata_map(
590    lakehouse: Arc<LakehouseContext>,
591    blocks_view: &BlocksView,
592    partitions: &PartitionCache,
593    insert_range: TimeRange,
594    process: &ProcessMetadata,
595    stream_tag: &str,
596) -> Result<HashMap<Uuid, (Arc<StreamMetadata>, String)>> {
597    let process_id = &process.process_id;
598    let begin_iso = insert_range.begin.to_rfc3339();
599    let end_iso = insert_range.end.to_rfc3339();
600    let sql = format!(
601        r#"SELECT stream_id,
602               first_value("process_id")                    as process_id,
603               first_value("streams.dependencies_metadata") as dependencies_metadata,
604               first_value("streams.objects_metadata")      as objects_metadata,
605               first_value("streams.tags")                  as tags,
606               first_value("streams.properties")            as properties,
607               first_value("streams.format")                as format
608        FROM source
609        WHERE process_id = '{process_id}'
610        AND array_has( "streams.tags", '{stream_tag}' )
611        AND insert_time >= '{begin_iso}'
612        AND insert_time < '{end_iso}'
613        GROUP BY stream_id;"#
614    );
615    let filtered = partitions.filter_insert_range(insert_range).partitions;
616    let reader_factory = lakehouse.reader_factory().clone();
617    let df = instrument_named!(
618        query_partitions(
619            lakehouse.runtime().clone(),
620            reader_factory,
621            lakehouse.lake().blob_storage.inner(),
622            blocks_view.get_file_schema(),
623            Arc::new(filtered),
624            &sql,
625        ),
626        "query_partitions"
627    )
628    .await?;
629    let rbs = instrument_named!(df.collect(), "collect_stream_metadata").await?;
630    let mut map = HashMap::new();
631    for rb in &rbs {
632        let format_column = string_column_by_name(rb, "format")?;
633        for ir in 0..rb.num_rows() {
634            let stream = stream_metadata_from_batch_row(rb, ir)
635                .with_context(|| "stream_metadata_from_batch_row")?;
636            let format = format_column.value(ir)?.to_string();
637            map.insert(stream.stream_id, (Arc::new(stream), format));
638        }
639    }
640    Ok(map)
641}
642
643async fn get_insert_time_range(
644    lakehouse: Arc<LakehouseContext>,
645    blocks_view: &BlocksView,
646    query_time_range: &TimeRange,
647    stream: Arc<StreamMetadata>,
648) -> Result<Option<TimeRange>> {
649    // we would need a PartitionCache built from event time range and then filtered for insert time range
650    let part_provider = LivePartitionProvider::new(lakehouse.lake().db_pool.clone());
651    let partitions = part_provider
652        .fetch(
653            &blocks_view.get_view_set_name(),
654            &blocks_view.get_view_instance_id(),
655            Some(*query_time_range),
656            blocks_view.get_file_schema_hash(),
657        )
658        .await?;
659    let stream_id = &stream.stream_id;
660    let begin_range_iso = query_time_range.begin.to_rfc3339();
661    let end_range_iso = query_time_range.end.to_rfc3339();
662    let sql = format!(
663        r#"SELECT MIN(insert_time) as min_insert_time, MAX(insert_time) as max_insert_time
664        FROM source
665        WHERE stream_id = '{stream_id}'
666        AND begin_time <= '{end_range_iso}'
667        AND end_time >= '{begin_range_iso}';"#
668    );
669    let reader_factory = lakehouse.reader_factory().clone();
670    let rbs = query_partitions(
671        lakehouse.runtime().clone(),
672        reader_factory,
673        lakehouse.lake().blob_storage.inner(),
674        blocks_view.get_file_schema(),
675        Arc::new(partitions),
676        &sql,
677    )
678    .await?
679    .collect()
680    .await?;
681    if rbs.is_empty() {
682        return Ok(None);
683    }
684    if rbs[0].num_rows() == 0 {
685        return Ok(None);
686    }
687    let min_insert_time = get_single_row_primitive_value::<TimestampNanosecondType>(&rbs, 0)?;
688    let max_insert_time = get_single_row_primitive_value::<TimestampNanosecondType>(&rbs, 1)?;
689    Ok(Some(TimeRange::new(
690        DateTime::from_timestamp_nanos(min_insert_time),
691        DateTime::from_timestamp_nanos(max_insert_time),
692    )))
693}
694
695/// Generates a segment of JIT partitions.
696pub async fn generate_stream_jit_partitions_segment(
697    config: &JitPartitionConfig,
698    lakehouse: Arc<LakehouseContext>,
699    blocks_view: &BlocksView,
700    partitions: &PartitionCache,
701    insert_time_range: &TimeRange,
702    stream: Arc<StreamMetadata>,
703    process: Arc<ProcessMetadata>,
704) -> Result<Vec<SourceDataBlocksInMemory>> {
705    let partitions = partitions
706        .filter_insert_range(*insert_time_range)
707        .partitions;
708
709    let stream_id = &stream.stream_id;
710    let begin_range_iso = insert_time_range.begin.to_rfc3339();
711    let end_range_iso = insert_time_range.end.to_rfc3339();
712    let sql = format!(
713        r#"SELECT block_id, stream_id, process_id, begin_time, end_time, begin_ticks, end_ticks, nb_objects, object_offset, payload_size, insert_time, "streams.format"
714             FROM source
715             WHERE stream_id = '{stream_id}'
716             AND insert_time >= '{begin_range_iso}'
717             AND insert_time < '{end_range_iso}'
718             ORDER BY insert_time, block_id;"#
719    );
720
721    let reader_factory = lakehouse.reader_factory().clone();
722    let rbs = query_partitions(
723        lakehouse.runtime().clone(),
724        reader_factory,
725        lakehouse.lake().blob_storage.inner(),
726        blocks_view.get_file_schema(),
727        Arc::new(partitions),
728        &sql,
729    )
730    .await?
731    .collect()
732    .await?;
733
734    let mut blocks = vec![];
735    for rb in rbs {
736        let format_column = string_column_by_name(&rb, "streams.format")?;
737        for ir in 0..rb.num_rows() {
738            let block = block_from_batch_row(&rb, ir).with_context(|| "block_from_batch_row")?;
739            let format = format_column.value(ir)?.to_string();
740            blocks.push(Arc::new(PartitionSourceBlock {
741                block,
742                stream: stream.clone(),
743                process: process.clone(),
744                format,
745            }));
746        }
747    }
748
749    Ok(group_blocks_into_partitions(config, blocks))
750}
751
752/// generate_stream_jit_partitions lists the partitiions that are needed to cover a time span
753/// these partitions may not exist or they could be out of date
754/// Generates JIT partitions for a given time range.
755///
756/// Batches its block queries: `batch_windows`, derived from a
757/// per-bucket `COUNT(*)`, picks how many insert-time buckets one query covers, and each batch's
758/// rows are then split back into per-bucket runs (`split_into_buckets`) and grouped independently
759/// -- byte-identical to running `generate_stream_jit_partitions_segment` once per bucket, just
760/// fewer, wider queries to get there. `generate_stream_jit_partitions_segment` itself is kept, not
761/// called from here anymore.
762#[span_fn]
763pub async fn generate_stream_jit_partitions(
764    config: &JitPartitionConfig,
765    lakehouse: Arc<LakehouseContext>,
766    blocks_view: &BlocksView,
767    query_time_range: &TimeRange,
768    stream: Arc<StreamMetadata>,
769    process: Arc<ProcessMetadata>,
770) -> Result<Vec<SourceDataBlocksInMemory>> {
771    let insert_time_range = get_insert_time_range(
772        lakehouse.clone(),
773        blocks_view,
774        query_time_range,
775        stream.clone(),
776    )
777    .await?;
778    if insert_time_range.is_none() {
779        return Ok(vec![]);
780    }
781    let insert_time_range = insert_time_range.with_context(|| "missing insert_time_range")?;
782    let insert_time_range = TimeRange::new(
783        insert_time_range
784            .begin
785            .duration_trunc(config.max_insert_time_slice)?,
786        insert_time_range
787            .end
788            .duration_trunc(config.max_insert_time_slice)?
789            + config.max_insert_time_slice,
790    );
791    let source_partitions = instrument_named!(
792        PartitionCache::fetch_overlapping_insert_range_for_view(
793            &lakehouse.lake().db_pool,
794            blocks_view.get_view_set_name(),
795            blocks_view.get_view_instance_id(),
796            insert_time_range,
797        ),
798        "fetch_overlapping_insert_range_for_view"
799    )
800    .await?;
801
802    let stream_id = stream.stream_id;
803    let identity_predicate = format!("stream_id = '{stream_id}'");
804    // A single-bucket insert_time_range can only ever produce one batch window (see
805    // `batch_windows`'s doc comment), regardless of bucket_counts, so skip the COUNT(*) round
806    // trip entirely in that case.
807    let bucket_counts =
808        if insert_time_range.end - insert_time_range.begin <= config.max_insert_time_slice {
809            vec![]
810        } else {
811            fetch_bucket_counts(
812                lakehouse.clone(),
813                blocks_view,
814                &source_partitions,
815                insert_time_range,
816                config.max_insert_time_slice,
817                &identity_predicate,
818            )
819            .await?
820        };
821
822    let windows: Vec<TimeRange> = batch_windows(
823        insert_time_range,
824        config.max_insert_time_slice,
825        &bucket_counts,
826        config.target_rows_per_query,
827    )
828    .collect();
829    debug!(
830        "generate_stream_jit_partitions: stream={stream_id}: derived {} batch window(s) from {} \
831         non-empty bucket(s) (~{} row(s) total, target_rows_per_query={})",
832        windows.len(),
833        bucket_counts.len(),
834        bucket_counts.iter().map(|(_, n)| n).sum::<i64>(),
835        config.target_rows_per_query
836    );
837
838    let mut partitions = vec![];
839    for batch_range in windows {
840        let filtered = source_partitions
841            .filter_insert_range(batch_range)
842            .partitions;
843        let begin_iso = batch_range.begin.to_rfc3339();
844        let end_iso = batch_range.end.to_rfc3339();
845        let sql = format!(
846            r#"SELECT block_id, stream_id, process_id, begin_time, end_time, begin_ticks, end_ticks, nb_objects, object_offset, payload_size, insert_time, "streams.format"
847                 FROM source
848                 WHERE stream_id = '{stream_id}'
849                 AND insert_time >= '{begin_iso}'
850                 AND insert_time < '{end_iso}'
851                 ORDER BY insert_time, block_id;"#
852        );
853        let reader_factory = lakehouse.reader_factory().clone();
854        let df = instrument_named!(
855            query_partitions(
856                lakehouse.runtime().clone(),
857                reader_factory,
858                lakehouse.lake().blob_storage.inner(),
859                blocks_view.get_file_schema(),
860                Arc::new(filtered),
861                &sql,
862            ),
863            "query_partitions"
864        )
865        .await?;
866        let rbs = instrument_named!(df.collect(), "collect_partition_blocks").await?;
867
868        let mut blocks = vec![];
869        for rb in rbs {
870            let format_column = string_column_by_name(&rb, "streams.format")?;
871            for ir in 0..rb.num_rows() {
872                let block =
873                    block_from_batch_row(&rb, ir).with_context(|| "block_from_batch_row")?;
874                let format = format_column.value(ir)?.to_string();
875                blocks.push(Arc::new(PartitionSourceBlock {
876                    block,
877                    stream: stream.clone(),
878                    process: process.clone(),
879                    format,
880                }));
881            }
882        }
883        for bucket_blocks in split_into_buckets(blocks, config.max_insert_time_slice)? {
884            partitions.append(&mut group_blocks_into_partitions(config, bucket_blocks));
885        }
886    }
887    Ok(partitions)
888}
889
890/// generate_process_jit_partitions lists the partitions that are needed to cover a time span for a specific process
891/// these partitions may not exist or they could be out of date
892/// Generates JIT partitions for a given time range filtered by process.
893///
894/// Batches its block queries the same way `generate_stream_jit_partitions` does, and additionally
895/// applies the lean projection:
896/// `fetch_stream_metadata_map` fetches every stream's metadata once for the whole insert range,
897/// and each batch's `fetch_process_blocks` call looks it up per block instead of projecting stream
898/// blobs onto every row and rebuilding `StreamMetadata` per row. The grouping call is inlined
899/// into the batch loop below.
900#[span_fn]
901pub async fn generate_process_jit_partitions(
902    config: &JitPartitionConfig,
903    lakehouse: Arc<LakehouseContext>,
904    blocks_view: &BlocksView,
905    query_time_range: &TimeRange,
906    process: Arc<ProcessMetadata>,
907    stream_tag: &str,
908) -> Result<Vec<SourceDataBlocksInMemory>> {
909    // Get insert time range for all blocks in this process
910    let part_provider = LivePartitionProvider::new(lakehouse.lake().db_pool.clone());
911    let view_set_name = blocks_view.get_view_set_name();
912    let view_instance_id = blocks_view.get_view_instance_id();
913    let partitions = instrument_named!(
914        part_provider.fetch(
915            &view_set_name,
916            &view_instance_id,
917            Some(*query_time_range),
918            blocks_view.get_file_schema_hash(),
919        ),
920        "live_partition_provider_fetch"
921    )
922    .await?;
923
924    let process_id = &process.process_id;
925    let begin_range_iso = query_time_range.begin.to_rfc3339();
926    let end_range_iso = query_time_range.end.to_rfc3339();
927    let sql = format!(
928        r#"SELECT MIN(insert_time) as min_insert_time, MAX(insert_time) as max_insert_time
929        FROM source
930        WHERE process_id = '{process_id}'
931        AND array_has( "streams.tags", '{stream_tag}' )
932        AND begin_time <= '{end_range_iso}'
933        AND end_time >= '{begin_range_iso}';"#
934    );
935
936    let reader_factory = lakehouse.reader_factory().clone();
937    let df = instrument_named!(
938        query_partitions(
939            lakehouse.runtime().clone(),
940            reader_factory,
941            lakehouse.lake().blob_storage.inner(),
942            blocks_view.get_file_schema(),
943            Arc::new(partitions),
944            &sql,
945        ),
946        "query_partitions"
947    )
948    .await?;
949    let rbs = instrument_named!(df.collect(), "collect_insert_time_range").await?;
950
951    if rbs.is_empty() || rbs[0].num_rows() == 0 {
952        return Ok(vec![]);
953    }
954
955    let min_insert_time = get_single_row_primitive_value::<TimestampNanosecondType>(&rbs, 0)?;
956    let max_insert_time = get_single_row_primitive_value::<TimestampNanosecondType>(&rbs, 1)?;
957
958    if min_insert_time == 0 || max_insert_time == 0 {
959        return Ok(vec![]);
960    }
961
962    let insert_time_range = TimeRange::new(
963        DateTime::from_timestamp_nanos(min_insert_time)
964            .duration_trunc(config.max_insert_time_slice)?,
965        DateTime::from_timestamp_nanos(max_insert_time)
966            .duration_trunc(config.max_insert_time_slice)?
967            + config.max_insert_time_slice,
968    );
969
970    let source_partitions = instrument_named!(
971        PartitionCache::fetch_overlapping_insert_range_for_view(
972            &lakehouse.lake().db_pool,
973            blocks_view.get_view_set_name(),
974            blocks_view.get_view_instance_id(),
975            insert_time_range,
976        ),
977        "fetch_overlapping_insert_range_for_view"
978    )
979    .await?;
980
981    let identity_predicate =
982        format!(r#"process_id = '{process_id}' AND array_has( "streams.tags", '{stream_tag}' )"#);
983    // A single-bucket insert_time_range can only ever produce one batch window (see
984    // `batch_windows`'s doc comment), regardless of bucket_counts, so skip the COUNT(*) round
985    // trip entirely in that case.
986    let bucket_counts =
987        if insert_time_range.end - insert_time_range.begin <= config.max_insert_time_slice {
988            vec![]
989        } else {
990            fetch_bucket_counts(
991                lakehouse.clone(),
992                blocks_view,
993                &source_partitions,
994                insert_time_range,
995                config.max_insert_time_slice,
996                &identity_predicate,
997            )
998            .await?
999        };
1000
1001    let stream_metadata = fetch_stream_metadata_map(
1002        lakehouse.clone(),
1003        blocks_view,
1004        &source_partitions,
1005        insert_time_range,
1006        &process,
1007        stream_tag,
1008    )
1009    .await?;
1010
1011    let windows: Vec<TimeRange> = batch_windows(
1012        insert_time_range,
1013        config.max_insert_time_slice,
1014        &bucket_counts,
1015        config.target_rows_per_query,
1016    )
1017    .collect();
1018    debug!(
1019        "generate_process_jit_partitions: process={process_id} stream_tag={stream_tag}: derived \
1020         {} batch window(s) from {} non-empty bucket(s) (~{} row(s) total, \
1021         target_rows_per_query={})",
1022        windows.len(),
1023        bucket_counts.len(),
1024        bucket_counts.iter().map(|(_, n)| n).sum::<i64>(),
1025        config.target_rows_per_query
1026    );
1027
1028    let mut partitions = vec![];
1029    for batch_range in windows {
1030        let blocks = fetch_process_blocks(
1031            lakehouse.clone(),
1032            blocks_view,
1033            &source_partitions,
1034            &batch_range,
1035            process.clone(),
1036            stream_tag,
1037            &stream_metadata,
1038        )
1039        .await?;
1040        for bucket_blocks in split_into_buckets(blocks, config.max_insert_time_slice)? {
1041            partitions.append(&mut group_blocks_into_partitions(config, bucket_blocks));
1042        }
1043    }
1044    Ok(partitions)
1045}
1046
1047/// One `lakehouse_partitions` candidate row, as fetched by `fetch_freshness_candidates`:
1048/// `(begin_insert_time, end_insert_time, file_schema_hash, source_data_hash)`. Fields are `pub` so
1049/// `analytics/tests/jit_freshness_tests.rs` can build rows directly, without a live database.
1050#[derive(Debug, Clone)]
1051pub struct PartitionFreshnessRow {
1052    pub begin_insert_time: DateTime<Utc>,
1053    pub end_insert_time: DateTime<Utc>,
1054    pub file_schema_hash: Vec<u8>,
1055    pub source_data_hash: Vec<u8>,
1056}
1057
1058/// Fetches every `lakehouse_partitions` row for `view_meta` whose insert range *inclusively
1059/// overlaps* `range` (`begin_insert_time <= range.end AND end_insert_time >= range.begin`) -- a
1060/// superset of what any of the three `BlockOrder`-dependent per-spec queries used to return (an
1061/// exact-equality row necessarily overlaps the spec's own range), with the variant-specific
1062/// narrowing applied in Rust by `spec_is_up_to_date`. Used both by `is_jit_partition_up_to_date`
1063/// (one spec's own range) and `find_up_to_date_partitions` (one range spanning many specs).
1064async fn fetch_freshness_candidates(
1065    pool: &sqlx::PgPool,
1066    view_meta: &ViewMetadata,
1067    range: TimeRange,
1068) -> Result<Vec<PartitionFreshnessRow>> {
1069    let rows = sqlx::query(
1070        "SELECT begin_insert_time, end_insert_time, file_schema_hash, source_data_hash
1071         FROM lakehouse_partitions
1072         WHERE view_set_name = $1
1073         AND view_instance_id = $2
1074         AND begin_insert_time <= $3
1075         AND end_insert_time >= $4
1076         ;",
1077    )
1078    .bind(&*view_meta.view_set_name)
1079    .bind(&*view_meta.view_instance_id)
1080    .bind(range.end)
1081    .bind(range.begin)
1082    .fetch_all(pool)
1083    .await
1084    .with_context(|| "fetching freshness candidates")?;
1085    rows.into_iter()
1086        .map(|r| {
1087            Ok(PartitionFreshnessRow {
1088                begin_insert_time: r.try_get("begin_insert_time")?,
1089                end_insert_time: r.try_get("end_insert_time")?,
1090                file_schema_hash: r.try_get("file_schema_hash")?,
1091                source_data_hash: r.try_get("source_data_hash")?,
1092            })
1093        })
1094        .collect()
1095}
1096
1097/// Filters `candidates` down to the rows the per-spec SQL used to return (exact range equality for
1098/// `BlockOrder::EventTime`, exact match for a degenerate `BlockOrder::InsertTime` range, inclusive
1099/// overlap otherwise), then applies the same `rows.len() == 1` / `file_schema_hash` /
1100/// object-count checks `is_jit_partition_up_to_date` always has.
1101///
1102/// `block_order` selects which comparison applies (see `BlockOrder`):
1103/// - `BlockOrder::EventTime` (`thread_spans`/`net_spans` only) requires exact insert-range and
1104///   exact count equality. Their cut points can move between `jit_update` runs, so a later run's
1105///   spec can be narrower than an already-written partition that still overlaps it; the overlap/`>=`
1106///   test below would call that stale, wider partition up to date and leave it in place forever.
1107///   Exact equality reports "not up to date" instead, so the write falls through to
1108///   `RetireMatch::Overlap` (see `write_partition.rs`).
1109/// - `BlockOrder::InsertTime` (every other JIT view) keeps the original overlap/`>=`-count test
1110///   (with an exact-match branch for a degenerate spec range, to avoid matching multiple/wider
1111///   rows). Their cut points are stable across runs, so a stale spec already covered by a wider
1112///   committed partition -- e.g. from a concurrent `jit_update` that lost a race -- must stay a
1113///   no-op here: `RetireMatch::Containment` cannot retire a merely-overlapping partition, so
1114///   calling such a spec "not up to date" would make the subsequent insert trip the
1115///   `lakehouse_partitions_no_overlap` exclusion constraint.
1116pub fn spec_is_up_to_date(
1117    view_meta: &ViewMetadata,
1118    spec: &SourceDataBlocksInMemory,
1119    block_order: BlockOrder,
1120    candidates: &[PartitionFreshnessRow],
1121) -> Result<bool> {
1122    let (min_insert_time, max_insert_time) =
1123        get_part_insert_time_range(spec).with_context(|| "get_event_time_range")?;
1124    let desc = format!(
1125        "[{}, {}] {} {}",
1126        min_insert_time.to_rfc3339(),
1127        max_insert_time.to_rfc3339(),
1128        *view_meta.view_set_name,
1129        *view_meta.view_instance_id,
1130    );
1131
1132    // See: https://github.com/madesroches/micromegas/issues/488
1133    let matches: Vec<&PartitionFreshnessRow> = candidates
1134        .iter()
1135        .filter(|r| match block_order {
1136            // Exact insert-range equality (see this function's docs for why).
1137            BlockOrder::EventTime => {
1138                r.begin_insert_time == min_insert_time && r.end_insert_time == max_insert_time
1139            }
1140            // Degenerate range: exact-match on the single insert time, to avoid matching
1141            // multiple/wider overlapping rows (see this function's docs).
1142            BlockOrder::InsertTime if min_insert_time == max_insert_time => {
1143                r.begin_insert_time == min_insert_time && r.end_insert_time == min_insert_time
1144            }
1145            // Overlap test, using inclusive inequalities (<=, >=) to prevent race conditions: with
1146            // exclusive inequalities (<, >), identical time ranges never match, causing partitions
1147            // to be unnecessarily recreated on every query (see this function's docs and
1148            // https://github.com/madesroches/micromegas/issues/488).
1149            BlockOrder::InsertTime => {
1150                r.begin_insert_time <= max_insert_time && r.end_insert_time >= min_insert_time
1151            }
1152        })
1153        .collect();
1154    if matches.len() != 1 {
1155        debug!("{desc}: found {} partitions (expected 1)", matches.len());
1156        for (i, r) in matches.iter().enumerate() {
1157            let source_row_count = hash_to_object_count(&r.source_data_hash)?;
1158            debug!(
1159                "{desc}: partition {}: file_schema_hash={:?}, source_rows={}",
1160                i, r.file_schema_hash, source_row_count
1161            );
1162        }
1163        info!("{desc}: found {} partitions", matches.len());
1164        return Ok(false);
1165    }
1166    let r = matches[0];
1167    if r.file_schema_hash != view_meta.file_schema_hash {
1168        // this is dangerous because we could be creating a new partition smaller than the old one, which is not supported.
1169        // let's make sure there is no old data loitering
1170        warn!("{desc}: found matching partition with different file schema");
1171        return Ok(false);
1172    }
1173    let existing_count = hash_to_object_count(&r.source_data_hash)?;
1174    let required_count = hash_to_object_count(&spec.block_ids_hash)?;
1175    let up_to_date = match block_order {
1176        // Exact count equality: see this function's docs.
1177        BlockOrder::EventTime => existing_count == required_count,
1178        // `>=`, not exact equality: a stale spec covered by an already-wider partition is a no-op
1179        // here, not a rewrite (see this function's docs).
1180        BlockOrder::InsertTime => existing_count >= required_count,
1181    };
1182    if !up_to_date {
1183        info!(
1184            "{desc}: existing partition object count does not satisfy block_order={block_order:?} \
1185             (existing={existing_count}, required={required_count}): creating a new partition"
1186        );
1187        return Ok(false);
1188    }
1189    info!("{desc}: partition up to date");
1190    Ok(true)
1191}
1192
1193/// is_jit_partition_up_to_date compares a partition spec with the partitions that exist to know if it should be recreated
1194/// Checks if a JIT partition is up to date.
1195///
1196/// Fetches the spec's own candidate range (`fetch_freshness_candidates`) and runs the shared
1197/// matcher (`spec_is_up_to_date`) against it -- see that function's docs for the per-`BlockOrder`
1198/// semantics. Kept for the `BlockOrder::EventTime` callers (`net_spans`/`thread_spans`), whose
1199/// checks stay per-spec, interleaved with retire logic inside their own `update_partition`. The
1200/// five `BlockOrder::InsertTime` callers instead batch through `find_up_to_date_partitions`.
1201#[span_fn]
1202pub async fn is_jit_partition_up_to_date(
1203    pool: &sqlx::PgPool,
1204    view_meta: ViewMetadata,
1205    spec: &SourceDataBlocksInMemory,
1206    block_order: BlockOrder,
1207) -> Result<bool> {
1208    let (min_insert_time, max_insert_time) =
1209        get_part_insert_time_range(spec).with_context(|| "get_event_time_range")?;
1210    let candidates = instrument_named!(
1211        fetch_freshness_candidates(
1212            pool,
1213            &view_meta,
1214            TimeRange::new(min_insert_time, max_insert_time),
1215        ),
1216        "sql_select_matching_partitions"
1217    )
1218    .await?;
1219    spec_is_up_to_date(&view_meta, spec, block_order, &candidates)
1220}
1221
1222/// One candidates fetch over `[specs.first().min, specs.last().max]` (specs have ascending,
1223/// non-overlapping insert ranges), then the matcher per spec, run to a fixpoint. An empty `specs`
1224/// returns `Ok(vec![])` without issuing any fetch -- reachable at every call site, since
1225/// `generate_*_jit_partitions` returns no specs when the range holds no blocks.
1226///
1227/// The first pass runs `spec_is_up_to_date` for every spec against the full candidate set. After
1228/// that, only a worklist of specs whose verdict might have changed is re-evaluated: because specs
1229/// have ascending, non-overlapping insert ranges, a candidate row can be entirely contained in at
1230/// most two (necessarily adjacent) specs' ranges, so when spec `i` newly becomes stale, the only
1231/// other specs whose verdict can possibly change as a result are `i`'s immediate neighbors in the
1232/// sorted order (index `i - 1` and `i + 1`) -- no non-adjacent spec's verdict can ever be affected.
1233/// A re-evaluated spec `j` is checked against a candidate set that drops any row entirely
1234/// contained in the insert range of some *other*, currently-stale spec `i` (`i != j`) -- such a
1235/// row is a `RetireMatch::Containment` match for spec `i` and will be gone once `i`'s write runs
1236/// this call, so it must not count towards `j`'s freshness. Spec `j`'s own range is always excluded from that drop: a row `j` itself would
1237/// retire is not evidence about whether `j`'s *own* data is current, so it must stay in the set
1238/// used to (re-)evaluate `j`. A spec, once found stale, is never recomputed again (so verdicts are
1239/// monotone -- up to date to stale only -- by construction, with no separate clamping step), and
1240/// only newly-stale specs push their neighbors onto the worklist. This bounds total work to at
1241/// most `specs.len()` initial `spec_is_up_to_date` calls plus at most two more per spec (once per
1242/// neighbor, if ever re-triggered) -- O(specs), not O(specs^2) or O(specs^3). Returns up-to-date
1243/// flags parallel to `specs`.
1244#[span_fn]
1245pub async fn find_up_to_date_partitions(
1246    pool: &sqlx::PgPool,
1247    view_meta: ViewMetadata,
1248    block_order: BlockOrder,
1249    specs: &[SourceDataBlocksInMemory],
1250) -> Result<Vec<bool>> {
1251    if specs.is_empty() {
1252        return Ok(vec![]);
1253    }
1254    let ranges: Vec<(DateTime<Utc>, DateTime<Utc>)> = specs
1255        .iter()
1256        .map(get_part_insert_time_range)
1257        .collect::<Result<Vec<_>>>()?;
1258    let outer_range = TimeRange::new(
1259        ranges
1260            .first()
1261            .with_context(|| "find_up_to_date_partitions: empty ranges")?
1262            .0,
1263        ranges
1264            .last()
1265            .with_context(|| "find_up_to_date_partitions: empty ranges")?
1266            .1,
1267    );
1268    debug!(
1269        "find_up_to_date_partitions: fetching freshness candidates for {} spec(s) over [{}, {}]",
1270        specs.len(),
1271        outer_range.begin.to_rfc3339(),
1272        outer_range.end.to_rfc3339(),
1273    );
1274    let candidates = instrument_named!(
1275        fetch_freshness_candidates(pool, &view_meta, outer_range),
1276        "sql_select_freshness_candidates"
1277    )
1278    .await?;
1279
1280    resolve_up_to_date_fixpoint(specs, &view_meta, block_order, candidates)
1281}
1282
1283/// The pure fixpoint at the heart of `find_up_to_date_partitions` (see that function's docs for
1284/// the derivation): runs `spec_is_up_to_date` once for every spec against `candidates`, then
1285/// drains a worklist of specs that might need re-evaluation because a *neighbor* newly became
1286/// stale. Because specs have ascending, non-overlapping insert ranges, a candidate row can be
1287/// entirely contained in at most two specs' ranges, and only when those two specs are adjacent in
1288/// the sorted order (sharing a boundary point, for a degenerate `begin == end` row) -- so the only
1289/// specs whose verdict can ever change as a consequence of spec `i` becoming stale are `i`'s
1290/// immediate neighbors, index `i - 1` and `i + 1`. A spec is re-evaluated against a candidate set
1291/// that drops rows entirely contained in some *other*, currently-stale spec's insert range (never
1292/// its own range -- a row a spec would itself retire is not evidence about its own freshness); if
1293/// that recompute still comes back stale, the spec is marked stale (it is never recomputed again
1294/// -- once stale, verdicts are monotone by construction, with no separate clamping step needed)
1295/// and its own neighbors are pushed onto the worklist in turn. Takes an already-fetched
1296/// `candidates` and does no I/O, so `analytics/tests/jit_freshness_tests.rs` can drive it directly
1297/// without a live `PgPool`.
1298pub fn resolve_up_to_date_fixpoint(
1299    specs: &[SourceDataBlocksInMemory],
1300    view_meta: &ViewMetadata,
1301    block_order: BlockOrder,
1302    candidates: Vec<PartitionFreshnessRow>,
1303) -> Result<Vec<bool>> {
1304    let ranges: Vec<(DateTime<Utc>, DateTime<Utc>)> = specs
1305        .iter()
1306        .map(get_part_insert_time_range)
1307        .collect::<Result<Vec<_>>>()?;
1308
1309    // Initial verdicts: one full pass, every spec against the full, unfiltered candidate set.
1310    // This is the one unavoidable full pass; everything after this only re-checks specs that a
1311    // neighbor's staleness could plausibly have affected.
1312    let mut up_to_date: Vec<bool> = specs
1313        .iter()
1314        .map(|spec| spec_is_up_to_date(view_meta, spec, block_order, &candidates))
1315        .collect::<Result<Vec<_>>>()?;
1316
1317    // Which spec(s) entirely contain each candidate row -- computed once, up front, because
1318    // `ranges` are ascending and non-overlapping (documented on `SourceDataBlocksInMemory`), so a
1319    // row can be contained in at most two specs' ranges (only at a shared boundary point, for a
1320    // degenerate `begin == end` row), and this containment relationship never changes as specs are
1321    // discovered stale. Parallel to `candidates`; O(N*M) once, not O(N*M) per re-evaluation.
1322    let containing_specs: Vec<Vec<usize>> = candidates
1323        .iter()
1324        .map(|row| {
1325            ranges
1326                .iter()
1327                .enumerate()
1328                .filter(|(_, (min_i, max_i))| {
1329                    row.begin_insert_time >= *min_i && row.end_insert_time <= *max_i
1330                })
1331                .map(|(i, _)| i)
1332                .collect()
1333        })
1334        .collect();
1335
1336    // Worklist of spec indices to (re-)evaluate. Only a stale spec's immediate neighbors
1337    // (index - 1, index + 1) can ever need re-evaluation: a row can be entirely contained in at
1338    // most two specs' ranges, and those two are necessarily adjacent, so no non-adjacent spec's
1339    // verdict can be affected by this spec's staleness. Seed it with the neighbors of every spec
1340    // that is already stale after the initial pass.
1341    let mut pending: VecDeque<usize> = VecDeque::new();
1342    for (i, is_up_to_date) in up_to_date.iter().enumerate() {
1343        if !is_up_to_date {
1344            if i > 0 {
1345                pending.push_back(i - 1);
1346            }
1347            if i + 1 < specs.len() {
1348                pending.push_back(i + 1);
1349            }
1350        }
1351    }
1352
1353    while let Some(j) = pending.pop_front() {
1354        // Already known stale: verdicts are monotone (up to date -> stale only), and this spec's
1355        // neighbors were already pushed onto the worklist the moment it first became stale, so
1356        // re-checking it again would be a no-op. Skipping without calling the matcher also means
1357        // `spec_is_up_to_date`'s internal "partition up to date" log can never be logged for a
1358        // spec whose returned verdict is actually stale.
1359        if !up_to_date[j] {
1360            continue;
1361        }
1362        let spec = &specs[j];
1363        let filtered: Vec<PartitionFreshnessRow> = candidates
1364            .iter()
1365            .zip(containing_specs.iter())
1366            .filter(|(_, containing)| !containing.iter().any(|&i| i != j && !up_to_date[i]))
1367            .map(|(row, _)| row.clone())
1368            .collect();
1369        let recomputed = spec_is_up_to_date(view_meta, spec, block_order, &filtered)?;
1370        if !recomputed {
1371            up_to_date[j] = false;
1372            if j > 0 {
1373                pending.push_back(j - 1);
1374            }
1375            if j + 1 < specs.len() {
1376                pending.push_back(j + 1);
1377            }
1378        }
1379    }
1380
1381    Ok(up_to_date)
1382}
1383
1384/// get_event_time_range returns the time range covered by a partition spec
1385/// Returns the insert time range covered by a partition spec.
1386fn get_part_insert_time_range(
1387    spec: &SourceDataBlocksInMemory,
1388) -> Result<(DateTime<Utc>, DateTime<Utc>)> {
1389    let range = blocks_insert_time_range(&spec.blocks)
1390        .with_context(|| "empty partition should not exist")?;
1391    Ok((range.begin, range.end))
1392}
1393
1394/// Writes a partition from a set of blocks.
1395///
1396/// `block_processors` keys per-block dispatch on the source block's `format`;
1397/// see `BlockPartitionSpec::write` for the unknown-format behavior.
1398#[span_fn]
1399pub async fn write_partition_from_blocks(
1400    lake: Arc<DataLakeConnection>,
1401    view_metadata: ViewMetadata,
1402    schema: Arc<Schema>,
1403    source_data: SourceDataBlocksInMemory,
1404    block_processors: Arc<BlockProcessorMap>,
1405) -> Result<()> {
1406    if source_data.blocks.is_empty() {
1407        anyhow::bail!("empty partition spec");
1408    }
1409    let insert_range = blocks_insert_time_range(&source_data.blocks)
1410        .with_context(|| "blocks_insert_time_range")?;
1411    let block_spec = BlockPartitionSpec {
1412        view_metadata,
1413        schema,
1414        insert_range,
1415        source_data: Arc::new(source_data),
1416        block_processors,
1417    };
1418    let null_response_writer = Arc::new(ResponseWriter::new(None));
1419    block_spec
1420        .write(lake, null_response_writer)
1421        .await
1422        .with_context(|| "block_spec.write")?;
1423    Ok(())
1424}