Skip to main content

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}