micromegas_analytics/lakehouse/
metadata_partition_spec.rs1use super::{
2 dataframe_time_bounds::DataFrameTimeBounds,
3 view::{PartitionSpec, ViewMetadata},
4};
5use crate::{
6 lakehouse::write_partition::{PartitionRowSet, RetireMatch, write_partition_from_rows},
7 response_writer::Logger,
8 sql_arrow_bridge::rows_to_record_batch,
9 time::TimeRange,
10};
11use anyhow::{Context, Result};
12use async_trait::async_trait;
13use datafusion::{
14 arrow::{compute::cast, datatypes::Schema, record_batch::RecordBatch},
15 prelude::*,
16};
17use futures::TryStreamExt;
18use micromegas_ingestion::data_lake_connection::DataLakeConnection;
19use micromegas_tracing::prelude::*;
20use sqlx::{Row, postgres::PgRow};
21use std::sync::Arc;
22use tokio::sync::mpsc::Sender;
23
24const SOURCE_BYTES_PER_BATCH: usize = 8 * 1024 * 1024;
29
30#[derive(Debug)]
31pub struct MetadataPartitionSpec {
32 pub view_metadata: ViewMetadata,
33 pub schema: Arc<Schema>,
34 pub insert_range: TimeRange,
35 pub record_count: i64,
36 pub data_sql: Arc<String>,
37 pub default_audience: Option<Arc<str>>,
43 pub compute_time_bounds: Arc<dyn DataFrameTimeBounds>,
44 pub sort_order: Option<Vec<String>>,
47 pub unfiltered_count: Option<i64>,
54}
55
56pub fn mismatch_excluded_count(unfiltered: i64, kept: i64) -> i64 {
62 (unfiltered - kept).max(0)
63}
64
65#[expect(clippy::too_many_arguments)]
66pub async fn fetch_metadata_partition_spec(
67 pool: &sqlx::PgPool,
68 source_count_query: &str,
69 data_sql: Arc<String>,
70 view_metadata: ViewMetadata,
71 schema: Arc<Schema>,
72 insert_range: TimeRange,
73 compute_time_bounds: Arc<dyn DataFrameTimeBounds>,
74 sort_order: Option<Vec<String>>,
75 default_audience: Option<Arc<str>>,
76) -> Result<MetadataPartitionSpec> {
77 let row = instrument_named!(
79 sqlx::query(source_count_query)
80 .bind(insert_range.begin)
81 .bind(insert_range.end)
82 .fetch_one(pool),
83 "sql_select_source_count"
84 )
85 .await
86 .with_context(|| "select count source metadata")?;
87 let unfiltered_count: Option<i64> = row.try_get("unfiltered").ok();
90 Ok(MetadataPartitionSpec {
91 view_metadata,
92 schema,
93 insert_range,
94 record_count: row.try_get("count").with_context(|| "reading count")?,
95 data_sql,
96 default_audience,
97 compute_time_bounds,
98 sort_order,
99 unfiltered_count,
100 })
101}
102
103fn estimate_row_bytes(row: &PgRow) -> usize {
108 let mut total = 0usize;
109 for i in 0..row.len() {
110 if let Ok(raw) = row.try_get_raw(i)
111 && let Ok(bytes) = raw.as_bytes()
112 {
113 total += bytes.len();
114 }
115 }
116 total
117}
118
119pub fn cast_to_file_schema(batch: RecordBatch, file_schema: &Schema) -> Result<RecordBatch> {
130 let batch_schema = batch.schema();
131 let mut fields = Vec::with_capacity(batch_schema.fields().len());
132 let mut columns = Vec::with_capacity(batch.num_columns());
133 let mut any_cast = false;
134 for (i, field) in batch_schema.fields().iter().enumerate() {
135 let column = batch.column(i);
136 match file_schema.fields().get(i) {
137 Some(declared) if declared.data_type() != field.data_type() => {
138 columns.push(cast(column, declared.data_type()).with_context(|| {
139 format!(
140 "casting column {i} ({}) from {:?} to the declared {:?}",
141 field.name(),
142 field.data_type(),
143 declared.data_type()
144 )
145 })?);
146 fields.push(Arc::new(
147 field
148 .as_ref()
149 .clone()
150 .with_data_type(declared.data_type().clone()),
151 ));
152 any_cast = true;
153 }
154 _ => {
155 columns.push(column.clone());
156 fields.push(field.clone());
157 }
158 }
159 }
160 if !any_cast {
161 return Ok(batch);
162 }
163 RecordBatch::try_new(Arc::new(Schema::new(fields)), columns)
164 .with_context(|| "rebuilding the batch after casting to the declared file schema")
165}
166
167async fn flush_chunk(
170 chunk: &mut Vec<PgRow>,
171 ctx: &SessionContext,
172 file_schema: &Schema,
173 compute_time_bounds: &Arc<dyn DataFrameTimeBounds>,
174 tx: &Sender<Result<PartitionRowSet, anyhow::Error>>,
175) -> Result<()> {
176 let record_batch = cast_to_file_schema(
177 rows_to_record_batch(chunk).with_context(|| "converting rows to record batch")?,
178 file_schema,
179 )?;
180 chunk.clear();
181 let event_time_range = compute_time_bounds
182 .get_time_bounds(
183 ctx.read_batch(record_batch.clone())
184 .with_context(|| "read_batch")?,
185 )
186 .await?;
187 tx.send(Ok(PartitionRowSet::new(
188 event_time_range,
189 record_batch,
190 None,
191 )))
192 .await
193 .with_context(|| "sending partition row set")?;
194 Ok(())
195}
196
197#[async_trait]
198impl PartitionSpec for MetadataPartitionSpec {
199 fn is_empty(&self) -> bool {
200 self.record_count < 1
201 }
202
203 fn get_source_data_hash(&self) -> Vec<u8> {
204 self.record_count.to_le_bytes().to_vec()
205 }
206
207 async fn write(&self, lake: Arc<DataLakeConnection>, logger: Arc<dyn Logger>) -> Result<()> {
208 let desc = format!(
211 "[{}, {}] {} {}",
212 self.view_metadata.view_set_name,
213 self.view_metadata.view_instance_id,
214 self.insert_range.begin.to_rfc3339(),
215 self.insert_range.end.to_rfc3339()
216 );
217 logger.write_log_entry(format!("writing {desc}")).await?;
218
219 if let Some(unfiltered) = self.unfiltered_count {
224 let excluded = mismatch_excluded_count(unfiltered, self.record_count);
225 if excluded > 0 {
226 warn!(
227 "{excluded} blocks excluded from {desc} by the audience-mismatch predicate \
228 -- see block_audience_mismatch_rows for per-block detail"
229 );
230 imetric!("block_audience_mismatch_excluded", "count", excluded as u64);
231 }
232 }
233
234 let (tx, rx) = tokio::sync::mpsc::channel(1);
235 let join_handle = spawn_with_context(write_partition_from_rows(
236 lake.clone(),
237 self.view_metadata.clone(),
238 self.schema.clone(),
239 self.insert_range,
240 self.get_source_data_hash(),
241 self.sort_order.clone(),
242 RetireMatch::Containment,
243 Vec::new(),
244 rx,
245 logger.clone(),
246 ));
247
248 let stream_result: Result<()> = instrument_named!(
249 async {
250 if self.record_count > 0 {
251 let mut query = sqlx::query(&self.data_sql)
252 .bind(self.insert_range.begin)
253 .bind(self.insert_range.end);
254 if let Some(default_audience) = self.default_audience.as_deref() {
255 query = query.bind(default_audience.to_owned());
256 }
257 let mut rows = query.fetch(&lake.db_pool);
258 let ctx = SessionContext::new();
259 let mut chunk: Vec<PgRow> = Vec::new();
260 let mut chunk_bytes = 0usize;
261 while let Some(row) = rows.try_next().await? {
262 chunk_bytes += estimate_row_bytes(&row);
263 chunk.push(row);
264 if chunk_bytes >= SOURCE_BYTES_PER_BATCH {
265 flush_chunk(
266 &mut chunk,
267 &ctx,
268 &self.schema,
269 &self.compute_time_bounds,
270 &tx,
271 )
272 .await?;
273 chunk_bytes = 0;
274 }
275 }
276 if !chunk.is_empty() {
277 flush_chunk(
278 &mut chunk,
279 &ctx,
280 &self.schema,
281 &self.compute_time_bounds,
282 &tx,
283 )
284 .await?;
285 }
286 }
287 Ok(())
288 },
289 "sql_select_partition_source_data"
290 )
291 .await;
292
293 match stream_result {
294 Ok(()) => {
295 drop(tx);
296 join_handle.await??;
297 Ok(())
298 }
299 Err(e) => {
300 let _ = tx
305 .send(Err(anyhow::anyhow!("metadata partition stream aborted")))
306 .await;
307 drop(tx);
308 let _ = join_handle.await;
309 Err(e)
310 }
311 }
312 }
313}