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}