micromegas_ingestion/web_ingestion_service.rs
1use crate::data_lake_config::DataLakeConfig;
2use crate::data_lake_connection::{DataLakeConnection, WritablePolicy, connect_to_data_lake};
3use crate::remote_data_lake::migrate_db;
4use crate::write_audience::WriteAudience;
5use anyhow::Context;
6use bytes::Buf;
7use micromegas_telemetry::blob_storage::PutIfAbsent;
8use micromegas_telemetry::block_wire_format;
9use micromegas_telemetry::property::Property;
10use micromegas_telemetry::property::RESERVED_PROPERTY_PREFIX;
11use micromegas_telemetry::property::make_properties;
12use micromegas_telemetry::stream_info::StreamInfo;
13use micromegas_telemetry::wire_format::encode_cbor;
14use micromegas_tracing::prelude::*;
15use moka::sync::Cache;
16use std::sync::{Arc, LazyLock, Mutex};
17use std::time::Instant;
18use thiserror::Error;
19use uuid::Uuid;
20
21static EMPTY_TRANSIT_METADATA_CBOR_BYTES: LazyLock<Vec<u8>> = LazyLock::new(|| {
22 let mut buf = Vec::new();
23 ciborium::ser::into_writer(&Vec::<()>::new(), &mut buf)
24 .expect("encoding an empty Vec to CBOR is infallible");
25 buf
26});
27
28/// Sentinel for `dependencies_metadata` / `objects_metadata` on streams that
29/// don't use the transit/POD wire format (e.g. OTLP). Existing readers decode
30/// these BYTEA columns as `Vec<UserDefinedType>` and iterate; an empty Vec
31/// makes those loops no-ops without touching consumer code.
32pub fn empty_transit_metadata_cbor() -> &'static [u8] {
33 &EMPTY_TRANSIT_METADATA_CBOR_BYTES
34}
35
36/// Format string for native streams (transit-encoded payload, CBOR envelope).
37pub const FORMAT_TRANSIT: &str = "micromegas-transit";
38
39/// Stream `format` value for OTel logs (one `ResourceLogs` proto per block payload).
40pub const FORMAT_OTLP_LOGS: &str = "otlp/v1/logs";
41
42/// Stream `format` value for OTel metrics (one `ResourceMetrics` proto per block payload).
43pub const FORMAT_OTLP_METRICS: &str = "otlp/v1/metrics";
44
45/// Stream `format` value for OTel traces (one `ResourceSpans` proto per block payload).
46pub const FORMAT_OTLP_TRACES: &str = "otlp/v1/traces";
47
48/// Error type for ingestion service operations.
49/// Categorizes errors to enable proper HTTP status code mapping.
50#[derive(Error, Debug)]
51pub enum IngestionServiceError {
52 /// Client-side errors (malformed input) - maps to 400 Bad Request
53 #[error("Parse error: {0}")]
54 ParseError(String),
55
56 /// Database errors - maps to 500 Internal Server Error
57 #[error("Database error: {0}")]
58 DatabaseError(String),
59
60 /// Object storage errors - maps to 500 Internal Server Error
61 #[error("Storage error: {0}")]
62 StorageError(String),
63
64 /// A re-registration of an existing `process_id` under a different audience than the one
65 /// it was originally stamped with. Maps to 403 Forbidden -- the one invariant that makes
66 /// `ownership_rewrite.rs`'s `MAX(audience)` per-process resolution sound rather than merely
67 /// assumed.
68 #[error(
69 "Audience conflict: process_id {process_id} was registered under audience {existing:?}, \
70 this request carries {incoming:?}"
71 )]
72 AudienceConflict {
73 process_id: Uuid,
74 existing: String,
75 incoming: String,
76 },
77
78 /// A re-registration of an existing `stream_id` under a different audience than the one it
79 /// was originally stamped with -- the stream-side mirror of [`Self::AudienceConflict`].
80 /// Maps to 403 Forbidden.
81 #[error(
82 "Audience conflict: stream_id {stream_id} was registered under audience {existing:?}, \
83 this request carries {incoming:?}"
84 )]
85 StreamAudienceConflict {
86 stream_id: Uuid,
87 existing: String,
88 incoming: String,
89 },
90}
91
92/// Drops every client-supplied property whose key starts with the reserved `micromegas.`
93/// namespace ([`RESERVED_PROPERTY_PREFIX`]), so that namespace can never be asserted from the
94/// payload. A dropped key is `warn!`-logged once per call, naming the key -- a native client
95/// setting e.g. `micromegas.audience` is either attempting a client-side self-stamp or probing,
96/// and both are worth seeing. Stripping rather than rejecting the request with 400 keeps such a
97/// producer's telemetry flowing, even though the self-stamp attempt has no effect.
98///
99/// `pub`, not private: `tests/write_audience_tests.rs` asserts this directly (see
100/// `handler::build_webhook_request`'s doc comment for the identical precedent in
101/// `micromegas-otel-ingestion`).
102pub fn strip_reserved_properties(properties: Vec<Property>) -> Vec<Property> {
103 properties
104 .into_iter()
105 .filter(|p| {
106 if p.key_str().starts_with(RESERVED_PROPERTY_PREFIX) {
107 warn!(
108 "dropping client-supplied reserved property {:?} -- the {RESERVED_PROPERTY_PREFIX} \
109 namespace is server-written only",
110 p.key_str()
111 );
112 false
113 } else {
114 true
115 }
116 })
117 .collect()
118}
119
120/// Bound on [`WebIngestionService::process_audience_cache`]. A handful of thousand entries
121/// comfortably covers the distinct processes any one deployment has live at once. See the
122/// field's doc comment for the separate, time-based bound that limits staleness.
123const PROCESS_AUDIENCE_CACHE_CAPACITY: u64 = 10_000;
124
125/// Time-to-live on [`WebIngestionService::process_audience_cache`]. See the field's doc comment
126/// for why a bounded TTL is required rather than relying on capacity eviction alone.
127const PROCESS_AUDIENCE_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(60);
128
129/// Bound on [`WebIngestionService::stream_audience_cache`], mirroring
130/// [`PROCESS_AUDIENCE_CACHE_CAPACITY`] for the same reason.
131const STREAM_AUDIENCE_CACHE_CAPACITY: u64 = 10_000;
132
133/// Time-to-live on [`WebIngestionService::stream_audience_cache`], mirroring
134/// [`PROCESS_AUDIENCE_CACHE_TTL`] for the same reason (`delete_empty_streams` can delete and a
135/// later call re-register the same `stream_id` under a genuinely different audience).
136const STREAM_AUDIENCE_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(60);
137
138#[derive(Clone)]
139pub struct WebIngestionService {
140 lake: DataLakeConnection,
141 ready_ok_until: Arc<Mutex<Option<Instant>>>,
142 /// Memoizes `process_id -> audience` for processes [`Self::check_process_audience_conflict`]
143 /// has already confirmed conflict-free, so a steady-state re-registration (the common case:
144 /// `otel-ingestion`'s `write_blocks` calls `register_otel_process` once per prepared block on
145 /// every export request, not once per process lifetime) skips the `SELECT` entirely instead
146 /// of re-querying it on every call.
147 ///
148 /// Bounded by a TTL ([`PROCESS_AUDIENCE_CACHE_TTL`]), not just capacity: a `process_id`'s
149 /// audience is immutable *while the row exists*, but the row itself is not permanent. The
150 /// maintenance sweep's `delete_empty_processes` (`rust/analytics/src/delete.rs`, driven by
151 /// `delete_old_data`) deletes a `processes` row once it has no streams and is past the
152 /// retention window, and `mkdocs/docs/admin/authentication.md` documents a manual
153 /// `DELETE FROM processes WHERE process_id = ...` as the operator recovery path after a
154 /// squatted-process conflict. Either path lets the same `process_id` be deleted and later
155 /// re-registered under a genuinely different audience, which this in-memory cache -- scoped
156 /// to a single server process -- would otherwise keep serving indefinitely (bounded only by
157 /// 10,000-entry LRU eviction or a restart). The TTL closes that staleness window to a small,
158 /// fixed size instead of leaving it open for the server's entire uptime; capacity remains a
159 /// separate, memory-only bound.
160 process_audience_cache: Cache<Uuid, WriteAudience>,
161 /// Memoizes `stream_id -> audience` for streams
162 /// [`Self::check_stream_audience_conflict`] has already confirmed conflict-free -- the
163 /// stream-side mirror of [`Self::process_audience_cache`]. Bounded by a TTL (not just
164 /// capacity) since `delete_empty_streams` can delete a row that a later call re-registers
165 /// under a different audience.
166 stream_audience_cache: Cache<Uuid, WriteAudience>,
167 /// The deployment's resolved default audience, resolved once at
168 /// startup by the caller (`serve_ingestion`, via `micromegas_auth::policy::default_audience_from_env`
169 /// -- `micromegas-ingestion` depends on neither `micromegas-auth` nor `micromegas-analytics`,
170 /// so it cannot resolve this itself) and passed to [`Self::new`]. [`Self::check_process_audience_conflict`]
171 /// uses it to resolve a legacy row with a NULL `audience` column the same way every
172 /// reader already does, so the comparison is sound; the five OTLP `IdentityContext` call
173 /// sites use it via [`WriteAudience::id_namespace`] to decide which id namespace a given
174 /// write audience occupies.
175 default_audience: WriteAudience,
176}
177
178impl WebIngestionService {
179 pub fn new(lake: DataLakeConnection, default_audience: WriteAudience) -> Self {
180 Self {
181 lake,
182 ready_ok_until: Arc::new(Mutex::new(None)),
183 process_audience_cache: Cache::builder()
184 .max_capacity(PROCESS_AUDIENCE_CACHE_CAPACITY)
185 .time_to_live(PROCESS_AUDIENCE_CACHE_TTL)
186 .build(),
187 stream_audience_cache: Cache::builder()
188 .max_capacity(STREAM_AUDIENCE_CACHE_CAPACITY)
189 .time_to_live(STREAM_AUDIENCE_CACHE_TTL)
190 .build(),
191 default_audience,
192 }
193 }
194
195 /// The deployment's resolved default audience -- see the `default_audience` field's doc
196 /// comment. Exposed so the HTTP edge (`resolve_write_audience`) and the five OTLP
197 /// `IdentityContext` construction sites can reach it without re-resolving the environment.
198 pub fn default_audience(&self) -> &WriteAudience {
199 &self.default_audience
200 }
201
202 pub async fn check_ready(&self) -> bool {
203 let now = Instant::now();
204 {
205 let guard = self.ready_ok_until.lock().expect("readiness cache lock");
206 if let Some(ok_until) = *guard
207 && ok_until > now
208 {
209 return true;
210 }
211 }
212
213 let probe_db = instrument_named!(
214 sqlx::query("SELECT 1").execute(&self.lake.db_pool),
215 "sql_readiness_probe"
216 );
217 let probe_blob = self.lake.blob_storage.probe();
218
219 let result = tokio::time::timeout(std::time::Duration::from_secs(2), async {
220 tokio::join!(probe_db, probe_blob)
221 })
222 .await;
223
224 match result {
225 Ok((Ok(_), Ok(()))) => {
226 let mut guard = self.ready_ok_until.lock().expect("readiness cache lock");
227 *guard = Some(Instant::now() + std::time::Duration::from_secs(1));
228 true
229 }
230 _ => {
231 let mut guard = self.ready_ok_until.lock().expect("readiness cache lock");
232 *guard = None;
233 false
234 }
235 }
236 }
237
238 /// Pre-seeds the readiness cache to `until`. Intended for testing only.
239 #[doc(hidden)]
240 pub fn set_ready_until(&self, until: Instant) {
241 let mut guard = self.ready_ok_until.lock().expect("readiness cache lock");
242 *guard = Some(until);
243 }
244
245 /// Pre-seeds [`Self::process_audience_cache`] with `process_id -> audience`, bypassing the
246 /// database entirely. Intended for testing only: lets a test exercise
247 /// [`Self::check_process_audience_conflict`]'s cache-hit path against a service whose
248 /// database is unreachable, proving that path never touches the database.
249 #[doc(hidden)]
250 pub fn prime_process_audience_cache_for_test(&self, process_id: Uuid, audience: WriteAudience) {
251 self.process_audience_cache.insert(process_id, audience);
252 }
253
254 /// Exposes [`Self::check_process_audience_conflict`] to integration tests, which otherwise
255 /// can't reach a private method. Intended for testing only.
256 #[doc(hidden)]
257 pub async fn check_process_audience_conflict_for_test(
258 &self,
259 process_id: Uuid,
260 audience: &WriteAudience,
261 ) -> Result<(), IngestionServiceError> {
262 self.check_process_audience_conflict(process_id, audience)
263 .await
264 }
265
266 /// Reads MICROMEGAS_SQL_CONNECTION_STRING and MICROMEGAS_OBJECT_STORE_URI,
267 /// connects to the data lake, runs ingestion migrations, and returns
268 /// a ready-to-use service.
269 ///
270 /// `default_audience` -- the deployment's resolved default audience -- is threaded
271 /// straight into [`Self::new`]. `micromegas-ingestion` depends on neither `micromegas-auth`
272 /// nor `micromegas-analytics`, so it cannot resolve this itself; the caller resolves it
273 /// (typically via `micromegas_auth::policy::default_audience_from_env`) and passes it in.
274 pub async fn from_env(default_audience: WriteAudience) -> anyhow::Result<Arc<Self>> {
275 let cfg = DataLakeConfig::from_env()?;
276 let lake = connect_to_data_lake(
277 WritablePolicy::Require,
278 &cfg.sql_connection_string,
279 &cfg.object_store_uri,
280 )
281 .await?;
282 migrate_db(lake.db_pool.clone())
283 .await
284 .with_context(|| "migrate_db")?;
285 Ok(Arc::new(Self::new(lake, default_audience)))
286 }
287
288 #[span_fn]
289 pub async fn insert_block(
290 &self,
291 body: bytes::Bytes,
292 audience: &WriteAudience,
293 ) -> Result<(), IngestionServiceError> {
294 let block: block_wire_format::Block = ciborium::from_reader(body.reader())
295 .map_err(|e| IngestionServiceError::ParseError(format!("parsing block: {e}")))?;
296 self.insert_block_typed(block, audience).await
297 }
298
299 /// Inserts a block whose payload is already typed (no envelope round-trip on the caller side).
300 ///
301 /// The caller hands us a fully-built `Block`; we CBOR-encode the payload envelope once,
302 /// write it to object storage, and INSERT the row. Used by the OTLP adapter where
303 /// constructing the CBOR `Block` envelope just so `insert_block` could decode it
304 /// would be wasted work.
305 ///
306 /// Stamps the row with `audience`: the block's own column, not derived from the
307 /// `process_id`/`stream_id` it claims. A credential bound to audience A that knows
308 /// another audience's `process_id`/`stream_id` can still append a row naming those ids,
309 /// but the row is labelled A, so `blocks_view`'s mismatch predicate excludes it from
310 /// every reader's view rather than surfacing it mislabelled to either audience.
311 #[span_fn]
312 pub async fn insert_block_typed(
313 &self,
314 block: block_wire_format::Block,
315 audience: &WriteAudience,
316 ) -> Result<(), IngestionServiceError> {
317 let encoded_payload = encode_cbor(&block.payload)
318 .map_err(|e| IngestionServiceError::ParseError(format!("encoding payload: {e}")))?;
319 let payload_size = encoded_payload.len();
320
321 let process_id = &block.process_id;
322 let stream_id = &block.stream_id;
323 let block_id = &block.block_id;
324 let obj_path = format!("blobs/{process_id}/{stream_id}/{block_id}");
325 debug!("writing {obj_path}");
326
327 use sqlx::types::chrono::{DateTime, FixedOffset};
328 let begin_time = DateTime::<FixedOffset>::parse_from_rfc3339(&block.begin_time)
329 .map_err(|e| IngestionServiceError::ParseError(format!("parsing begin_time: {e}")))?;
330 let end_time = DateTime::<FixedOffset>::parse_from_rfc3339(&block.end_time)
331 .map_err(|e| IngestionServiceError::ParseError(format!("parsing end_time: {e}")))?;
332 let put_outcome = {
333 let begin_put = now();
334 let outcome = self
335 .lake
336 .blob_storage
337 .put_if_absent(&obj_path, encoded_payload.into())
338 .await
339 .map_err(|e| {
340 IngestionServiceError::StorageError(format!(
341 "writing block to blob storage: {e}"
342 ))
343 })?;
344 imetric!("put_duration", "ticks", (now() - begin_put) as u64);
345 outcome
346 };
347
348 debug!("recording block_id={block_id} stream_id={stream_id} process_id={process_id}");
349 let begin_insert = now();
350 let insert_time = sqlx::types::chrono::Utc::now();
351 let result = instrument_named!(
352 sqlx::query(
353 "INSERT INTO blocks (block_id, stream_id, process_id, begin_time, begin_ticks,
354 end_time, end_ticks, nb_objects, object_offset, payload_size, insert_time,
355 audience)
356 VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
357 ON CONFLICT (block_id) DO NOTHING;",
358 )
359 .bind(block_id)
360 .bind(stream_id)
361 .bind(process_id)
362 .bind(begin_time)
363 .bind(block.begin_ticks)
364 .bind(end_time)
365 .bind(block.end_ticks)
366 .bind(block.nb_objects)
367 .bind(block.object_offset)
368 .bind(payload_size as i64)
369 .bind(insert_time)
370 .bind(audience.as_str())
371 .execute(&self.lake.db_pool),
372 "sql_insert_block"
373 )
374 .await
375 .map_err(|e| IngestionServiceError::DatabaseError(format!("inserting into blocks: {e}")))?;
376 imetric!("insert_duration", "ticks", (now() - begin_insert) as u64);
377
378 // The object write (create-only) and the row insert (ON CONFLICT DO NOTHING) each
379 // independently succeed or find the target already present, so there are four
380 // (object, row) combinations, each handled below with its own log line and counter.
381 let row_inserted = result.rows_affected() > 0;
382 match (put_outcome, row_inserted) {
383 (PutIfAbsent::Created, true) => {
384 // Normal first write — covered by the unconditional "recorded" debug! below.
385 }
386 (PutIfAbsent::AlreadyExists, false) => {
387 // Retry, or two distinct events with identical bytes.
388 warn!(
389 "duplicate block: object and row both already exist \
390 block_id={block_id} process_id={process_id} stream_id={stream_id}"
391 );
392 imetric!("block_object_duplicate", "count", 1_u64);
393 }
394 (PutIfAbsent::AlreadyExists, true) => {
395 // Orphaned object healed (a prior attempt died between PUT and INSERT), or
396 // the losing side of a concurrent-duplicate race.
397 warn!(
398 "healed orphaned block object (row was missing) \
399 block_id={block_id} process_id={process_id} stream_id={stream_id}"
400 );
401 imetric!("block_orphan_object_healed", "count", 1_u64);
402 }
403 (PutIfAbsent::Created, false) => {
404 // Row existed but object did not (object lost or deleted out from under its
405 // row), or the winning side of a concurrent-duplicate race.
406 debug!(
407 "recreated block object for a row that already existed \
408 block_id={block_id} process_id={process_id} stream_id={stream_id}"
409 );
410 imetric!("block_object_recreated", "count", 1_u64);
411 }
412 }
413 // Only count bytes that were actually stored: on the AlreadyExists arms, put_if_absent
414 // rejected the write and the payload was discarded, so counting payload_size there would
415 // inflate reported ingest volume by the redelivery/duplicate rate.
416 if put_outcome == PutIfAbsent::Created {
417 imetric!(
418 target: "micromegas::ingestion",
419 "payload_size_inserted",
420 "bytes",
421 payload_size as u64
422 );
423 }
424 debug!("recorded block_id={block_id} stream_id={stream_id} process_id={process_id}");
425
426 Ok(())
427 }
428
429 /// Registers a stream whose blocks will be ingested in the transit format, stamping it with
430 /// `audience` -- resolved by the caller from the authenticated credential, exactly as
431 /// `insert_process` resolves its own.
432 ///
433 /// This accepts any `process_id` unconditionally; there is no check that the caller is
434 /// authorized to write a stream onto that process. The stream is stamped with the caller's
435 /// own audience, not the process's, so `blocks_view`'s mismatch predicate is what keeps a
436 /// later block from surfacing under the wrong audience if `process_id` belongs to someone
437 /// else.
438 ///
439 /// A re-registration of an existing `stream_id` under a *different* audience is rejected
440 /// via `Self::check_stream_audience_conflict`, mirroring
441 /// `Self::check_process_audience_conflict`.
442 #[span_fn]
443 pub async fn insert_stream(
444 &self,
445 body: bytes::Bytes,
446 audience: &WriteAudience,
447 ) -> Result<(), IngestionServiceError> {
448 let stream_info: StreamInfo = ciborium::from_reader(body.reader())
449 .map_err(|e| IngestionServiceError::ParseError(format!("parsing StreamInfo: {e}")))?;
450 info!(
451 "new stream {} {:?} {:?}",
452 stream_info.stream_id, &stream_info.tags, &stream_info.properties
453 );
454 let dependencies_metadata =
455 encode_cbor(&stream_info.dependencies_metadata).map_err(|e| {
456 IngestionServiceError::ParseError(format!("encoding dependencies_metadata: {e}"))
457 })?;
458 let objects_metadata = encode_cbor(&stream_info.objects_metadata).map_err(|e| {
459 IngestionServiceError::ParseError(format!("encoding objects_metadata: {e}"))
460 })?;
461 let result = instrument_named!(
462 sqlx::query(
463 "INSERT INTO streams (stream_id, process_id, dependencies_metadata, objects_metadata, tags, properties, insert_time, format, audience)
464 VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
465 ON CONFLICT (stream_id) DO NOTHING;",
466 )
467 .bind(stream_info.stream_id)
468 .bind(stream_info.process_id)
469 .bind(dependencies_metadata)
470 .bind(objects_metadata)
471 .bind(&stream_info.tags)
472 .bind(strip_reserved_properties(make_properties(
473 &stream_info.properties,
474 )))
475 .bind(sqlx::types::chrono::Utc::now())
476 .bind(FORMAT_TRANSIT)
477 .bind(audience.as_str())
478 .execute(&self.lake.db_pool),
479 "sql_insert_stream"
480 )
481 .await
482 .map_err(|e| {
483 IngestionServiceError::DatabaseError(format!("inserting into streams: {e}"))
484 })?;
485
486 if result.rows_affected() == 0 {
487 self.check_stream_audience_conflict(stream_info.stream_id, audience)
488 .await?;
489 } else {
490 self.remember_stream_audience(stream_info.stream_id, audience);
491 }
492 Ok(())
493 }
494
495 /// Registers a stream produced by an OTLP ingestion path, stamping it with `audience`,
496 /// exactly like [`Self::insert_stream`].
497 ///
498 /// `dependencies_metadata` and `objects_metadata` are filled with the CBOR sentinel
499 /// for an empty `Vec<UserDefinedType>` so legacy decode sites continue to work.
500 /// `format` distinguishes per-block dispatch downstream (e.g. `"otlp/v1/logs"`).
501 /// Stream `properties` are always empty for OTel — scope and per-event attrs
502 /// live on individual rows during materialization, not on the stream.
503 ///
504 /// Hack: piggybacking OTLP onto the transit-shaped `streams` row (with empty
505 /// metadata sentinels) is expedient for two formats but won't scale. To support
506 /// more formats cleanly, `dependencies_metadata`, `objects_metadata`, and `format`
507 /// should be merged into a single per-format payload column.
508 ///
509 /// Same conflict guard as [`Self::insert_stream`] -- see
510 /// `Self::check_stream_audience_conflict`.
511 #[span_fn]
512 pub async fn register_otel_stream(
513 &self,
514 stream_id: Uuid,
515 process_id: Uuid,
516 tags: Vec<String>,
517 format: &str,
518 audience: &WriteAudience,
519 ) -> Result<(), IngestionServiceError> {
520 let result = instrument_named!(
521 sqlx::query(
522 "INSERT INTO streams (stream_id, process_id, dependencies_metadata, objects_metadata, tags, properties, insert_time, format, audience)
523 VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
524 ON CONFLICT (stream_id) DO NOTHING;",
525 )
526 .bind(stream_id)
527 .bind(process_id)
528 .bind(empty_transit_metadata_cbor())
529 .bind(empty_transit_metadata_cbor())
530 .bind(tags)
531 .bind(Vec::<Property>::new())
532 .bind(sqlx::types::chrono::Utc::now())
533 .bind(format)
534 .bind(audience.as_str())
535 .execute(&self.lake.db_pool),
536 "sql_insert_stream"
537 )
538 .await
539 .map_err(|e| {
540 IngestionServiceError::DatabaseError(format!("inserting otel stream: {e}"))
541 })?;
542
543 if result.rows_affected() == 0 {
544 self.check_stream_audience_conflict(stream_id, audience)
545 .await?;
546 } else {
547 self.remember_stream_audience(stream_id, audience);
548 }
549 Ok(())
550 }
551
552 /// On a conflicting `insert_stream`/`register_otel_stream` re-registration, enforces one
553 /// audience per stream -- the stream-side mirror of
554 /// [`Self::check_process_audience_conflict`], same cache-then-`SELECT` shape and 403
555 /// response. Before this guard, a re-pointed stream would keep its original audience, and
556 /// every later block on it would be silently excluded from `blocks` by the audience-mismatch
557 /// predicate.
558 async fn check_stream_audience_conflict(
559 &self,
560 stream_id: Uuid,
561 audience: &WriteAudience,
562 ) -> Result<(), IngestionServiceError> {
563 let incoming = audience.as_str();
564 if let Some(cached) = self.stream_audience_cache.get(&stream_id)
565 && cached.as_str() == incoming
566 {
567 debug!(
568 "duplicate stream_id={stream_id} skipped (already exists, same audience, cached)"
569 );
570 return Ok(());
571 }
572 let existing: Option<Option<String>> = instrument_named!(
573 sqlx::query_scalar("SELECT audience FROM streams WHERE stream_id = $1")
574 .bind(stream_id)
575 .fetch_optional(&self.lake.db_pool),
576 "sql_select_stream_audience"
577 )
578 .await
579 .map_err(|e| {
580 IngestionServiceError::DatabaseError(format!(
581 "reading existing stream audience for conflict check: {e}"
582 ))
583 })?;
584 let Some(existing) = existing else {
585 // The row disappeared between our INSERT ... ON CONFLICT DO NOTHING and this SELECT
586 // (a concurrent delete_empty_streams sweep) -- nothing left to conflict with.
587 debug!(
588 "duplicate stream_id={stream_id} skipped (row deleted concurrently, no conflict)"
589 );
590 return Ok(());
591 };
592 // A NULL `audience` column is a legacy row registered before its ingestion binary
593 // reached this stage. Resolve it the same way every reader does rather than treating
594 // "unstamped" as a state that matches anything.
595 let existing = existing.unwrap_or_else(|| self.default_audience.as_str().to_string());
596 if existing != incoming {
597 warn!(
598 "stream_id={stream_id} audience conflict: existing={existing:?} \
599 incoming={incoming:?} -- rejecting re-registration"
600 );
601 return Err(IngestionServiceError::StreamAudienceConflict {
602 stream_id,
603 existing,
604 incoming: incoming.to_string(),
605 });
606 }
607 debug!("duplicate stream_id={stream_id} skipped (already exists, same audience)");
608 self.remember_stream_audience(stream_id, audience);
609 Ok(())
610 }
611
612 /// Records `audience` as the confirmed-conflict-free audience for `stream_id` in
613 /// [`Self::stream_audience_cache`] -- the stream-side mirror of
614 /// [`Self::remember_process_audience`].
615 fn remember_stream_audience(&self, stream_id: Uuid, audience: &WriteAudience) {
616 self.stream_audience_cache
617 .insert(stream_id, audience.clone());
618 }
619
620 /// Registers a process from the native (CBOR) ingestion path, stamping it with `audience`.
621 /// `audience` is resolved by the caller from the authenticated credential
622 /// (`AuthContext.bound_audience`); this method never trusts a client-supplied
623 /// `micromegas.*` property -- [`strip_reserved_properties`] drops it, and there is no
624 /// property to re-append: the stamp lands on the `audience` column instead.
625 ///
626 /// A conflicting re-registration (an existing `process_id` under a *different* audience
627 /// than `audience`) is rejected with [`IngestionServiceError::AudienceConflict`] rather
628 /// than silently no-op'd: `process_id` is client-chosen on this path, so a reused id under a
629 /// different credential is a real, reachable case, and it is what keeps
630 /// `ownership_rewrite.rs`'s `MAX(audience)` per-process resolution sound. An existing row
631 /// with a NULL `audience` column (legacy data with no stamp; admin replication hard-fails on
632 /// a missing `audience` column) is resolved to the deployment default the same way every
633 /// reader does, then compared like any other row; it is never retro-stamped, ever -- see the
634 /// module-level design doc.
635 #[span_fn]
636 pub async fn insert_process(
637 &self,
638 body: bytes::Bytes,
639 audience: &WriteAudience,
640 ) -> Result<(), IngestionServiceError> {
641 let process_info: ProcessInfo = ciborium::from_reader(body.reader())
642 .map_err(|e| IngestionServiceError::ParseError(format!("parsing ProcessInfo: {e}")))?;
643
644 let properties = strip_reserved_properties(make_properties(&process_info.properties));
645
646 let insert_time = sqlx::types::chrono::Utc::now();
647 let result = instrument_named!(
648 sqlx::query(
649 "INSERT INTO processes
650 (process_id, exe, username, realname, computer, distro, cpu_brand,
651 tsc_frequency, start_time, start_ticks, insert_time, parent_process_id,
652 properties, audience)
653 VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
654 ON CONFLICT (process_id) DO NOTHING;",
655 )
656 .bind(process_info.process_id)
657 .bind(process_info.exe)
658 .bind(process_info.username)
659 .bind(process_info.realname)
660 .bind(process_info.computer)
661 .bind(process_info.distro)
662 .bind(process_info.cpu_brand)
663 .bind(process_info.tsc_frequency)
664 .bind(process_info.start_time)
665 .bind(process_info.start_ticks)
666 .bind(insert_time)
667 .bind(process_info.parent_process_id)
668 .bind(properties)
669 .bind(audience.as_str())
670 .execute(&self.lake.db_pool),
671 "sql_insert_process"
672 )
673 .await
674 .map_err(|e| {
675 IngestionServiceError::DatabaseError(format!("inserting into processes: {e}"))
676 })?;
677
678 if result.rows_affected() == 0 {
679 self.check_process_audience_conflict(process_info.process_id, audience)
680 .await?;
681 } else {
682 self.remember_process_audience(process_info.process_id, audience);
683 }
684 Ok(())
685 }
686
687 /// On a conflicting `insert_process` re-registration, enforces one audience per process.
688 /// No-op (aside from a `debug!`) when the existing row's audience -- resolved the same way
689 /// every reader resolves it (the row's own `audience` column, or the deployment default for
690 /// a legacy/replicated row with a NULL one) -- matches `audience` exactly;
691 /// `Err(IngestionServiceError::AudienceConflict)` when it resolves to a *different* audience
692 /// than this request carries.
693 ///
694 /// Consults [`Self::process_audience_cache`] before touching the database: a hit for this
695 /// `process_id` whose cached audience matches `audience` means a prior call already proved
696 /// there's no conflict, so the `SELECT` below is skipped entirely. A miss (including an
697 /// expired entry, or a cached audience that doesn't match) falls through and re-runs the real
698 /// check against the database, which is authoritative.
699 async fn check_process_audience_conflict(
700 &self,
701 process_id: Uuid,
702 audience: &WriteAudience,
703 ) -> Result<(), IngestionServiceError> {
704 let incoming = audience.as_str();
705 if let Some(cached) = self.process_audience_cache.get(&process_id)
706 && cached.as_str() == incoming
707 {
708 debug!(
709 "duplicate process_id={process_id} skipped (already exists, same audience, cached)"
710 );
711 return Ok(());
712 }
713 let existing: Option<Option<String>> = instrument_named!(
714 sqlx::query_scalar("SELECT audience FROM processes WHERE process_id = $1")
715 .bind(process_id)
716 .fetch_optional(&self.lake.db_pool),
717 "sql_select_process_audience"
718 )
719 .await
720 .map_err(|e| {
721 IngestionServiceError::DatabaseError(format!(
722 "reading existing process audience for conflict check: {e}"
723 ))
724 })?;
725 let Some(existing) = existing else {
726 // The row disappeared between our INSERT ... ON CONFLICT DO NOTHING and this SELECT
727 // (a concurrent delete_empty_processes sweep, or an operator's manual recovery
728 // DELETE) -- nothing left to conflict with.
729 debug!(
730 "duplicate process_id={process_id} skipped (row deleted concurrently, no conflict)"
731 );
732 return Ok(());
733 };
734 // An existing row with a NULL `audience` column is legacy data registered before its
735 // ingestion binary reached this stage. Resolve it the same way every reader does,
736 // rather than treating "unstamped" as a state that matches anything.
737 let existing = existing.unwrap_or_else(|| self.default_audience.as_str().to_string());
738 if existing != incoming {
739 warn!(
740 "process_id={process_id} audience conflict: existing={existing:?} \
741 incoming={incoming:?} -- rejecting re-registration"
742 );
743 return Err(IngestionServiceError::AudienceConflict {
744 process_id,
745 existing,
746 incoming: incoming.to_string(),
747 });
748 }
749 debug!("duplicate process_id={process_id} skipped (already exists, same audience)");
750 // Caching the resolved value for a legacy/replicated unstamped row is correct and
751 // deliberate: that *is* the audience the row resolves to, the deployment default cannot
752 // change within a process lifetime, and there is no `UPDATE processes` path anywhere in
753 // the codebase that could retro-stamp the row out from under this entry.
754 self.remember_process_audience(process_id, audience);
755 Ok(())
756 }
757
758 /// Records `audience` as the confirmed-conflict-free audience for `process_id` in
759 /// [`Self::process_audience_cache`], so a later call with the same `process_id`/`audience`
760 /// pair can skip [`Self::check_process_audience_conflict`]'s `SELECT`. Unconditional:
761 /// `WriteAudience` is single-state, so a resolved-to-default caller is byte-for-byte
762 /// indistinguishable from any other labelled caller, and there is nothing left to guard on.
763 /// Only ever called with an audience already known to be conflict-free (a fresh `INSERT`, or
764 /// a guard check that just passed) -- never on a rejection.
765 fn remember_process_audience(&self, process_id: Uuid, audience: &WriteAudience) {
766 self.process_audience_cache
767 .insert(process_id, audience.clone());
768 }
769
770 /// Registers a process originating from OTLP. Idempotent via `ON CONFLICT DO NOTHING`.
771 ///
772 /// `realname` is set equal to `username` (OTel has no separate "real name" concept).
773 /// `parent_process_id` is always NULL — OTel has no parent-process model.
774 /// `insert_time` is the server wall clock, matching the existing `insert_process` path.
775 ///
776 /// `audience` is stamped on the `audience` column, exactly like `insert_process`;
777 /// client-supplied `micromegas.*` properties are stripped via [`strip_reserved_properties`]
778 /// the same way. **Same conflict guard as `insert_process`, and for a confidentiality
779 /// reason, not just consistency**: `processes` is a single table shared with
780 /// the native path, and `insert_process` accepts a client-chosen `process_id` stamped with
781 /// the caller's own audience. Because `process_id_from_resource`'s derivation formula is
782 /// public, any ingestion credential can pre-register (via the native path) the exact
783 /// `process_id` a victim audience's OTLP producer will later derive; without this guard the
784 /// genuine producer's stream/blocks would silently land on a row stamped with the squatter's
785 /// audience, leaking that audience's data to the squatter. `check_process_audience_conflict`
786 /// closes that hole the same way it does for `insert_process`.
787 #[span_fn]
788 #[expect(clippy::too_many_arguments, reason = "OTel process identity fields")]
789 pub async fn register_otel_process(
790 &self,
791 process_id: Uuid,
792 exe: String,
793 username: String,
794 computer: String,
795 distro: String,
796 cpu_brand: String,
797 tsc_frequency: i64,
798 start_time: sqlx::types::chrono::DateTime<sqlx::types::chrono::Utc>,
799 start_ticks: i64,
800 properties: Vec<Property>,
801 audience: &WriteAudience,
802 ) -> Result<(), IngestionServiceError> {
803 let properties = strip_reserved_properties(properties);
804 let insert_time = sqlx::types::chrono::Utc::now();
805 let result = instrument_named!(
806 sqlx::query(
807 "INSERT INTO processes
808 (process_id, exe, username, realname, computer, distro, cpu_brand,
809 tsc_frequency, start_time, start_ticks, insert_time, parent_process_id, properties,
810 audience)
811 VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,NULL,$12,$13)
812 ON CONFLICT (process_id) DO NOTHING;",
813 )
814 .bind(process_id)
815 .bind(exe)
816 .bind(&username)
817 .bind(&username)
818 .bind(computer)
819 .bind(distro)
820 .bind(cpu_brand)
821 .bind(tsc_frequency)
822 .bind(start_time)
823 .bind(start_ticks)
824 .bind(insert_time)
825 .bind(properties)
826 .bind(audience.as_str())
827 .execute(&self.lake.db_pool),
828 "sql_insert_process"
829 )
830 .await
831 .map_err(|e| {
832 IngestionServiceError::DatabaseError(format!("inserting otel process: {e}"))
833 })?;
834
835 if result.rows_affected() == 0 {
836 self.check_process_audience_conflict(process_id, audience)
837 .await?;
838 } else {
839 self.remember_process_audience(process_id, audience);
840 }
841 Ok(())
842 }
843}