Skip to main content

micromegas_ingestion/
data_lake_connection.rs

1use anyhow::{Context, Result};
2use micromegas_object_cache::CacheClientStore;
3use micromegas_object_cache::prefetch::{ObjectPrefetch, PrefetchItem, PrefixPrefetch};
4use micromegas_telemetry::blob_storage::BlobStorage;
5use micromegas_tracing::prelude::*;
6use object_store::ObjectStore;
7use sqlx::PgPool;
8use sqlx::postgres::{PgConnection, PgPoolOptions};
9use std::io;
10use std::sync::{Arc, Mutex};
11use std::time::{Duration, Instant};
12use tokio::task::JoinHandle;
13
14/// A connection to the data lake, including a database pool and a blob storage client.
15#[derive(Debug, Clone)]
16pub struct DataLakeConnection {
17    pub db_pool: PgPool,
18    pub blob_storage: Arc<BlobStorage>,
19    /// `Some` when the object cache is configured for this connection; used to
20    /// fire-and-forget warm freshly-written objects (`warm_object`).
21    /// `None` when the cache is not configured.
22    prefetch: Option<Arc<dyn ObjectPrefetch>>,
23}
24
25impl DataLakeConnection {
26    pub fn new(db_pool: PgPool, blob_storage: Arc<BlobStorage>) -> Self {
27        Self {
28            db_pool,
29            blob_storage,
30            prefetch: None,
31        }
32    }
33
34    /// Like `new`, but also wires the object cache's prefetch face for
35    /// write-time warming (see `warm_object`).
36    pub fn new_with_prefetch(
37        db_pool: PgPool,
38        blob_storage: Arc<BlobStorage>,
39        prefetch: Option<Arc<dyn ObjectPrefetch>>,
40    ) -> Self {
41        Self {
42            db_pool,
43            blob_storage,
44            prefetch,
45        }
46    }
47
48    /// Warm a freshly-written object in the object cache by key. Fire-and-forget
49    /// at prefetch priority: spawns a detached task and returns immediately, so the
50    /// caller's write path is never delayed or failed by a warm. No-op when the
51    /// cache is not configured or `size <= 0`. Returns the spawned task handle (or
52    /// None) purely so tests can await completion deterministically; production
53    /// callers ignore it.
54    ///
55    /// This is a general "warm any object" primitive — the write-partition path is
56    /// its first caller, but nothing here is partition-specific (e.g. the ingestion
57    /// service could warm raw payloads the same way). `key` is the lake-root-relative
58    /// object key; the configured prefetch handle applies the lake root prefix so the
59    /// warmed key matches the key demand reads produce.
60    pub fn warm_object(&self, key: &str, size: i64) -> Option<JoinHandle<()>> {
61        let prefetch = self.prefetch.as_ref()?.clone();
62        if size <= 0 {
63            return None; // nothing to warm
64        }
65        let key = key.to_string(); // owned copy: the spawned future must be 'static
66        let item = PrefetchItem {
67            key: key.clone(),
68            size: size as u64,
69            ranges: None,
70        };
71        imetric!("object_warm_requested", "count", 1_u64);
72        Some(spawn_with_context(async move {
73            match prefetch.prefetch(vec![item]).await {
74                Ok(resp) => debug!(
75                    "write-time warm enqueued accepted={} rejected={} dropped={}",
76                    resp.accepted, resp.rejected, resp.dropped
77                ),
78                // CacheClientStore::prefetch already bumps range_cache_client_prefetch_error;
79                // keep this at debug — a failed warm just means the first read is a cold miss.
80                Err(e) => debug!("write-time warm failed for {key}: {e}"),
81            }
82        }))
83    }
84}
85
86/// Wrap `direct` with the object cache when configured, returning the store
87/// layer and — when enabled — the same client's `ObjectPrefetch` face for
88/// write-time warming.
89pub(crate) fn make_cache(
90    direct: Arc<dyn ObjectStore>,
91) -> (Arc<dyn ObjectStore>, Option<Arc<dyn ObjectPrefetch>>) {
92    let cache_url = std::env::var("MICROMEGAS_OBJECT_CACHE_URL").ok();
93    let api_key = std::env::var("MICROMEGAS_OBJECT_CACHE_API_KEY").ok();
94    match cache_url {
95        Some(url) if api_key.is_some() => {
96            let client = Arc::new(CacheClientStore::new(url, api_key, direct));
97            (
98                client.clone() as Arc<dyn ObjectStore>,
99                Some(client as Arc<dyn ObjectPrefetch>),
100            )
101        }
102        Some(url) => {
103            // URL without key: disabled, warn (preserve current behavior)
104            warn!(
105                "MICROMEGAS_OBJECT_CACHE_URL is set ({url}) but MICROMEGAS_OBJECT_CACHE_API_KEY is missing: the object cache is disabled and requests will go directly to the store"
106            );
107            (direct, None)
108        }
109        None => (direct, None),
110    }
111}
112
113/// Which write-session guarantee a lake connection pool enforces on every connection it hands
114/// out.
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116pub enum WritablePolicy {
117    /// Never hand out a read-only connection: reject it in `after_connect` (sqlx retries with
118    /// a fresh DNS lookup) and evict it in `before_acquire` if it turned read-only while pooled.
119    /// A write that arrives during a failover waits in `acquire` instead of failing against a
120    /// demoted writer.
121    Require,
122    /// Keep trying for a writable connection but fall back to a read-only one after
123    /// [`FALLBACK_AFTER`] with no writable connect, re-probing for a writable primary every
124    /// [`PROBE_INTERVAL`] while on a replica. Used by standalone FlightSQL, which has no other
125    /// writer to fail over to and would rather serve reads from a replica than refuse to start.
126    Prefer,
127}
128
129/// How long a [`WritablePolicy::Prefer`] pool keeps rejecting read-only connections before
130/// falling back, once no writable connection has succeeded since the last one (or since
131/// startup).
132pub const FALLBACK_AFTER: Duration = Duration::from_secs(10);
133
134/// How often a [`WritablePolicy::Prefer`] pool that has fallen back to a read-only connection
135/// evicts a pooled one to re-probe for a writable primary.
136pub const PROBE_INTERVAL: Duration = Duration::from_secs(30);
137
138/// True when `setting` (a Postgres `transaction_read_only` value, as read by `SHOW
139/// transaction_read_only`) marks the session as read-only. Anything other than `"on"` counts as
140/// writable: this matches libpq's `target_session_attrs=read-write` on pre-14 servers, and an
141/// unexpected value shouldn't take the service down.
142pub fn is_read_only(setting: &str) -> bool {
143    setting == "on"
144}
145
146async fn connection_is_read_only(conn: &mut PgConnection) -> Result<bool, sqlx::Error> {
147    let setting: String = sqlx::query_scalar("SHOW transaction_read_only")
148        .fetch_one(conn)
149        .await?;
150    Ok(is_read_only(&setting))
151}
152
153fn read_only_rejection() -> sqlx::Error {
154    sqlx::Error::Io(io::Error::other(
155        "connected to a read-only postgres instance",
156    ))
157}
158
159/// Pool-wide state backing [`WritablePolicy::Prefer`]'s accept/evict decisions, shared by every
160/// connection of the pool (and by every pool cloned from its options) via the `Arc` the
161/// `after_connect`/`before_acquire` closures capture. Never held across an await.
162#[derive(Debug)]
163struct ReadOnlyFallbackState {
164    /// The first read-only rejection since the last writable connect (`None` once a writable
165    /// connect has cleared it).
166    streak_start: Option<Instant>,
167    /// The last time a pooled read-only connection was evicted for a re-probe, `None` until the
168    /// first re-probe of the current streak.
169    last_probe: Option<Instant>,
170}
171
172/// Shared handle to a [`WritablePolicy::Prefer`] pool's fallback state. See [`Self::on_connect`]
173/// and [`Self::on_acquire`] for the decisions it drives.
174#[derive(Debug, Clone)]
175pub struct ReadOnlyFallback(Arc<Mutex<ReadOnlyFallbackState>>);
176
177impl Default for ReadOnlyFallback {
178    fn default() -> Self {
179        Self::new()
180    }
181}
182
183/// Clears an in-progress fallback streak on a writable connect/acquire (shared by
184/// [`ReadOnlyFallback::on_connect`] and [`ReadOnlyFallback::on_acquire`]).
185fn clear_streak(state: &mut ReadOnlyFallbackState) {
186    if state.streak_start.take().is_some() {
187        info!("writable postgres connection succeeded, ending read-only fallback");
188    }
189    state.last_probe = None;
190}
191
192impl ReadOnlyFallback {
193    pub fn new() -> Self {
194        Self(Arc::new(Mutex::new(ReadOnlyFallbackState {
195            streak_start: None,
196            last_probe: None,
197        })))
198    }
199
200    /// `after_connect` decision for a freshly opened connection: accept it? A writable
201    /// connection always clears the streak and is accepted. A read-only connection starts the
202    /// streak (if none is running) and is rejected until `now - streak_start >= FALLBACK_AFTER`.
203    pub fn on_connect(&self, read_only: bool, now: Instant) -> bool {
204        let mut state = self.0.lock().expect("ReadOnlyFallback mutex poisoned");
205        if !read_only {
206            clear_streak(&mut state);
207            return true;
208        }
209        let streak_start = *state.streak_start.get_or_insert(now);
210        now.duration_since(streak_start) >= FALLBACK_AFTER
211    }
212
213    /// `before_acquire` decision for a pooled connection: keep it? A writable connection clears
214    /// the streak (the same as [`Self::on_connect`]) and is kept. A read-only connection is
215    /// evicted immediately if no fallback streak is running (a writable connect has succeeded
216    /// since the fallback, so this connection is stale) or if the streak hasn't reached
217    /// `FALLBACK_AFTER` yet (a connection turned read-only mid-pool, but we haven't actually
218    /// fallen back -- `on_connect`'s window check doesn't cover already-pooled connections, so
219    /// this is the only place that can reject them). Once the window has elapsed, it's evicted
220    /// at most once per `PROBE_INTERVAL`, as a re-probe, and kept in between.
221    pub fn on_acquire(&self, read_only: bool, now: Instant) -> bool {
222        let mut state = self.0.lock().expect("ReadOnlyFallback mutex poisoned");
223        if !read_only {
224            clear_streak(&mut state);
225            return true;
226        }
227        let Some(streak_start) = state.streak_start else {
228            return false;
229        };
230        if now.duration_since(streak_start) < FALLBACK_AFTER {
231            return false;
232        }
233        match state.last_probe {
234            Some(probed_at) if now.duration_since(probed_at) < PROBE_INTERVAL => true,
235            _ => {
236                state.last_probe = Some(now);
237                false
238            }
239        }
240    }
241}
242
243/// Pool options for a pool whose write-session guarantee is `policy` -- see [`WritablePolicy`].
244///
245/// Both variants install `after_connect`/`before_acquire` hooks that run `SHOW
246/// transaction_read_only` and `test_before_acquire(false)`: the `before_acquire` query already
247/// makes one round trip and fails on a dead connection, so it replaces sqlx's default `ping`,
248/// keeping the hot path at one round trip per acquire.
249pub fn pool_options(policy: WritablePolicy) -> PgPoolOptions {
250    let options = PgPoolOptions::new().test_before_acquire(false);
251    match policy {
252        WritablePolicy::Require => options
253            .after_connect(|conn, _meta| {
254                Box::pin(async move {
255                    if connection_is_read_only(conn).await? {
256                        warn!("connected to a read-only postgres instance, reconnecting");
257                        imetric!("pg_read_only_connection_rejected", "count", 1_u64);
258                        Err(read_only_rejection())
259                    } else {
260                        Ok(())
261                    }
262                })
263            })
264            .before_acquire(|conn, _meta| {
265                Box::pin(async move {
266                    if connection_is_read_only(conn).await? {
267                        warn!("evicting pooled connection that turned read-only");
268                        imetric!("pg_read_only_connection_rejected", "count", 1_u64);
269                        Ok(false)
270                    } else {
271                        Ok(true)
272                    }
273                })
274            }),
275        WritablePolicy::Prefer => {
276            let fallback = ReadOnlyFallback::new();
277            let connect_fallback = fallback.clone();
278            let acquire_fallback = fallback;
279            options
280                .after_connect(move |conn, _meta| {
281                    let fallback = connect_fallback.clone();
282                    Box::pin(async move {
283                        let read_only = connection_is_read_only(conn).await?;
284                        if !fallback.on_connect(read_only, Instant::now()) {
285                            warn!("connected to a read-only postgres instance, reconnecting");
286                            imetric!("pg_read_only_connection_rejected", "count", 1_u64);
287                            return Err(read_only_rejection());
288                        }
289                        if read_only {
290                            warn!(
291                                "accepting a read-only postgres connection: no writable primary within the fallback window"
292                            );
293                            imetric!("pg_read_only_connection_accepted", "count", 1_u64);
294                        }
295                        Ok(())
296                    })
297                })
298                .before_acquire(move |conn, _meta| {
299                    let fallback = acquire_fallback.clone();
300                    Box::pin(async move {
301                        let read_only = connection_is_read_only(conn).await?;
302                        if fallback.on_acquire(read_only, Instant::now()) {
303                            Ok(true)
304                        } else {
305                            warn!("evicting pooled read-only postgres connection");
306                            imetric!("pg_read_only_connection_rejected", "count", 1_u64);
307                            Ok(false)
308                        }
309                    })
310                })
311        }
312    }
313}
314
315/// Pool options for a pool that must reach a writable primary -- [`pool_options`] with
316/// [`WritablePolicy::Require`].
317pub fn read_write_pool_options() -> PgPoolOptions {
318    pool_options(WritablePolicy::Require)
319}
320
321/// Finds a `sqlx::Error` carrying SQLSTATE `25006` (`cannot execute ... in a read-only
322/// transaction`) anywhere in `err`'s chain -- the error a write raises when its connection turned
323/// read-only mid-query, after already passing this crate's connect/acquire checks (e.g. an
324/// instance demoted between `before_acquire` and the query, or a [`WritablePolicy::Prefer`] pool
325/// that fell back to a replica on purpose).
326pub fn is_read_only_violation(err: &anyhow::Error) -> bool {
327    err.chain()
328        .find_map(|e| e.downcast_ref::<sqlx::Error>())
329        .and_then(sqlx::Error::as_database_error)
330        .and_then(|db_err| db_err.code())
331        .is_some_and(|code| code == "25006")
332}
333
334/// Connects to the data lake.
335pub async fn connect_to_data_lake(
336    policy: WritablePolicy,
337    db_uri: &str,
338    object_store_url: &str,
339) -> Result<DataLakeConnection> {
340    info!("connecting to blob storage");
341    let (raw_store, root) = BlobStorage::parse_url_opts(object_store_url)
342        .with_context(|| "connecting to blob storage")?;
343    let (layered, prefetch_client) = make_cache(raw_store);
344    let blob_storage = Arc::new(BlobStorage::new(layered, root.clone()));
345    let prefetch =
346        prefetch_client.map(|p| Arc::new(PrefixPrefetch::new(p, root)) as Arc<dyn ObjectPrefetch>);
347    let pool = pool_options(policy)
348        .connect(db_uri)
349        .await
350        .with_context(|| String::from("Connecting to telemetry database"))?;
351    Ok(DataLakeConnection::new_with_prefetch(
352        pool,
353        blob_storage,
354        prefetch,
355    ))
356}