harn_vm/stdlib/postgres/shared.rs
1//! Server-lifetime shared Postgres pool registry.
2//!
3//! # Why this exists
4//!
5//! Under `harn serve`, a fresh [`Vm`](crate::vm::Vm) is constructed per request,
6//! and dispatch runs on a multi-thread tokio runtime. The per-VM Postgres state
7//! ([`super::POOLS`] etc.) is **thread-local**, which is exactly right for the
8//! CLI one-shot model (one VM, one thread, deterministic teardown) but means a
9//! server re-opens a brand-new connection pool on every single request — the
10//! pool can never be reused across requests because the registry holding it
11//! lives on a thread the next request may not even run on.
12//!
13//! This module provides an **opt-in, process-lifetime** registry that an
14//! embedder (e.g. the harn-serve `SiteServer` wiring) installs **once** at
15//! startup via [`install_shared_pool_registry`]. When installed,
16//! [`super::open_pool`] consults it: repeated `pg_pool(same source, same
17//! options)` calls — across requests and across worker threads — return the
18//! **same** underlying [`PoolRecord`] (a cheap `Arc` clone of the sqlx `Pool`).
19//! When **not** installed (the default, and the entire CLI surface), behavior is
20//! byte-identical to before: every `pg_pool` builds its own pool in the
21//! thread-local registry.
22//!
23//! # Security: the pool key is the full connection identity
24//!
25//! Two `pg_pool` calls share a pool **only** when their entire connection
26//! identity matches: the resolved connection string (host, port, database,
27//! user, **password**, and every query parameter), the SSL mode, the
28//! application name, the replica set, and every pool-shaping option
29//! (sizing/timeouts/statement-cache/routing/circuit-breaker). The key is the
30//! SHA-256 of a canonical serialization of all of these (see [`PoolKey::new`]).
31//! Crucially the key is built from the **resolved** URL — after `env:`/`secret:`
32//! indirection — never from a user-supplied alias, so two callers cannot
33//! collide on a pool by naming the same alias while resolving to different
34//! credentials.
35//!
36//! This is safe to share across tenants because the cloud platform scopes RLS
37//! **per-transaction** via `set_config('app.current_tenant_id', …, /*local*/
38//! true)` (see `ALLOWED_TRANSACTION_SETTINGS` in the parent module) — tenancy is
39//! *never* encoded in the pool/connection itself. A shared pool therefore only
40//! ever multiplexes transactions that each set their own tenant scope; it can
41//! never leak one tenant's rows to another. If a future caller ever moved
42//! tenant scoping onto the connection (e.g. a per-tenant `SET ROLE` at connect
43//! time), that tenant discriminator would have to become part of the pool key —
44//! today it is not, because it is not on the connection.
45//!
46//! # Lock discipline (no deadlock across await)
47//!
48//! The registry is a `std::sync::Mutex<HashMap<PoolKey, Arc<PoolRecord>>>`. We
49//! **never** hold the mutex across an `.await`. Opening a pool is a double-
50//! checked init: (1) lock, look up the key, clone-and-return on hit, else drop
51//! the lock; (2) build the pool *outside* any lock (the only `.await`); (3)
52//! re-lock and insert, but if a racing request inserted the same key while we
53//! were connecting, we keep the existing entry and drop ours (its pool closes on
54//! `Drop`). The guard returned from step 1/3 is a plain `MutexGuard` that is
55//! dropped before any await — enforced by structure, not convention.
56
57use std::collections::BTreeMap;
58use std::collections::HashMap;
59use std::sync::{Arc, Mutex, OnceLock};
60
61use sha2::{Digest, Sha256};
62
63use crate::value::VmValue;
64
65use super::PoolRecord;
66
67/// Opaque, collision-resistant key for a fully-resolved connection identity.
68///
69/// Wraps the hex SHA-256 of a canonical description of everything that makes
70/// two pools interchangeable. Two `PoolKey`s are equal iff every identity- and
71/// shape-affecting input matched. We hash (rather than store the raw string)
72/// so the in-memory key never retains the plaintext password from the resolved
73/// URL.
74#[derive(Clone, PartialEq, Eq, Hash, Debug)]
75pub(super) struct PoolKey(String);
76
77impl PoolKey {
78 /// Build a key from the resolved primary URL, resolved replica URLs (order
79 /// preserved — it affects round-robin identity), and the raw options dict
80 /// the caller passed to `pg_pool`/`pg_connect`.
81 ///
82 /// `single_connection` distinguishes `pg_connect` (max 1) from `pg_pool`
83 /// even when the options dict is identical, so the two never alias.
84 ///
85 /// The options dict is folded into the key in a canonical, order-independent
86 /// way (sorted keys; values stringified). This is intentionally a superset
87 /// of the options `build_pool` actually consults: hashing *every* provided
88 /// option is conservatively safe (it can only ever cause two calls that
89 /// differ in some exotic/no-op option to NOT share — never the reverse), and
90 /// it future-proofs the key against new options gaining pool semantics
91 /// without anyone remembering to update this list.
92 pub(super) fn new(
93 primary_url: &str,
94 replica_urls: &[String],
95 options: Option<&crate::value::DictMap>,
96 single_connection: bool,
97 ) -> Self {
98 let mut hasher = Sha256::new();
99 // Domain separator + version, so the key scheme can evolve without
100 // silently colliding with a future variant.
101 hasher.update(b"harn-pg-shared-pool-key\x01");
102 hasher.update(b"single_connection:");
103 hasher.update([u8::from(single_connection)]);
104 hasher.update(b"\x00primary:");
105 hash_len_prefixed(&mut hasher, primary_url.as_bytes());
106 hasher.update(b"\x00replicas:");
107 hasher.update((replica_urls.len() as u64).to_le_bytes());
108 for url in replica_urls {
109 hash_len_prefixed(&mut hasher, url.as_bytes());
110 }
111 hasher.update(b"\x00options:");
112 if let Some(options) = options {
113 // BTreeMap iterates in sorted key order already, but be explicit:
114 // collect into a BTreeMap of canonical (key -> display) so the
115 // serialization is deterministic regardless of insertion order.
116 let canonical: BTreeMap<&str, String> = options
117 .iter()
118 .map(|(key, value)| (key.as_str(), canonical_option_value(value)))
119 .collect();
120 hasher.update((canonical.len() as u64).to_le_bytes());
121 for (key, value) in canonical {
122 hash_len_prefixed(&mut hasher, key.as_bytes());
123 hash_len_prefixed(&mut hasher, value.as_bytes());
124 }
125 } else {
126 hasher.update(0u64.to_le_bytes());
127 }
128 PoolKey(hex::encode(hasher.finalize()))
129 }
130}
131
132/// Length-prefix then write `bytes`, so two adjacent fields can never be
133/// ambiguously concatenated (`"ab" + "c"` vs `"a" + "bc"`).
134fn hash_len_prefixed(hasher: &mut Sha256, bytes: &[u8]) {
135 hasher.update((bytes.len() as u64).to_le_bytes());
136 hasher.update(bytes);
137}
138
139/// Canonical, deterministic stringification of an option value for the key.
140///
141/// Nested lists/dicts (e.g. `replicas`, `circuit_breaker`) are recursively
142/// serialized so two structurally-identical option dicts produce the same key
143/// and two different ones do not.
144fn canonical_option_value(value: &VmValue) -> String {
145 match value {
146 VmValue::List(items) => {
147 let mut out = String::from("[");
148 for (i, item) in items.iter().enumerate() {
149 if i > 0 {
150 out.push(',');
151 }
152 out.push_str(&canonical_option_value(item));
153 }
154 out.push(']');
155 out
156 }
157 VmValue::Dict(dict) => {
158 // Sort keys for order-independence.
159 let sorted: BTreeMap<&crate::value::HarnStr, &VmValue> = dict.iter().collect();
160 let mut out = String::from("{");
161 for (i, (key, val)) in sorted.iter().enumerate() {
162 if i > 0 {
163 out.push(',');
164 }
165 out.push_str(key.as_str());
166 out.push('=');
167 out.push_str(&canonical_option_value(val));
168 }
169 out.push('}');
170 out
171 }
172 other => other.display(),
173 }
174}
175
176type SharedRegistry = Mutex<HashMap<PoolKey, Arc<PoolRecord>>>;
177
178/// The process-global shared registry. `None` until an embedder installs it.
179/// `OnceLock` so installation is one-shot and lock-free to read.
180static SHARED_POOLS: OnceLock<SharedRegistry> = OnceLock::new();
181
182/// Install the process-lifetime shared pool registry. Idempotent: calling it
183/// more than once is harmless (subsequent calls are no-ops and keep the first
184/// registry). Intended to be called **once** by a long-lived server embedder at
185/// startup, before serving requests.
186///
187/// After this is installed, `pg_pool`/`pg_connect` calls whose full connection
188/// identity matches an already-open pool reuse it instead of opening a new one,
189/// across requests and worker threads. The CLI never calls this, so the CLI
190/// one-shot path is unchanged.
191pub fn install_shared_pool_registry() {
192 let _ = SHARED_POOLS.get_or_init(|| Mutex::new(HashMap::new()));
193}
194
195/// True when the shared registry has been installed (server mode).
196pub(super) fn is_installed() -> bool {
197 SHARED_POOLS.get().is_some()
198}
199
200/// Look up an already-shared pool by key, returning a cheap `Arc` clone.
201///
202/// Holds the mutex only for the map lookup; never across an await.
203pub(super) fn get(key: &PoolKey) -> Option<Arc<PoolRecord>> {
204 let registry = SHARED_POOLS.get()?;
205 let guard = registry.lock().expect("shared pg pool registry poisoned");
206 guard.get(key).map(Arc::clone)
207}
208
209/// Insert `record` under `key`, returning the record that ends up canonical.
210///
211/// If a concurrent caller already inserted this key while we were connecting
212/// (the classic double-checked-init race), we keep the **existing** entry and
213/// return it; the just-built `record`'s pool is dropped by the caller (its sqlx
214/// `Pool` closes its connections on `Drop`). Holds the mutex only for the
215/// insert/lookup; never across an await.
216pub(super) fn get_or_insert(key: PoolKey, record: Arc<PoolRecord>) -> Arc<PoolRecord> {
217 let Some(registry) = SHARED_POOLS.get() else {
218 // Not installed: caller should not have reached here, but be safe and
219 // just hand back the record unshared.
220 return record;
221 };
222 let mut guard = registry.lock().expect("shared pg pool registry poisoned");
223 Arc::clone(guard.entry(key).or_insert(record))
224}
225
226/// Test-only: clear and uninstall semantics are not possible on a `OnceLock`,
227/// so for test isolation we expose a way to empty the map. The `OnceLock`
228/// itself stays installed, which is fine: an emptied registry behaves like a
229/// freshly installed one.
230#[cfg(test)]
231pub(super) fn clear_for_test() {
232 if let Some(registry) = SHARED_POOLS.get() {
233 registry
234 .lock()
235 .expect("shared pg pool registry poisoned")
236 .clear();
237 }
238}
239
240#[cfg(test)]
241pub(super) fn len_for_test() -> usize {
242 SHARED_POOLS
243 .get()
244 .map(|registry| registry.lock().expect("poisoned").len())
245 .unwrap_or(0)
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251
252 fn s(value: &str) -> VmValue {
253 VmValue::String(arcstr::ArcStr::from(value))
254 }
255
256 fn opts(pairs: &[(&str, VmValue)]) -> crate::value::DictMap {
257 pairs
258 .iter()
259 .map(|(k, v)| (crate::value::intern_key(k), v.clone()))
260 .collect()
261 }
262
263 const URL_A: &str = "postgres://app:secret@db.internal:5432/tenants";
264 const URL_B: &str = "postgres://app:secret@db.internal:5432/other";
265 const URL_A_DIFF_PW: &str = "postgres://app:HUNTER2@db.internal:5432/tenants";
266
267 /// Identical resolved URL + identical options + identical mode share a key.
268 #[test]
269 fn same_identity_same_key() {
270 let o = opts(&[("max_connections", VmValue::Int(5))]);
271 let k1 = PoolKey::new(URL_A, &[], Some(&o), false);
272 let k2 = PoolKey::new(URL_A, &[], Some(&o), false);
273 assert_eq!(k1, k2);
274 }
275
276 /// A different database in the resolved URL must NOT collide.
277 #[test]
278 fn different_database_different_key() {
279 let k1 = PoolKey::new(URL_A, &[], None, false);
280 let k2 = PoolKey::new(URL_B, &[], None, false);
281 assert_ne!(k1, k2);
282 }
283
284 /// SECURITY: a different password (credential) must NOT collide, even though
285 /// host/port/db/user are identical.
286 #[test]
287 fn different_credentials_different_key() {
288 let k1 = PoolKey::new(URL_A, &[], None, false);
289 let k2 = PoolKey::new(URL_A_DIFF_PW, &[], None, false);
290 assert_ne!(k1, k2);
291 }
292
293 /// `pg_pool` and `pg_connect` (single_connection) on the same URL must not
294 /// alias even with identical options.
295 #[test]
296 fn single_connection_flag_distinguishes_key() {
297 let k_pool = PoolKey::new(URL_A, &[], None, false);
298 let k_conn = PoolKey::new(URL_A, &[], None, true);
299 assert_ne!(k_pool, k_conn);
300 }
301
302 /// Option dict ordering does not affect the key (canonical, sorted).
303 #[test]
304 fn option_order_is_canonical() {
305 let o1 = opts(&[
306 ("max_connections", VmValue::Int(5)),
307 ("application_name", s("svc")),
308 ]);
309 let o2 = opts(&[
310 ("application_name", s("svc")),
311 ("max_connections", VmValue::Int(5)),
312 ]);
313 assert_eq!(
314 PoolKey::new(URL_A, &[], Some(&o1), false),
315 PoolKey::new(URL_A, &[], Some(&o2), false)
316 );
317 }
318
319 /// A differing pool-shaping option (max_connections) yields a different key,
320 /// so two calls that asked for differently-sized pools never silently share.
321 #[test]
322 fn different_pool_shape_different_key() {
323 let o1 = opts(&[("max_connections", VmValue::Int(5))]);
324 let o2 = opts(&[("max_connections", VmValue::Int(20))]);
325 assert_ne!(
326 PoolKey::new(URL_A, &[], Some(&o1), false),
327 PoolKey::new(URL_A, &[], Some(&o2), false)
328 );
329 }
330
331 /// A differing application_name yields a different key.
332 #[test]
333 fn different_application_name_different_key() {
334 let o1 = opts(&[("application_name", s("svc-a"))]);
335 let o2 = opts(&[("application_name", s("svc-b"))]);
336 assert_ne!(
337 PoolKey::new(URL_A, &[], Some(&o1), false),
338 PoolKey::new(URL_A, &[], Some(&o2), false)
339 );
340 }
341
342 /// Replica set membership and order are part of the key.
343 #[test]
344 fn replica_set_is_part_of_key() {
345 let r1 = vec![URL_B.to_string()];
346 let r2 = vec![URL_B.to_string(), URL_A.to_string()];
347 assert_ne!(
348 PoolKey::new(URL_A, &[], None, false),
349 PoolKey::new(URL_A, &r1, None, false)
350 );
351 assert_ne!(
352 PoolKey::new(URL_A, &r1, None, false),
353 PoolKey::new(URL_A, &r2, None, false)
354 );
355 }
356
357 /// Nested option dicts (e.g. `circuit_breaker`) participate structurally.
358 #[test]
359 fn nested_option_dicts_affect_key() {
360 let cb1 = VmValue::dict(opts(&[("failure_threshold", VmValue::Int(3))]));
361 let cb2 = VmValue::dict(opts(&[("failure_threshold", VmValue::Int(9))]));
362 let o1 = opts(&[("circuit_breaker", cb1)]);
363 let o2 = opts(&[("circuit_breaker", cb2)]);
364 assert_ne!(
365 PoolKey::new(URL_A, &[], Some(&o1), false),
366 PoolKey::new(URL_A, &[], Some(&o2), false)
367 );
368 }
369
370 /// The key never retains the plaintext password (it is a SHA-256 hex digest).
371 #[test]
372 fn key_does_not_leak_plaintext_credentials() {
373 let key = PoolKey::new(URL_A_DIFF_PW, &[], None, false);
374 assert!(!key.0.contains("HUNTER2"));
375 assert_eq!(key.0.len(), 64); // 32-byte SHA-256, hex-encoded.
376 assert!(key.0.chars().all(|c| c.is_ascii_hexdigit()));
377 }
378
379 /// When the registry is NOT installed, `get`/`is_installed` report empty —
380 /// this is the CLI default path and the precondition for byte-identical
381 /// behavior. (Asserting the *not-installed* state requires a process where
382 /// nobody installed; under nextest each test is its own process.)
383 #[test]
384 fn not_installed_returns_none_by_default() {
385 // We cannot assert globally because another test in the same process may
386 // have installed; instead assert the lookup contract: an absent key is
387 // None regardless of install state.
388 let key = PoolKey::new(
389 "postgres://nobody@nowhere/db_never_inserted",
390 &[],
391 None,
392 true,
393 );
394 assert!(get(&key).is_none());
395 }
396}