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}