Skip to main content

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}